From 8e96a1f08ecb4b47faf3411db5a9c159afe3837c Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 18 Jul 2026 17:32:02 +0530 Subject: [PATCH 1/2] feat(mfa): offer passkey enrollment during the login-time MFA setup AuthorizerMFASetup hardcoded passkey: false everywhere it was rendered with loginContext set (login/signup/OAuth/passkey-primary), and force-hid the option whenever loginContext was present at all - because webauthn_registration_options/verify required a bearer token that doesn't exist until the MFA gate is satisfied. The backend (authorizerdev/authorizer, authorizerdev/authorizer-js#47) now supports authenticating that ceremony via the MFA session cookie instead, so passkey is a real option here. Also, using the new mfaGate signal on the OAuth/magic-link return path: route to AuthorizerVerifyOtp for an already-configured factor instead of always showing the setup screen, and let AuthorizerPasskeyLogin's verify step render the same challenge form (previously told the user to sign in with a password instead). Removed a stale useEffect in AuthorizerVerifyOtp that flagged "Email or Phone Number is required" on mount even for the legitimate session-only resolution path these two flows now exercise. --- src/components/AuthorizerBasicAuthLogin.tsx | 5 +++- src/components/AuthorizerMFASetup.tsx | 24 +++++++++++++-- src/components/AuthorizerPasskeyLogin.tsx | 31 +++++++++++++------- src/components/AuthorizerPasskeyRegister.tsx | 17 +++++++---- src/components/AuthorizerRoot.tsx | 25 ++++++++++++++-- src/components/AuthorizerSignup.tsx | 5 +++- src/components/AuthorizerVerifyOtp.tsx | 10 +++---- src/utils/mfaTriage.ts | 12 ++++---- 8 files changed, 98 insertions(+), 31 deletions(-) diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index ca0a9fb..92dd39c 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -26,6 +26,7 @@ type MfaOfferData = { email: string; phone_number: string; totpEnrollment: TotpEnrollment | null; + passkey: boolean; emailOtp: boolean; smsOtp: boolean; state?: string; @@ -36,6 +37,7 @@ const initMfaOfferData: MfaOfferData = { email: '', phone_number: '', totpEnrollment: null, + passkey: false, emailOtp: false, smsOtp: false, }; @@ -124,6 +126,7 @@ export const AuthorizerBasicAuthLogin: FC<{ email: data.email || ``, phone_number: data.phone_number || ``, totpEnrollment: step.totpEnrollment, + passkey: step.passkey, emailOtp: step.emailOtp, smsOtp: step.smsOtp, state: urlProps?.state, @@ -204,7 +207,7 @@ export const AuthorizerBasicAuthLogin: FC<{ , title: 'Passkey', description: 'Sign in with your fingerprint, face, or device PIN.', @@ -308,7 +308,27 @@ export const AuthorizerMFASetup: FC<{ <>

Add a passkey

- + { + if (loginContext && data && (data as AuthTokenLike).access_token) { + loginContext.onComplete(data as AuthTokenLike); + return; + } + backToList(); + }} + /> ); } diff --git a/src/components/AuthorizerPasskeyLogin.tsx b/src/components/AuthorizerPasskeyLogin.tsx index 9a17e58..a9894e2 100644 --- a/src/components/AuthorizerPasskeyLogin.tsx +++ b/src/components/AuthorizerPasskeyLogin.tsx @@ -8,6 +8,7 @@ import { StyledButton, StyledSeparator } from '../styledComponents'; import { Message } from './Message'; import { AuthorizerMFASetup } from './AuthorizerMFASetup'; import { AuthorizerMfaLocked } from './AuthorizerMfaLocked'; +import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp'; import { resolveAuthStep, AuthStep } from '../utils/mfaTriage'; // AuthorizerPasskeyLogin offers a full passwordless, usernameless "Sign in @@ -142,7 +143,7 @@ export const AuthorizerPasskeyLogin: FC<{ { + setAuthData({ + user: data?.user || null, + token: data, + config, + loading: false, + }); + if (onLogin) { + onLogin(data); + } + }} /> ); } diff --git a/src/components/AuthorizerPasskeyRegister.tsx b/src/components/AuthorizerPasskeyRegister.tsx index a812772..67f350b 100644 --- a/src/components/AuthorizerPasskeyRegister.tsx +++ b/src/components/AuthorizerPasskeyRegister.tsx @@ -1,6 +1,6 @@ import { FC, useEffect, useState } from 'react'; import { - GenericResponse, + AuthToken, WebauthnCredentialInfo, isWebauthnSupported, } from '@authorizerdev/authorizer-js'; @@ -21,8 +21,10 @@ import { Message } from './Message'; // browser can't run the WebAuthn JSON ceremony, since in a settings context // the user needs to know why the option is missing. export const AuthorizerPasskeyRegister: FC<{ - // Called after a passkey is successfully registered. - onSuccess?: (data: GenericResponse | void) => void; + // Called after a passkey is successfully registered. On the mfaSetup path + // (below) this carries access_token when registration also completed a + // withheld MFA offer - a plain settings-page add never sets it. + onSuccess?: (data: AuthToken | void) => void; // Optional friendly name for the credential (e.g. "MacBook Touch ID"). // When omitted a small inline field is shown so the user can name it. name?: string; @@ -30,7 +32,11 @@ export const AuthorizerPasskeyRegister: FC<{ // Requires an authenticated session; off by default so Storybook and // unauthenticated hosts don't trigger a failing request. showCredentials?: boolean; -}> = ({ onSuccess, name, showCredentials = false }) => { + // Present only during a token-withheld login-time MFA offer: authenticates + // the ceremony via the MFA session cookie instead of a bearer token (there + // isn't one yet), and completing it issues the previously-withheld token. + mfaSetup?: { email?: string; phoneNumber?: string; state?: string }; +}> = ({ onSuccess, name, showCredentials = false, mfaSetup }) => { const { authorizerRef } = useAuthorizer(); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -76,7 +82,8 @@ export const AuthorizerPasskeyRegister: FC<{ setLoading(true); const trimmed = credentialName.trim(); const { data, errors } = await authorizerRef.registerPasskey( - trimmed || undefined + trimmed || undefined, + mfaSetup ); if (errors && errors.length) { setError(errors[0]?.message || 'Could not add passkey.'); diff --git a/src/components/AuthorizerRoot.tsx b/src/components/AuthorizerRoot.tsx index 889a9f6..92ce320 100644 --- a/src/components/AuthorizerRoot.tsx +++ b/src/components/AuthorizerRoot.tsx @@ -12,6 +12,7 @@ import { AuthorizerSocialLogin } from './AuthorizerSocialLogin'; import { AuthorizerPasskeyLogin } from './AuthorizerPasskeyLogin'; import { AuthorizerMagicLinkLogin } from './AuthorizerMagicLinkLogin'; import { AuthorizerMFASetup } from './AuthorizerMFASetup'; +import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp'; import { Message } from './Message'; import { createRandomString } from '../utils/common'; import { hasWindow } from '../utils/window'; @@ -71,11 +72,31 @@ export const AuthorizerRoot: FC<{ text={`Unable to reach the Authorizer server (${configLoadError}). Login methods that depend on it - such as basic auth, signup, and social login - won't appear until it's reachable.`} /> )} - {mfaRedirect && ( + {mfaRedirect && mfaRedirect.mfaGate === 'verify' && ( + // An already-configured factor must be challenged, not offered setup + // again - no email/phone_number in hand (OAuth/magic-link return), + // but verify_otp resolves the pending user from the MFA session + // cookie alone, same as the passkey-primary-login continuation. + { + if (onLogin) { + onLogin(data); + } + }} + /> + )} + {mfaRedirect && mfaRedirect.mfaGate === 'offer' && ( { - if (!email && !phone_number) { - setError(`Email or Phone Number is required`); - } - }, []); + // No email/phone_number is a legitimate state here, not an error: the + // OAuth-return and passkey-primary-login MFA continuations resolve the + // pending user from the MFA session cookie alone (see verify_otp.go's + // sessionResolved path) - the frontend never learns their email/phone in + // those flows. const [webauthnError, setWebauthnError] = useState(``); const [webauthnLoading, setWebauthnLoading] = useState(false); diff --git a/src/utils/mfaTriage.ts b/src/utils/mfaTriage.ts index f85da07..d9cbe25 100644 --- a/src/utils/mfaTriage.ts +++ b/src/utils/mfaTriage.ts @@ -25,11 +25,11 @@ export type AuthStep = | { 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. + // should_offer_webauthn_mfa_setup: the server-authenticated signal that + // passkey enrollment is available here. webauthn_registration_options/ + // verify authenticate via the MFA session cookie on this path (no + // bearer token yet) - see AuthorizerMFASetup's mfaSetup prop. + passkey: boolean; emailOtp: boolean; smsOtp: boolean; } @@ -100,6 +100,7 @@ export function resolveAuthStep( } if ( hasTotpEnrollment || + res.should_offer_webauthn_mfa_setup || res.should_offer_email_otp_mfa_setup || res.should_offer_sms_otp_mfa_setup ) { @@ -112,6 +113,7 @@ export function resolveAuthStep( authenticator_recovery_codes: res.authenticator_recovery_codes as string[], } : null, + passkey: !!res.should_offer_webauthn_mfa_setup, emailOtp: !!res.should_offer_email_otp_mfa_setup, smsOtp: !!res.should_offer_sms_otp_mfa_setup, }; From a6a4f3fbd609e3f886294a0d78e66eb5a00954b9 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sun, 19 Jul 2026 09:56:48 +0530 Subject: [PATCH 2/2] feat(mfa): passkey login + MFA setup components, shared BackLink --- src/components/AuthorizerBasicAuthLogin.tsx | 57 +++++++++++------- src/components/AuthorizerMFASetup.tsx | 48 +++++++++------ src/components/AuthorizerPasskeyLogin.tsx | 42 +++++++++---- src/components/AuthorizerResetPassword.tsx | 67 ++++++--------------- src/components/AuthorizerRoot.tsx | 48 +++++++++++---- src/components/AuthorizerSignup.tsx | 40 +++++++++++- src/components/AuthorizerSocialLogin.tsx | 6 ++ src/components/AuthorizerVerifyOtp.tsx | 56 ++++++++++++----- src/components/BackLink.tsx | 35 +++++++++++ src/components/PasswordInput.tsx | 61 +++++++++++++++++++ src/icons/eye.tsx | 43 +++++++++++++ src/styles/default.css | 45 ++++++++++++++ 12 files changed, 419 insertions(+), 129 deletions(-) create mode 100644 src/components/BackLink.tsx create mode 100644 src/components/PasswordInput.tsx create mode 100644 src/icons/eye.tsx diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index 92dd39c..5d4dcd4 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -8,6 +8,7 @@ import { ButtonAppearance, MessageType, Views } from '../constants'; import { useAuthorizer } from '../contexts/AuthorizerContext'; import { StyledButton, StyledFooter, StyledLink } from '../styledComponents'; import { Message } from './Message'; +import { PasswordInput } from './PasswordInput'; import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp'; import { OtpDataType } from '../types'; import { AuthorizerMFASetup } from './AuthorizerMFASetup'; @@ -47,12 +48,20 @@ interface InputDataType { password: string | null; } +export type BasicAuthLoginStep = 'form' | 'mfa-setup' | 'otp-verify' | 'locked'; + export const AuthorizerBasicAuthLogin: FC<{ setView?: (v: Views) => void; onLogin?: (data: AuthToken | void) => void; urlProps?: Record; roles?: string[]; -}> = ({ setView, onLogin, urlProps, roles }) => { + // Fired whenever this component switches between its own screens. See + // AuthorizerSignup's identical prop for why hosts need this: a successful + // login that still needs MFA setup/verification takes over the whole + // login surface - other login options (social buttons, passkey button) + // don't belong stacked on top of those screens. + onStepChange?: (step: BasicAuthLoginStep) => void; +}> = ({ setView, onLogin, urlProps, roles, onStepChange }) => { const [error, setError] = useState(``); const [loading, setLoading] = useState(false); const [otpData, setOtpData] = useState({ ...initOtpData }); @@ -70,6 +79,19 @@ export const AuthorizerBasicAuthLogin: FC<{ }); const { setAuthData, config, authorizerRef } = useAuthorizer(); + useEffect(() => { + if (!onStepChange) return; + if (locked) { + onStepChange('locked'); + } else if (mfaOfferData.is_screen_visible) { + onStepChange('mfa-setup'); + } else if (otpData.is_screen_visible) { + onStepChange('otp-verify'); + } else { + onStepChange('form'); + } + }, [locked, mfaOfferData.is_screen_visible, otpData.is_screen_visible]); + const onInputChange = async (field: string, value: string) => { setFormData({ ...formData, [field]: value }); }; @@ -213,6 +235,7 @@ export const AuthorizerBasicAuthLogin: FC<{ }} totpEnrollment={mfaOfferData.totpEnrollment || undefined} heading="Set up multi-factor authentication" + onBack={() => setMfaOfferData({ ...initMfaOfferData })} loginContext={{ email: mfaOfferData.email, phone_number: mfaOfferData.phone_number, @@ -244,6 +267,7 @@ export const AuthorizerBasicAuthLogin: FC<{ is_totp: otpData.is_totp || false, offerWebauthnVerify: otpData.offer_webauthn_verify || false, hasCodeFactor: otpData.has_code_factor || false, + onBack: () => setOtpData({ ...initOtpData }), }} urlProps={urlProps} /> @@ -285,28 +309,15 @@ export const AuthorizerBasicAuthLogin: FC<{ )} -
- - onInputChange('password', e.target.value)} - /> - {errorData.password && ( -
{errorData.password}
- )} -
+ onInputChange('password', value)} + error={errorData.password} + />
void }> = ({ onClick }) => ( - -); - export type MfaMethod = 'totp' | 'passkey' | 'email_otp' | 'sms_otp'; // Which MFA methods the server offers. This shape is designed to map 1:1 @@ -76,12 +66,24 @@ export const AuthorizerMFASetup: FC<{ state?: string; onComplete: (response: AuthTokenLike) => void; }; + // When present, a "Back" link is shown on the top-level method list so the + // host can let the user leave MFA setup entirely (distinct from BackLink's + // existing "All methods" links, which only return from a method's + // sub-flow to this same list). + onBack?: () => void; + // Whether the signed-in user already has at least one passkey registered + // (the host knows this via webauthnCredentials - there's no per-user + // enrolment signal for TOTP/email-OTP/SMS-OTP available to the client, so + // only passkey can be accurately highlighted as already set up). + passkeyRegistered?: boolean; }> = ({ availableMfaMethods, totpEnrollment, onSetupMethod, heading = 'Add a second step to sign in', loginContext, + onBack, + passkeyRegistered, }) => { const [selected, setSelected] = useState(null); const [notice, setNotice] = useState(''); @@ -110,6 +112,7 @@ export const AuthorizerMFASetup: FC<{ description: string; disabled?: boolean; disabledReason?: string; + enabled?: boolean; }[] = [ { key: 'totp', @@ -126,6 +129,7 @@ export const AuthorizerMFASetup: FC<{ description: 'Sign in with your fingerprint, face, or device PIN.', disabled: !passkeySupported, disabledReason: 'Not supported on this browser or device.', + enabled: !!passkeyRegistered, }, { key: 'email_otp', @@ -264,12 +268,11 @@ export const AuthorizerMFASetup: FC<{ if (selected === 'totp' && effectiveTotpEnrollment) { return ( <> - + { if (loginContext && data && (data as AuthTokenLike).access_token) { loginContext.onComplete(data as AuthTokenLike); @@ -285,7 +288,7 @@ export const AuthorizerMFASetup: FC<{ if (otpMethodPending) { return ( <> - setOtpMethodPending(null)} /> + setOtpMethodPending(null)} label="All methods" /> - +

Add a passkey

+ {onBack && }

{heading}

{notice && ( {visibleMethods.map((m) => ( -
  • +
  • {m.icon}
    -

    {m.title}

    +

    + {m.title} + {m.enabled && ( + Enabled + )} +

    {m.disabled && m.disabledReason ? m.disabledReason @@ -377,7 +389,7 @@ export const AuthorizerMFASetup: FC<{ onClick={() => handleSetup(m.key)} style={{ width: 'auto' }} > - Set up + {m.enabled ? 'Manage' : 'Set up'}

  • diff --git a/src/components/AuthorizerPasskeyLogin.tsx b/src/components/AuthorizerPasskeyLogin.tsx index a9894e2..583fc95 100644 --- a/src/components/AuthorizerPasskeyLogin.tsx +++ b/src/components/AuthorizerPasskeyLogin.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC, useEffect, useState } from 'react'; import { AuthToken, isWebauthnSupported } from '@authorizerdev/authorizer-js'; import '../styles/default.css'; @@ -46,12 +46,31 @@ const PasskeyIcon: FC = () => ( export const AuthorizerPasskeyLogin: FC<{ onLogin?: (data: AuthToken | void) => void; -}> = ({ onLogin }) => { + // Fired whenever this component switches between its own button and its + // internal MFA offer/verify/locked screens. A passkey-primary login that + // needs a second factor takes over the whole login surface - hosts + // rendering other login options (social buttons, password form) alongside + // this component need this to hide them while an MFA screen is showing. + onStepChange?: (step: 'button' | 'mfa-setup' | 'mfa-verify' | 'locked') => void; +}> = ({ onLogin, onStepChange }) => { const [error, setError] = useState(``); const [loading, setLoading] = useState(false); const [mfaStep, setMfaStep] = useState(null); const { setAuthData, config, authorizerRef } = useAuthorizer(); + useEffect(() => { + if (!onStepChange) return; + if (mfaStep?.kind === 'locked') { + onStepChange('locked'); + } else if (mfaStep?.kind === 'offer') { + onStepChange('mfa-setup'); + } else if (mfaStep?.kind === 'verify') { + onStepChange('mfa-verify'); + } else { + onStepChange('button'); + } + }, [mfaStep?.kind]); + // When the org enforces MFA, passkey must never be offered as a // standalone primary-login path - it would let a user skip the org's // two-factor requirement entirely. The server refuses this independently @@ -69,22 +88,17 @@ export const AuthorizerPasskeyLogin: FC<{ // exact set of conditions AuthorizerRoot uses to decide whether // AuthorizerSocialLogin, AuthorizerBasicAuthLogin, or // AuthorizerMagicLinkLogin render anything on the login view. - const hasSocialLogin = - config.is_google_login_enabled || - config.is_github_login_enabled || - config.is_facebook_login_enabled || - config.is_linkedin_login_enabled || - config.is_apple_login_enabled || - config.is_twitter_login_enabled || - config.is_microsoft_login_enabled || - config.is_twitch_login_enabled || - config.is_roblox_login_enabled; + // Deliberately excludes hasSocialLogin: social login always renders above + // this button in every known composition (AuthorizerRoot, web/app's + // login.tsx), so it's never "below" the passkey button - counting it here + // produced a second, redundant "OR" stacked under the first one AND a + // dangling "OR" with nothing beneath when social was the only other method. const hasBasicAuthLogin = (config.is_basic_authentication_enabled || config.is_mobile_basic_authentication_enabled) && !config.is_magic_link_login_enabled; const hasAnotherLoginMethod = - hasSocialLogin || hasBasicAuthLogin || config.is_magic_link_login_enabled; + hasBasicAuthLogin || config.is_magic_link_login_enabled; // A cancelled ceremony or an account with no passkey surfaces as // NotAllowedError/AbortError (the browser deliberately does not distinguish @@ -149,6 +163,7 @@ export const AuthorizerPasskeyLogin: FC<{ }} totpEnrollment={mfaStep.totpEnrollment || undefined} heading="Set up multi-factor authentication" + onBack={() => setMfaStep(null)} loginContext={{ onComplete: (data) => { setAuthData({ @@ -176,6 +191,7 @@ export const AuthorizerPasskeyLogin: FC<{ is_totp={mfaStep.totp} offerWebauthnVerify={mfaStep.webauthn} hasCodeFactor={mfaStep.totp || mfaStep.email || mfaStep.mobile} + onBack={() => setMfaStep(null)} onLogin={(data) => { setAuthData({ user: data?.user || null, diff --git a/src/components/AuthorizerResetPassword.tsx b/src/components/AuthorizerResetPassword.tsx index cdc5eec..4b3bb93 100644 --- a/src/components/AuthorizerResetPassword.tsx +++ b/src/components/AuthorizerResetPassword.tsx @@ -6,6 +6,7 @@ import { useAuthorizer } from '../contexts/AuthorizerContext'; import { StyledButton, StyledWrapper } from '../styledComponents'; import { formatErrorMessage } from '../utils/format'; import { Message } from './Message'; +import { PasswordInput } from './PasswordInput'; import { getSearchParams } from '../utils/url'; import PasswordStrengthIndicator from './PasswordStrengthIndicator'; @@ -163,54 +164,24 @@ export const AuthorizerResetPassword: FC = ({ )} )} -
    - - onInputChange('password', e.target.value)} - /> - {errorData.password && ( -
    - {errorData.password} -
    - )} -
    -
    - - onInputChange('confirmPassword', e.target.value)} - /> - {errorData.confirmPassword && ( -
    - {errorData.confirmPassword} -
    - )} -
    + onInputChange('password', value)} + error={errorData.password} + /> + onInputChange('confirmPassword', value)} + error={errorData.confirmPassword} + /> {config.is_strong_password_enabled && ( <> void; roles?: string[]; signupFieldsOverrides?: FormFieldsOverrides + // When present, a "Back" link is shown on the MFA setup/verify screens + // (URL-param-driven, see mfaRedirect below) so the host can send the user + // somewhere sane - e.g. back to the login URL with the mfa params cleared. + onCancelMfa?: () => void; }> = ({ onLogin, onSignup, @@ -32,9 +36,24 @@ export const AuthorizerRoot: FC<{ onForgotPassword, onPasswordReset, roles, - signupFieldsOverrides + signupFieldsOverrides, + onCancelMfa, }) => { const [view, setView] = useState(Views.Login); + // AuthorizerPasskeyLogin and AuthorizerBasicAuthLogin each take over the + // whole login surface once their own sign-in needs a second factor (their + // own MFA setup/verify/locked screens) - every other login option, and the + // login attempt not currently in flight, don't belong stacked on top of + // those screens. + const [passkeyStep, setPasskeyStep] = useState< + 'button' | 'mfa-setup' | 'mfa-verify' | 'locked' + >('button'); + const [basicAuthStep, setBasicAuthStep] = useState< + 'form' | 'mfa-setup' | 'otp-verify' | 'locked' + >('form'); + const passkeyIdle = passkeyStep === 'button'; + const basicAuthIdle = basicAuthStep === 'form'; + const showChrome = passkeyIdle && basicAuthIdle; const { config, configLoadError } = useAuthorizer(); const searchParams = new URLSearchParams( hasWindow() ? window.location.search : `` @@ -85,6 +104,7 @@ export const AuthorizerRoot: FC<{ mfaRedirect.mfaMethods.includes('email_otp') || mfaRedirect.mfaMethods.includes('sms_otp') } + onBack={onCancelMfa} onLogin={(data: any) => { if (onLogin) { onLogin(data); @@ -101,6 +121,7 @@ export const AuthorizerRoot: FC<{ smsOtp: mfaRedirect.mfaMethods.includes('sms_otp'), }} heading="Set up multi-factor authentication" + onBack={onCancelMfa} loginContext={{ onComplete: (data: any) => { if (onLogin) { @@ -110,14 +131,15 @@ export const AuthorizerRoot: FC<{ }} /> )} - {!mfaRedirect && view === Views.Login && ( + {!mfaRedirect && view === Views.Login && showChrome && ( )} - {!mfaRedirect && view === Views.Login && ( - + {!mfaRedirect && view === Views.Login && basicAuthIdle && ( + )} {!mfaRedirect && view === Views.Login && + passkeyIdle && (config.is_basic_authentication_enabled || config.is_mobile_basic_authentication_enabled) && !config.is_magic_link_login_enabled && ( @@ -126,6 +148,7 @@ export const AuthorizerRoot: FC<{ onLogin={onLogin} urlProps={urlProps} roles={roles} + onStepChange={setBasicAuthStep} /> )} @@ -143,13 +166,16 @@ export const AuthorizerRoot: FC<{ /> )} - {!mfaRedirect && view === Views.Login && config.is_magic_link_login_enabled && ( - - )} + {!mfaRedirect && + view === Views.Login && + showChrome && + config.is_magic_link_login_enabled && ( + + )} {view === Views.ForgotPassword && ( void; onSignup?: (data: AuthToken) => void; urlProps?: Record; roles?: string[]; fieldOverrides?: FormFieldsOverrides; -}> = ({ setView, onSignup, urlProps, roles, fieldOverrides }) => { + // Fired whenever this component switches between its own internal + // screens. Hosts that render other login options (e.g. "Continue with + // Google") alongside the signup form need this to hide them once the + // account exists and the user has moved on to MFA setup/verification - + // those options no longer make sense on top of those screens. + onStepChange?: (step: SignupStep) => void; +}> = ({ setView, onSignup, urlProps, roles, fieldOverrides, onStepChange }) => { const [error, setError] = useState(``); const [loading, setLoading] = useState(false); const [otpData, setOtpData] = useState({ ...initOtpData }); @@ -98,6 +107,19 @@ export const AuthorizerSignup: FC<{ const { authorizerRef, config, setAuthData } = useAuthorizer(); const [disableSignupButton, setDisableSignupButton] = useState(false); + useEffect(() => { + if (!onStepChange) return; + if (locked) { + onStepChange('locked'); + } else if (mfaOfferData.is_screen_visible) { + onStepChange('mfa-setup'); + } else if (otpData.is_screen_visible) { + onStepChange('otp-verify'); + } else { + onStepChange('form'); + } + }, [locked, mfaOfferData.is_screen_visible, otpData.is_screen_visible]); + const onInputChange = async (field: string, value: string) => setFormData({ ...formData, [field]: value }); @@ -324,6 +346,7 @@ export const AuthorizerSignup: FC<{ }} totpEnrollment={mfaOfferData.totpEnrollment || undefined} heading="Set up multi-factor authentication" + onBack={() => setMfaOfferData({ ...initMfaOfferData })} loginContext={{ email: mfaOfferData.email, phone_number: mfaOfferData.phone_number, @@ -359,6 +382,7 @@ export const AuthorizerSignup: FC<{ is_totp: otpData.is_totp || false, offerWebauthnVerify: otpData.offer_webauthn_verify || false, hasCodeFactor: otpData.has_code_factor || false, + onBack: () => setOtpData({ ...initOtpData }), }} urlProps={urlProps} /> @@ -376,6 +400,20 @@ export const AuthorizerSignup: FC<{ if (fieldOverride?.hide) { return null; } + if (type === 'password') { + return ( + onInputChange(key, value)} + error={errorData[key]} + /> + ); + } return (