From 0e504ce23a1102ee4295f2a7f5864cf334bbf91c Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Tue, 3 Mar 2026 08:52:39 -0800 Subject: [PATCH 01/10] Add /tmp to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From d5c47f88aa0b228c3e8a6bb808528294ef436948 Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Fri, 13 Mar 2026 18:27:49 -0400 Subject: [PATCH 02/10] feat: add OAuthGate server + client components for user-level OAuth connections --- components/oauth-gate-screen.tsx | 203 +++++++++++++++++++++++++++++++ components/oauth-gate.tsx | 109 +++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 components/oauth-gate-screen.tsx create mode 100644 components/oauth-gate.tsx diff --git a/components/oauth-gate-screen.tsx b/components/oauth-gate-screen.tsx new file mode 100644 index 0000000..adb7349 --- /dev/null +++ b/components/oauth-gate-screen.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface ProviderStatus { + status: "missing" | "elevation_required"; + access?: string; + currentAccess?: string; + requiredAccess?: string; + resourceId?: string; +} + +interface OAuthGateScreenProps { + providers: Record; + authUrls: Record; +} + +interface OAuthRequirement { + provider: string; + requiredAccess: string; + resourceId: string; +} + +const PROVIDER_DISPLAY: Record React.ReactNode }> = { + google: { name: "Google", logo: GoogleLogo }, +}; + +export function OAuthGateScreen({ providers, authUrls }: OAuthGateScreenProps) { + const [isIframe, setIsIframe] = useState(false); + const [oauthError, setOauthError] = useState(null); + + useEffect(() => { + setIsIframe(window.parent !== window); + + const params = new URLSearchParams(window.location.search); + const error = params.get("oauth_error"); + + if (error === "declined") { + setOauthError("Connection was declined. Please try again."); + } + }, []); + + useEffect(() => { + if (!isIframe) { + return; + } + + const requirements: OAuthRequirement[] = Object.entries(providers).map( + ([provider, status]) => ({ + provider, + requiredAccess: status.requiredAccess || status.access || "readonly", + resourceId: status.resourceId || "", + }) + ); + + window.parent.postMessage( + { type: "MAJOR_USER_OAUTH_REQUIRED", requirements }, + "*" + ); + }, [isIframe, providers]); + + if (isIframe) { + return ( +
+
+

Waiting for connection...

+
+
+ ); + } + + 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, +}: { + provider: string; + displayName: string; + isElevation: boolean; + authUrl?: string; + logo?: () => React.ReactNode; +}) { + const handleClick = () => { + if (authUrl) { + window.location.href = authUrl; + } + }; + + if (provider === "google") { + return ( + + ); + } + + // Fallback for other providers + return ( + + ); +} + +function GoogleLogo() { + return ( + + ); +} diff --git a/components/oauth-gate.tsx b/components/oauth-gate.tsx new file mode 100644 index 0000000..2809fab --- /dev/null +++ b/components/oauth-gate.tsx @@ -0,0 +1,109 @@ +import { cookies, headers } from "next/headers"; +import type { ReactNode } from "react"; + +import { OAuthGateScreen } from "./oauth-gate-screen"; +import type { ProviderStatus } from "./oauth-gate-screen"; + +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; +} + +function buildCurrentUrl(h: Headers): string { + const proto = h.get("x-forwarded-proto") || "https"; + const host = h.get("host") || "localhost"; + const path = h.get("x-forwarded-uri") || h.get("x-invoke-path") || "/"; + + return `${proto}://${host}${path}`; +} + +export async function OAuthGate({ children }: { children: ReactNode }) { + const cookieStore = await cookies(); + + if (cookieStore.get("major-user-oauth")) { + return <>{children}; + } + + const h = await headers(); + const userJwt = h.get("x-major-user-jwt"); + + if (!userJwt) { + return <>{children}; + } + + let statusData: StatusResponse; + + try { + const res = await fetch(`${RESOURCE_API_URL}/internal/user-oauth/status`, { + headers: { "x-major-user-jwt": userJwt }, + cache: "no-store", + }); + + if (!res.ok) { + return <>{children}; + } + + statusData = (await res.json()) as StatusResponse; + } catch { + // Fail open — platform outage should not block deployed apps + 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) { + cookieStore.set("major-user-oauth", "1", { + maxAge: 300, + httpOnly: true, + sameSite: "lax", + path: "/", + }); + + return <>{children}; + } + + // Build auth URLs for providers that need connection + const currentUrl = buildCurrentUrl(h); + const authUrls: Record = {}; + const gateProviders: Record = {}; + + for (const [provider, status] of needsConnection) { + if (status.resourceId) { + const params = new URLSearchParams({ + resourceId: status.resourceId, + returnUrl: currentUrl, + }); + + authUrls[provider] = `${RESOURCE_API_URL}/user-oauth/${provider}/auth-url?${params.toString()}`; + } + + gateProviders[provider] = { + status: status.status as "missing" | "elevation_required", + access: status.access, + currentAccess: status.currentAccess, + requiredAccess: status.requiredAccess, + resourceId: status.resourceId, + }; + } + + return ; +} From 648fb5197d583ee301724c92ad7ec93ee23c1c94 Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Fri, 13 Mar 2026 18:42:11 -0400 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20remove=20cookies().set()=20?= =?UTF-8?q?=E2=80=94=20not=20allowed=20in=20server=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/oauth-gate.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/components/oauth-gate.tsx b/components/oauth-gate.tsx index 2809fab..f517588 100644 --- a/components/oauth-gate.tsx +++ b/components/oauth-gate.tsx @@ -1,4 +1,4 @@ -import { cookies, headers } from "next/headers"; +import { headers } from "next/headers"; import type { ReactNode } from "react"; import { OAuthGateScreen } from "./oauth-gate-screen"; @@ -28,12 +28,6 @@ function buildCurrentUrl(h: Headers): string { } export async function OAuthGate({ children }: { children: ReactNode }) { - const cookieStore = await cookies(); - - if (cookieStore.get("major-user-oauth")) { - return <>{children}; - } - const h = await headers(); const userJwt = h.get("x-major-user-jwt"); @@ -71,13 +65,6 @@ export async function OAuthGate({ children }: { children: ReactNode }) { ); if (needsConnection.length === 0) { - cookieStore.set("major-user-oauth", "1", { - maxAge: 300, - httpOnly: true, - sameSite: "lax", - path: "/", - }); - return <>{children}; } From 2e0a0c50090c76109fac887550820227b0870eca Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Sat, 14 Mar 2026 18:27:01 -0400 Subject: [PATCH 04/10] fix: remove iframe postMessage bridge from OAuthGateScreen The deployed app handles OAuth entirely on its own via the OAuthGate server component and x-major-user-jwt. No need to delegate to the parent shell when running inside the dashboard iframe. Co-Authored-By: Claude Opus 4.6 --- components/oauth-gate-screen.tsx | 38 -------------------------------- 1 file changed, 38 deletions(-) diff --git a/components/oauth-gate-screen.tsx b/components/oauth-gate-screen.tsx index adb7349..6ebe960 100644 --- a/components/oauth-gate-screen.tsx +++ b/components/oauth-gate-screen.tsx @@ -15,23 +15,14 @@ interface OAuthGateScreenProps { authUrls: Record; } -interface OAuthRequirement { - provider: string; - requiredAccess: string; - resourceId: string; -} - const PROVIDER_DISPLAY: Record React.ReactNode }> = { google: { name: "Google", logo: GoogleLogo }, }; export function OAuthGateScreen({ providers, authUrls }: OAuthGateScreenProps) { - const [isIframe, setIsIframe] = useState(false); const [oauthError, setOauthError] = useState(null); useEffect(() => { - setIsIframe(window.parent !== window); - const params = new URLSearchParams(window.location.search); const error = params.get("oauth_error"); @@ -40,35 +31,6 @@ export function OAuthGateScreen({ providers, authUrls }: OAuthGateScreenProps) { } }, []); - useEffect(() => { - if (!isIframe) { - return; - } - - const requirements: OAuthRequirement[] = Object.entries(providers).map( - ([provider, status]) => ({ - provider, - requiredAccess: status.requiredAccess || status.access || "readonly", - resourceId: status.resourceId || "", - }) - ); - - window.parent.postMessage( - { type: "MAJOR_USER_OAUTH_REQUIRED", requirements }, - "*" - ); - }, [isIframe, providers]); - - if (isIframe) { - return ( -
-
-

Waiting for connection...

-
-
- ); - } - const providerEntries = Object.entries(providers); return ( From 3b7692423ab15076c25c5321bda5d8671a3ab2e6 Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Sat, 14 Mar 2026 21:46:31 -0400 Subject: [PATCH 05/10] fix: split resource API URL for SSR vs browser OAuth redirects RESOURCE_API_URL is pod-reachable for server-side status checks. RESOURCE_API_BROWSER_URL is browser-reachable for OAuth redirect links. Locally these differ (host.docker.internal vs localhost). Co-Authored-By: Claude Opus 4.6 --- components/oauth-gate.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/components/oauth-gate.tsx b/components/oauth-gate.tsx index f517588..6a7a805 100644 --- a/components/oauth-gate.tsx +++ b/components/oauth-gate.tsx @@ -4,9 +4,14 @@ import type { ReactNode } from "react"; import { OAuthGateScreen } from "./oauth-gate-screen"; import type { ProviderStatus } from "./oauth-gate-screen"; +// Pod-reachable URL for server-side status checks const RESOURCE_API_URL = process.env.RESOURCE_API_URL || process.env.MAJOR_API_BASE_URL || "https://go-api.prod.major.build"; +// Browser-reachable URL for OAuth redirects (falls back to RESOURCE_API_URL for prod/staging where they're the same) +const RESOURCE_API_BROWSER_URL = + process.env.RESOURCE_API_BROWSER_URL || RESOURCE_API_URL; + interface StatusResponseProvider { status: string; access?: string; @@ -80,7 +85,7 @@ export async function OAuthGate({ children }: { children: ReactNode }) { returnUrl: currentUrl, }); - authUrls[provider] = `${RESOURCE_API_URL}/user-oauth/${provider}/auth-url?${params.toString()}`; + authUrls[provider] = `${RESOURCE_API_BROWSER_URL}/user-oauth/${provider}/auth-url?${params.toString()}`; } gateProviders[provider] = { From d07648522c1d6e48416148e9d959b207259d1288 Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Sat, 14 Mar 2026 21:52:40 -0400 Subject: [PATCH 06/10] fix: resolve lint errors in OAuth gate components - Move JSX out of try/catch in oauth-gate.tsx (react-hooks/error-boundaries) - Use lazy useState initializer instead of useEffect+setState in oauth-gate-screen.tsx (react-hooks/set-state-in-effect) Co-Authored-By: Claude Opus 4.6 --- components/oauth-gate-screen.tsx | 18 +++++++++--------- components/oauth-gate.tsx | 11 ++++++----- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/components/oauth-gate-screen.tsx b/components/oauth-gate-screen.tsx index 6ebe960..3cf8c67 100644 --- a/components/oauth-gate-screen.tsx +++ b/components/oauth-gate-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; export interface ProviderStatus { status: "missing" | "elevation_required"; @@ -20,16 +20,16 @@ const PROVIDER_DISPLAY: Record React.ReactNo }; export function OAuthGateScreen({ providers, authUrls }: OAuthGateScreenProps) { - const [oauthError, setOauthError] = useState(null); + const [oauthError] = useState(() => { + if (typeof window === "undefined") { + return null; + } - useEffect(() => { const params = new URLSearchParams(window.location.search); - const error = params.get("oauth_error"); - - if (error === "declined") { - setOauthError("Connection was declined. Please try again."); - } - }, []); + return params.get("oauth_error") === "declined" + ? "Connection was declined. Please try again." + : null; + }); const providerEntries = Object.entries(providers); diff --git a/components/oauth-gate.tsx b/components/oauth-gate.tsx index 6a7a805..7f5aec9 100644 --- a/components/oauth-gate.tsx +++ b/components/oauth-gate.tsx @@ -40,7 +40,7 @@ export async function OAuthGate({ children }: { children: ReactNode }) { return <>{children}; } - let statusData: StatusResponse; + let statusData: StatusResponse | null = null; try { const res = await fetch(`${RESOURCE_API_URL}/internal/user-oauth/status`, { @@ -48,13 +48,14 @@ export async function OAuthGate({ children }: { children: ReactNode }) { cache: "no-store", }); - if (!res.ok) { - return <>{children}; + if (res.ok) { + statusData = (await res.json()) as StatusResponse; } - - statusData = (await res.json()) as StatusResponse; } catch { // Fail open — platform outage should not block deployed apps + } + + if (!statusData) { return <>{children}; } From b092de27c60a16da6c8e1cd8076c2466f4792eb1 Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Sat, 14 Mar 2026 22:17:16 -0400 Subject: [PATCH 07/10] fix: fetch auth URL from endpoint instead of navigating directly The OAuthGate sign-in button was navigating directly to the go-api auth-url endpoint, which returns JSON. Now it fetches the endpoint, extracts the actual Google OAuth URL, and redirects to that. Co-Authored-By: Claude Opus 4.6 --- components/oauth-gate-screen.tsx | 50 ++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/components/oauth-gate-screen.tsx b/components/oauth-gate-screen.tsx index 3cf8c67..7a9751b 100644 --- a/components/oauth-gate-screen.tsx +++ b/components/oauth-gate-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; export interface ProviderStatus { status: "missing" | "elevation_required"; @@ -104,22 +104,46 @@ function ProviderButton({ authUrl?: string; logo?: () => React.ReactNode; }) { - const handleClick = () => { - if (authUrl) { - window.location.href = authUrl; + const [loading, setLoading] = useState(false); + + const handleClick = useCallback(async () => { + if (!authUrl) { + return; + } + + setLoading(true); + + try { + const res = await fetch(authUrl, { credentials: "include" }); + + if (!res.ok) { + console.error("Failed to get OAuth auth URL"); + setLoading(false); + return; + } + + const data = await res.json(); + window.location.href = data.authUrl; + } catch (err) { + console.error("Failed to initiate OAuth connection:", err); + setLoading(false); } - }; + }, [authUrl]); if (provider === "google") { return ( ); @@ -129,13 +153,15 @@ function ProviderButton({ return ( ); From f218cffc872555848c7839d235dd4b463f326d02 Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Sat, 14 Mar 2026 22:36:23 -0400 Subject: [PATCH 08/10] fix: resolve auth URLs server-side in OAuthGate SSR The auth-url endpoint requires session auth, which isn't available from the deployed app's browser context. Move URL resolution to the server component using a new internal JWT-authenticated endpoint, so the client receives actual Google OAuth URLs it can navigate to directly. Co-Authored-By: Claude Opus 4.6 --- components/oauth-gate-screen.tsx | 50 ++++++++------------------------ components/oauth-gate.tsx | 20 +++++++++++-- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/components/oauth-gate-screen.tsx b/components/oauth-gate-screen.tsx index 7a9751b..3cf8c67 100644 --- a/components/oauth-gate-screen.tsx +++ b/components/oauth-gate-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState } from "react"; export interface ProviderStatus { status: "missing" | "elevation_required"; @@ -104,46 +104,22 @@ function ProviderButton({ authUrl?: string; logo?: () => React.ReactNode; }) { - const [loading, setLoading] = useState(false); - - const handleClick = useCallback(async () => { - if (!authUrl) { - return; - } - - setLoading(true); - - try { - const res = await fetch(authUrl, { credentials: "include" }); - - if (!res.ok) { - console.error("Failed to get OAuth auth URL"); - setLoading(false); - return; - } - - const data = await res.json(); - window.location.href = data.authUrl; - } catch (err) { - console.error("Failed to initiate OAuth connection:", err); - setLoading(false); + const handleClick = () => { + if (authUrl) { + window.location.href = authUrl; } - }, [authUrl]); + }; if (provider === "google") { return ( ); @@ -153,15 +129,13 @@ function ProviderButton({ return ( ); diff --git a/components/oauth-gate.tsx b/components/oauth-gate.tsx index 7f5aec9..011ea66 100644 --- a/components/oauth-gate.tsx +++ b/components/oauth-gate.tsx @@ -74,7 +74,8 @@ export async function OAuthGate({ children }: { children: ReactNode }) { return <>{children}; } - // Build auth URLs for providers that need connection + // Fetch actual OAuth authorization URLs server-side (the endpoint requires auth + // which is only available via the JWT, not from the browser) const currentUrl = buildCurrentUrl(h); const authUrls: Record = {}; const gateProviders: Record = {}; @@ -86,7 +87,22 @@ export async function OAuthGate({ children }: { children: ReactNode }) { returnUrl: currentUrl, }); - authUrls[provider] = `${RESOURCE_API_BROWSER_URL}/user-oauth/${provider}/auth-url?${params.toString()}`; + 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] = { From 5f5c2b42418adbca30f46f51bae606e1ab2ebfb3 Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Sat, 14 Mar 2026 22:41:14 -0400 Subject: [PATCH 09/10] fix: remove unused RESOURCE_API_BROWSER_URL constant Co-Authored-By: Claude Opus 4.6 --- components/oauth-gate.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/components/oauth-gate.tsx b/components/oauth-gate.tsx index 011ea66..b775357 100644 --- a/components/oauth-gate.tsx +++ b/components/oauth-gate.tsx @@ -4,14 +4,10 @@ import type { ReactNode } from "react"; import { OAuthGateScreen } from "./oauth-gate-screen"; import type { ProviderStatus } from "./oauth-gate-screen"; -// Pod-reachable URL for server-side status checks +// 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"; -// Browser-reachable URL for OAuth redirects (falls back to RESOURCE_API_URL for prod/staging where they're the same) -const RESOURCE_API_BROWSER_URL = - process.env.RESOURCE_API_BROWSER_URL || RESOURCE_API_URL; - interface StatusResponseProvider { status: string; access?: string; From e4d26fa0756fb2d485da27c474fe98bf280192bb Mon Sep 17 00:00:00 2001 From: Rishikesh Date: Sat, 14 Mar 2026 22:51:36 -0400 Subject: [PATCH 10/10] fix: always use popup for OAuth flow in OAuthGate Google blocks OAuth consent in iframes (403), and the redirect flow had returnUrl issues. Switch to popup-only: opens Google consent in a popup window, listens for postMessage/close, then reloads to re-check status via SSR. Works uniformly in both dashboard iframe and standalone. Co-Authored-By: Claude Opus 4.6 --- components/oauth-gate-screen.tsx | 70 +++++++++++++++++++++++++++----- components/oauth-gate.tsx | 14 ++----- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/components/oauth-gate-screen.tsx b/components/oauth-gate-screen.tsx index 3cf8c67..e1a21c1 100644 --- a/components/oauth-gate-screen.tsx +++ b/components/oauth-gate-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; export interface ProviderStatus { status: "missing" | "elevation_required"; @@ -15,21 +15,67 @@ interface OAuthGateScreenProps { 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] = useState(() => { - if (typeof window === "undefined") { - return null; + 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); } - const params = new URLSearchParams(window.location.search); - return params.get("oauth_error") === "declined" - ? "Connection was declined. Please try again." - : null; - }); + 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); @@ -75,6 +121,7 @@ export function OAuthGateScreen({ providers, authUrls }: OAuthGateScreenProps) { isElevation={isElevation} authUrl={authUrl} logo={display?.logo} + onConnect={handleConnect} /> ); @@ -97,16 +144,18 @@ function ProviderButton({ isElevation, authUrl, logo: Logo, + onConnect, }: { provider: string; displayName: string; isElevation: boolean; authUrl?: string; logo?: () => React.ReactNode; + onConnect: (authUrl: string) => void; }) { const handleClick = () => { if (authUrl) { - window.location.href = authUrl; + onConnect(authUrl); } }; @@ -125,7 +174,6 @@ function ProviderButton({ ); } - // Fallback for other providers return (