diff --git a/.changeset/guard-prime-before-init.md b/.changeset/guard-prime-before-init.md new file mode 100644 index 0000000..e402c66 --- /dev/null +++ b/.changeset/guard-prime-before-init.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": patch +--- + +Disable the consent action while the handoff exchange and initial session state load, preventing early interactions from being dropped. diff --git a/packages/managed-auth-react/src/KernelManagedAuth.test.tsx b/packages/managed-auth-react/src/KernelManagedAuth.test.tsx new file mode 100644 index 0000000..0a92e78 --- /dev/null +++ b/packages/managed-auth-react/src/KernelManagedAuth.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { KernelManagedAuth } from "./KernelManagedAuth"; + +let renderer: ReactTestRenderer | null = null; + +afterEach(() => { + renderer?.unmount(); + renderer = null; +}); + +function pendingExchangeFetch(): typeof fetch { + const pendingExchange = new Promise(() => {}); + return (async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = String(input); + if (url.endsWith("/exchange")) return pendingExchange; + throw new Error(`Unexpected request: ${init?.method} ${url}`); + }) as typeof fetch; +} + +describe("KernelManagedAuth initialization", () => { + test("disables the consent action while the session initializes", () => { + act(() => { + renderer = create( + createElement(KernelManagedAuth, { + sessionId: "session-id", + handoffCode: "handoff-code", + fetch: pendingExchangeFetch(), + }), + ); + }); + + const output = JSON.stringify(renderer!.toJSON()); + const button = renderer!.root.findByType("button"); + expect(output).toContain("Preparing secure sign-in"); + expect(output).not.toContain("Sign in to "); + expect(button.props.disabled).toBe(true); + expect(button.children).toEqual(["Loading..."]); + }); + + test("does not show consent while a skip-prime session initializes", () => { + act(() => { + renderer = create( + createElement(KernelManagedAuth, { + sessionId: "session-id", + handoffCode: "handoff-code", + fetch: pendingExchangeFetch(), + appearance: { layout: { skipPrimeStep: true } }, + }), + ); + }); + + const output = JSON.stringify(renderer!.toJSON()); + expect(output).toContain("Discovering login requirements..."); + expect(renderer!.root.findAllByType("button")).toHaveLength(0); + }); +}); diff --git a/packages/managed-auth-react/src/KernelManagedAuth.tsx b/packages/managed-auth-react/src/KernelManagedAuth.tsx index 8be1d02..7ba9df4 100644 --- a/packages/managed-auth-react/src/KernelManagedAuth.tsx +++ b/packages/managed-auth-react/src/KernelManagedAuth.tsx @@ -74,6 +74,7 @@ function KernelManagedAuthInner({ const { state, uiState, + isInitializing, submitError, initError, isSubmitting, @@ -91,7 +92,7 @@ function KernelManagedAuthInner({ ); diff --git a/packages/managed-auth-react/src/components/StepPrime.tsx b/packages/managed-auth-react/src/components/StepPrime.tsx index ae407dd..50cccd7 100644 --- a/packages/managed-auth-react/src/components/StepPrime.tsx +++ b/packages/managed-auth-react/src/components/StepPrime.tsx @@ -30,6 +30,13 @@ export function StepPrime({ const displayName = primaryLabel.charAt(0).toUpperCase() + primaryLabel.slice(1); + const isInitializing = isLoading && !targetDomain; + const title = isInitializing + ? l.primeLoadingTitle + : l.primeTitle(displayName); + const subtitle = isInitializing + ? l.primeLoadingSubtitle + : l.primeSubtitle(siteName); const showSecurityCard = layout?.showSecurityCard !== false; const showLegalText = layout?.showLegalText !== false; @@ -40,8 +47,8 @@ export function StepPrime({
-

{l.primeTitle(displayName)}

-

{l.primeSubtitle(siteName)}

+

{title}

+

{subtitle}

{showSecurityCard && ( diff --git a/packages/managed-auth-react/src/localization/defaults.ts b/packages/managed-auth-react/src/localization/defaults.ts index 9dddebc..96d759f 100644 --- a/packages/managed-auth-react/src/localization/defaults.ts +++ b/packages/managed-auth-react/src/localization/defaults.ts @@ -5,6 +5,8 @@ export const DEFAULT_LOCALIZATION: Localizer = { primeSubtitle: (site) => `Enter your ${site} credentials to continue`, primeContinueButton: "Continue", primeLoadingButton: "Loading...", + primeLoadingTitle: "Preparing secure sign-in", + primeLoadingSubtitle: "Loading connection details...", securityEncryption: "Your credentials are encrypted end-to-end", // Matches the second sentence of `credentialSafetyNotice` so the consent // step and the form footer make the same promise verbatim. diff --git a/packages/managed-auth-react/src/localization/types.ts b/packages/managed-auth-react/src/localization/types.ts index 73d28a7..a7a6007 100644 --- a/packages/managed-auth-react/src/localization/types.ts +++ b/packages/managed-auth-react/src/localization/types.ts @@ -10,6 +10,8 @@ export interface Localization { primeSubtitle?: (siteName: string) => string; primeContinueButton?: string; primeLoadingButton?: string; + primeLoadingTitle?: string; + primeLoadingSubtitle?: string; securityEncryption?: string; securityNoThirdParty?: string; legalPrefix?: string; diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts index def4b85..e78c5f4 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts @@ -140,6 +140,71 @@ async function renderSession( }; } +describe("useManagedAuthSession initialization", () => { + test("reports initialization until the session is ready", async () => { + const exchange = deferred(); + let value: ManagedAuthSessionValue | null = null; + + const fetchImpl = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const url = String(input); + if (url.endsWith("/exchange")) return exchange.promise; + if (init?.method === "GET") return response(awaitingInputState()); + throw new Error(`Unexpected request: ${init?.method} ${url}`); + }) as typeof fetch; + + function Harness() { + value = useManagedAuthSession({ + sessionId: "session-id", + handoffCode: "handoff-code", + fetch: fetchImpl, + }); + return null; + } + + act(() => { + renderer = create(createElement(Harness)); + }); + + expect(value!.uiState).toBe("prime"); + expect(value!.isInitializing).toBe(true); + + await act(async () => { + exchange.resolve(response({ jwt: "jwt" })); + await flushPromises(); + }); + + expect(value!.uiState).toBe("prime"); + expect(value!.isInitializing).toBe(false); + }); + + test("leaves initialization when the handoff exchange fails", async () => { + let value: ManagedAuthSessionValue | null = null; + const fetchImpl = (async (_input: RequestInfo | URL, _init?: RequestInit) => + response({ message: "Invalid handoff" }, 401)) as typeof fetch; + + function Harness() { + value = useManagedAuthSession({ + sessionId: "session-id", + handoffCode: "handoff-code", + fetch: fetchImpl, + }); + return null; + } + + await act(async () => { + renderer = create(createElement(Harness)); + await flushPromises(); + }); + + expect(value!.uiState).toBe("error"); + expect(value!.isInitializing).toBe(false); + expect(value!.initError).toBe("Invalid handoff"); + }); +}); + describe("useManagedAuthSession stale interaction recovery", () => { test("does not reconnect after the session is unmounted", async () => { const refresh = deferred(); diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.ts index a6cc575..238455d 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.ts @@ -64,6 +64,7 @@ export interface ManagedAuthSessionOptions extends ApiClientOptions { export interface ManagedAuthSessionValue { state: ManagedAuthResponse | null; uiState: UIState; + isInitializing: boolean; isSubmitting: boolean; isReconnecting: boolean; submitError: string | null; @@ -86,7 +87,10 @@ export function useManagedAuthSession( const [jwt, setJwt] = useState(null); const [state, setState] = useState(null); - const [uiState, setUIState] = useState("prime"); + const [uiState, setUIState] = useState( + autoStart ? "discovering" : "prime", + ); + const [isInitializing, setIsInitializing] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -308,7 +312,15 @@ export function useManagedAuthSession( terminalRef.current = false; reconnectAttemptsRef.current = 0; callbackFiredRef.current = { success: false, error: false }; + stateRef.current = null; + setJwt(null); + setState(null); + setUIState(autoStart ? "discovering" : "prime"); + setIsInitializing(true); setIsSubmitting(false); + setIsReconnecting(false); + setSubmitError(null); + setInitError(null); const ref = { key: exchangeKey, active: true }; exchangeRef.current = ref; @@ -328,6 +340,7 @@ export function useManagedAuthSession( if (exchangeRef.current !== ref || !ref.active) return; stateRef.current = initial; setState(initial); + setIsInitializing(false); const derived = deriveUIState(initial); if (isTerminal(derived)) { terminalRef.current = true; @@ -356,6 +369,7 @@ export function useManagedAuthSession( if (exchangeRef.current !== ref || !ref.active) return; const message = err instanceof Error ? err.message : "Failed to start session"; + setIsInitializing(false); setInitError(message); setUIState("error"); terminalRef.current = true; @@ -530,6 +544,7 @@ export function useManagedAuthSession( return { state, uiState, + isInitializing, isSubmitting, isReconnecting, submitError,