diff --git a/docs/frontend-ui-audit-2026-08-11/OAuthSessionSetup.md b/docs/frontend-ui-audit-2026-08-11/OAuthSessionSetup.md new file mode 100644 index 0000000000..7196a54d60 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-11/OAuthSessionSetup.md @@ -0,0 +1,21 @@ +# Frontend UI Audit — OAuth Session Setup + +Scope: the Claude Code and Codex OAuth session-setup components plus their extracted shared shell. Provider-specific credential mapping remains outside the shared presentation/lifecycle boundary. + +| Line | Element | Verdict | Reason | Suggested change | +| -------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:63` | OAuth idle/browser/loading/error/success presentation | abstract | Claude Code and Codex duplicated the same section layout, WebView chrome, progress indicator, overlays, alerts, and debug container. A provider-copy contract preserves localized differences without parallel JSX. | Reuse one shell and pass provider copy/test identity explicitly. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:286` | OAuth browser lifecycle | abstract | Both providers used the same open, delayed native start, success collapse, retry, parent-layout sync, and external-close transitions. One owner makes cleanup and duplicate retry policy consistent. | Keep transient browser intent in the shared shell; retain provider capture state in the existing hooks. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:123` | Refresh and close icon buttons | fix | The duplicated icon-only controls had no accessible names. The shared shell can apply localized labels once for both providers. | Add `aria-label` and `title` from existing common actions copy. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:172` | Loading and error overlays | fix | Visual states existed but did not expose status/alert semantics to assistive technology. | Mark loading as `role="status"` and errors as `role="alert"`. | +| `src/features/SessionSetup/components/OAuthSessionSetupShell.tsx:391` | Two-step progress indicator | fix | Active styling was only visual. | Expose the active item with `aria-current="step"`. | +| `src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx:58` | Claude Code capture and account metadata mapping | keep with reason | Claude Code owns its callback response and optional organization metadata; generalizing this mapping would weaken provider types. | Keep mapping in the provider adapter and pass normalized capture state to the shell. | +| `src/features/SessionSetup/components/CodexSessionSetup/index.tsx:41` | Codex capture and required token mapping | keep with reason | Codex requires refresh and ID tokens and supports initial auto-start. Those are provider contract differences, not presentation variants. | Keep mapping and auto-start input in the provider adapter. | + +## Summary + +- Fix: 3 +- Keep with reason: 2 +- Abstract: 2 +- Sweep candidates: 0 +- Visual behavior: unchanged; accessibility semantics are additive. diff --git a/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx b/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx index 4e2ad6f96a..16dc82f753 100644 --- a/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx +++ b/src/features/SessionSetup/components/ClaudeCodeSessionSetup/index.tsx @@ -1,26 +1,12 @@ -import { - AlertCircle, - CheckCircle, - ChevronRight, - Loader2, - LogIn, - RefreshCw, - X, -} from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import Button from "@src/components/Button"; -import InlineAlert from "@src/components/InlineAlert"; -import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; import { useClaudeCodeOAuthCapture, useWebviewPositionSync, } from "@src/hooks/workStation/sessionCapture"; -import { - SectionContainer, - SectionRow, -} from "@src/modules/shared/layouts/SectionLayout"; + +import { OAuthSessionSetupShell } from "../OAuthSessionSetupShell"; export interface ClaudeCodeSessionValues { accessToken: string; @@ -58,7 +44,7 @@ function toClaudeCodeAccountMetadata( return Object.keys(out).length > 0 ? out : undefined; } -const ClaudeCodeSessionSetup: React.FC = ({ +export default function ClaudeCodeSessionSetup({ onSessionCaptured, onBrowserStateChange, debug = false, @@ -66,27 +52,10 @@ const ClaudeCodeSessionSetup: React.FC = ({ tokenError = null, onClearTokenError, closeSignal = 0, -}) => { +}: ClaudeCodeSessionSetupProps) { const { t } = useTranslation("integrations"); - const [showBrowser, setShowBrowser] = useState(false); const containerRef = useRef(null); - - const { - isSigningIn, - isSignedIn, - isWebviewOpen, - isWebviewLoading, - currentUrl, - authUrl, - error, - accessToken, - refreshToken, - expiresIn, - startLogin, - closeWebview, - reset, - updatePosition, - } = useClaudeCodeOAuthCapture({ + const capture = useClaudeCodeOAuthCapture({ containerRef, debug, onTokenCaptured: (response) => { @@ -99,262 +68,71 @@ const ClaudeCodeSessionSetup: React.FC = ({ }, }); - useEffect(() => { - onBrowserStateChange?.(showBrowser); - }, [showBrowser, onBrowserStateChange]); - - useEffect(() => { - if (!isWebviewOpen && isSignedIn) { - queueMicrotask(() => setShowBrowser(false)); - } - }, [isSignedIn, isWebviewOpen]); - - useEffect(() => { - if (!showBrowser || isWebviewOpen || isSigningIn) return; - - const timer = setTimeout(() => { - void startLogin(); - }, 100); - - return () => clearTimeout(timer); - }, [isSigningIn, isWebviewOpen, showBrowser, startLogin]); - - useWebviewPositionSync(containerRef, isWebviewOpen, updatePosition); - - const handleCloseBrowser = useCallback(() => { - void closeWebview(); - setShowBrowser(false); - }, [closeWebview]); - - useEffect(() => { - if (closeSignal <= 0 || !showBrowser) return; - queueMicrotask(() => handleCloseBrowser()); - }, [closeSignal, handleCloseBrowser, showBrowser]); - - const handleRetry = useCallback(() => { - reset(); - setShowBrowser(true); - void startLogin(); - }, [reset, startLogin]); + useWebviewPositionSync( + containerRef, + capture.isWebviewOpen, + capture.updatePosition + ); - const hasToken = tokenDetected || isSignedIn || Boolean(accessToken); - const displayError = error ?? tokenError; - const currentStep = hasToken ? 2 : 1; + const hasToken = + tokenDetected || capture.isSignedIn || Boolean(capture.accessToken); return ( -
- {!showBrowser ? ( - - - - - - ) : ( -
-
-
- {currentUrl || authUrl || t("keyVault.claudeCodeReadyToSignIn")} + +
+ Access Token:{" "} + {capture.accessToken + ? `${capture.accessToken.slice(0, 24)}...` + : "null"}
-
- -
-
- - - +
+ Refresh Token:{" "} + {capture.refreshToken + ? `${capture.refreshToken.slice(0, 24)}...` + : "null"}
- {!hasToken && ( - - {t("keyVault.claudeCodeBrowserHint")} - - )} -
- -
- {(isSigningIn || isWebviewLoading) && ( -
- - - {t("keyVault.loadingText")} - -
- )} - {displayError && ( -
- -
- {t("keyVault.failedToLoadBrowser")} -
-
- {displayError} -
- -
- )} - {!isWebviewOpen && !isSigningIn && !displayError && ( -
- {hasToken ? ( - - ) : ( - - )} -
- {hasToken - ? t("keyVault.claudeCodeSignedIn") - : t("keyVault.claudeCodeReadyToSignIn")} -
-
- {t("keyVault.claudeCodeOAuthHint")} -
-
- )} -
-
- )} - - {hasToken && !showBrowser && ( - - {t("keyVault.claudeCodeSignedIn")} - - )} - - {displayError && !showBrowser && ( - - {t("keyVault.claudeCodeSignInErrorHint")} - - )} - - {debug && ( -
-
- Access Token:{" "} - {accessToken ? `${accessToken.slice(0, 24)}...` : "null"} -
-
- Refresh Token:{" "} - {refreshToken ? `${refreshToken.slice(0, 24)}...` : "null"} -
-
Expires In: {expiresIn ?? "null"}
-
Is Webview Open: {String(isWebviewOpen)}
-
Current URL: {currentUrl || "null"}
-
- )} -
+
Expires In: {capture.expiresIn ?? "null"}
+
Is Webview Open: {String(capture.isWebviewOpen)}
+
Current URL: {capture.currentUrl || "null"}
+ + ) : undefined + } + /> ); -}; - -interface StepIndicatorProps { - step: number; - currentStep: number; - label: string; - completed: boolean; } - -const StepIndicator: React.FC = ({ - step, - currentStep, - label, - completed, -}) => { - const isActive = step === currentStep; - const isPast = step < currentStep || completed; - - return ( -
-
- {isPast ? : step} -
- - {label} - -
- ); -}; - -export default ClaudeCodeSessionSetup; diff --git a/src/features/SessionSetup/components/CodexSessionSetup/index.tsx b/src/features/SessionSetup/components/CodexSessionSetup/index.tsx index a16616b720..4b9b339562 100644 --- a/src/features/SessionSetup/components/CodexSessionSetup/index.tsx +++ b/src/features/SessionSetup/components/CodexSessionSetup/index.tsx @@ -1,26 +1,12 @@ -import { - AlertCircle, - CheckCircle, - ChevronRight, - Loader2, - LogIn, - RefreshCw, - X, -} from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; -import Button from "@src/components/Button"; -import InlineAlert from "@src/components/InlineAlert"; -import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; import { useCodexOAuthCapture, useWebviewPositionSync, } from "@src/hooks/workStation/sessionCapture"; -import { - SectionContainer, - SectionRow, -} from "@src/modules/shared/layouts/SectionLayout"; + +import { OAuthSessionSetupShell } from "../OAuthSessionSetupShell"; export interface CodexSessionValues { accessToken: string; @@ -40,7 +26,7 @@ export interface CodexSessionSetupProps { autoStart?: boolean; } -const CodexSessionSetup: React.FC = ({ +export default function CodexSessionSetup({ onSessionCaptured, onBrowserStateChange, debug = false, @@ -49,28 +35,10 @@ const CodexSessionSetup: React.FC = ({ onClearTokenError, closeSignal = 0, autoStart = false, -}) => { +}: CodexSessionSetupProps) { const { t } = useTranslation("integrations"); - const [showBrowser, setShowBrowser] = useState(autoStart); const containerRef = useRef(null); - - const { - isSigningIn, - isSignedIn, - isWebviewOpen, - isWebviewLoading, - currentUrl, - authUrl, - error, - accessToken, - refreshToken, - idToken, - expiresIn, - startLogin, - closeWebview, - reset, - updatePosition, - } = useCodexOAuthCapture({ + const capture = useCodexOAuthCapture({ containerRef, debug, onTokenCaptured: (response) => { @@ -83,259 +51,76 @@ const CodexSessionSetup: React.FC = ({ }, }); - useEffect(() => { - onBrowserStateChange?.(showBrowser); - }, [showBrowser, onBrowserStateChange]); - - useEffect(() => { - if (!isWebviewOpen && isSignedIn) { - queueMicrotask(() => setShowBrowser(false)); - } - }, [isSignedIn, isWebviewOpen]); - - useEffect(() => { - if (!showBrowser || isWebviewOpen || isSigningIn) return; - - const timer = setTimeout(() => { - void startLogin(); - }, 100); - - return () => clearTimeout(timer); - }, [isSigningIn, isWebviewOpen, showBrowser, startLogin]); - - useWebviewPositionSync(containerRef, isWebviewOpen, updatePosition); - - const handleCloseBrowser = useCallback(() => { - void closeWebview(); - setShowBrowser(false); - }, [closeWebview]); - - useEffect(() => { - if (closeSignal <= 0 || !showBrowser) return; - queueMicrotask(() => handleCloseBrowser()); - }, [closeSignal, handleCloseBrowser, showBrowser]); - - const handleRetry = useCallback(() => { - reset(); - setShowBrowser(true); - void startLogin(); - }, [reset, startLogin]); + useWebviewPositionSync( + containerRef, + capture.isWebviewOpen, + capture.updatePosition + ); - const hasToken = tokenDetected || isSignedIn || Boolean(accessToken); - const displayError = error ?? tokenError; - const currentStep = hasToken ? 2 : 1; + const hasToken = + tokenDetected || capture.isSignedIn || Boolean(capture.accessToken); return ( -
- {!showBrowser ? ( - - - - - - ) : ( -
-
-
- {currentUrl || authUrl || t("keyVault.codexReadyToSignIn")} + +
+ Access Token:{" "} + {capture.accessToken + ? `${capture.accessToken.slice(0, 24)}...` + : "null"}
-
- -
-
- - - +
+ Refresh Token:{" "} + {capture.refreshToken + ? `${capture.refreshToken.slice(0, 24)}...` + : "null"}
- {!hasToken && ( - - {t("keyVault.codexBrowserHint")} - - )} -
- -
- {(isSigningIn || isWebviewLoading) && ( -
- - - {t("keyVault.loadingText")} - -
- )} - {displayError && ( -
- -
- {t("keyVault.failedToLoadBrowser")} -
-
- {displayError} -
- -
- )} - {!isWebviewOpen && !isSigningIn && !displayError && ( -
- {hasToken ? ( - - ) : ( - - )} -
- {hasToken - ? t("keyVault.codexSignedIn") - : t("keyVault.codexReadyToSignIn")} -
-
- {t("keyVault.codexOAuthHint")} -
-
- )} -
-
- )} - - {hasToken && !showBrowser && ( - {t("keyVault.codexSignedIn")} - )} - - {displayError && !showBrowser && ( - - {t("keyVault.codexSignInErrorHint")} - - )} - - {debug && ( -
-
- Access Token:{" "} - {accessToken ? `${accessToken.slice(0, 24)}...` : "null"} -
-
- Refresh Token:{" "} - {refreshToken ? `${refreshToken.slice(0, 24)}...` : "null"} -
-
Id Token: {idToken ? `${idToken.slice(0, 24)}...` : "null"}
-
Expires In: {expiresIn ?? "null"}
-
Is Webview Open: {String(isWebviewOpen)}
-
Current URL: {currentUrl || "null"}
-
- )} -
+
+ Id Token:{" "} + {capture.idToken ? `${capture.idToken.slice(0, 24)}...` : "null"} +
+
Expires In: {capture.expiresIn ?? "null"}
+
Is Webview Open: {String(capture.isWebviewOpen)}
+
Current URL: {capture.currentUrl || "null"}
+ + ) : undefined + } + /> ); -}; - -interface StepIndicatorProps { - step: number; - currentStep: number; - label: string; - completed: boolean; } - -const StepIndicator: React.FC = ({ - step, - currentStep, - label, - completed, -}) => { - const isActive = step === currentStep; - const isPast = step < currentStep || completed; - - return ( -
-
- {isPast ? : step} -
- - {label} - -
- ); -}; - -export default CodexSessionSetup; diff --git a/src/features/SessionSetup/components/OAuthSessionSetupShell.test.ts b/src/features/SessionSetup/components/OAuthSessionSetupShell.test.ts new file mode 100644 index 0000000000..34b012f189 --- /dev/null +++ b/src/features/SessionSetup/components/OAuthSessionSetupShell.test.ts @@ -0,0 +1,119 @@ +import { createElement, createRef } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { + type OAuthSessionSetupCopy, + OAuthSessionSetupView, + shouldCollapseOAuthBrowser, + shouldHandleOAuthCloseSignal, + shouldStartOAuthLogin, +} from "./OAuthSessionSetupShell"; + +const copy: OAuthSessionSetupCopy = { + signInTitle: "Sign in", + signInDescription: "Connect your account", + signInButton: "Continue", + signedInTitle: "Connected", + signedInStatus: "Signed in", + loginStep: "Login", + browserHint: "Complete login in the browser", + readyTitle: "Ready to sign in", + oauthHint: "The browser will open here", + loading: "Loading", + failedToLoadBrowser: "Browser failed", + retry: "Retry", + close: "Close", + errorHint: "Try signing in again", +}; + +function renderView( + overrides: Partial[0]> = {} +) { + return renderToStaticMarkup( + createElement(OAuthSessionSetupView, { + providerId: "provider", + containerRef: createRef(), + showBrowser: false, + hasToken: false, + isSigningIn: false, + isWebviewOpen: false, + isWebviewLoading: false, + currentUrl: "", + authUrl: null, + displayError: null, + copy, + onOpenBrowser: vi.fn(), + onCloseBrowser: vi.fn(), + onRetry: vi.fn(), + ...overrides, + }) + ); +} + +describe("OAuthSessionSetupView", () => { + it("renders the idle sign-in action", () => { + const markup = renderView(); + + expect(markup).toContain('data-testid="provider-session-setup"'); + expect(markup).toContain('data-testid="provider-oauth-signin"'); + expect(markup).toContain("Continue"); + expect(markup).not.toContain("provider-oauth-browser-shell"); + }); + + it("keeps the browser shell visible while login is loading", () => { + const markup = renderView({ + showBrowser: true, + isSigningIn: true, + currentUrl: "https://example.com/login", + }); + + expect(markup).toContain('data-testid="provider-oauth-browser-shell"'); + expect(markup).toContain("https://example.com/login"); + expect(markup).toContain('role="status"'); + expect(markup).toContain('aria-label="Retry"'); + expect(markup).toContain('aria-label="Close"'); + expect(markup).toContain('aria-current="step"'); + }); + + it("renders an in-place browser error with recovery", () => { + const markup = renderView({ + showBrowser: true, + displayError: "network unavailable", + }); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Browser failed"); + expect(markup).toContain("network unavailable"); + expect(markup).toContain("Retry"); + }); + + it("renders the provider success state after the browser closes", () => { + const markup = renderView({ hasToken: true }); + + expect(markup).toContain("Connected"); + expect(markup).toContain("Signed in"); + expect(markup).not.toContain("provider-oauth-browser-shell"); + }); +}); + +describe("OAuth session setup lifecycle", () => { + it("starts only for a requested browser with no active login or webview", () => { + expect(shouldStartOAuthLogin(true, false, false)).toBe(true); + expect(shouldStartOAuthLogin(false, false, false)).toBe(false); + expect(shouldStartOAuthLogin(true, true, false)).toBe(false); + expect(shouldStartOAuthLogin(true, false, true)).toBe(false); + }); + + it("collapses only after sign-in has completed and the webview closed", () => { + expect(shouldCollapseOAuthBrowser(false, true)).toBe(true); + expect(shouldCollapseOAuthBrowser(true, true)).toBe(false); + expect(shouldCollapseOAuthBrowser(false, false)).toBe(false); + }); + + it("honors an external close signal only while the browser is visible", () => { + expect(shouldHandleOAuthCloseSignal(1, true)).toBe(true); + expect(shouldHandleOAuthCloseSignal(0, true)).toBe(false); + expect(shouldHandleOAuthCloseSignal(1, false)).toBe(false); + }); +}); diff --git a/src/features/SessionSetup/components/OAuthSessionSetupShell.tsx b/src/features/SessionSetup/components/OAuthSessionSetupShell.tsx new file mode 100644 index 0000000000..43c4257275 --- /dev/null +++ b/src/features/SessionSetup/components/OAuthSessionSetupShell.tsx @@ -0,0 +1,421 @@ +import { + AlertCircle, + CheckCircle, + ChevronRight, + Loader2, + LogIn, + RefreshCw, + X, +} from "lucide-react"; +import { + type ReactNode, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import Button from "@src/components/Button"; +import InlineAlert from "@src/components/InlineAlert"; +import { SPINNER_TOKENS } from "@src/config/spinnerTokens"; +import { + SectionContainer, + SectionRow, +} from "@src/modules/shared/layouts/SectionLayout"; + +export interface OAuthSessionSetupCopy { + signInTitle: string; + signInDescription: string; + signInButton: string; + signedInTitle: string; + signedInStatus: string; + loginStep: string; + browserHint: string; + readyTitle: string; + oauthHint: string; + loading: string; + failedToLoadBrowser: string; + retry: string; + close: string; + errorHint: string; +} + +interface OAuthSessionSetupViewProps { + providerId: string; + containerRef: RefObject; + showBrowser: boolean; + hasToken: boolean; + isSigningIn: boolean; + isWebviewOpen: boolean; + isWebviewLoading: boolean; + currentUrl: string; + authUrl: string | null; + displayError: string | null; + copy: OAuthSessionSetupCopy; + onOpenBrowser: () => void; + onCloseBrowser: () => void; + onRetry: () => void; + onDismissError?: () => void; + debugContent?: ReactNode; +} + +export function OAuthSessionSetupView({ + providerId, + containerRef, + showBrowser, + hasToken, + isSigningIn, + isWebviewOpen, + isWebviewLoading, + currentUrl, + authUrl, + displayError, + copy, + onOpenBrowser, + onCloseBrowser, + onRetry, + onDismissError, + debugContent, +}: OAuthSessionSetupViewProps) { + const currentStep = hasToken ? 2 : 1; + + return ( +
+ {!showBrowser ? ( + + + + + + ) : ( +
+
+
+ {currentUrl || authUrl || copy.readyTitle} +
+
+ +
+
+ + + +
+ {!hasToken && ( + + {copy.browserHint} + + )} +
+ +
+ {(isSigningIn || isWebviewLoading) && ( +
+ + {copy.loading} +
+ )} + {displayError && ( +
+ +
+ {copy.failedToLoadBrowser} +
+
+ {displayError} +
+ +
+ )} + {!isWebviewOpen && !isSigningIn && !displayError && ( +
+ {hasToken ? ( + + ) : ( + + )} +
+ {hasToken ? copy.signedInTitle : copy.readyTitle} +
+
+ {copy.oauthHint} +
+
+ )} +
+
+ )} + + {hasToken && !showBrowser && ( + {copy.signedInTitle} + )} + + {displayError && !showBrowser && ( + + {copy.errorHint} + + )} + + {debugContent && ( +
+ {debugContent} +
+ )} +
+ ); +} + +interface OAuthSessionSetupShellProps extends Omit< + OAuthSessionSetupViewProps, + | "showBrowser" + | "displayError" + | "onOpenBrowser" + | "onCloseBrowser" + | "onRetry" + | "onDismissError" +> { + isSignedIn: boolean; + captureError: string | null; + tokenError?: string | null; + onClearTokenError?: () => void; + onBrowserStateChange?: (isOpen: boolean) => void; + closeSignal?: number; + initiallyOpen?: boolean; + startLogin: () => Promise; + closeWebview: () => Promise; + reset: () => void; +} + +export function shouldCollapseOAuthBrowser( + isWebviewOpen: boolean, + isSignedIn: boolean +): boolean { + return !isWebviewOpen && isSignedIn; +} + +export function shouldStartOAuthLogin( + showBrowser: boolean, + isWebviewOpen: boolean, + isSigningIn: boolean +): boolean { + return showBrowser && !isWebviewOpen && !isSigningIn; +} + +export function shouldHandleOAuthCloseSignal( + closeSignal: number, + showBrowser: boolean +): boolean { + return closeSignal > 0 && showBrowser; +} + +export function OAuthSessionSetupShell({ + isSignedIn, + captureError, + tokenError = null, + onClearTokenError, + onBrowserStateChange, + closeSignal = 0, + initiallyOpen = false, + startLogin, + closeWebview, + reset, + ...viewProps +}: OAuthSessionSetupShellProps) { + const [showBrowser, setShowBrowser] = useState(initiallyOpen); + const showBrowserRef = useRef(initiallyOpen); + const retryInFlightRef = useRef(false); + + const setBrowserVisibility = useCallback( + (isOpen: boolean) => { + if (showBrowserRef.current === isOpen) return; + showBrowserRef.current = isOpen; + setShowBrowser(isOpen); + onBrowserStateChange?.(isOpen); + }, + [onBrowserStateChange] + ); + + // Synchronize the owning wizard layout on initial auto-start and whenever + // its callback identity changes. All later visibility transitions notify at + // their originating event or native WebView transition. + useEffect(() => { + onBrowserStateChange?.(showBrowserRef.current); + }, [onBrowserStateChange]); + + useEffect(() => { + if (shouldCollapseOAuthBrowser(viewProps.isWebviewOpen, isSignedIn)) { + queueMicrotask(() => setBrowserVisibility(false)); + } + }, [isSignedIn, setBrowserVisibility, viewProps.isWebviewOpen]); + + useEffect(() => { + if ( + !shouldStartOAuthLogin( + showBrowser, + viewProps.isWebviewOpen, + viewProps.isSigningIn + ) + ) { + return; + } + + const timer = setTimeout(() => { + void startLogin(); + }, 100); + + return () => clearTimeout(timer); + }, [startLogin, showBrowser, viewProps.isSigningIn, viewProps.isWebviewOpen]); + + const handleCloseBrowser = useCallback(() => { + if (!showBrowserRef.current) return; + void closeWebview(); + setBrowserVisibility(false); + }, [closeWebview, setBrowserVisibility]); + + useEffect(() => { + if (!shouldHandleOAuthCloseSignal(closeSignal, showBrowser)) return; + queueMicrotask(() => handleCloseBrowser()); + }, [closeSignal, handleCloseBrowser, showBrowser]); + + const handleRetry = useCallback(() => { + if (retryInFlightRef.current) return; + retryInFlightRef.current = true; + reset(); + setBrowserVisibility(true); + void startLogin().finally(() => { + retryInFlightRef.current = false; + }); + }, [reset, setBrowserVisibility, startLogin]); + + const displayError = captureError ?? tokenError; + + return ( + setBrowserVisibility(true)} + onCloseBrowser={handleCloseBrowser} + onRetry={handleRetry} + onDismissError={captureError ? reset : onClearTokenError} + /> + ); +} + +interface StepIndicatorProps { + step: number; + currentStep: number; + label: string; + completed: boolean; +} + +function StepIndicator({ + step, + currentStep, + label, + completed, +}: StepIndicatorProps) { + const isActive = step === currentStep; + const isPast = step < currentStep || completed; + + return ( +
+
+ {isPast ? : step} +
+ + {label} + +
+ ); +}