Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/guard-prime-before-init.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions packages/managed-auth-react/src/KernelManagedAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Response>(() => {});
return (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
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);
});
});
3 changes: 2 additions & 1 deletion packages/managed-auth-react/src/KernelManagedAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function KernelManagedAuthInner({
const {
state,
uiState,
isInitializing,
submitError,
initError,
isSubmitting,
Expand All @@ -91,7 +92,7 @@ function KernelManagedAuthInner({
<StepPrime
targetDomain={targetDomain}
onContinue={startFlow}
isLoading={isSubmitting}
isLoading={isInitializing || isSubmitting}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
masnwilliams marked this conversation as resolved.
layout={appearance?.layout}
/>
);
Expand Down
11 changes: 9 additions & 2 deletions packages/managed-auth-react/src/components/StepPrime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -40,8 +47,8 @@ export function StepPrime({
</div>

<div className="kma-step__header">
<h1 {...slot("title", "kma-title")}>{l.primeTitle(displayName)}</h1>
<p {...slot("subtitle", "kma-subtitle")}>{l.primeSubtitle(siteName)}</p>
<h1 {...slot("title", "kma-title")}>{title}</h1>
<p {...slot("subtitle", "kma-subtitle")}>{subtitle}</p>
</div>

{showSecurityCard && (
Expand Down
2 changes: 2 additions & 0 deletions packages/managed-auth-react/src/localization/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/managed-auth-react/src/localization/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,71 @@ async function renderSession(
};
}

describe("useManagedAuthSession initialization", () => {
test("reports initialization until the session is ready", async () => {
const exchange = deferred<Response>();
let value: ManagedAuthSessionValue | null = null;

const fetchImpl = (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
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<Response>();
Expand Down
17 changes: 16 additions & 1 deletion packages/managed-auth-react/src/session/useManagedAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -86,7 +87,10 @@ export function useManagedAuthSession(

const [jwt, setJwt] = useState<string | null>(null);
const [state, setState] = useState<ManagedAuthResponse | null>(null);
const [uiState, setUIState] = useState<UIState>("prime");
const [uiState, setUIState] = useState<UIState>(
autoStart ? "discovering" : "prime",
);
const [isInitializing, setIsInitializing] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -530,6 +544,7 @@ export function useManagedAuthSession(
return {
state,
uiState,
isInitializing,
isSubmitting,
isReconnecting,
submitError,
Expand Down
Loading