From af92fd6b60b55b2c14be0ec490e5e101b6589ab1 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 13 Jul 2026 08:59:21 +0530 Subject: [PATCH 01/11] fix(auth): surface config-load errors, fix condition/state bugs, and stop the auto-firing passkey prompt - AuthorizerRoot/AuthorizerContext: surface a configLoadError banner instead of silently rendering a blank login screen when the backend meta fetch fails. - AuthorizerRoot: gate AuthorizerSocialLogin behind the Login view like its siblings, instead of rendering on every view. - AuthorizerBasicAuthLogin/AuthorizerSignup: switch validation effects to functional state updates and null-guard pristine fields, so simultaneous effects can't clobber each other's error state on mount. - AuthorizerBasicAuthLogin: defer the WebAuthn conditional-mediation (passkey autofill) ceremony from mount to the email field's first focus, so it no longer fires a native passkey prompt on every page load. - example app: drop noisy console.log callbacks (onStateChangeCallback, onLogin/onMagicLinkLogin/onSignup) that spammed the console on every state dispatch. --- example/src/index.tsx | 3 -- example/src/pages/login.tsx | 12 +---- src/components/AuthorizerBasicAuthLogin.tsx | 48 +++++++++++------ src/components/AuthorizerRoot.tsx | 15 ++++-- src/components/AuthorizerSignup.tsx | 57 ++++++++++++--------- src/contexts/AuthorizerContext.tsx | 13 +++++ src/types/index.ts | 2 + 7 files changed, 91 insertions(+), 59 deletions(-) diff --git a/example/src/index.tsx b/example/src/index.tsx index 82c2fc4..e8b5989 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -25,9 +25,6 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( authorizerURL: 'http://localhost:8080', redirectURL: window.location.origin, }} - onStateChangeCallback={async ({ user, token }: any) => { - console.log(user, token); - }} > diff --git a/example/src/pages/login.tsx b/example/src/pages/login.tsx index 9c0bde9..60470ba 100644 --- a/example/src/pages/login.tsx +++ b/example/src/pages/login.tsx @@ -6,17 +6,7 @@ const Login: React.FC = () => { <>

Welcome to Authorizer


- { - console.log({ loginData }); - }} - onMagicLinkLogin={(mData: any) => { - console.log({ mData }); - }} - onSignup={(sData: any) => { - console.log({ sData }); - }} - /> + ); }; diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index 1a38654..7706d88 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { AuthToken, LoginRequest } from '@authorizerdev/authorizer-js'; import validator from 'validator'; const { isEmail, isMobilePhone } = validator; @@ -157,28 +157,29 @@ export const AuthorizerBasicAuthLogin: FC<{ useEffect(() => { if (formData.email_or_phone_number === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Email OR Phone Number is required', - }); + })); } else if ( + formData.email_or_phone_number !== null && !isEmail(formData.email_or_phone_number || '') && !isMobilePhone(formData.email_or_phone_number || '') ) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Invalid Email OR Phone Number', - }); + })); } else { - setErrorData({ ...errorData, email_or_phone_number: null }); + setErrorData(prev => ({ ...prev, email_or_phone_number: null })); } }, [formData.email_or_phone_number]); useEffect(() => { if (formData.password === '') { - setErrorData({ ...errorData, password: 'Password is required' }); + setErrorData(prev => ({ ...prev, password: 'Password is required' })); } else { - setErrorData({ ...errorData, password: null }); + setErrorData(prev => ({ ...prev, password: null })); } }, [formData.password]); @@ -186,14 +187,23 @@ export const AuthorizerBasicAuthLogin: FC<{ // user's device support it, discoverable passkeys are offered inline in the // email/username field's autofill dropdown. Best-effort and silent - the // explicit "Sign in with a passkey" button remains the primary path, and any - // unsupported/cancelled/aborted ceremony is ignored. Started once on mount - // and aborted on unmount. - useEffect(() => { - let active = true; + // unsupported/cancelled/aborted ceremony is ignored. + // Started on the email/phone field's first focus rather than on mount: some + // browser/platform authenticator combinations (e.g. Chrome + macOS Touch ID) + // don't wait for the field to actually be focused before surfacing a native + // passkey prompt, which showed up as an unprompted popup on every page load. + // Deferring to focus keeps the ceremony tied to a real user interaction. + const passkeyAutofillActive = useRef(false); + + const startPasskeyAutofill = () => { + if (passkeyAutofillActive.current) { + return; + } + passkeyAutofillActive.current = true; authorizerRef .loginWithPasskeyAutofill() .then(({ data, errors }) => { - if (!active || (errors && errors.length) || !data) { + if (!passkeyAutofillActive.current || (errors && errors.length) || !data) { return; } setAuthData({ @@ -209,8 +219,11 @@ export const AuthorizerBasicAuthLogin: FC<{ .catch(() => { // Autofill is best-effort; ignore unsupported/cancelled ceremonies. }); + }; + + useEffect(() => { return () => { - active = false; + passkeyAutofillActive.current = false; authorizerRef.cancelPasskeyAutofill(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -274,12 +287,13 @@ export const AuthorizerBasicAuthLogin: FC<{ type="text" // Enables WebAuthn passkey autofill: browsers offer discoverable // passkeys inline in this field's autofill dropdown. Paired with - // the loginWithPasskeyAutofill() ceremony started on mount below. + // the loginWithPasskeyAutofill() ceremony started on focus below. autoComplete="username webauthn" value={formData.email_or_phone_number || ''} onChange={e => onInputChange('email_or_phone_number', e.target.value) } + onFocus={startPasskeyAutofill} /> {errorData.email_or_phone_number && (
diff --git a/src/components/AuthorizerRoot.tsx b/src/components/AuthorizerRoot.tsx index 7c2e9c8..d06fc86 100644 --- a/src/components/AuthorizerRoot.tsx +++ b/src/components/AuthorizerRoot.tsx @@ -4,13 +4,14 @@ import { AuthToken } from '@authorizerdev/authorizer-js'; import { AuthorizerBasicAuthLogin } from './AuthorizerBasicAuthLogin'; import { useAuthorizer } from '../contexts/AuthorizerContext'; import { StyledWrapper } from '../styledComponents'; -import { Views } from '../constants'; +import { Views, MessageType } from '../constants'; import { AuthorizerSignup } from './AuthorizerSignup'; import type { FormFieldsOverrides } from './AuthorizerSignup'; import { AuthorizerForgotPassword } from './AuthorizerForgotPassword'; import { AuthorizerSocialLogin } from './AuthorizerSocialLogin'; import { AuthorizerPasskeyLogin } from './AuthorizerPasskeyLogin'; import { AuthorizerMagicLinkLogin } from './AuthorizerMagicLinkLogin'; +import { Message } from './Message'; import { createRandomString } from '../utils/common'; import { hasWindow } from '../utils/window'; @@ -32,7 +33,7 @@ export const AuthorizerRoot: FC<{ signupFieldsOverrides }) => { const [view, setView] = useState(Views.Login); - const { config } = useAuthorizer(); + const { config, configLoadError } = useAuthorizer(); const searchParams = new URLSearchParams( hasWindow() ? window.location.search : `` ); @@ -60,7 +61,15 @@ export const AuthorizerRoot: FC<{ urlProps.redirect_uri = urlProps.redirectURL; return ( - + {configLoadError && ( + + )} + {view === Views.Login && ( + + )} {view === Views.Login && } {view === Views.Login && (config.is_basic_authentication_enabled || diff --git a/src/components/AuthorizerSignup.tsx b/src/components/AuthorizerSignup.tsx index ee00475..d45444d 100644 --- a/src/components/AuthorizerSignup.tsx +++ b/src/components/AuthorizerSignup.tsx @@ -168,10 +168,13 @@ export const AuthorizerSignup: FC<{ ) { return; } - if ((formData.given_name || '').trim() === '') { - setErrorData({ ...errorData, given_name: 'First Name is required' }); + if ( + formData.given_name !== null && + (formData.given_name || '').trim() === '' + ) { + setErrorData(prev => ({ ...prev, given_name: 'First Name is required' })); } else { - setErrorData({ ...errorData, given_name: null }); + setErrorData(prev => ({ ...prev, given_name: null })); } }, [formData.given_name]); @@ -182,65 +185,69 @@ export const AuthorizerSignup: FC<{ ) { return; } - if ((formData.family_name || '').trim() === '') { - setErrorData({ ...errorData, family_name: 'Last Name is required' }); + if ( + formData.family_name !== null && + (formData.family_name || '').trim() === '' + ) { + setErrorData(prev => ({ ...prev, family_name: 'Last Name is required' })); } else { - setErrorData({ ...errorData, family_name: null }); + setErrorData(prev => ({ ...prev, family_name: null })); } }, [formData.family_name]); useEffect(() => { if (formData.email_or_phone_number === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Email OR Phone Number is required', - }); + })); } else if ( + formData.email_or_phone_number !== null && !isEmail(formData.email_or_phone_number || '') && !isMobilePhone(formData.email_or_phone_number || '') ) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Invalid Email OR Phone Number', - }); + })); } else { - setErrorData({ ...errorData, email_or_phone_number: null }); + setErrorData(prev => ({ ...prev, email_or_phone_number: null })); } }, [formData.email_or_phone_number]); useEffect(() => { if (formData.password === '') { - setErrorData({ ...errorData, password: 'Password is required' }); + setErrorData(prev => ({ ...prev, password: 'Password is required' })); } else { - setErrorData({ ...errorData, password: null }); + setErrorData(prev => ({ ...prev, password: null })); } }, [formData.password]); useEffect(() => { if (formData.confirmPassword === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, confirmPassword: 'Confirm password is required', - }); + })); } else { - setErrorData({ ...errorData, confirmPassword: null }); + setErrorData(prev => ({ ...prev, confirmPassword: null })); } }, [formData.confirmPassword]); useEffect(() => { if (formData.password && formData.confirmPassword) { if (formData.confirmPassword !== formData.password) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, password: `Password and confirm passwords don't match`, confirmPassword: `Password and confirm passwords don't match`, - }); + })); } else { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, password: null, confirmPassword: null, - }); + })); } } }, [formData.password, formData.confirmPassword]); diff --git a/src/contexts/AuthorizerContext.tsx b/src/contexts/AuthorizerContext.tsx index 612f4f1..30f6bba 100644 --- a/src/contexts/AuthorizerContext.tsx +++ b/src/contexts/AuthorizerContext.tsx @@ -40,6 +40,7 @@ const AuthorizerContext = createContext({ is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, }, + configLoadError: null, user: null, token: null, loading: false, @@ -91,6 +92,7 @@ let initialState: AuthorizerState = { user: null, token: null, loading: true, + configLoadError: null, config: { authorizerURL: '', redirectURL: '/', @@ -159,6 +161,13 @@ export const AuthorizerProvider: FC<{ const getToken = async () => { const { data: metaRes, errors: metaResErrors } = await authorizer.getMetaData(); + const configLoadError = + metaResErrors && metaResErrors.length ? metaResErrors[0].message : null; + if (configLoadError) { + console.error( + `authorizer-react: failed to load config from ${state.config.authorizerURL}/graphql - ${configLoadError}. Login methods that depend on this config (basic auth, signup, social login, magic link) will not render until it succeeds.` + ); + } try { if (metaResErrors && metaResErrors.length) { throw new Error(metaResErrors[0].message); @@ -178,6 +187,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -206,6 +216,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -221,6 +232,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -303,6 +315,7 @@ export const AuthorizerProvider: FC<{ token: null, loading: false, config: state.config, + configLoadError: state.configLoadError, }; dispatch({ type: AuthorizerProviderActionType.SET_AUTH_DATA, diff --git a/src/types/index.ts b/src/types/index.ts index b8dfd78..6b38ead 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,7 @@ export type AuthorizerState = { token: AuthToken | null; loading: boolean; config: AuthorizerConfig; + configLoadError?: string | null; }; export type AuthorizerProviderAction = { @@ -62,6 +63,7 @@ export type AuthorizerContextPropsType = { user: null | User; token: null | AuthToken; loading: boolean; + configLoadError?: string | null; logout: () => Promise; setLoading: (data: boolean) => void; setUser: (data: null | User) => void; From ff81756bcddf9c135b8b263b2f4c84b9992dc791 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 13 Jul 2026 09:00:48 +0530 Subject: [PATCH 02/11] chore: ignore .worktrees/ (local git-worktree scratch dir) --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8041469..0ffafa8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist .parcel-cache .yalc *storybook.log -storybook-static \ No newline at end of file +storybook-static +.worktrees/ From 995dbd1a93ffbac09a70948f2ad79f1e8cd8fb71 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 21:32:48 +0530 Subject: [PATCH 03/11] docs: add MFA behavior redesign UI design spec Fixes the actual gaps hit during manual testing: login/signup/passkey handlers have zero wiring for the withheld-token contract, and AuthorizerPasskeyLogin silently treats a withheld (null-token) response as a successful login. Reuses the existing AuthorizerMFASetup picker and AuthorizerVerifyOtp screens rather than rebuilding them. --- ...7-15-mfa-behavior-redesign-react-design.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-mfa-behavior-redesign-react-design.md diff --git a/docs/superpowers/specs/2026-07-15-mfa-behavior-redesign-react-design.md b/docs/superpowers/specs/2026-07-15-mfa-behavior-redesign-react-design.md new file mode 100644 index 0000000..7300491 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-mfa-behavior-redesign-react-design.md @@ -0,0 +1,160 @@ +# authorizer-react: MFA Behavior Redesign UI — Design + +## Status + +Closes the gap discovered during manual testing: the component library has +zero wiring for the withheld-token MFA contract shipped in authorizer#686 +and authorizer-js#46 (merged into this session's work). Login/signup/passkey +responses that carry `should_offer_*`/`should_show_totp_screen` flags with +no `access_token` currently render nothing, or — worse, in +`AuthorizerPasskeyLogin` — get treated as a successful login with a null +token. This is a real defect, not unfinished polish. + +Supersedes authorizer-react PR #61 (closed as superseded earlier this +session — built against the pre-#686 "issue token immediately, offer setup +after" contract). + +## What already exists and is being reused, not rebuilt + +- **`AuthorizerMFASetup.tsx`** — a complete multi-method picker UI + (TOTP/passkey/email-OTP/SMS-OTP), built in an earlier PR with the explicit + stated intent of mapping onto exactly this kind of server-driven + `should_offer_*` flag set. It has no Skip action and is never invoked from + the login flow today — both close gaps, not a rewrite. +- **`AuthorizerVerifyOtp.tsx`** — the code-entry screen for TOTP/email/SMS + OTP verification, including existing lockout UI (from the older + failed-attempt lockout feature, PR #53 — distinct from the new permanent + `MFALockedAt` lockout this spec adds a screen for). +- **`AuthorizerTOTPScanner.tsx`**, **`AuthorizerPasskeyRegister.tsx`** — + existing enrollment-ceremony components, reused as-is. + +## Root causes, precisely + +1. `AuthorizerBasicAuthLogin.tsx`'s login handler (~line 96-152) only checks + `should_show_totp_screen` (+ TOTP enrollment fields) and the old + `should_show_email_otp_screen`/`should_show_mobile_otp_screen` flags. No + reference anywhere to `should_offer_webauthn_mfa_setup`, + `should_offer_email_otp_mfa_setup`, `should_offer_sms_otp_mfa_setup`, or + `should_offer_webauthn_mfa_verify`. +2. `AuthorizerPasskeyLogin.tsx`'s `onClick` (~line 86-115) unconditionally + calls `setAuthData` whenever `res` is truthy, without checking whether + `res.access_token` is actually present — a withheld-token response is + silently treated as a successful login with a null token. +3. `skipMfaSetup`/`lockMfa`/`emailOtpMfaSetup`/`smsOtpMfaSetup` (the 4 new + SDK methods from authorizer-js#46) are referenced nowhere in this + package. +4. No locked-account UI exists for the new permanent `MFALockedAt` case + (distinct from the existing transient failed-attempt lockout UI). +5. No component wires `parseMfaRedirectParams` for the OAuth + `mfa_required=1` redirect case. +6. `example/` doesn't exercise any of the above, so none of it is + end-to-end testable today. + +## Design + +### 1. Shared response-triage helper + +One function, `resolveAuthResponseStep(res: Types.AuthResponse)`, used by +every entry point that can receive a withheld-token response (login, +signup, passkey login, OAuth redirect). Returns a discriminated union: + +```typescript +type AuthStep = + | { kind: 'complete'; response: Types.AuthResponse } // access_token present + | { kind: 'verify'; totp: boolean; webauthn: boolean; email: boolean; mobile: boolean } + | { kind: 'offer'; totp: TotpEnrollment | null; webauthn: boolean; email: boolean; sms: boolean } + | { kind: 'locked' }; +``` + +`'locked'` is inferred from the login/verify call's *error* (the backend +returns a `FailedPrecondition` error for a locked account, not a flag on a +successful `AuthResponse` — there is no `should_show_locked_screen` field, +confirmed against the backend contract), not from response fields — the +triage helper's actual signature takes the `{ data, errors }` shape every +SDK call already returns, and the `'locked'` case is detected from the +error message/kind, not `res`. + +Every call site (`AuthorizerBasicAuthLogin`, `AuthorizerSignup`, +`AuthorizerPasskeyLogin`, the new OAuth-redirect handler) calls this once +right after its SDK call, then renders based on the returned `AuthStep` +instead of hand-rolling its own flag checks. This directly fixes root cause +2 (`AuthorizerPasskeyLogin` can no longer skip the access-token check) as a +side effect of using the shared helper instead of ad hoc `if (res)`. + +### 2. `AuthorizerMFASetup` gains a "login mode" + +New optional prop, e.g. `loginContext?: { email?: string; phone_number?: string; state?: string; onComplete: (res: Types.AuthResponse) => void }`. +When present: +- A "Skip for now" action appears (absent in the existing settings-screen + usage, which has no `loginContext`). +- Skip calls the real `skipMfaSetup({ email, phone_number, state })` and, + on success, calls `onComplete` with the returned (now-populated) + `AuthResponse` — same completion path `AuthorizerBasicAuthLogin` already + uses for a normal successful login. +- Completing a method (TOTP scan+verify, passkey registration, email/SMS + OTP send+verify) also calls `onComplete` once the corresponding + `verify_otp`/`webauthn_registration_verify` call returns a token — these + ceremonies already exist (`AuthorizerTOTPScanner`, + `AuthorizerPasskeyRegister`); this spec doesn't change their internals, only + makes sure the login-mode wrapper wires their result into `onComplete`. +- When `loginContext` is absent (existing settings-screen usage): unchanged + behavior, no Skip button, no `onComplete` callback — a signed-in user + enrolling a second factor from account settings, not from the login gate. + +### 3. Real default wiring for email/SMS OTP setup + +`AuthorizerMFASetup`'s `onSetupMethod` currently only *delegates* to the +host app for `email_otp`/`sms_otp` (per its own doc comment — "email- and +SMS-OTP are... delegated to the host") since those SDK methods didn't exist +yet when it was built. They exist now +(`emailOtpMfaSetup`/`smsOtpMfaSetup`). Add a real internal +implementation — selecting email/SMS OTP triggers the send-code call +directly, then renders `AuthorizerVerifyOtp` (already built, reused as-is) +for the code-entry step. `onSetupMethod` stays as an *override* escape +hatch for a host that wants custom behavior, but the package now works +correctly with zero host-side wiring required, closing the exact gap this +session's manual testing hit. + +### 4. Locked-account screen + +New, small component: shows the backend's own error message ("contact your +administrator to regain access") with no retry action — matches the +existing session's established pattern (`login.go`/`webauthn.go`/etc. all +already produce a clear, complete message; the UI's only job is to display +it distinctly from a generic error banner, not invent new copy). + +### 5. OAuth redirect handling + +New: whatever page/component in `example/` handles the OAuth callback +redirect calls `parseMfaRedirectParams(window.location.href)`. If non-null, +route into the same `AuthorizerMFASetup`/`AuthorizerVerifyOtp` flow the +password-login path uses (same triage helper, same components) rather than +assuming a token is present. This is example-app wiring plus, if needed, a +small exported helper in the library itself for a host app to reuse (follow +whatever pattern `AuthorizerRoot`/`AuthorizerContext` already establish for +similar cross-cutting concerns — decided at plan time by reading those +files, not speculated here). + +### 6. `example/` app + +Update the example to actually mount and exercise all of the above end to +end — this is what manual testing hit first, so it's explicitly in scope, +not an afterthought. + +## Explicitly out of scope + +- Self-service deletion of a single non-passkey MFA method (tracked + separately, see the `mfa_self_service_method_deletion_gap` project + memory). +- Any new visual design system, icon set, or styling beyond what + `AuthorizerMFASetup`/`AuthorizerVerifyOtp` already establish. + +## Testing + +This package has no existing Jest/RTL unit-test suite for components (spot- +checked: none of `AuthorizerBasicAuthLogin.tsx`/`AuthorizerMFASetup.tsx` +have a corresponding `*.test.tsx` file) — manual testing via `example/` is +the established verification method for this repo, confirmed at plan time +before assuming otherwise. The plan's tasks each end with a concrete manual +test procedure against the real linked backend + SDK, not a fabricated unit +test suite this repo doesn't otherwise have. From bc57145d111d00a0cf488809db1875e34a4f787f Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 21:39:19 +0530 Subject: [PATCH 04/11] docs: add MFA behavior redesign UI implementation plan 6 tasks: expose new meta fields on config, shared AuthResponse triage helper, AuthorizerMFASetup login mode with skip + real email/SMS OTP wiring, wire login/signup/passkey-login through the triage helper (fixes the passkey null-token bug), locked-account screen, OAuth mfa_required redirect handling. --- .../2026-07-15-mfa-behavior-redesign-react.md | 1202 +++++++++++++++++ 1 file changed, 1202 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-mfa-behavior-redesign-react.md diff --git a/docs/superpowers/plans/2026-07-15-mfa-behavior-redesign-react.md b/docs/superpowers/plans/2026-07-15-mfa-behavior-redesign-react.md new file mode 100644 index 0000000..57a8358 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-mfa-behavior-redesign-react.md @@ -0,0 +1,1202 @@ +# authorizer-react MFA Behavior Redesign UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the withheld-token MFA contract (authorizer#686, authorizer-js#46) actually work end-to-end through this component library — today it renders nothing for the new response shape, and `AuthorizerPasskeyLogin` incorrectly treats a withheld (null-token) response as a successful login. + +**Architecture:** One shared response-triage function used by every login-capable component; `AuthorizerMFASetup` (an existing, already-built multi-method picker) gains a "login mode" with a Skip action and real default wiring for email/SMS OTP; a new locked-account screen; `AuthorizerRoot` gains OAuth-redirect `mfa_required` detection. + +**Tech Stack:** React, TypeScript. This repo has no component test suite (`npm test` is `tsc --noEmit` only, verified — no `*.test.tsx` files exist anywhere in `src/`). Every task's verification is (a) `npx tsc --noEmit` passing and (b) an exact manual procedure against the real linked SDK, a running backend on `localhost:8080` (already running, confirmed on the authorizer#686 branch), and the example app on `localhost:5174` (already running). + +## Global Constraints + +- No new fields beyond what the spec lists — do not touch anything outside this plan's file list. +- **Passkey is NOT offered as a "Set up" option in login mode** (only in the existing settings-screen mode) — `webauthn_registration_options`/`webauthn_registration_verify` require a bearer token that doesn't exist in the withheld-token state; this is a known, separately-tracked backend gap (the same class of gap `email_otp_mfa_setup`/`sms_otp_mfa_setup` had before this session's "I2" fix), not something this plan fixes. `should_offer_webauthn_mfa_setup` may still arrive from the backend in a login-mode response; the UI must not surface it as a clickable option there. +- Locked-account detection is by error `code === 'FAILED_PRECONDITION'` **and** message containing the substring `'multi-factor authentication is locked'` (verified against `internal/service/login.go`'s exact lockout message text) — `FAILED_PRECONDITION` alone is not specific enough (other MFA errors, e.g. "cannot skip... enforced", share the same code). This is a pragmatic client-side match against a stable backend message string, not a dedicated error code — documented here because there isn't a better mechanism today. +- `Types.AuthResponse`'s boolean flags (`should_show_totp_screen`, `should_offer_webauthn_mfa_verify`, `should_offer_webauthn_mfa_setup`, `should_offer_email_otp_mfa_setup`, `should_offer_sms_otp_mfa_setup`) are all `boolean | null` — treat `null`/`undefined`/`false` identically (falsy check), matching this codebase's existing convention throughout. +- Match existing code style exactly: no semicolons after JSX-returning arrow function bodies beyond what's already there, existing `Message`/`StyledButton`/`StyledFooter` component usage patterns, existing `useAuthorizer()` destructuring style. + +--- + +## Task 1: Expose the new `meta` fields on `config` + +**Files:** +- Modify: `src/types/index.ts` (`AuthorizerConfig` type ~line 4-25, `AuthorizerContextPropsType.config` ~line 41-62) +- Modify: `src/contexts/AuthorizerContext.tsx` (both default config object literals: the `createContext` default ~line 21-42, and `initialState.config` ~line 96-117) + +**Interfaces:** +- Produces: `config.is_totp_mfa_enabled`, `config.is_email_otp_mfa_enabled`, `config.is_sms_otp_mfa_enabled`, `config.is_webauthn_enabled`, `config.is_mfa_enforced` (all `boolean`) — consumed by Task 3 (`AuthorizerMFASetup`'s `availableMfaMethods` derivation) and Task 4 (`AuthorizerPasskeyLogin`'s existing `is_mfa_enforced` gating, if any is added there). + +**Context**: `AuthorizerContext.tsx`'s `getToken()` (~line 161-224) already spreads the full `getMetaData()` response into `config` at runtime (`config: { ...state.config, ...metaRes }`) — the runtime object already carries these fields (authorizer-js's `Meta` interface has them today). Only the TypeScript type declarations are missing, which is why no component can reference `config.is_totp_mfa_enabled` etc. without a compile error today. + +- [ ] **Step 1: Extend `AuthorizerConfig` in `src/types/index.ts`** + +Find (ends at `is_phone_verification_enabled: boolean;` followed by `};`): +```typescript +export type AuthorizerConfig = { + authorizerURL: string; + redirectURL: string; + client_id: string; + is_google_login_enabled: boolean; + is_github_login_enabled: boolean; + is_facebook_login_enabled: boolean; + is_linkedin_login_enabled: boolean; + is_apple_login_enabled: boolean; + is_twitter_login_enabled: boolean; + is_microsoft_login_enabled: boolean; + is_twitch_login_enabled: boolean; + is_roblox_login_enabled: boolean; + is_email_verification_enabled: boolean; + is_basic_authentication_enabled: boolean; + is_magic_link_login_enabled: boolean; + is_sign_up_enabled: boolean; + is_strong_password_enabled: boolean; + is_multi_factor_auth_enabled: boolean; + is_mobile_basic_authentication_enabled: boolean; + is_phone_verification_enabled: boolean; +}; +``` +Replace the closing 4 lines with: +```typescript + is_mobile_basic_authentication_enabled: boolean; + is_phone_verification_enabled: boolean; + is_totp_mfa_enabled: boolean; + is_email_otp_mfa_enabled: boolean; + is_sms_otp_mfa_enabled: boolean; + is_webauthn_enabled: boolean; + is_mfa_enforced: boolean; +}; +``` + +- [ ] **Step 2: Apply the identical addition to `AuthorizerContextPropsType.config` in the same file** + +Same 5 lines, same position (after `is_phone_verification_enabled: boolean;`), inside the `config: { ... }` block of `AuthorizerContextPropsType` (~line 41-62). + +- [ ] **Step 3: Add matching default values to both config literals in `src/contexts/AuthorizerContext.tsx`** + +In the `createContext` default (~line 21-42) and in `initialState.config` (~line 96-117), both currently end with `is_phone_verification_enabled: false,`. Add after it, in BOTH places: +```typescript + is_totp_mfa_enabled: false, + is_email_otp_mfa_enabled: false, + is_sms_otp_mfa_enabled: false, + is_webauthn_enabled: false, + is_mfa_enforced: false, +``` + +- [ ] **Step 4: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS, no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/types/index.ts src/contexts/AuthorizerContext.tsx +git commit -m "feat(mfa): expose is_totp/email_otp/sms_otp/webauthn_mfa_enabled + is_mfa_enforced on config" +``` + +--- + +## Task 2: Shared `AuthResponse` triage helper + +**Files:** +- Create: `src/utils/mfaTriage.ts` + +**Interfaces:** +- Consumes: `Types.AuthResponse`, `Types.AuthorizerSDKError` from `@authorizerdev/authorizer-js` (already linked, verified: `AuthResponse` has `message, should_show_email_otp_screen, should_show_mobile_otp_screen, should_show_totp_screen, should_offer_webauthn_mfa_verify, should_offer_webauthn_mfa_setup, should_offer_email_otp_mfa_setup, should_offer_sms_otp_mfa_setup, access_token, id_token, refresh_token, expires_in, user, authenticator_scanner_image, authenticator_secret, authenticator_recovery_codes` — all boolean/string fields except `message` are `| null`; `AuthorizerSDKError` has `message: string` and `code?: string`). +- Produces: `AuthStep` type and `resolveAuthStep(res: Types.AuthResponse | undefined, errors: Types.AuthorizerSDKError[]) => AuthStep` — consumed by Task 4 (login/signup/passkey-login components) and Task 5 (OAuth redirect handling in `AuthorizerRoot`). + +- [ ] **Step 1: Create `src/utils/mfaTriage.ts`** + +```typescript +import * as Types from '@authorizerdev/authorizer-js'; + +// The message text internal/service/login.go (and webauthn.go, oauth_mfa_gate.go) +// use for a locked account, verified against the backend source. There is no +// dedicated error code for "locked" - FAILED_PRECONDITION is shared with other +// MFA errors (e.g. "cannot skip, MFA is enforced"), so matching on this stable +// message substring is the only reliable client-side signal today. +const LOCKED_MESSAGE_SUBSTRING = 'multi-factor authentication is locked'; + +export type TotpEnrollment = { + authenticator_scanner_image: string; + authenticator_secret: string; + authenticator_recovery_codes: string[]; +}; + +export type AuthStep = + | { kind: 'complete'; response: Types.AuthResponse } + | { + kind: 'verify'; + totp: boolean; + webauthn: boolean; + email: boolean; + mobile: boolean; + } + | { + kind: 'offer'; + totpEnrollment: TotpEnrollment | null; + // Passkey is intentionally excluded from the offer surface here - see + // this plan's Global Constraints: webauthn_registration_options/verify + // require a bearer token that doesn't exist in this withheld-token + // state, a known backend gap tracked separately, not fixed by this + // component. + emailOtp: boolean; + smsOtp: boolean; + } + | { kind: 'locked' } + | { kind: 'error'; message: string }; + +function isLockedError(errors: Types.AuthorizerSDKError[]): boolean { + return errors.some( + (e) => + e.code === 'FAILED_PRECONDITION' && + (e.message || '').includes(LOCKED_MESSAGE_SUBSTRING), + ); +} + +// resolveAuthStep is the single place every login-capable component decides +// what to render next from an SDK call's { data, errors } result. Replaces +// each component's own ad hoc should_show_*/should_offer_* checks - the bug +// that caused a withheld (null access_token) response to be silently treated +// as a successful login in AuthorizerPasskeyLogin was exactly this kind of +// duplicated, incomplete check. +export function resolveAuthStep( + res: Types.AuthResponse | undefined, + errors: Types.AuthorizerSDKError[], +): AuthStep { + if (errors && errors.length) { + if (isLockedError(errors)) { + return { kind: 'locked' }; + } + return { kind: 'error', message: errors[0]?.message || '' }; + } + if (!res) { + return { kind: 'error', message: 'No response from server' }; + } + if (res.access_token) { + return { kind: 'complete', response: res }; + } + // Withheld: either a verify (user already has a completed factor) or an + // offer (first-time, nothing completed yet). should_show_totp_screen is + // shared between both - the enrollment fields being present is what + // distinguishes "offer TOTP setup" from "verify with your existing TOTP". + const hasTotpEnrollment = !!( + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ); + if (res.should_show_totp_screen && !hasTotpEnrollment) { + // Verified TOTP factor exists; asking for a code, not enrollment. + return { + kind: 'verify', + totp: true, + webauthn: !!res.should_offer_webauthn_mfa_verify, + email: !!res.should_show_email_otp_screen, + mobile: !!res.should_show_mobile_otp_screen, + }; + } + if ( + res.should_offer_webauthn_mfa_verify || + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen + ) { + return { + kind: 'verify', + totp: !!res.should_show_totp_screen && !hasTotpEnrollment, + webauthn: !!res.should_offer_webauthn_mfa_verify, + email: !!res.should_show_email_otp_screen, + mobile: !!res.should_show_mobile_otp_screen, + }; + } + if ( + hasTotpEnrollment || + res.should_offer_email_otp_mfa_setup || + res.should_offer_sms_otp_mfa_setup + ) { + return { + kind: 'offer', + totpEnrollment: hasTotpEnrollment + ? { + authenticator_scanner_image: res.authenticator_scanner_image as string, + authenticator_secret: res.authenticator_secret as string, + authenticator_recovery_codes: res.authenticator_recovery_codes as string[], + } + : null, + emailOtp: !!res.should_offer_email_otp_mfa_setup, + smsOtp: !!res.should_offer_sms_otp_mfa_setup, + }; + } + // No access_token and none of the known MFA flags set - treat as an error + // rather than silently completing with a null token (the exact bug this + // helper exists to close). + return { kind: 'error', message: res.message || 'Unable to sign in' }; +} +``` + +- [ ] **Step 2: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/utils/mfaTriage.ts +git commit -m "feat(mfa): add shared AuthResponse triage helper" +``` + +(No manual UI test for this task in isolation — it's a pure function with no rendering; it's exercised end-to-end by Task 4's manual test procedures.) + +--- + +## Task 3: `AuthorizerMFASetup` login mode + +**Files:** +- Modify: `src/components/AuthorizerMFASetup.tsx` (entire file — new props, new Skip UI, real email/SMS OTP wiring) + +**Interfaces:** +- Consumes: `Types.SkipMfaSetupRequest`, `Types.OtpMfaSetupRequest` from `@authorizerdev/authorizer-js`; `TotpEnrollment` from `src/utils/mfaTriage.ts` (Task 2; identical shape to this file's own pre-existing local `TotpEnrollment` type — replace the local one with the shared import to avoid duplication). +- Produces: new `loginContext` prop — consumed by Task 4. + +**Context**: This component already exists and is fully built for the settings-screen (logged-in, bearer-token) case. This task adds an opt-in "login mode" without breaking that existing usage — when `loginContext` is omitted, behavior is unchanged byte-for-byte. + +- [ ] **Step 1: Add the `loginContext` prop and Skip handler** + +Replace the component's prop type and destructuring (currently ~line 58-74): +```typescript +export const AuthorizerMFASetup: FC<{ + availableMfaMethods: AvailableMfaMethods; + totpEnrollment?: TotpEnrollment; + onSetupMethod?: (method: MfaMethod) => void; + heading?: string; + // When present, this is the login-time (withheld-token) offer screen, not + // the settings-screen "add a second factor" hub: a Skip action appears, + // and completing a method calls onComplete with the token issued by + // skip_mfa_setup / verify_otp / the OTP setup+verify cycle instead of the + // settings-screen's "just close/refresh" behavior. + loginContext?: { + email?: string; + phone_number?: string; + state?: string; + onComplete: (response: AuthTokenLike) => void; + }; +}> = ({ + availableMfaMethods, + totpEnrollment, + onSetupMethod, + heading = 'Add a second step to sign in', + loginContext, +}) => { +``` + +Remove this file's own local `TotpEnrollment` type (currently ~line 48-52): +```typescript +type TotpEnrollment = { + authenticator_scanner_image: string; + authenticator_secret: string; + authenticator_recovery_codes: string[]; +}; +``` +Replace it with an import from the shared util (added in Task 2) — `src/utils/mfaTriage.ts` only imports from `@authorizerdev/authorizer-js`, never from any component, so importing it here creates no cycle: +```typescript +import { TotpEnrollment } from '../utils/mfaTriage'; +``` + +Also add this new type above the component (there is no existing local type to place it near — it's new): +```typescript +type AuthTokenLike = { access_token?: string | null; [key: string]: any }; +``` + +- [ ] **Step 2: Add the Skip action UI and handler** + +Add state near the existing `useState` calls (~line 75-76): +```typescript + const [skipping, setSkipping] = useState(false); + const [skipError, setSkipError] = useState(''); +``` + +Add the handler (place it near `handleSetup`, ~line 124): +```typescript + const { authorizerRef } = useAuthorizer(); + + const handleSkip = async () => { + if (!loginContext) return; + setSkipError(''); + setSkipping(true); + try { + const { data, errors } = await authorizerRef.skipMfaSetup({ + email: loginContext.email, + phone_number: loginContext.phone_number, + state: loginContext.state, + }); + if (errors && errors.length) { + setSkipError(errors[0]?.message || 'Failed to skip MFA setup'); + return; + } + if (data) { + loginContext.onComplete(data); + } + } catch (err) { + setSkipError((err as Error).message); + } finally { + setSkipping(false); + } + }; +``` +Add the `useAuthorizer` import at the top of the file: +```typescript +import { useAuthorizer } from '../contexts/AuthorizerContext'; +``` + +Render the Skip button and any `skipError` in the main return block (the one that renders `visibleMethods`, ~line 172-217) — add right after the closing `)}` of the `visibleMethods.length === 0 ? ... : (...)` conditional, still inside the outer `<>...`: +```typescript + {skipError && ( + setSkipError('')} + /> + )} + {loginContext && ( +
+ + {skipping ? 'Skipping ...' : 'Skip for now'} + +
+ )} +``` + +- [ ] **Step 3: Wire login-mode completion into the existing TOTP and passkey enrollment branches** + +Find the TOTP branch (~line 149-160): +```typescript + if (selected === 'totp' && totpEnrollment) { + return ( + <> + + setSelected(null)} + onLogin={() => setSelected(null)} + /> + + ); + } +``` +Replace with: +```typescript + if (selected === 'totp' && totpEnrollment) { + return ( + <> + + setSelected(null)} + onLogin={(data) => { + if (loginContext && data && (data as AuthTokenLike).access_token) { + loginContext.onComplete(data as AuthTokenLike); + return; + } + setSelected(null); + }} + /> + + ); + } +``` +(`AuthorizerTOTPScanner`'s existing `email`/`phone_number`/`onLogin` props are used elsewhere in this codebase already — read `src/components/AuthorizerTOTPScanner.tsx` before this step to confirm its exact prop names/`onLogin` call signature match this usage; adapt only if they genuinely differ from what `AuthorizerBasicAuthLogin.tsx`'s existing usage of the same component shows.) + +The passkey branch (~line 162-170) is UNCHANGED — per this plan's Global Constraint, passkey "Set up" is not offered when `loginContext` is present (Step 4 handles hiding it from the list entirely), so this branch is only ever reached in settings mode, where its existing behavior is already correct. + +- [ ] **Step 4: Hide the passkey option when in login mode** + +Find the `methods` array (~line 80-120), the `passkey` entry: +```typescript + { + key: 'passkey', + available: !!availableMfaMethods.passkey, + icon: , + title: 'Passkey', + description: 'Sign in with your fingerprint, face, or device PIN.', + disabled: !passkeySupported, + disabledReason: 'Not supported on this browser or device.', + }, +``` +Change `available` to also require the absence of `loginContext`: +```typescript + { + key: 'passkey', + available: !!availableMfaMethods.passkey && !loginContext, + icon: , + title: 'Passkey', + description: 'Sign in with your fingerprint, face, or device PIN.', + disabled: !passkeySupported, + disabledReason: 'Not supported on this browser or device.', + }, +``` + +- [ ] **Step 5: Real default wiring for email/SMS OTP setup** + +Find `handleSetup` (~line 124-145): +```typescript + const handleSetup = (method: MfaMethod) => { + setNotice(''); + if (method === 'totp') { + if (totpEnrollment) { + setSelected('totp'); + } else { + onSetupMethod?.('totp'); + } + return; + } + if (method === 'passkey') { + setSelected('passkey'); + return; + } + // email_otp / sms_otp are enabled on the server; hand off to the host. + onSetupMethod?.(method); + setNotice( + method === 'email_otp' + ? 'Email one-time codes will be sent the next time you sign in.' + : 'SMS one-time codes will be sent the next time you sign in.' + ); + }; +``` +Replace with: +```typescript + const handleSetup = async (method: MfaMethod) => { + setNotice(''); + if (method === 'totp') { + if (totpEnrollment) { + setSelected('totp'); + } else { + onSetupMethod?.('totp'); + } + return; + } + if (method === 'passkey') { + setSelected('passkey'); + return; + } + // email_otp / sms_otp: if the host supplied onSetupMethod, defer to it + // (escape hatch for custom behavior). Otherwise use the real SDK calls + // directly - this is the default, host-free path this task adds. + if (onSetupMethod) { + onSetupMethod(method); + setNotice( + method === 'email_otp' + ? 'Email one-time codes will be sent the next time you sign in.' + : 'SMS one-time codes will be sent the next time you sign in.' + ); + return; + } + setOtpSetupError(''); + setSendingOtpSetup(true); + try { + const params = { + email: loginContext?.email, + phone_number: loginContext?.phone_number, + }; + const { errors } = + method === 'email_otp' + ? await authorizerRef.emailOtpMfaSetup(params) + : await authorizerRef.smsOtpMfaSetup(params); + if (errors && errors.length) { + setOtpSetupError(errors[0]?.message || 'Failed to send code'); + return; + } + setOtpMethodPending(method); + } catch (err) { + setOtpSetupError((err as Error).message); + } finally { + setSendingOtpSetup(false); + } + }; +``` + +Add the new state alongside the ones from Step 2: +```typescript + const [sendingOtpSetup, setSendingOtpSetup] = useState(false); + const [otpSetupError, setOtpSetupError] = useState(''); + const [otpMethodPending, setOtpMethodPending] = useState< + 'email_otp' | 'sms_otp' | null + >(null); +``` + +Add a new render branch, placed alongside the existing `selected === 'totp'`/`selected === 'passkey'` early-return branches (~after line 170, before the main return): +```typescript + if (otpMethodPending) { + return ( + <> + setOtpMethodPending(null)} /> + { + if (loginContext && data && (data as AuthTokenLike).access_token) { + loginContext.onComplete(data as AuthTokenLike); + return; + } + setOtpMethodPending(null); + }} + /> + + ); + } +``` +Add the import: +```typescript +import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp'; +``` +(`AuthorizerVerifyOtp` calls `verifyOtp` internally, which — per Task 6 of the authorizer backend plan — marks the pending unverified Authenticator row `VerifiedAt` and, in the withheld-token state, mints the token via the MFA session cookie. No changes needed to `AuthorizerVerifyOtp.tsx` itself for this to work correctly, since it's driven purely by the cookie the browser already holds, not by anything this component passes explicitly beyond `email`/`phone_number` to identify the pending user — same mechanism `skip_mfa_setup`/`lock_mfa` already rely on.) + +Render `otpSetupError`/`sendingOtpSetup` feedback: add near the top of the main return block's JSX, alongside the existing `notice` message (~line 175-181): +```typescript + {otpSetupError && ( + setOtpSetupError('')} + /> + )} +``` +And disable the relevant "Set up" button while `sendingOtpSetup` — find the `StyledButton` inside the `visibleMethods.map` (~line 202-210) and add `disabled={m.disabled || sendingOtpSetup}`. + +- [ ] **Step 6: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS, no errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/components/AuthorizerMFASetup.tsx +git commit -m "feat(mfa): add login mode (skip + email/SMS OTP wiring) to AuthorizerMFASetup" +``` + +(Manual test deferred to Task 4, where this component is actually reachable from the login flow.) + +--- + +## Task 4: Wire the triage helper into login, signup, and passkey login; fix the null-token bug + +**Files:** +- Modify: `src/components/AuthorizerBasicAuthLogin.tsx` (login response handling) +- Modify: `src/components/AuthorizerPasskeyLogin.tsx` (fixes the null-token bug) +- Modify: `src/components/AuthorizerSignup.tsx` (signup response handling — read the full file first; it wasn't included in this brief's research, mirror whatever pattern `AuthorizerBasicAuthLogin.tsx` used before this task for its own signup call, adapting to this task's new triage-based approach) + +**Interfaces:** +- Consumes: `resolveAuthStep` (Task 2), `AuthorizerMFASetup` with `loginContext` (Task 3). + +- [ ] **Step 1: Replace `AuthorizerBasicAuthLogin`'s response handling** + +Find the entire block from `const { data: res, errors } = await authorizerRef.login(data);` through `if (onLogin) { onLogin(res); }` (~line 96-146): +```typescript + const { data: res, errors } = await authorizerRef.login(data); + if (errors && errors.length) { + setError(errors[0].message); + return; + } + // if totp is enabled for the first time show totp screen with scanner + if ( + res && + res.should_show_totp_screen && + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ) { + setTotpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + authenticator_scanner_image: res.authenticator_scanner_image, + authenticator_secret: res.authenticator_secret, + authenticator_recovery_codes: res.authenticator_recovery_codes, + }); + return; + } + if ( + res && + (res?.should_show_email_otp_screen || + res?.should_show_mobile_otp_screen || + res?.should_show_totp_screen) + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res?.should_show_totp_screen || false, + }); + return; + } + + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + + if (onLogin) { + onLogin(res); + } +``` +Replace with: +```typescript + const { data: res, errors } = await authorizerRef.login(data); + const step = resolveAuthStep(res, errors || []); + if (step.kind === 'error') { + setError(step.message); + return; + } + if (step.kind === 'locked') { + setLocked(true); + return; + } + if (step.kind === 'offer') { + setMfaOfferData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + totpEnrollment: step.totpEnrollment, + emailOtp: step.emailOtp, + smsOtp: step.smsOtp, + state: urlProps?.state, + }); + return; + } + if (step.kind === 'verify') { + if (step.totp) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: true, + }); + return; + } + if (step.email || step.mobile) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: false, + }); + return; + } + // WebAuthn-verify-only case (should_offer_webauthn_mfa_verify with no + // TOTP/email/mobile fallback offered) - this component has no passkey- + // verify ceremony of its own; report the situation via the existing + // error banner rather than silently doing nothing. + setError( + 'This account requires passkey verification. Use "Sign in with a passkey" instead.', + ); + return; + } + // step.kind === 'complete' + setError(``); + setAuthData({ + user: step.response.user || null, + token: step.response, + config, + loading: false, + }); + if (onLogin) { + onLogin(step.response); + } +``` + +Add the import: +```typescript +import { resolveAuthStep } from '../utils/mfaTriage'; +``` + +- [ ] **Step 2: Replace the `otpData`/`totpData` state with a unified `mfaOfferData` state and a `locked` flag** + +Replace (~line 16-29): +```typescript +const initOtpData: OtpDataType = { + is_screen_visible: false, + email: '', + phone_number: '', +}; + +const initTotpData: TotpDataType = { + is_screen_visible: false, + email: '', + phone_number: '', + authenticator_scanner_image: '', + authenticator_secret: '', + authenticator_recovery_codes: [], +}; +``` +with: +```typescript +const initOtpData: OtpDataType = { + is_screen_visible: false, + email: '', + phone_number: '', +}; + +type MfaOfferData = { + is_screen_visible: boolean; + email: string; + phone_number: string; + totpEnrollment: TotpEnrollment | null; + emailOtp: boolean; + smsOtp: boolean; + state?: string; +}; + +const initMfaOfferData: MfaOfferData = { + is_screen_visible: false, + email: '', + phone_number: '', + totpEnrollment: null, + emailOtp: false, + smsOtp: false, +}; +``` +Remove the `TotpDataType` import (no longer used in this file) and add: +```typescript +import { resolveAuthStep, TotpEnrollment } from '../utils/mfaTriage'; +``` +(keep the existing `OtpDataType` import from `'../types'`). + +Replace the `totpData` state declaration (~line 45) with: +```typescript + const [mfaOfferData, setMfaOfferData] = useState({ + ...initMfaOfferData, + }); + const [locked, setLocked] = useState(false); +``` +(remove the old `const [totpData, setTotpData] = useState({ ...initTotpData });` line — it's fully superseded). + +- [ ] **Step 3: Render the offer screen and locked screen** + +Find the existing early-return render block (~line 232-263): +```typescript + if (totpData.is_screen_visible) { + return ( + + ); + } + + if (otpData.is_screen_visible) { + return ( + + ); + } +``` +Replace with: +```typescript + if (locked) { + return ; + } + + if (mfaOfferData.is_screen_visible) { + return ( + { + setAuthData({ + user: (data as any).user || null, + token: data as any, + config, + loading: false, + }); + if (onLogin) { + onLogin(data as any); + } + }, + }} + /> + ); + } + + if (otpData.is_screen_visible) { + return ( + + ); + } +``` +Add imports: +```typescript +import { AuthorizerMFASetup } from './AuthorizerMFASetup'; +import { AuthorizerMfaLocked } from './AuthorizerMfaLocked'; +``` +Remove the now-unused `AuthorizerTOTPScanner` import from this file if nothing else in it references `AuthorizerTOTPScanner` directly (check before removing — `AuthorizerMFASetup` uses its own internally, this file no longer needs a direct reference). + +`availableMfaMethods.totp` uses `!!mfaOfferData.totpEnrollment || config.is_totp_mfa_enabled` because the `offer` step only includes `totpEnrollment` when TOTP is actually available server-side (per `resolveAuthStep`'s construction in Task 2) — this is a belt-and-suspenders match against `config.is_totp_mfa_enabled` (Task 1) for consistency with how the other three methods are driven, but `totpEnrollment` presence is the authoritative signal for whether TOTP is actually selectable here. + +- [ ] **Step 4: Fix `AuthorizerPasskeyLogin`'s null-token bug** + +Find (~line 86-115): +```typescript + const onClick = async () => { + setError(``); + try { + setLoading(true); + const { data: res, errors } = await authorizerRef.loginWithPasskey(); + if (errors && errors.length) { + if (!isUserDismissed(errors[0])) { + setError(errors[0]?.message || ``); + } + return; + } + if (res) { + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } + } catch (err) { + if (!isUserDismissed(err as { code?: string })) { + setError((err as Error).message); + } + } finally { + setLoading(false); + } + }; +``` +Replace with: +```typescript + const [mfaStep, setMfaStep] = useState(null); + + const onClick = async () => { + setError(``); + try { + setLoading(true); + const { data: res, errors } = await authorizerRef.loginWithPasskey(); + if (errors && errors.length) { + if (!isUserDismissed(errors[0])) { + setError(errors[0]?.message || ``); + } + return; + } + const step = resolveAuthStep(res, errors || []); + if (step.kind === 'error') { + setError(step.message); + return; + } + if (step.kind === 'locked' || step.kind === 'offer' || step.kind === 'verify') { + setMfaStep(step); + return; + } + setAuthData({ + user: step.response.user || null, + token: step.response, + config, + loading: false, + }); + if (onLogin) { + onLogin(step.response); + } + } catch (err) { + if (!isUserDismissed(err as { code?: string })) { + setError((err as Error).message); + } + } finally { + setLoading(false); + } + }; +``` +Add imports: +```typescript +import { resolveAuthStep, AuthStep } from '../utils/mfaTriage'; +``` + +Add rendering for `mfaStep` before the component's main `return` (~line 119): +```typescript + if (mfaStep?.kind === 'locked') { + return ; + } + if (mfaStep?.kind === 'offer') { + return ( + { + setAuthData({ + user: (data as any).user || null, + token: data as any, + config, + loading: false, + }); + if (onLogin) { + onLogin(data as any); + } + }, + }} + /> + ); + } + if (mfaStep?.kind === 'verify') { + // A passkey-primary login that still needs a second factor has no + // email/phone_number in hand (usernameless login) - the existing + // AuthorizerVerifyOtp component requires one of those to identify the + // pending user via the MFA session cookie, so TOTP/email/SMS verify + // can't be completed from here. This is a real, narrower gap than the + // password-login path (which always has an email/phone from the form) - + // report it plainly rather than rendering a broken form. + return ( + + ); + } +``` +Add the `AuthorizerMFASetup`/`AuthorizerMfaLocked` imports: +```typescript +import { AuthorizerMFASetup } from './AuthorizerMFASetup'; +import { AuthorizerMfaLocked } from './AuthorizerMfaLocked'; +``` + +- [ ] **Step 5: Read and update `AuthorizerSignup.tsx`** + +Read the full file first (it wasn't part of this plan's research). Find its `authorizerRef.signup(...)` call and whatever response handling follows it. Apply the same triage pattern as Step 1 (`resolveAuthStep`, then branch on `step.kind` exactly as in `AuthorizerBasicAuthLogin.tsx`), reusing the same `mfaOfferData`/`locked` state shape and the same `AuthorizerMFASetup`/`AuthorizerMfaLocked` rendering added in Steps 2-3. Do not invent a different pattern — mirror Steps 1-3 exactly, adapted only for whatever field names `AuthorizerSignup.tsx`'s local state currently uses for its own email/phone/otp tracking. + +- [ ] **Step 6: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS, no errors. (Task 5 creates `AuthorizerMfaLocked` — if this task is executed before Task 5 in a different order, this step will fail on the missing import; execute Task 5 first if reordering, or accept this task will not compile standalone until Task 5 lands. As numbered, Task 5 follows this task in the plan's sequence — but `AuthorizerMfaLocked` has no dependency on Tasks 1-4, so an implementer MAY do Task 5's `AuthorizerMfaLocked` component creation first if that ordering is more convenient; note this explicitly rather than treat the numeric order as rigid.) + +- [ ] **Step 7: Manual test — first-time MFA offer (TOTP)** + +Prerequisite: backend running on `localhost:8080` with `--disable-webauthn-mfa` NOT set and MFA available (default config already running, confirmed earlier). +1. Open `http://localhost:5174` in a browser with no existing session (private/incognito window). +2. Sign up a brand-new account (email + password) via the example app's signup form. +3. Expected: after signup, the multi-method picker (`AuthorizerMFASetup`) renders with "Authenticator app", "Email one-time code" (if `EnableEmailOTP`+SMTP configured on the backend - likely NOT visible with default backend config, that's correct), and a "Skip for now" button. No passkey tile. +4. Click "Set up" on "Authenticator app" — a QR code and secret should render (via the existing `AuthorizerTOTPScanner`). +5. Scan it with any TOTP app (or use the secret to generate a code manually), submit the 6-digit code. +6. Expected: the app transitions to the logged-in dashboard view — confirms `onComplete` correctly received a real token and `setAuthData` fired. + +- [ ] **Step 8: Manual test — Skip** + +1. Repeat steps 1-3 above with a second new account. +2. Click "Skip for now". +3. Expected: the app transitions directly to the logged-in dashboard — confirms `skipMfaSetup` was called and its returned token was used. +4. Log out, log back in with the same account/password. +5. Expected: logs in directly with no MFA offer screen (matches `HasSkippedMFASetupAt` being set — the backend's `mfaGateSkippedSetup` path). + +- [ ] **Step 9: Manual test — `AuthorizerPasskeyLogin` no longer treats a withheld response as success** + +1. Using an account created in Step 7 that has TOTP enrolled (not skipped), register a passkey for that account too (via the settings-screen `AuthorizerMFASetup`/`AuthorizerPasskeyRegister` flow — may require a small amount of manual GraphQL/dashboard interaction if no settings page is wired in the example app yet; if genuinely not reachable through the UI, register the passkey directly via a `webauthn_registration_options`/`_verify` GraphQL Playground call using that account's bearer token, then proceed). +3. Log out. On the login page, click "Sign in with a passkey" and complete the ceremony for that account. +4. Expected (this is the bug fix under test): since the account also has TOTP enrolled, the backend's gate returns `mfaGateBlockVerify` (withheld) for passkey-primary login — the UI must NOT show a logged-in dashboard with a blank/null user. It should show the `verify` branch's message ("Additional verification is required...") rather than silently completing. Confirm the browser's network tab shows `access_token: null` in the `webauthn_login_verify` response and that `setAuthData` was correctly NOT called (no dashboard transition). + +- [ ] **Step 10: Commit** + +```bash +git add src/components/AuthorizerBasicAuthLogin.tsx src/components/AuthorizerPasskeyLogin.tsx src/components/AuthorizerSignup.tsx +git commit -m "feat(mfa): wire triage helper into login/signup/passkey-login, fix null-token bug" +``` + +--- + +## Task 5: Locked-account screen + +**Files:** +- Create: `src/components/AuthorizerMfaLocked.tsx` + +**Interfaces:** +- Produces: `AuthorizerMfaLocked` component — consumed by Task 4. + +- [ ] **Step 1: Create the component** + +```typescript +import { FC } from 'react'; +import '../styles/default.css'; +import { MessageType } from '../constants'; +import { Message } from './Message'; + +// Shown when the backend rejects a login/verify attempt because the +// account's MFA is permanently locked (schemas.User.MFALockedAt set, via +// the lock_mfa mutation) - distinct from the transient failed-attempt +// lockout AuthorizerVerifyOtp already handles (TOO_MANY_REQUESTS). Only an +// admin can clear this (_update_user with reset_mfa: true), so there is no +// retry action here - matching the backend's own message, which already +// tells the user what to do. +export const AuthorizerMfaLocked: FC = () => ( + +); +``` + +- [ ] **Step 2: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/AuthorizerMfaLocked.tsx +git commit -m "feat(mfa): add locked-account screen" +``` + +(Manual test: exercised as part of Task 4's flow once a locked account exists — to produce one for testing, use the backend's `lock_mfa` mutation directly via GraphQL Playground with a valid MFA session cookie from an in-progress login, since there's no UI path to self-lock yet in this example app; confirms `AuthorizerMfaLocked` renders when `AuthorizerBasicAuthLogin`/`AuthorizerPasskeyLogin` receive a `FAILED_PRECONDITION` error containing the locked message.) + +--- + +## Task 6: OAuth redirect `mfa_required` handling in `AuthorizerRoot` + +**Files:** +- Modify: `src/components/AuthorizerRoot.tsx` + +**Interfaces:** +- Consumes: `parseMfaRedirectParams` from `@authorizerdev/authorizer-js` (already linked; verified export exists: `parseMfaRedirectParams(url: string | URL): { mfaRequired: true; mfaMethods: string[] } | null`), `AuthorizerMFASetup`/`AuthorizerMfaLocked` (Tasks 3/5). + +**Context**: `AuthorizerRoot.tsx` already parses `window.location.search` for `state`/`scope`/`redirect_uri` on every render (~line 37-59) — the OAuth callback lands on this same component (the example app's `login.tsx` renders `` at `/`, which is the configured `redirectURL`, confirmed: no separate OAuth-callback page exists in `example/`). This task adds `mfa_required` detection alongside that existing parsing. + +- [ ] **Step 1: Detect `mfa_required` on render** + +Find (~line 36-40): +```typescript + const { config, configLoadError } = useAuthorizer(); + const searchParams = new URLSearchParams( + hasWindow() ? window.location.search : `` + ); + const state = searchParams.get('state') || createRandomString(); +``` +Add immediately after (still before `const scope = ...`): +```typescript + const mfaRedirect = hasWindow() + ? parseMfaRedirectParams(window.location.href) + : null; +``` +Add the import: +```typescript +import { parseMfaRedirectParams } from '@authorizerdev/authorizer-js'; +``` + +- [ ] **Step 2: Render the MFA flow instead of the normal login view when present** + +Find the component's main return statement's opening (~line 62-70): +```typescript + return ( + + {configLoadError && ( + + )} + {view === Views.Login && ( +``` +Insert a new branch immediately after the `configLoadError` block, before `{view === Views.Login && (`: +```typescript + {mfaRedirect && ( + { + if (onLogin) { + onLogin(data); + } + }, + }} + /> + )} + {!mfaRedirect && view === Views.Login && ( +``` +(The remaining `{view === Views.Signup && ...}`, `{view === Views.Login && config.is_magic_link_login_enabled && ...}`, and `{view === Views.ForgotPassword && ...}` blocks are unchanged — they're already correctly conditioned on `view`, which stays `Views.Login` throughout since nothing sets it otherwise on an OAuth redirect. `mfaRedirect` being present short-circuits before any of them render meaningfully, but leaving their conditions as-is is simpler and safer than threading a new "hide everything else" flag through each one individually.) + +Note: this task doesn't have an `AuthorizerMFASetup`-reachable `email`/`phone_number` for `loginContext` (`mfa_required` redirects don't carry them in the URL — the MFA session cookie alone identifies the pending user for `skip_mfa_setup`/OTP setup in this flow, same as the passkey-login `verify` case in Task 4 Step 4 had no email/phone available). `skipMfaSetup`/`emailOtpMfaSetup`/`smsOtpMfaSetup` all accept an OMITTED `email`/`phone_number` and still work via the bearer-token-absent, cookie-only path IF the backend can resolve the user from the cookie alone — **verify this against the actual backend code before assuming it**: `internal/service/skip_mfa_setup.go`/`otp_mfa_setup.go` currently REQUIRE `email` or `phone_number` to look up the user (the MFA session store is keyed by `(userID, sessionToken)`, not reverse-lookupable from the token alone) per this session's earlier backend research. This means the OAuth-redirect MFA flow, AS SPECIFIED, cannot actually complete skip/setup without an email/phone_number the redirect doesn't carry. **This is a real gap this task's implementer must escalate rather than silently ship a broken flow** — report status `NEEDS_CONTEXT` if this is confirmed still true against the live backend, rather than completing Step 2 with a `loginContext` that will fail every real call. A minimal fix within this task's scope (once confirmed necessary): extend `oauth_mfa_gate.go`'s redirect query params to also carry the resolved user's `email` (already available server-side at that point — `oauth_callback.go` has the resolved `user` object right there) and have this component's `mfaRedirect` parsing pick it up; this is a small, targeted backend change, not the full scope this plan avoided pulling in for passkey registration - flag it as a decision point rather than either silently building broken UI or unilaterally expanding scope back into the backend. + +- [ ] **Step 3: Verify** + +Run: `npx tsc --noEmit` +Expected: PASS, no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/components/AuthorizerRoot.tsx +git commit -m "feat(mfa): detect mfa_required OAuth redirect param in AuthorizerRoot" +``` + +(Manual test: requires a configured OAuth provider (Google/GitHub client ID+secret) on the backend to exercise end-to-end — if none is configured in this environment, this task's implementer should note that in their report as an untested-but-implemented path, consistent with how the backend plan handled the same limitation for its own OAuth gate work, rather than block on setting up a real OAuth app registration.) + +--- + +## Final Step: Full verification + +- [ ] Run `npx tsc --noEmit` once more across the whole package. +- [ ] Grep for `should_offer_mfa_setup` (the deprecated, unqualified field — distinct from the new qualified ones) across `src/` — confirm zero hits. +- [ ] Confirm `git log --oneline` shows the expected commits since branching from `main`. +- [ ] Re-run Task 4's manual test procedures once more end-to-end after all tasks land, since Tasks 5/6 touch files Task 4's tests exercise. From 824d5a9fbe9c40ed5d8532811ab95c2de470fbe5 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 21:43:58 +0530 Subject: [PATCH 05/11] feat(mfa): expose is_totp/email_otp/sms_otp/webauthn_mfa_enabled + is_mfa_enforced on config --- src/contexts/AuthorizerContext.tsx | 10 ++++++++++ src/types/index.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/contexts/AuthorizerContext.tsx b/src/contexts/AuthorizerContext.tsx index 30f6bba..9e93eee 100644 --- a/src/contexts/AuthorizerContext.tsx +++ b/src/contexts/AuthorizerContext.tsx @@ -39,6 +39,11 @@ const AuthorizerContext = createContext({ is_multi_factor_auth_enabled: false, is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, + is_totp_mfa_enabled: false, + is_email_otp_mfa_enabled: false, + is_sms_otp_mfa_enabled: false, + is_webauthn_enabled: false, + is_mfa_enforced: false, }, configLoadError: null, user: null, @@ -114,6 +119,11 @@ let initialState: AuthorizerState = { is_multi_factor_auth_enabled: false, is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, + is_totp_mfa_enabled: false, + is_email_otp_mfa_enabled: false, + is_sms_otp_mfa_enabled: false, + is_webauthn_enabled: false, + is_mfa_enforced: false, }, }; diff --git a/src/types/index.ts b/src/types/index.ts index 6b38ead..7990e6a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,11 @@ export type AuthorizerConfig = { is_multi_factor_auth_enabled: boolean; is_mobile_basic_authentication_enabled: boolean; is_phone_verification_enabled: boolean; + is_totp_mfa_enabled: boolean; + is_email_otp_mfa_enabled: boolean; + is_sms_otp_mfa_enabled: boolean; + is_webauthn_enabled: boolean; + is_mfa_enforced: boolean; }; export type AuthorizerState = { @@ -59,6 +64,11 @@ export type AuthorizerContextPropsType = { is_multi_factor_auth_enabled: boolean; is_mobile_basic_authentication_enabled: boolean; is_phone_verification_enabled: boolean; + is_totp_mfa_enabled: boolean; + is_email_otp_mfa_enabled: boolean; + is_sms_otp_mfa_enabled: boolean; + is_webauthn_enabled: boolean; + is_mfa_enforced: boolean; }; user: null | User; token: null | AuthToken; From e9b6f7abd6cea035ef014976ea7ac0050bba18a0 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 21:45:41 +0530 Subject: [PATCH 06/11] feat(mfa): add shared AuthResponse triage helper --- src/utils/mfaTriage.ts | 123 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/utils/mfaTriage.ts diff --git a/src/utils/mfaTriage.ts b/src/utils/mfaTriage.ts new file mode 100644 index 0000000..f85da07 --- /dev/null +++ b/src/utils/mfaTriage.ts @@ -0,0 +1,123 @@ +import * as Types from '@authorizerdev/authorizer-js'; + +// The message text internal/service/login.go (and webauthn.go, oauth_mfa_gate.go) +// use for a locked account, verified against the backend source. There is no +// dedicated error code for "locked" - FAILED_PRECONDITION is shared with other +// MFA errors (e.g. "cannot skip, MFA is enforced"), so matching on this stable +// message substring is the only reliable client-side signal today. +const LOCKED_MESSAGE_SUBSTRING = 'multi-factor authentication is locked'; + +export type TotpEnrollment = { + authenticator_scanner_image: string; + authenticator_secret: string; + authenticator_recovery_codes: string[]; +}; + +export type AuthStep = + | { kind: 'complete'; response: Types.AuthResponse } + | { + kind: 'verify'; + totp: boolean; + webauthn: boolean; + email: boolean; + mobile: boolean; + } + | { + kind: 'offer'; + totpEnrollment: TotpEnrollment | null; + // Passkey is intentionally excluded from the offer surface here - see + // this plan's Global Constraints: webauthn_registration_options/verify + // require a bearer token that doesn't exist in this withheld-token + // state, a known backend gap tracked separately, not fixed by this + // component. + emailOtp: boolean; + smsOtp: boolean; + } + | { kind: 'locked' } + | { kind: 'error'; message: string }; + +function isLockedError(errors: Types.AuthorizerSDKError[]): boolean { + return errors.some( + (e) => + e.code === 'FAILED_PRECONDITION' && + (e.message || '').includes(LOCKED_MESSAGE_SUBSTRING), + ); +} + +// resolveAuthStep is the single place every login-capable component decides +// what to render next from an SDK call's { data, errors } result. Replaces +// each component's own ad hoc should_show_*/should_offer_* checks - the bug +// that caused a withheld (null access_token) response to be silently treated +// as a successful login in AuthorizerPasskeyLogin was exactly this kind of +// duplicated, incomplete check. +export function resolveAuthStep( + res: Types.AuthResponse | undefined, + errors: Types.AuthorizerSDKError[], +): AuthStep { + if (errors && errors.length) { + if (isLockedError(errors)) { + return { kind: 'locked' }; + } + return { kind: 'error', message: errors[0]?.message || '' }; + } + if (!res) { + return { kind: 'error', message: 'No response from server' }; + } + if (res.access_token) { + return { kind: 'complete', response: res }; + } + // Withheld: either a verify (user already has a completed factor) or an + // offer (first-time, nothing completed yet). should_show_totp_screen is + // shared between both - the enrollment fields being present is what + // distinguishes "offer TOTP setup" from "verify with your existing TOTP". + const hasTotpEnrollment = !!( + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ); + if (res.should_show_totp_screen && !hasTotpEnrollment) { + // Verified TOTP factor exists; asking for a code, not enrollment. + return { + kind: 'verify', + totp: true, + webauthn: !!res.should_offer_webauthn_mfa_verify, + email: !!res.should_show_email_otp_screen, + mobile: !!res.should_show_mobile_otp_screen, + }; + } + if ( + res.should_offer_webauthn_mfa_verify || + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen + ) { + return { + kind: 'verify', + totp: !!res.should_show_totp_screen && !hasTotpEnrollment, + webauthn: !!res.should_offer_webauthn_mfa_verify, + email: !!res.should_show_email_otp_screen, + mobile: !!res.should_show_mobile_otp_screen, + }; + } + if ( + hasTotpEnrollment || + res.should_offer_email_otp_mfa_setup || + res.should_offer_sms_otp_mfa_setup + ) { + return { + kind: 'offer', + totpEnrollment: hasTotpEnrollment + ? { + authenticator_scanner_image: res.authenticator_scanner_image as string, + authenticator_secret: res.authenticator_secret as string, + authenticator_recovery_codes: res.authenticator_recovery_codes as string[], + } + : null, + emailOtp: !!res.should_offer_email_otp_mfa_setup, + smsOtp: !!res.should_offer_sms_otp_mfa_setup, + }; + } + // No access_token and none of the known MFA flags set - treat as an error + // rather than silently completing with a null token (the exact bug this + // helper exists to close). + return { kind: 'error', message: res.message || 'Unable to sign in' }; +} From eaf420b73bbcb186c01320dc278d1caa0e6961b8 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 22:28:47 +0530 Subject: [PATCH 07/11] feat(mfa): add login mode (skip + email/SMS OTP wiring) to AuthorizerMFASetup --- src/components/AuthorizerMFASetup.tsx | 156 +++++++++++++++++++++++--- 1 file changed, 140 insertions(+), 16 deletions(-) diff --git a/src/components/AuthorizerMFASetup.tsx b/src/components/AuthorizerMFASetup.tsx index 20c3361..522e7d1 100644 --- a/src/components/AuthorizerMFASetup.tsx +++ b/src/components/AuthorizerMFASetup.tsx @@ -13,6 +13,9 @@ import { StyledButton } from '../styledComponents'; import { Message } from './Message'; import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner'; import { AuthorizerPasskeyRegister } from './AuthorizerPasskeyRegister'; +import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp'; +import { useAuthorizer } from '../contexts/AuthorizerContext'; +import { TotpEnrollment } from '../utils/mfaTriage'; const BackLink: FC<{ onClick: () => void }> = ({ onClick }) => (