diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index ca0a9fb..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'; @@ -26,6 +27,7 @@ type MfaOfferData = { email: string; phone_number: string; totpEnrollment: TotpEnrollment | null; + passkey: boolean; emailOtp: boolean; smsOtp: boolean; state?: string; @@ -36,6 +38,7 @@ const initMfaOfferData: MfaOfferData = { email: '', phone_number: '', totpEnrollment: null, + passkey: false, emailOtp: false, smsOtp: false, }; @@ -45,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 }); @@ -68,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 }); }; @@ -124,6 +148,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,12 +229,13 @@ export const AuthorizerBasicAuthLogin: FC<{ setMfaOfferData({ ...initMfaOfferData })} loginContext={{ email: mfaOfferData.email, phone_number: mfaOfferData.phone_number, @@ -241,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} /> @@ -282,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', @@ -120,12 +123,13 @@ export const AuthorizerMFASetup: FC<{ }, { key: 'passkey', - available: !!availableMfaMethods.passkey && !loginContext, + 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.', + 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

- + { + if (loginContext && data && (data as AuthTokenLike).access_token) { + loginContext.onComplete(data as AuthTokenLike); + return; + } + backToList(); + }} + /> ); } return ( <> + {onBack && }

{heading}

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

    {m.title}

    +

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

    {m.disabled && m.disabledReason ? m.disabledReason @@ -357,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 9a17e58..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'; @@ -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 @@ -45,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 @@ -68,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 @@ -142,12 +157,13 @@ export const AuthorizerPasskeyLogin: FC<{ setMfaStep(null)} loginContext={{ onComplete: (data) => { setAuthData({ @@ -166,16 +182,27 @@ export const AuthorizerPasskeyLogin: FC<{ } 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. + // email/phone_number in hand (usernameless login). AuthorizerVerifyOtp + // still works here: verify_otp/resend_otp resolve the pending user from + // the MFA session cookie alone when no identifier is supplied (the same + // session-only path the OAuth-return continuation uses). return ( - setMfaStep(null)} + onLogin={(data) => { + 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/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, @@ -31,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 : `` @@ -71,15 +91,37 @@ 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 (onLogin) { @@ -89,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 && ( @@ -105,6 +148,7 @@ export const AuthorizerRoot: FC<{ onLogin={onLogin} urlProps={urlProps} roles={roles} + onStepChange={setBasicAuthStep} /> )} @@ -122,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 }); @@ -96,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 }); @@ -173,6 +197,7 @@ export const AuthorizerSignup: FC<{ email: data.email || ``, phone_number: data.phone_number || ``, totpEnrollment: step.totpEnrollment, + passkey: step.passkey, emailOtp: step.emailOtp, smsOtp: step.smsOtp, state: urlProps?.state, @@ -315,12 +340,13 @@ export const AuthorizerSignup: FC<{ setMfaOfferData({ ...initMfaOfferData })} loginContext={{ email: mfaOfferData.email, phone_number: mfaOfferData.phone_number, @@ -356,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} /> @@ -373,6 +400,20 @@ export const AuthorizerSignup: FC<{ if (fieldOverride?.hide) { return null; } + if (type === 'password') { + return ( + onInputChange(key, value)} + error={errorData[key]} + /> + ); + } return (