From 8a20c5a97946dc76241587f35e81c639c49ca5a0 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:27:48 +0000 Subject: [PATCH 1/5] Guard consent until session initialization --- .changeset/guard-prime-before-init.md | 5 +++ .../src/session/useManagedAuthSession.test.ts | 39 +++++++++++++++++++ .../src/session/useManagedAuthSession.ts | 9 ++++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/guard-prime-before-init.md diff --git a/.changeset/guard-prime-before-init.md b/.changeset/guard-prime-before-init.md new file mode 100644 index 0000000..d3282e4 --- /dev/null +++ b/.changeset/guard-prime-before-init.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": patch +--- + +Keep the consent action disabled until the handoff exchange and initial session state have loaded, preventing early clicks from being dropped. diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts index def4b85..889edf2 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts @@ -140,6 +140,45 @@ async function renderSession( }; } +describe("useManagedAuthSession initialization", () => { + test("does not show the consent step before 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("discovering"); + + await act(async () => { + exchange.resolve(response({ jwt: "jwt" })); + await flushPromises(); + }); + + expect(value!.uiState).toBe("prime"); + }); +}); + 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..fee20a3 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.ts @@ -86,7 +86,7 @@ export function useManagedAuthSession( const [jwt, setJwt] = useState(null); const [state, setState] = useState(null); - const [uiState, setUIState] = useState("prime"); + const [uiState, setUIState] = useState("discovering"); const [isSubmitting, setIsSubmitting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -308,7 +308,14 @@ export function useManagedAuthSession( terminalRef.current = false; reconnectAttemptsRef.current = 0; callbackFiredRef.current = { success: false, error: false }; + stateRef.current = null; + setJwt(null); + setState(null); + setUIState("discovering"); setIsSubmitting(false); + setIsReconnecting(false); + setSubmitError(null); + setInitError(null); const ref = { key: exchangeKey, active: true }; exchangeRef.current = ref; From 5051e9ff4c6b7472c4187bc7afabd9d274a51fe8 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:56:22 +0000 Subject: [PATCH 2/5] Show neutral session initialization --- .changeset/guard-prime-before-init.md | 2 +- .../src/KernelManagedAuth.test.tsx | 64 +++++++++++++++++++ .../src/KernelManagedAuth.tsx | 7 ++ .../src/components/LoadingState.tsx | 48 +++++++++----- .../src/localization/defaults.ts | 2 + .../src/localization/types.ts | 3 + .../src/session/useManagedAuthSession.test.ts | 30 ++++++++- .../src/session/useManagedAuthSession.ts | 10 ++- 8 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 packages/managed-auth-react/src/KernelManagedAuth.test.tsx diff --git a/.changeset/guard-prime-before-init.md b/.changeset/guard-prime-before-init.md index d3282e4..30bafa7 100644 --- a/.changeset/guard-prime-before-init.md +++ b/.changeset/guard-prime-before-init.md @@ -2,4 +2,4 @@ "@onkernel/managed-auth-react": patch --- -Keep the consent action disabled until the handoff exchange and initial session state have loaded, preventing early clicks from being dropped. +Show a distinct initialization state until the handoff exchange and initial session state have loaded, preventing early consent 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..9e59d06 --- /dev/null +++ b/packages/managed-auth-react/src/KernelManagedAuth.test.tsx @@ -0,0 +1,64 @@ +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 deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function response(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("KernelManagedAuth initialization", () => { + test("shows a neutral initialization state before consent", async () => { + const exchange = deferred(); + 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({ + domain: "example.com", + profile_name: "example-profile", + flow_status: "IN_PROGRESS", + flow_step: "DISCOVERING", + flow_type: "LOGIN", + }); + } + throw new Error(`Unexpected request: ${init?.method} ${url}`); + }) as typeof fetch; + + act(() => { + renderer = create( + createElement(KernelManagedAuth, { + sessionId: "session-id", + handoffCode: "handoff-code", + fetch: fetchImpl, + }), + ); + }); + + const initializing = JSON.stringify(renderer!.toJSON()); + expect(initializing).toContain("Preparing secure sign-in..."); + expect(initializing).not.toContain("Discovering login requirements..."); + expect(initializing).not.toContain("Continue"); + }); +}); diff --git a/packages/managed-auth-react/src/KernelManagedAuth.tsx b/packages/managed-auth-react/src/KernelManagedAuth.tsx index 8be1d02..fc8b3a8 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, @@ -86,6 +87,12 @@ function KernelManagedAuthInner({ const targetDomain = useMemo(() => state?.domain ?? "", [state?.domain]); + if (isInitializing) { + return ( + + ); + } + if (uiState === "prime") { return ( ReactNode> = + [LockIcon]; const DISCOVERY_ICONS: Array<(props: { className?: string }) => ReactNode> = [ GlobeIcon, SearchIcon, @@ -36,13 +38,23 @@ export function LoadingState({ const l = useLocalization(); const [currentStep, setCurrentStep] = useState(0); - const steps = - variant === "discovering" ? l.loadingDiscoverySteps : l.loadingAuthSteps; - const icons = variant === "discovering" ? DISCOVERY_ICONS : AUTH_ICONS; + let steps: string[]; + let icons: Array<(props: { className?: string }) => ReactNode>; + if (variant === "initializing") { + steps = [l.initializingStep]; + icons = INITIALIZING_ICONS; + } else if (variant === "discovering") { + steps = l.loadingDiscoverySteps; + icons = DISCOVERY_ICONS; + } else { + steps = l.loadingAuthSteps; + icons = AUTH_ICONS; + } const stepCount = Math.min(steps.length, icons.length); useEffect(() => { setCurrentStep(0); + if (stepCount <= 1) return; const id = setInterval(() => { setCurrentStep((prev) => (prev < stepCount - 1 ? prev + 1 : prev)); }, STEP_INTERVAL_MS); @@ -69,19 +81,23 @@ export function LoadingState({

{steps[currentStep]}

- + {stepCount > 1 && ( + + )} -

{l.loadingTimeHint}

+ {variant !== "initializing" && ( +

{l.loadingTimeHint}

+ )} ); } diff --git a/packages/managed-auth-react/src/localization/defaults.ts b/packages/managed-auth-react/src/localization/defaults.ts index 9dddebc..c7001d9 100644 --- a/packages/managed-auth-react/src/localization/defaults.ts +++ b/packages/managed-auth-react/src/localization/defaults.ts @@ -13,6 +13,8 @@ export const DEFAULT_LOCALIZATION: Localizer = { legalPrivacyPolicy: "Privacy Policy", legalTermsOfService: "Terms of Service", legalConjunction: "and", + initializingMessage: "Preparing secure sign-in...", + initializingStep: "Establishing a secure connection...", discoveringMessage: "Discovering login requirements...", waitingForFormMessage: "Waiting for login form...", submittingMessage: "Signing in...", diff --git a/packages/managed-auth-react/src/localization/types.ts b/packages/managed-auth-react/src/localization/types.ts index 73d28a7..6b7d484 100644 --- a/packages/managed-auth-react/src/localization/types.ts +++ b/packages/managed-auth-react/src/localization/types.ts @@ -16,6 +16,9 @@ export interface Localization { legalPrivacyPolicy?: string; legalTermsOfService?: string; legalConjunction?: string; + /** Session initialization. */ + initializingMessage?: string; + initializingStep?: string; /** Discovery / loading. */ discoveringMessage?: string; waitingForFormMessage?: string; diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts index 889edf2..8813143 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts @@ -141,7 +141,7 @@ async function renderSession( } describe("useManagedAuthSession initialization", () => { - test("does not show the consent step before the session is ready", async () => { + test("reports initialization until the session is ready", async () => { const exchange = deferred(); let value: ManagedAuthSessionValue | null = null; @@ -168,7 +168,8 @@ describe("useManagedAuthSession initialization", () => { renderer = create(createElement(Harness)); }); - expect(value!.uiState).toBe("discovering"); + expect(value!.uiState).toBe("prime"); + expect(value!.isInitializing).toBe(true); await act(async () => { exchange.resolve(response({ jwt: "jwt" })); @@ -176,6 +177,31 @@ describe("useManagedAuthSession initialization", () => { }); 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!.isInitializing).toBe(false); + expect(value!.uiState).toBe("error"); + expect(value!.initError).toBe("Invalid handoff"); }); }); diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.ts index fee20a3..4b071cf 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,8 @@ export function useManagedAuthSession( const [jwt, setJwt] = useState(null); const [state, setState] = useState(null); - const [uiState, setUIState] = useState("discovering"); + const [uiState, setUIState] = useState("prime"); + const [isInitializing, setIsInitializing] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -311,7 +313,8 @@ export function useManagedAuthSession( stateRef.current = null; setJwt(null); setState(null); - setUIState("discovering"); + setUIState("prime"); + setIsInitializing(true); setIsSubmitting(false); setIsReconnecting(false); setSubmitError(null); @@ -335,6 +338,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; @@ -363,6 +367,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; @@ -537,6 +542,7 @@ export function useManagedAuthSession( return { state, uiState, + isInitializing, isSubmitting, isReconnecting, submitError, From b2d54871cbaeefd5bf540cb7b1bc5509b503cf8e Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:00:39 +0000 Subject: [PATCH 3/5] Model session initialization explicitly --- .changeset/guard-prime-before-init.md | 2 +- packages/demo/README.md | 2 +- packages/demo/src/Appearances.tsx | 9 +++++++++ packages/demo/src/States.tsx | 9 +++++++++ packages/managed-auth-react/src/KernelManagedAuth.tsx | 3 +-- packages/managed-auth-react/src/lib/types.ts | 1 + .../src/session/useManagedAuthSession.test.ts | 5 +---- .../src/session/useManagedAuthSession.ts | 10 ++-------- 8 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.changeset/guard-prime-before-init.md b/.changeset/guard-prime-before-init.md index 30bafa7..65fb35e 100644 --- a/.changeset/guard-prime-before-init.md +++ b/.changeset/guard-prime-before-init.md @@ -2,4 +2,4 @@ "@onkernel/managed-auth-react": patch --- -Show a distinct initialization state until the handoff exchange and initial session state have loaded, preventing early consent interactions from being dropped. +Add an initialization UI state while the handoff exchange and initial session state load, preventing early consent interactions from being dropped. diff --git a/packages/demo/README.md b/packages/demo/README.md index 8e3f445..6635ae8 100644 --- a/packages/demo/README.md +++ b/packages/demo/README.md @@ -2,7 +2,7 @@ Local-only Vite app for previewing [`@onkernel/managed-auth-react`](../managed-auth-react). Two tabs: -- **States** — every UI state in the package (prime, discovering, awaiting input + SSO + MFA + sign-in options, external action, success, expired, error) rendered against the default theme. Use the top-right state picker to scrub. +- **States** — every UI state in the package (initializing, prime, discovering, awaiting input + SSO + MFA + sign-in options, external action, success, expired, error) rendered against the default theme. Use the top-right state picker to scrub. - **Appearances** — eight worked customization variants (default + 7 brand-inspired: Linear, Vercel, Stripe, Notion, Anthropic, Supabase, Kernel brand). Each variant renders the same step in lockstep so you can audit how every brand handles every screen. The auth flow is rendered headless (no real Kernel API calls); we mount the package's step components directly so you can iterate on styling without needing a live session. diff --git a/packages/demo/src/Appearances.tsx b/packages/demo/src/Appearances.tsx index 91a3e30..d9bb05a 100644 --- a/packages/demo/src/Appearances.tsx +++ b/packages/demo/src/Appearances.tsx @@ -60,6 +60,7 @@ const mockSignInOptions: SignInOption[] = [ ]; type Step = + | "initializing" | "prime" | "discovering" | "awaiting_input" @@ -73,6 +74,7 @@ type Step = | "error"; const allSteps: Step[] = [ + "initializing", "prime", "discovering", "awaiting_input", @@ -88,6 +90,13 @@ const allSteps: Step[] = [ function renderStep(step: Step) { switch (step) { + case "initializing": + return ( + + ); case "prime": return {}} />; case "discovering": diff --git a/packages/demo/src/States.tsx b/packages/demo/src/States.tsx index 39fc0d1..083f3e8 100644 --- a/packages/demo/src/States.tsx +++ b/packages/demo/src/States.tsx @@ -71,6 +71,7 @@ const mockSignInOptions: SignInOption[] = [ // Local superset of the package's UIState — adds explicit multi-section // screens so we can scrub through every meaningful permutation in one place. type StateName = + | "initializing" | "prime" | "discovering" | "awaiting_input" @@ -85,6 +86,7 @@ type StateName = | "error"; const allStates: StateName[] = [ + "initializing", "prime", "discovering", "awaiting_input", @@ -110,6 +112,13 @@ export function States() { const renderState = () => { switch (currentState) { + case "initializing": + return ( + + ); case "prime": return ( state?.domain ?? "", [state?.domain]); - if (isInitializing) { + if (uiState === "initializing") { return ( ); diff --git a/packages/managed-auth-react/src/lib/types.ts b/packages/managed-auth-react/src/lib/types.ts index 01fb154..d16bbca 100644 --- a/packages/managed-auth-react/src/lib/types.ts +++ b/packages/managed-auth-react/src/lib/types.ts @@ -147,6 +147,7 @@ export interface ManagedAuthResponse { } export type UIState = + | "initializing" | "prime" | "discovering" | "awaiting_input" diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts index 8813143..a2bdf5c 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts @@ -168,8 +168,7 @@ describe("useManagedAuthSession initialization", () => { renderer = create(createElement(Harness)); }); - expect(value!.uiState).toBe("prime"); - expect(value!.isInitializing).toBe(true); + expect(value!.uiState).toBe("initializing"); await act(async () => { exchange.resolve(response({ jwt: "jwt" })); @@ -177,7 +176,6 @@ describe("useManagedAuthSession initialization", () => { }); expect(value!.uiState).toBe("prime"); - expect(value!.isInitializing).toBe(false); }); test("leaves initialization when the handoff exchange fails", async () => { @@ -199,7 +197,6 @@ describe("useManagedAuthSession initialization", () => { await flushPromises(); }); - expect(value!.isInitializing).toBe(false); expect(value!.uiState).toBe("error"); expect(value!.initError).toBe("Invalid handoff"); }); diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.ts index 4b071cf..c672ea3 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.ts @@ -64,7 +64,6 @@ export interface ManagedAuthSessionOptions extends ApiClientOptions { export interface ManagedAuthSessionValue { state: ManagedAuthResponse | null; uiState: UIState; - isInitializing: boolean; isSubmitting: boolean; isReconnecting: boolean; submitError: string | null; @@ -87,8 +86,7 @@ export function useManagedAuthSession( const [jwt, setJwt] = useState(null); const [state, setState] = useState(null); - const [uiState, setUIState] = useState("prime"); - const [isInitializing, setIsInitializing] = useState(true); + const [uiState, setUIState] = useState("initializing"); const [isSubmitting, setIsSubmitting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -313,8 +311,7 @@ export function useManagedAuthSession( stateRef.current = null; setJwt(null); setState(null); - setUIState("prime"); - setIsInitializing(true); + setUIState("initializing"); setIsSubmitting(false); setIsReconnecting(false); setSubmitError(null); @@ -338,7 +335,6 @@ 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; @@ -367,7 +363,6 @@ 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; @@ -542,7 +537,6 @@ export function useManagedAuthSession( return { state, uiState, - isInitializing, isSubmitting, isReconnecting, submitError, From 93e79fd4d8fb76b3eefd54cef71a3d5c2d4a6af8 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:14:02 +0000 Subject: [PATCH 4/5] Keep initialization within consent step --- .changeset/guard-prime-before-init.md | 2 +- packages/demo/README.md | 2 +- packages/demo/src/Appearances.tsx | 9 ---- packages/demo/src/States.tsx | 9 ---- .../src/KernelManagedAuth.test.tsx | 37 +++----------- .../src/KernelManagedAuth.tsx | 9 +--- .../src/components/LoadingState.tsx | 48 +++++++------------ packages/managed-auth-react/src/lib/types.ts | 1 - .../src/localization/defaults.ts | 2 - .../src/localization/types.ts | 3 -- .../src/session/useManagedAuthSession.test.ts | 5 +- .../src/session/useManagedAuthSession.ts | 10 +++- 12 files changed, 38 insertions(+), 99 deletions(-) diff --git a/.changeset/guard-prime-before-init.md b/.changeset/guard-prime-before-init.md index 65fb35e..e402c66 100644 --- a/.changeset/guard-prime-before-init.md +++ b/.changeset/guard-prime-before-init.md @@ -2,4 +2,4 @@ "@onkernel/managed-auth-react": patch --- -Add an initialization UI state while the handoff exchange and initial session state load, preventing early consent interactions from being dropped. +Disable the consent action while the handoff exchange and initial session state load, preventing early interactions from being dropped. diff --git a/packages/demo/README.md b/packages/demo/README.md index 6635ae8..8e3f445 100644 --- a/packages/demo/README.md +++ b/packages/demo/README.md @@ -2,7 +2,7 @@ Local-only Vite app for previewing [`@onkernel/managed-auth-react`](../managed-auth-react). Two tabs: -- **States** — every UI state in the package (initializing, prime, discovering, awaiting input + SSO + MFA + sign-in options, external action, success, expired, error) rendered against the default theme. Use the top-right state picker to scrub. +- **States** — every UI state in the package (prime, discovering, awaiting input + SSO + MFA + sign-in options, external action, success, expired, error) rendered against the default theme. Use the top-right state picker to scrub. - **Appearances** — eight worked customization variants (default + 7 brand-inspired: Linear, Vercel, Stripe, Notion, Anthropic, Supabase, Kernel brand). Each variant renders the same step in lockstep so you can audit how every brand handles every screen. The auth flow is rendered headless (no real Kernel API calls); we mount the package's step components directly so you can iterate on styling without needing a live session. diff --git a/packages/demo/src/Appearances.tsx b/packages/demo/src/Appearances.tsx index d9bb05a..91a3e30 100644 --- a/packages/demo/src/Appearances.tsx +++ b/packages/demo/src/Appearances.tsx @@ -60,7 +60,6 @@ const mockSignInOptions: SignInOption[] = [ ]; type Step = - | "initializing" | "prime" | "discovering" | "awaiting_input" @@ -74,7 +73,6 @@ type Step = | "error"; const allSteps: Step[] = [ - "initializing", "prime", "discovering", "awaiting_input", @@ -90,13 +88,6 @@ const allSteps: Step[] = [ function renderStep(step: Step) { switch (step) { - case "initializing": - return ( - - ); case "prime": return {}} />; case "discovering": diff --git a/packages/demo/src/States.tsx b/packages/demo/src/States.tsx index 083f3e8..39fc0d1 100644 --- a/packages/demo/src/States.tsx +++ b/packages/demo/src/States.tsx @@ -71,7 +71,6 @@ const mockSignInOptions: SignInOption[] = [ // Local superset of the package's UIState — adds explicit multi-section // screens so we can scrub through every meaningful permutation in one place. type StateName = - | "initializing" | "prime" | "discovering" | "awaiting_input" @@ -86,7 +85,6 @@ type StateName = | "error"; const allStates: StateName[] = [ - "initializing", "prime", "discovering", "awaiting_input", @@ -112,13 +110,6 @@ export function States() { const renderState = () => { switch (currentState) { - case "initializing": - return ( - - ); case "prime": return ( { renderer = null; }); -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -function response(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} - describe("KernelManagedAuth initialization", () => { - test("shows a neutral initialization state before consent", async () => { - const exchange = deferred(); + test("disables the consent action while the session initializes", () => { + const pendingExchange = new Promise(() => {}); 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({ - domain: "example.com", - profile_name: "example-profile", - flow_status: "IN_PROGRESS", - flow_step: "DISCOVERING", - flow_type: "LOGIN", - }); - } + if (url.endsWith("/exchange")) return pendingExchange; throw new Error(`Unexpected request: ${init?.method} ${url}`); }) as typeof fetch; @@ -56,9 +32,8 @@ describe("KernelManagedAuth initialization", () => { ); }); - const initializing = JSON.stringify(renderer!.toJSON()); - expect(initializing).toContain("Preparing secure sign-in..."); - expect(initializing).not.toContain("Discovering login requirements..."); - expect(initializing).not.toContain("Continue"); + const button = renderer!.root.findByType("button"); + expect(button.props.disabled).toBe(true); + expect(button.children).toEqual(["Loading..."]); }); }); diff --git a/packages/managed-auth-react/src/KernelManagedAuth.tsx b/packages/managed-auth-react/src/KernelManagedAuth.tsx index 07b176c..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, @@ -86,18 +87,12 @@ function KernelManagedAuthInner({ const targetDomain = useMemo(() => state?.domain ?? "", [state?.domain]); - if (uiState === "initializing") { - return ( - - ); - } - if (uiState === "prime") { return ( ); diff --git a/packages/managed-auth-react/src/components/LoadingState.tsx b/packages/managed-auth-react/src/components/LoadingState.tsx index 98c5c0b..e759941 100644 --- a/packages/managed-auth-react/src/components/LoadingState.tsx +++ b/packages/managed-auth-react/src/components/LoadingState.tsx @@ -13,11 +13,9 @@ import { interface LoadingStateProps { message: string; - variant?: "initializing" | "discovering" | "authenticating"; + variant?: "discovering" | "authenticating"; } -const INITIALIZING_ICONS: Array<(props: { className?: string }) => ReactNode> = - [LockIcon]; const DISCOVERY_ICONS: Array<(props: { className?: string }) => ReactNode> = [ GlobeIcon, SearchIcon, @@ -38,23 +36,13 @@ export function LoadingState({ const l = useLocalization(); const [currentStep, setCurrentStep] = useState(0); - let steps: string[]; - let icons: Array<(props: { className?: string }) => ReactNode>; - if (variant === "initializing") { - steps = [l.initializingStep]; - icons = INITIALIZING_ICONS; - } else if (variant === "discovering") { - steps = l.loadingDiscoverySteps; - icons = DISCOVERY_ICONS; - } else { - steps = l.loadingAuthSteps; - icons = AUTH_ICONS; - } + const steps = + variant === "discovering" ? l.loadingDiscoverySteps : l.loadingAuthSteps; + const icons = variant === "discovering" ? DISCOVERY_ICONS : AUTH_ICONS; const stepCount = Math.min(steps.length, icons.length); useEffect(() => { setCurrentStep(0); - if (stepCount <= 1) return; const id = setInterval(() => { setCurrentStep((prev) => (prev < stepCount - 1 ? prev + 1 : prev)); }, STEP_INTERVAL_MS); @@ -81,23 +69,19 @@ export function LoadingState({

{steps[currentStep]}

- {stepCount > 1 && ( - - )} + - {variant !== "initializing" && ( -

{l.loadingTimeHint}

- )} +

{l.loadingTimeHint}

); } diff --git a/packages/managed-auth-react/src/lib/types.ts b/packages/managed-auth-react/src/lib/types.ts index d16bbca..01fb154 100644 --- a/packages/managed-auth-react/src/lib/types.ts +++ b/packages/managed-auth-react/src/lib/types.ts @@ -147,7 +147,6 @@ export interface ManagedAuthResponse { } export type UIState = - | "initializing" | "prime" | "discovering" | "awaiting_input" diff --git a/packages/managed-auth-react/src/localization/defaults.ts b/packages/managed-auth-react/src/localization/defaults.ts index c7001d9..9dddebc 100644 --- a/packages/managed-auth-react/src/localization/defaults.ts +++ b/packages/managed-auth-react/src/localization/defaults.ts @@ -13,8 +13,6 @@ export const DEFAULT_LOCALIZATION: Localizer = { legalPrivacyPolicy: "Privacy Policy", legalTermsOfService: "Terms of Service", legalConjunction: "and", - initializingMessage: "Preparing secure sign-in...", - initializingStep: "Establishing a secure connection...", discoveringMessage: "Discovering login requirements...", waitingForFormMessage: "Waiting for login form...", submittingMessage: "Signing in...", diff --git a/packages/managed-auth-react/src/localization/types.ts b/packages/managed-auth-react/src/localization/types.ts index 6b7d484..73d28a7 100644 --- a/packages/managed-auth-react/src/localization/types.ts +++ b/packages/managed-auth-react/src/localization/types.ts @@ -16,9 +16,6 @@ export interface Localization { legalPrivacyPolicy?: string; legalTermsOfService?: string; legalConjunction?: string; - /** Session initialization. */ - initializingMessage?: string; - initializingStep?: string; /** Discovery / loading. */ discoveringMessage?: string; waitingForFormMessage?: string; diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts index a2bdf5c..e78c5f4 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.test.ts @@ -168,7 +168,8 @@ describe("useManagedAuthSession initialization", () => { renderer = create(createElement(Harness)); }); - expect(value!.uiState).toBe("initializing"); + expect(value!.uiState).toBe("prime"); + expect(value!.isInitializing).toBe(true); await act(async () => { exchange.resolve(response({ jwt: "jwt" })); @@ -176,6 +177,7 @@ describe("useManagedAuthSession initialization", () => { }); expect(value!.uiState).toBe("prime"); + expect(value!.isInitializing).toBe(false); }); test("leaves initialization when the handoff exchange fails", async () => { @@ -198,6 +200,7 @@ describe("useManagedAuthSession initialization", () => { }); expect(value!.uiState).toBe("error"); + expect(value!.isInitializing).toBe(false); expect(value!.initError).toBe("Invalid handoff"); }); }); diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.ts index c672ea3..4b071cf 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,8 @@ export function useManagedAuthSession( const [jwt, setJwt] = useState(null); const [state, setState] = useState(null); - const [uiState, setUIState] = useState("initializing"); + const [uiState, setUIState] = useState("prime"); + const [isInitializing, setIsInitializing] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false); const [submitError, setSubmitError] = useState(null); @@ -311,7 +313,8 @@ export function useManagedAuthSession( stateRef.current = null; setJwt(null); setState(null); - setUIState("initializing"); + setUIState("prime"); + setIsInitializing(true); setIsSubmitting(false); setIsReconnecting(false); setSubmitError(null); @@ -335,6 +338,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; @@ -363,6 +367,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; @@ -537,6 +542,7 @@ export function useManagedAuthSession( return { state, uiState, + isInitializing, isSubmitting, isReconnecting, submitError, From 4af80f6febcc4047b54ba0f4b98e46a6131bdc93 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:19:22 +0000 Subject: [PATCH 5/5] Handle initializing consent variants --- .../src/KernelManagedAuth.test.tsx | 44 ++++++++++++++----- .../src/components/StepPrime.tsx | 11 ++++- .../src/localization/defaults.ts | 2 + .../src/localization/types.ts | 2 + .../src/session/useManagedAuthSession.ts | 6 ++- 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/packages/managed-auth-react/src/KernelManagedAuth.test.tsx b/packages/managed-auth-react/src/KernelManagedAuth.test.tsx index a18ec75..0a92e78 100644 --- a/packages/managed-auth-react/src/KernelManagedAuth.test.tsx +++ b/packages/managed-auth-react/src/KernelManagedAuth.test.tsx @@ -10,30 +10,52 @@ afterEach(() => { 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", () => { - const pendingExchange = new Promise(() => {}); - const fetchImpl = (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; - act(() => { renderer = create( createElement(KernelManagedAuth, { sessionId: "session-id", handoffCode: "handoff-code", - fetch: fetchImpl, + 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/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.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.ts index 4b071cf..238455d 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.ts @@ -87,7 +87,9 @@ 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); @@ -313,7 +315,7 @@ export function useManagedAuthSession( stateRef.current = null; setJwt(null); setState(null); - setUIState("prime"); + setUIState(autoStart ? "discovering" : "prime"); setIsInitializing(true); setIsSubmitting(false); setIsReconnecting(false);