diff --git a/.changeset/calm-codes-render.md b/.changeset/calm-codes-render.md new file mode 100644 index 0000000..6c09fa1 --- /dev/null +++ b/.changeset/calm-codes-render.md @@ -0,0 +1,5 @@ +--- +"@onkernel/managed-auth-react": patch +--- + +Render typed reauthentication blockers and rejected authenticator codes with specific recovery guidance. diff --git a/packages/managed-auth-react/src/components/StepError.test.tsx b/packages/managed-auth-react/src/components/StepError.test.tsx new file mode 100644 index 0000000..272a741 --- /dev/null +++ b/packages/managed-auth-react/src/components/StepError.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { errorDisplayForCode } from "./StepError"; + +describe("errorDisplayForCode", () => { + test("renders typed challenge outcomes with context-neutral copy", () => { + const cases = [ + ["totp_required", "Authenticator code required"], + ["sms_code_required", "SMS code required"], + ["email_code_required", "Email code required"], + ["account_choice_required", "Account selection required"], + ["customer_input_required", "Additional input required"], + ["external_action_required", "External action required"], + ]; + + for (const [code, title] of cases) { + const display = errorDisplayForCode(code); + expect(display?.title).toBe(title); + expect(display?.description.toLowerCase()).not.toContain("reauth"); + } + }); + + test("distinguishes a rejected authenticator code from invalid credentials", () => { + const display = errorDisplayForCode("totp_code_rejected"); + + expect(display?.title).toBe("Authenticator code rejected"); + expect(display?.description).toContain("Try a fresh code"); + expect(display).not.toEqual(errorDisplayForCode("credentials_invalid")); + }); +}); diff --git a/packages/managed-auth-react/src/components/StepError.tsx b/packages/managed-auth-react/src/components/StepError.tsx index 75ee41b..8f8e041 100644 --- a/packages/managed-auth-react/src/components/StepError.tsx +++ b/packages/managed-auth-react/src/components/StepError.tsx @@ -26,6 +26,39 @@ const ERROR_DISPLAY: Record = { title: "Invalid credentials", description: "The username or password was not accepted by the website.", }, + totp_required: { + title: "Authenticator code required", + description: + "An authenticator code is required, but no saved TOTP secret is available.", + }, + sms_code_required: { + title: "SMS code required", + description: "A code sent by SMS is required.", + }, + email_code_required: { + title: "Email code required", + description: "A code sent by email is required.", + }, + account_choice_required: { + title: "Account selection required", + description: + "An account or identity must be selected before login can continue.", + }, + customer_input_required: { + title: "Additional input required", + description: + "Additional customer input is required before login can continue.", + }, + external_action_required: { + title: "External action required", + description: + "An external action is required to continue. Check your authenticator app, email, or phone for a verification request.", + }, + totp_code_rejected: { + title: "Authenticator code rejected", + description: + "The website rejected the authenticator code. Try a fresh code, or reconnect the account if generated codes keep failing across new code windows.", + }, bot_detected: { title: "Verification required", description: @@ -66,6 +99,10 @@ const ERROR_DISPLAY: Record = { }, }; +export function errorDisplayForCode(errorCode?: string) { + return errorCode ? ERROR_DISPLAY[errorCode] : undefined; +} + function extractErrorText(raw: string): string { try { const parsed = JSON.parse(raw); @@ -92,7 +129,7 @@ export function StepError({ const siteName = extractDomainName(targetDomain); const [showDetails, setShowDetails] = useState(false); - const display = errorCode ? ERROR_DISPLAY[errorCode] : undefined; + const display = errorDisplayForCode(errorCode); const title = display?.title ?? l.errorTitle; const description = display?.description ?? l.errorGenericMessage;