Skip to content

Commit a6a4f3f

Browse files
committed
feat(mfa): passkey login + MFA setup components, shared BackLink
1 parent 8e96a1f commit a6a4f3f

12 files changed

Lines changed: 419 additions & 129 deletions

src/components/AuthorizerBasicAuthLogin.tsx

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ButtonAppearance, MessageType, Views } from '../constants';
88
import { useAuthorizer } from '../contexts/AuthorizerContext';
99
import { StyledButton, StyledFooter, StyledLink } from '../styledComponents';
1010
import { Message } from './Message';
11+
import { PasswordInput } from './PasswordInput';
1112
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
1213
import { OtpDataType } from '../types';
1314
import { AuthorizerMFASetup } from './AuthorizerMFASetup';
@@ -47,12 +48,20 @@ interface InputDataType {
4748
password: string | null;
4849
}
4950

51+
export type BasicAuthLoginStep = 'form' | 'mfa-setup' | 'otp-verify' | 'locked';
52+
5053
export const AuthorizerBasicAuthLogin: FC<{
5154
setView?: (v: Views) => void;
5255
onLogin?: (data: AuthToken | void) => void;
5356
urlProps?: Record<string, any>;
5457
roles?: string[];
55-
}> = ({ setView, onLogin, urlProps, roles }) => {
58+
// Fired whenever this component switches between its own screens. See
59+
// AuthorizerSignup's identical prop for why hosts need this: a successful
60+
// login that still needs MFA setup/verification takes over the whole
61+
// login surface - other login options (social buttons, passkey button)
62+
// don't belong stacked on top of those screens.
63+
onStepChange?: (step: BasicAuthLoginStep) => void;
64+
}> = ({ setView, onLogin, urlProps, roles, onStepChange }) => {
5665
const [error, setError] = useState(``);
5766
const [loading, setLoading] = useState(false);
5867
const [otpData, setOtpData] = useState<OtpDataType>({ ...initOtpData });
@@ -70,6 +79,19 @@ export const AuthorizerBasicAuthLogin: FC<{
7079
});
7180
const { setAuthData, config, authorizerRef } = useAuthorizer();
7281

82+
useEffect(() => {
83+
if (!onStepChange) return;
84+
if (locked) {
85+
onStepChange('locked');
86+
} else if (mfaOfferData.is_screen_visible) {
87+
onStepChange('mfa-setup');
88+
} else if (otpData.is_screen_visible) {
89+
onStepChange('otp-verify');
90+
} else {
91+
onStepChange('form');
92+
}
93+
}, [locked, mfaOfferData.is_screen_visible, otpData.is_screen_visible]);
94+
7395
const onInputChange = async (field: string, value: string) => {
7496
setFormData({ ...formData, [field]: value });
7597
};
@@ -213,6 +235,7 @@ export const AuthorizerBasicAuthLogin: FC<{
213235
}}
214236
totpEnrollment={mfaOfferData.totpEnrollment || undefined}
215237
heading="Set up multi-factor authentication"
238+
onBack={() => setMfaOfferData({ ...initMfaOfferData })}
216239
loginContext={{
217240
email: mfaOfferData.email,
218241
phone_number: mfaOfferData.phone_number,
@@ -244,6 +267,7 @@ export const AuthorizerBasicAuthLogin: FC<{
244267
is_totp: otpData.is_totp || false,
245268
offerWebauthnVerify: otpData.offer_webauthn_verify || false,
246269
hasCodeFactor: otpData.has_code_factor || false,
270+
onBack: () => setOtpData({ ...initOtpData }),
247271
}}
248272
urlProps={urlProps}
249273
/>
@@ -285,28 +309,15 @@ export const AuthorizerBasicAuthLogin: FC<{
285309
</div>
286310
)}
287311
</div>
288-
<div className="styled-form-group">
289-
<label
290-
className="form-input-label"
291-
htmlFor="authorizer-login-password"
292-
>
293-
<span>* </span>Password
294-
</label>
295-
<input
296-
name="password"
297-
id="authorizer-login-password"
298-
className={`form-input-field ${
299-
errorData.password ? 'input-error-content' : ''
300-
}`}
301-
placeholder="********"
302-
type="password"
303-
value={formData.password || ''}
304-
onChange={e => onInputChange('password', e.target.value)}
305-
/>
306-
{errorData.password && (
307-
<div className="form-input-error">{errorData.password}</div>
308-
)}
309-
</div>
312+
<PasswordInput
313+
id="authorizer-login-password"
314+
name="password"
315+
label="Password"
316+
autoComplete="current-password"
317+
value={formData.password || ''}
318+
onChange={(value) => onInputChange('password', value)}
319+
error={errorData.password}
320+
/>
310321
<br />
311322
<StyledButton
312323
type="submit"

src/components/AuthorizerMFASetup.tsx

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,13 @@ import {
1111
} from '../icons/mfa';
1212
import { StyledButton } from '../styledComponents';
1313
import { Message } from './Message';
14+
import { BackLink } from './BackLink';
1415
import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner';
1516
import { AuthorizerPasskeyRegister } from './AuthorizerPasskeyRegister';
1617
import { AuthorizerVerifyOtp } from './AuthorizerVerifyOtp';
1718
import { useAuthorizer } from '../contexts/AuthorizerContext';
1819
import { TotpEnrollment } from '../utils/mfaTriage';
1920

20-
const BackLink: FC<{ onClick: () => void }> = ({ onClick }) => (
21-
<button
22-
type="button"
23-
className="mfa-icon-button"
24-
onClick={onClick}
25-
style={{ border: 'none', background: 'none', padding: '4px 0' }}
26-
>
27-
&larr; All methods
28-
</button>
29-
);
30-
3121
export type MfaMethod = 'totp' | 'passkey' | 'email_otp' | 'sms_otp';
3222

3323
// Which MFA methods the server offers. This shape is designed to map 1:1
@@ -76,12 +66,24 @@ export const AuthorizerMFASetup: FC<{
7666
state?: string;
7767
onComplete: (response: AuthTokenLike) => void;
7868
};
69+
// When present, a "Back" link is shown on the top-level method list so the
70+
// host can let the user leave MFA setup entirely (distinct from BackLink's
71+
// existing "All methods" links, which only return from a method's
72+
// sub-flow to this same list).
73+
onBack?: () => void;
74+
// Whether the signed-in user already has at least one passkey registered
75+
// (the host knows this via webauthnCredentials - there's no per-user
76+
// enrolment signal for TOTP/email-OTP/SMS-OTP available to the client, so
77+
// only passkey can be accurately highlighted as already set up).
78+
passkeyRegistered?: boolean;
7979
}> = ({
8080
availableMfaMethods,
8181
totpEnrollment,
8282
onSetupMethod,
8383
heading = 'Add a second step to sign in',
8484
loginContext,
85+
onBack,
86+
passkeyRegistered,
8587
}) => {
8688
const [selected, setSelected] = useState<MfaMethod | null>(null);
8789
const [notice, setNotice] = useState('');
@@ -110,6 +112,7 @@ export const AuthorizerMFASetup: FC<{
110112
description: string;
111113
disabled?: boolean;
112114
disabledReason?: string;
115+
enabled?: boolean;
113116
}[] = [
114117
{
115118
key: 'totp',
@@ -126,6 +129,7 @@ export const AuthorizerMFASetup: FC<{
126129
description: 'Sign in with your fingerprint, face, or device PIN.',
127130
disabled: !passkeySupported,
128131
disabledReason: 'Not supported on this browser or device.',
132+
enabled: !!passkeyRegistered,
129133
},
130134
{
131135
key: 'email_otp',
@@ -264,12 +268,11 @@ export const AuthorizerMFASetup: FC<{
264268
if (selected === 'totp' && effectiveTotpEnrollment) {
265269
return (
266270
<>
267-
<BackLink onClick={backToList} />
271+
<BackLink onClick={backToList} label="All methods" />
268272
<AuthorizerTOTPScanner
269273
{...effectiveTotpEnrollment}
270274
email={loginContext?.email}
271275
phone_number={loginContext?.phone_number}
272-
setView={backToList}
273276
onLogin={(data) => {
274277
if (loginContext && data && (data as AuthTokenLike).access_token) {
275278
loginContext.onComplete(data as AuthTokenLike);
@@ -285,7 +288,7 @@ export const AuthorizerMFASetup: FC<{
285288
if (otpMethodPending) {
286289
return (
287290
<>
288-
<BackLink onClick={() => setOtpMethodPending(null)} />
291+
<BackLink onClick={() => setOtpMethodPending(null)} label="All methods" />
289292
<AuthorizerVerifyOtp
290293
email={loginContext?.email}
291294
phone_number={loginContext?.phone_number}
@@ -306,7 +309,7 @@ export const AuthorizerMFASetup: FC<{
306309
if (selected === 'passkey') {
307310
return (
308311
<>
309-
<BackLink onClick={backToList} />
312+
<BackLink onClick={backToList} label="All methods" />
310313
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>Add a passkey</p>
311314
<AuthorizerPasskeyRegister
312315
// showCredentials calls webauthn_credentials, which requires a
@@ -335,6 +338,7 @@ export const AuthorizerMFASetup: FC<{
335338

336339
return (
337340
<>
341+
{onBack && <BackLink onClick={onBack} />}
338342
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>{heading}</p>
339343
{notice && (
340344
<Message
@@ -359,10 +363,18 @@ export const AuthorizerMFASetup: FC<{
359363
) : (
360364
<ul className="mfa-list" aria-label="Available multi-factor methods">
361365
{visibleMethods.map((m) => (
362-
<li key={m.key} className="mfa-method">
366+
<li
367+
key={m.key}
368+
className={`mfa-method${m.enabled ? ' mfa-method-enabled' : ''}`}
369+
>
363370
<span className="mfa-method-icon">{m.icon}</span>
364371
<div className="mfa-method-body">
365-
<p className="mfa-method-title">{m.title}</p>
372+
<p className="mfa-method-title">
373+
{m.title}
374+
{m.enabled && (
375+
<span className="mfa-method-badge">Enabled</span>
376+
)}
377+
</p>
366378
<p className="mfa-method-desc">
367379
{m.disabled && m.disabledReason
368380
? m.disabledReason
@@ -377,7 +389,7 @@ export const AuthorizerMFASetup: FC<{
377389
onClick={() => handleSetup(m.key)}
378390
style={{ width: 'auto' }}
379391
>
380-
Set up
392+
{m.enabled ? 'Manage' : 'Set up'}
381393
</StyledButton>
382394
</div>
383395
</li>

src/components/AuthorizerPasskeyLogin.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FC, useState } from 'react';
1+
import { FC, useEffect, useState } from 'react';
22
import { AuthToken, isWebauthnSupported } from '@authorizerdev/authorizer-js';
33

44
import '../styles/default.css';
@@ -46,12 +46,31 @@ const PasskeyIcon: FC = () => (
4646

4747
export const AuthorizerPasskeyLogin: FC<{
4848
onLogin?: (data: AuthToken | void) => void;
49-
}> = ({ onLogin }) => {
49+
// Fired whenever this component switches between its own button and its
50+
// internal MFA offer/verify/locked screens. A passkey-primary login that
51+
// needs a second factor takes over the whole login surface - hosts
52+
// rendering other login options (social buttons, password form) alongside
53+
// this component need this to hide them while an MFA screen is showing.
54+
onStepChange?: (step: 'button' | 'mfa-setup' | 'mfa-verify' | 'locked') => void;
55+
}> = ({ onLogin, onStepChange }) => {
5056
const [error, setError] = useState(``);
5157
const [loading, setLoading] = useState(false);
5258
const [mfaStep, setMfaStep] = useState<AuthStep | null>(null);
5359
const { setAuthData, config, authorizerRef } = useAuthorizer();
5460

61+
useEffect(() => {
62+
if (!onStepChange) return;
63+
if (mfaStep?.kind === 'locked') {
64+
onStepChange('locked');
65+
} else if (mfaStep?.kind === 'offer') {
66+
onStepChange('mfa-setup');
67+
} else if (mfaStep?.kind === 'verify') {
68+
onStepChange('mfa-verify');
69+
} else {
70+
onStepChange('button');
71+
}
72+
}, [mfaStep?.kind]);
73+
5574
// When the org enforces MFA, passkey must never be offered as a
5675
// standalone primary-login path - it would let a user skip the org's
5776
// two-factor requirement entirely. The server refuses this independently
@@ -69,22 +88,17 @@ export const AuthorizerPasskeyLogin: FC<{
6988
// exact set of conditions AuthorizerRoot uses to decide whether
7089
// AuthorizerSocialLogin, AuthorizerBasicAuthLogin, or
7190
// AuthorizerMagicLinkLogin render anything on the login view.
72-
const hasSocialLogin =
73-
config.is_google_login_enabled ||
74-
config.is_github_login_enabled ||
75-
config.is_facebook_login_enabled ||
76-
config.is_linkedin_login_enabled ||
77-
config.is_apple_login_enabled ||
78-
config.is_twitter_login_enabled ||
79-
config.is_microsoft_login_enabled ||
80-
config.is_twitch_login_enabled ||
81-
config.is_roblox_login_enabled;
91+
// Deliberately excludes hasSocialLogin: social login always renders above
92+
// this button in every known composition (AuthorizerRoot, web/app's
93+
// login.tsx), so it's never "below" the passkey button - counting it here
94+
// produced a second, redundant "OR" stacked under the first one AND a
95+
// dangling "OR" with nothing beneath when social was the only other method.
8296
const hasBasicAuthLogin =
8397
(config.is_basic_authentication_enabled ||
8498
config.is_mobile_basic_authentication_enabled) &&
8599
!config.is_magic_link_login_enabled;
86100
const hasAnotherLoginMethod =
87-
hasSocialLogin || hasBasicAuthLogin || config.is_magic_link_login_enabled;
101+
hasBasicAuthLogin || config.is_magic_link_login_enabled;
88102

89103
// A cancelled ceremony or an account with no passkey surfaces as
90104
// NotAllowedError/AbortError (the browser deliberately does not distinguish
@@ -149,6 +163,7 @@ export const AuthorizerPasskeyLogin: FC<{
149163
}}
150164
totpEnrollment={mfaStep.totpEnrollment || undefined}
151165
heading="Set up multi-factor authentication"
166+
onBack={() => setMfaStep(null)}
152167
loginContext={{
153168
onComplete: (data) => {
154169
setAuthData({
@@ -176,6 +191,7 @@ export const AuthorizerPasskeyLogin: FC<{
176191
is_totp={mfaStep.totp}
177192
offerWebauthnVerify={mfaStep.webauthn}
178193
hasCodeFactor={mfaStep.totp || mfaStep.email || mfaStep.mobile}
194+
onBack={() => setMfaStep(null)}
179195
onLogin={(data) => {
180196
setAuthData({
181197
user: data?.user || null,

0 commit comments

Comments
 (0)