import { agentNativePath } from "@agent-native/core/client/api-path"; import { isInBuilderFrame, oauthRedirectUri, } from "@agent-native/core/client/host"; import { useT } from "@agent-native/core/client/i18n"; import { IconMail, IconX, IconExternalLink, IconCheck, IconCircle, IconLoader2, IconChevronUp, IconUpload, IconAlertTriangle, IconLogout, } from "@tabler/icons-react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { useGoogleAuthStatus, useGoogleAuthUrl, useGoogleAddAccountUrl, useDisconnectGoogle, } from "@/hooks/use-google-auth"; interface EnvKeyStatus { key: string; label: string; required: boolean; configured: boolean; } const STEPS = [ { titleKey: "mail.googleConnect.enableGmailApi", descriptionKey: "mail.googleConnect.enableGmailApiDescription", url: "https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com", linkTextKey: "mail.googleConnect.enableGmailApiLink", }, { titleKey: "mail.googleConnect.enablePeopleApi", descriptionKey: "mail.googleConnect.enablePeopleApiDescription", url: "https://console.cloud.google.com/flows/enableapi?apiid=people.googleapis.com", linkTextKey: "mail.googleConnect.enablePeopleApiLink", }, { titleKey: "mail.googleConnect.configureConsent", descriptionKey: "mail.googleConnect.configureConsentDescription", url: "https://console.cloud.google.com/apis/credentials/consent", linkTextKey: "mail.googleConnect.configureConsentLink", }, { titleKey: "mail.googleConnect.createCredentials", descriptionKey: "mail.googleConnect.createCredentialsDescription", url: "https://console.cloud.google.com/apis/credentials", linkTextKey: "mail.googleConnect.createCredentialsLink", showRedirectUri: true, }, { titleKey: "mail.googleConnect.uploadCredentialsJson", descriptionKey: "mail.googleConnect.uploadCredentialsJsonDescription", showUpload: true, }, ]; interface GoogleConnectBannerProps { variant?: "banner" | "hero"; } interface DesktopAuthIssue { error?: string; message?: string; code?: string; accountId?: string; existingOwner?: string; attemptedOwner?: string; } export function GoogleConnectBanner({ variant = "banner", }: GoogleConnectBannerProps) { const t = useT(); const [wantAuthUrl, setWantAuthUrl] = useState(false); const [wantAddAccount, setWantAddAccount] = useState(false); const [dismissed, setDismissed] = useState(false); const [showWizard, setShowWizard] = useState(false); const [desktopAuthIssue, setDesktopAuthIssue] = useState(null); const googleStatus = useGoogleAuthStatus(); const authUrl = useGoogleAuthUrl(wantAuthUrl); const addAccountUrl = useGoogleAddAccountUrl(wantAddAccount); const disconnectGoogle = useDisconnectGoogle(); const accounts = googleStatus.data?.accounts ?? []; const hasAccounts = accounts.length > 0; const isBuilderFrame = useMemo(() => isInBuilderFrame(), []); const useDesktopAuth = useMemo( () => /AgentNativeDesktop/i.test(navigator.userAgent) && !isBuilderFrame, [isBuilderFrame], ); const desktopPollRef = useRef | null>(null); const addAccountPollRef = useRef | null>(null); useEffect(() => { return () => { if (desktopPollRef.current) clearInterval(desktopPollRef.current); if (addAccountPollRef.current) clearInterval(addAccountPollRef.current); }; }, []); function signInViaDesktopBrowser(addAccount = false) { setDesktopAuthIssue(null); const flowId = crypto.randomUUID?.() || Math.random().toString(36).slice(2) + Date.now().toString(36); const origin = window.location.origin; const endpoint = addAccount ? "/_agent-native/google/add-account/auth-url" : "/_agent-native/google/auth-url"; const redirectUri = encodeURIComponent( oauthRedirectUri("/_agent-native/google/callback"), ); window.open( `${origin}${agentNativePath(endpoint)}?redirect_uri=${redirectUri}&desktop=1&flow_id=${flowId}&redirect=1`, "_blank", ); const start = Date.now(); if (desktopPollRef.current) clearInterval(desktopPollRef.current); desktopPollRef.current = setInterval(async () => { try { const res = await fetch( agentNativePath( `/_agent-native/auth/desktop-exchange?flow_id=${flowId}`, ), ); const data = await res.json(); if (data?.error) { clearInterval(desktopPollRef.current!); desktopPollRef.current = null; setDesktopAuthIssue(data); } else if (data?.token) { clearInterval(desktopPollRef.current!); desktopPollRef.current = null; await fetch( agentNativePath( `/_agent-native/auth/session?_session=${data.token}`, ), { credentials: "include", }, ); window.location.reload(); } else if (Date.now() - start > 120_000) { clearInterval(desktopPollRef.current!); desktopPollRef.current = null; } } catch { if (Date.now() - start > 120_000) { clearInterval(desktopPollRef.current!); desktopPollRef.current = null; } } }, 1500); } const [authError, setAuthError] = useState(null); // Wizard state const [currentStep, setCurrentStep] = useState(0); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [saveError, setSaveError] = useState(null); const [envStatus, setEnvStatus] = useState([]); const [copiedKey, setCopiedKey] = useState(null); const fileInputRef = useRef(null); const redirectUri = oauthRedirectUri("/_agent-native/google/callback"); const fetchStatus = useCallback(async () => { try { const res = await fetch(agentNativePath("/_agent-native/env-status")); if (res.ok) { const data: EnvKeyStatus[] = await res.json(); setEnvStatus(data); const allConfigured = data.every((k) => k.configured); if (allConfigured && data.length > 0) { setSaved(true); setCurrentStep(STEPS.length - 1); } } } catch { // ignore } }, []); // Check if credentials are already configured on mount useEffect(() => { fetchStatus(); }, [fetchStatus]); // When auth URL is ready, leave this tab for Google and let the callback // return here. Opening a popup leaves users with duplicate Mail tabs after // OAuth completes. // // `wantAuthUrl` is the user's retry intent and must be in the deps so a // second click re-runs this effect (the cached authUrl.data won't change on // its own). useEffect(() => { if (!wantAuthUrl || !authUrl.data?.url) return; const url = authUrl.data.url; setWantAuthUrl(false); // In a React Native WebView, window.open() is silently blocked (WKWebView // doesn't support it without onOpenWindow). Use postMessage to ask the // native wrapper to open the URL in the system browser (Safari). const rnWebView = (window as any).ReactNativeWebView; const isNativeWebView = typeof rnWebView !== "undefined"; if (isNativeWebView) { rnWebView.postMessage(JSON.stringify({ type: "openUrl", url })); return; } window.location.href = url; }, [wantAuthUrl, authUrl.data]); // When auth URL fails, show wizard (for missing credentials) or an error message useEffect(() => { if (authUrl.error) { setWantAuthUrl(false); setShowWizard(true); fetchStatus(); setAuthError( (authUrl.error as any)?.message || t("mail.error.failedToConnect"), ); } }, [authUrl.error, fetchStatus]); const allConfigured = envStatus.length > 0 && envStatus.every((k) => k.configured); const handleSignOutForGoogle = useCallback(async () => { try { await fetch(agentNativePath("/_agent-native/auth/logout"), { method: "POST", credentials: "include", }); } catch { // Reload below still lands on the auth screen if the local cookie changed. } window.location.reload(); }, []); // When add-account URL is ready, open it and poll for new account. // Same retry-intent rationale as the connect effect — `wantAddAccount` // is in the deps so a second click rerun the effect; the polling // interval lives in a ref so flipping wantAddAccount false here doesn't // tear down the running poll. useEffect(() => { if (!wantAddAccount || !addAccountUrl.data?.url) return; const isNativeWebView = typeof (window as any).ReactNativeWebView !== "undefined"; if (isNativeWebView) { window.location.href = addAccountUrl.data.url; } else if (isBuilderFrame) { window.location.href = addAccountUrl.data.url; } else { window.open(addAccountUrl.data.url, "_blank"); } setWantAddAccount(false); if (isNativeWebView || isBuilderFrame) return; const prevCount = accounts.length; if (addAccountPollRef.current) clearInterval(addAccountPollRef.current); addAccountPollRef.current = setInterval(async () => { const res = await fetch( agentNativePath("/_agent-native/google/status"), ).catch(() => null); if (res?.ok) { const data = await res.json(); if (data.accounts?.length > prevCount) { if (addAccountPollRef.current) { clearInterval(addAccountPollRef.current); addAccountPollRef.current = null; } window.location.reload(); } } }, 2000); // accounts.length is captured into prevCount above; including it in deps // would tear down and recreate the interval whenever the count changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [wantAddAccount, addAccountUrl.data, isBuilderFrame]); function handleConnect() { setDesktopAuthIssue(null); if (useDesktopAuth) { signInViaDesktopBrowser(); return; } setWantAuthUrl(true); } function handleAddAccount() { if (useDesktopAuth) { signInViaDesktopBrowser(true); return; } setWantAddAccount(true); } async function handleJsonUpload(file: File) { setSaving(true); setSaveError(null); try { const text = await file.text(); const json = JSON.parse(text); // Google's downloaded JSON has the credentials nested under "web" or "installed" const creds = json.web || json.installed || json; const clientId = creds.client_id; const clientSecret = creds.client_secret; if (!clientId || !clientSecret) { throw new Error(t("mail.error.missingGoogleCredentials")); } const res = await fetch(agentNativePath("/_agent-native/env-vars"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope: "workspace", vars: [ { key: "GOOGLE_CLIENT_ID", value: clientId }, { key: "GOOGLE_CLIENT_SECRET", value: clientSecret }, ], }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || t("mail.error.failedToSaveCredentials")); } setSaved(true); await fetchStatus(); // Reload after the server has persisted the scoped credentials. setTimeout(() => window.location.reload(), 1500); } catch (err) { setSaveError( err instanceof Error ? err.message : t("mail.error.failedToParseJson"), ); } finally { setSaving(false); } } function copyToClipboard(text: string, key: string) { navigator.clipboard.writeText(text); setCopiedKey(key); setTimeout(() => setCopiedKey(null), 2000); } if (dismissed) return null; // Full-page hero for setup / reconnection if (variant === "hero") { return (

{t("mail.googleConnect.connectTitle")}

{t("mail.googleConnect.heroDescription")}

setDesktopAuthIssue(null)} className="mt-5 w-full max-w-md" /> {authError && allConfigured && (

{authError}

)} {showWizard && !allConfigured && (

{t("mail.googleConnect.setupIntro")}

{STEPS.map((step, i) => { const isActive = i === currentStep; const isCompleted = i < currentStep || (i === STEPS.length - 1 && saved); return (
!saved && setCurrentStep(i)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); !saved && setCurrentStep(i); } }} >
{isCompleted ? ( ) : isActive ? ( ) : ( )}

{i + 1}. {t(step.titleKey)}

{isActive && (

{t(step.descriptionKey)}

{step.showRedirectUri && (
{redirectUri}
)} {step.url && ( )} {step.showUpload && !allConfigured && (
e.stopPropagation()} > { const file = e.target.files?.[0]; if (file) handleJsonUpload(file); }} /> {saveError && (

{saveError}

)}
)} {step.showUpload && allConfigured && (
{t( "mail.googleConnect.credentialsConfiguredSignIn", )}
)}
)}
); })}
)}
); } // Connected with accounts — show compact account strip if (hasAccounts) { return (
{accounts.map((account) => (
{account.email}
))}
setDesktopAuthIssue(null)} className="mx-4 mb-3" />
); } // Not connected or not configured — show setup banner return (
{/* Compact banner row */}

{allConfigured ? t("mail.googleConnect.readyToConnect") : t("mail.googleConnect.connectBanner")}

{showWizard && !allConfigured ? ( ) : allConfigured ? ( ) : ( )}
setDesktopAuthIssue(null)} className="mx-4 mb-3" /> {/* Inline setup wizard */} {showWizard && !allConfigured && (

{t("mail.googleConnect.setupIntro")}

{STEPS.map((step, i) => { const isActive = i === currentStep; const isCompleted = i < currentStep || (i === STEPS.length - 1 && saved); return (
!saved && setCurrentStep(i)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); !saved && setCurrentStep(i); } }} >
{isCompleted ? ( ) : isActive ? ( ) : ( )}

{i + 1}. {t(step.titleKey)}

{isActive && (

{t(step.descriptionKey)}

{step.showRedirectUri && (
{redirectUri}
)} {step.url && ( )} {step.showUpload && !allConfigured && (
e.stopPropagation()} > { const file = e.target.files?.[0]; if (file) handleJsonUpload(file); }} /> {saveError && (

{saveError}

)}
)} {step.showUpload && allConfigured && (
{t( "mail.googleConnect.credentialsConfiguredConnect", )}
)}
)}
); })}
)}
); } function GoogleAuthIssuePanel({ issue, onSignOut, onDismiss, className = "", }: { issue: DesktopAuthIssue | null; onSignOut: () => void; onDismiss: () => void; className?: string; }) { const t = useT(); if (!issue) return null; const account = issue.accountId || "that Google account"; const isOwnerMismatch = issue.code === "account_owner_mismatch"; const detail = isOwnerMismatch ? t("mail.googleConnect.signOutThenSignIn", { account }) : issue.message || issue.error || t("mail.googleConnect.signOutThenSignIn", { account }); const shouldOfferSignOut = isOwnerMismatch || Boolean(issue.existingOwner || issue.attemptedOwner || issue.accountId); return (

{isOwnerMismatch ? t("mail.googleConnect.ownerMismatch") : t("mail.googleConnect.connectionFailed")}

{detail}

{shouldOfferSignOut && (
)}
); } function GoogleIcon({ className = "h-4 w-4" }: { className?: string }) { return ( ); }