diff --git a/.gitignore b/.gitignore index 3ff613d..82bfb56 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* next-env.d.ts RESOURCES.md + +# tmp +/tmp diff --git a/components/oauth-gate-screen.tsx b/components/oauth-gate-screen.tsx new file mode 100644 index 0000000..e1a21c1 --- /dev/null +++ b/components/oauth-gate-screen.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; + +export interface ProviderStatus { + status: "missing" | "elevation_required"; + access?: string; + currentAccess?: string; + requiredAccess?: string; + resourceId?: string; +} + +interface OAuthGateScreenProps { + providers: Record; + authUrls: Record; +} + +const OAUTH_POPUP_WIDTH = 600; +const OAUTH_POPUP_HEIGHT = 700; +const OAUTH_POLL_INTERVAL_MS = 500; + +const PROVIDER_DISPLAY: Record React.ReactNode }> = { + google: { name: "Google", logo: GoogleLogo }, +}; + +export function OAuthGateScreen({ providers, authUrls }: OAuthGateScreenProps) { + const [oauthError, setOauthError] = useState(null); + const popupRef = useRef(null); + const pollRef = useRef | null>(null); + + // Listen for postMessage from popup callback + useEffect(() => { + const handler = (event: MessageEvent) => { + if (event.data?.type === "MAJOR_OAUTH_CONNECTED") { + popupRef.current?.close(); + popupRef.current = null; + window.location.reload(); + } + + if (event.data?.type === "MAJOR_OAUTH_ERROR") { + setOauthError("Connection failed. Please try again."); + popupRef.current?.close(); + popupRef.current = null; + } + }; + + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, []); + + const handleConnect = useCallback((authUrl: string) => { + const left = window.screenX + (window.outerWidth - OAUTH_POPUP_WIDTH) / 2; + const top = window.screenY + (window.outerHeight - OAUTH_POPUP_HEIGHT) / 2; + + popupRef.current = window.open( + authUrl, + "oauth-connect", + `width=${OAUTH_POPUP_WIDTH},height=${OAUTH_POPUP_HEIGHT},left=${left},top=${top},popup=yes`, + ); + + // Poll for popup close as fallback (e.g. user closes popup manually) + if (pollRef.current) { + clearInterval(pollRef.current); + } + + pollRef.current = setInterval(() => { + if (popupRef.current?.closed) { + popupRef.current = null; + + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + + window.location.reload(); + } + }, OAUTH_POLL_INTERVAL_MS); + }, []); + + const providerEntries = Object.entries(providers); + + return ( +
+
+
+

+ Connect Your Accounts +

+

+ This app needs access to the following services to work properly. +

+
+ + {oauthError && ( +
+ {oauthError} +
+ )} + +
+ {providerEntries.map(([provider, status]) => { + const authUrl = authUrls[provider]; + const isElevation = status.status === "elevation_required"; + const display = PROVIDER_DISPLAY[provider]; + const displayName = display?.name || provider.charAt(0).toUpperCase() + provider.slice(1); + + return ( +
+ {isElevation && ( +

+ This app needs{" "} + {status.requiredAccess}{" "} + access. You previously granted{" "} + {status.currentAccess}. +

+ )} + + +
+ ); + })} +
+ + {providerEntries.length > 1 && ( +

+ Step 1 of {providerEntries.length} +

+ )} +
+
+ ); +} + +function ProviderButton({ + provider, + displayName, + isElevation, + authUrl, + logo: Logo, + onConnect, +}: { + provider: string; + displayName: string; + isElevation: boolean; + authUrl?: string; + logo?: () => React.ReactNode; + onConnect: (authUrl: string) => void; +}) { + const handleClick = () => { + if (authUrl) { + onConnect(authUrl); + } + }; + + if (provider === "google") { + return ( + + ); + } + + return ( + + ); +} + +function GoogleLogo() { + return ( + + ); +} diff --git a/components/oauth-gate.tsx b/components/oauth-gate.tsx new file mode 100644 index 0000000..e8d84bb --- /dev/null +++ b/components/oauth-gate.tsx @@ -0,0 +1,106 @@ +import { headers } from "next/headers"; +import type { ReactNode } from "react"; + +import { OAuthGateScreen } from "./oauth-gate-screen"; +import type { ProviderStatus } from "./oauth-gate-screen"; + +// Pod-reachable URL for server-side API calls +const RESOURCE_API_URL = + process.env.RESOURCE_API_URL || process.env.MAJOR_API_BASE_URL || "https://go-api.prod.major.build"; + +interface StatusResponseProvider { + status: string; + access?: string; + currentAccess?: string; + requiredAccess?: string; + resourceId?: string; +} + +interface StatusResponse { + providers: Record; +} + +export async function OAuthGate({ children }: { children: ReactNode }) { + const h = await headers(); + const userJwt = h.get("x-major-user-jwt"); + + if (!userJwt) { + return <>{children}; + } + + let statusData: StatusResponse | null = null; + + try { + const res = await fetch(`${RESOURCE_API_URL}/internal/user-oauth/status`, { + headers: { "x-major-user-jwt": userJwt }, + cache: "no-store", + }); + + if (res.ok) { + statusData = (await res.json()) as StatusResponse; + } + } catch { + // Fail open — platform outage should not block deployed apps + } + + if (!statusData) { + return <>{children}; + } + + const providers = statusData.providers; + + if (!providers || Object.keys(providers).length === 0) { + return <>{children}; + } + + // Check if all providers are connected + const needsConnection = Object.entries(providers).filter( + ([, p]) => p.status === "missing" || p.status === "elevation_required" + ); + + if (needsConnection.length === 0) { + return <>{children}; + } + + // Fetch actual OAuth authorization URLs server-side (the endpoint requires auth + // which is only available via the JWT, not from the browser). + // No returnUrl — the client always uses a popup flow so the callback uses + // postMessage + window.close() instead of redirecting. + const authUrls: Record = {}; + const gateProviders: Record = {}; + + for (const [provider, status] of needsConnection) { + if (status.resourceId) { + const params = new URLSearchParams({ + resourceId: status.resourceId, + }); + + try { + const authRes = await fetch( + `${RESOURCE_API_URL}/internal/user-oauth/${provider}/auth-url?${params.toString()}`, + { + headers: { "x-major-user-jwt": userJwt }, + cache: "no-store", + }, + ); + + if (authRes.ok) { + const authData = (await authRes.json()) as { authUrl: string }; + authUrls[provider] = authData.authUrl; + } + } catch { + // Skip this provider if we can't get the auth URL + } + } + + gateProviders[provider] = { + status: status.status as "missing" | "elevation_required", + access: status.access, + currentAccess: status.currentAccess, + requiredAccess: status.requiredAccess, + resourceId: status.resourceId, + }; + } + + return ; +}