diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index d2bc3d7f63..a10c01d9a5 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -692,6 +692,7 @@ function CommunityApp({ { enterpriseLoginGate.onCancel(); if (transaction) void handleCommunityOnboardingCancel(); diff --git a/desktop/src/features/communities/EnterpriseBrowserLoginGate.tsx b/desktop/src/features/communities/EnterpriseBrowserLoginGate.tsx index a707951cf3..6d614a2436 100644 --- a/desktop/src/features/communities/EnterpriseBrowserLoginGate.tsx +++ b/desktop/src/features/communities/EnterpriseBrowserLoginGate.tsx @@ -7,6 +7,7 @@ import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; type EnterpriseBrowserLoginGateProps = { communityName: string; error?: string | null; + isPending?: boolean; onCancel: () => void; onContinue: () => void; }; @@ -14,6 +15,7 @@ type EnterpriseBrowserLoginGateProps = { export function EnterpriseBrowserLoginGate({ communityName, error, + isPending = false, onCancel, onContinue, }: EnterpriseBrowserLoginGateProps) { @@ -33,9 +35,9 @@ export function EnterpriseBrowserLoginGate({ Sign in with your company account

- {communityName} requires enterprise sign-in. Buzz will open your - system browser so your identity provider can handle SSO, passkeys, and - device-trust checks outside the app. + {isPending + ? "Finish sign-in in your browser, then return to Buzz. If the browser flow was closed or is stuck, cancel here to recover." + : `${communityName} requires enterprise sign-in. Buzz will open your system browser so your identity provider can handle SSO, passkeys, and device-trust checks outside the app.`}

{error ? (

- Continue in browser + {isPending ? "Waiting for browser sign-in…" : "Continue in browser"} diff --git a/desktop/src/features/communities/useCommunityInit.test.mjs b/desktop/src/features/communities/useCommunityInit.test.mjs index 45e2b65f93..efe858ca3b 100644 --- a/desktop/src/features/communities/useCommunityInit.test.mjs +++ b/desktop/src/features/communities/useCommunityInit.test.mjs @@ -301,6 +301,88 @@ test("useCommunityInit cancels an owned pending enterprise authentication login } }); +test("useCommunityInit keeps cancellation visible while browser enterprise login is pending", async () => { + const { cleanup, renderHook, waitFor, act } = await import( + "@testing-library/react" + ); + const { useCommunityInit } = await import("./useCommunityInit.ts"); + const calls = []; + let rejectLogin; + const restore = installTauriInvoke(async (command, args) => { + calls.push([command, args]); + if (command === "get_identity") { + return { pubkey: "pubkey-1", display_name: "Tester" }; + } + if (command === "set_agent_avatar_communities") return null; + if (command === "enterprise_login_gate") return { status: "required" }; + if (command === "get_enterprise_auth") return null; + if (command === "start_enterprise_auth_login") { + return new Promise((_, reject) => { + rejectLogin = reject; + }); + } + if (command === "cancel_enterprise_auth_login") return null; + if (command === "apply_workspace") { + throw new Error("apply_workspace must not run while login is pending"); + } + throw new Error(`unexpected command: ${command}`); + }); + + try { + const hook = renderHook(() => + useCommunityInit(testCommunity(), "community-key", false, true), + ); + + await waitFor(() => assert.ok("enterpriseLogin" in hook.result.current)); + assert.equal(hook.result.current.enterpriseLogin.isPending, false); + + await act(async () => { + hook.result.current.enterpriseLogin.onContinue(); + }); + + await waitFor(() => { + assert.equal( + calls.some(([command]) => command === "start_enterprise_auth_login"), + true, + ); + }); + assert.ok("enterpriseLogin" in hook.result.current); + assert.equal(hook.result.current.enterpriseLogin.isPending, true); + assert.equal(hook.result.current.enterpriseLogin.error, null); + + await act(async () => { + hook.result.current.enterpriseLogin.onCancel(); + rejectLogin(new Error("Enterprise sign-in canceled")); + }); + + await waitFor(() => + assert.deepEqual( + calls.find(([command]) => command === "cancel_enterprise_auth_login"), + [ + "cancel_enterprise_auth_login", + { + attemptId: calls.find( + ([command]) => command === "start_enterprise_auth_login", + )[1].attemptId, + }, + ], + ), + ); + await waitFor(() => + assert.equal(hook.result.current.error, "Enterprise sign-in canceled"), + ); + assert.equal( + calls.some(([command]) => command === "apply_workspace"), + false, + ); + hook.unmount(); + } finally { + restore(); + cleanup(); + mock.reset(); + } +}); + test("useCommunityInit waits for explicit enterprise browser consent", async () => { const { cleanup, renderHook, waitFor, act } = await import( "@testing-library/react" diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 048ee35396..5a516cafad 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -137,6 +137,7 @@ type CommunityInitResult = enterpriseLogin: { communityName: string; error: string | null; + isPending: boolean; onCancel: () => void; onContinue: () => void; }; @@ -175,15 +176,19 @@ export function useCommunityInit( const [enterpriseLoginPrompt, setEnterpriseLoginPrompt] = useState<{ communityName: string; error: string | null; + isPending: boolean; } | null>(null); const enterpriseLoginDecisionRef = useRef< ((allowed: boolean) => void) | null >(null); + const enterpriseLoginAttemptIdRef = useRef(null); const continueEnterpriseLogin = useCallback(() => { const resolve = enterpriseLoginDecisionRef.current; enterpriseLoginDecisionRef.current = null; - setEnterpriseLoginPrompt(null); + setEnterpriseLoginPrompt((prompt) => + prompt ? { ...prompt, error: null, isPending: true } : prompt, + ); resolve?.(true); }, []); @@ -191,6 +196,13 @@ export function useCommunityInit( const resolve = enterpriseLoginDecisionRef.current; enterpriseLoginDecisionRef.current = null; setEnterpriseLoginPrompt(null); + const attemptId = enterpriseLoginAttemptIdRef.current; + if (attemptId !== null) { + void cancelEnterpriseAuthLogin({ attemptId }).catch(() => { + // Best-effort cleanup for a browser login this hook invocation owns. + }); + enterpriseLoginAttemptIdRef.current = null; + } resolve?.(false); }, []); @@ -408,19 +420,28 @@ export function useCommunityInit( setEnterpriseLoginPrompt({ communityName: activeCommunity.name, error: null, + isPending: false, }); }); }, onBrowserLoginStarted: () => { ownedEnterpriseLoginAttemptId = enterpriseLoginAttemptId; + enterpriseLoginAttemptIdRef.current = enterpriseLoginAttemptId; }, }, ); enterpriseProfileForResult = authoritativeEnterpriseProfile(enterpriseAuth); ownedEnterpriseLoginAttemptId = null; + if (enterpriseLoginAttemptIdRef.current === enterpriseLoginAttemptId) { + enterpriseLoginAttemptIdRef.current = null; + } + setEnterpriseLoginPrompt(null); } catch (error) { ownedEnterpriseLoginAttemptId = null; + if (enterpriseLoginAttemptIdRef.current === enterpriseLoginAttemptId) { + enterpriseLoginAttemptIdRef.current = null; + } const errorMessage = error instanceof Error ? error.message @@ -428,7 +449,9 @@ export function useCommunityInit( console.error("Enterprise login gate failed:", error); if (!cancelled) { setEnterpriseLoginPrompt((prompt) => - prompt ? { ...prompt, error: errorMessage } : prompt, + prompt + ? { ...prompt, error: errorMessage, isPending: false } + : prompt, ); setResult({ isReady: false, @@ -546,6 +569,11 @@ export function useCommunityInit( }).catch(() => { // Best-effort cleanup for a browser login this hook invocation owns. }); + if ( + enterpriseLoginAttemptIdRef.current === ownedEnterpriseLoginAttemptId + ) { + enterpriseLoginAttemptIdRef.current = null; + } ownedEnterpriseLoginAttemptId = null; } };