Skip to content

Commit c6c62bc

Browse files
committed
feat(mfa): add opt-in MFA setup UI
- new AuthorizerMFASetup hub lists server-supported methods (TOTP, passkey, email/SMS OTP) via availableMfaMethods prop, mapping to the upcoming public meta fields - new AuthorizerPasskeyRegister enrols passkeys via SDK registerPasskey, guarding unsupported browsers - rework AuthorizerTOTPScanner: aligned QR/secret layout, copy setup key, recovery codes with copy/download/print + save-now warning - Storybook stories cover each method/state; fix preview build (drop broken addon-styling-webpack, automatic JSX runtime, global default.css)
1 parent 07811b9 commit c6c62bc

14 files changed

Lines changed: 1122 additions & 37 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ node_modules
55
dist
66
.parcel-cache
77
.yalc
8-
*storybook.log
8+
*storybook.log
9+
storybook-static

.storybook/main.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,51 @@ const config: StorybookConfig = {
99
'@storybook/addon-links',
1010
'@storybook/addon-essentials',
1111
'@storybook/addon-interactions',
12-
'@storybook/addon-styling-webpack',
13-
'@storybook/preset-scss'
12+
'@storybook/preset-scss',
1413
],
1514
framework: {
1615
name: '@storybook/react-webpack5',
1716
options: {
1817
strictMode: true,
1918
},
2019
},
20+
// Match the automatic JSX runtime used by tsconfig ("jsx": "react-jsx") so
21+
// story files don't need an explicit `import React`.
22+
swc: (swcConfig) => ({
23+
...swcConfig,
24+
jsc: {
25+
...swcConfig.jsc,
26+
transform: {
27+
...swcConfig.jsc?.transform,
28+
react: {
29+
...swcConfig.jsc?.transform?.react,
30+
runtime: 'automatic',
31+
},
32+
},
33+
},
34+
}),
2135
webpackFinal: async (currentConfig: WebpackConfiguration, { configType }) => {
2236
// get index of css rule
2337
const ruleCssIndex = currentConfig.module.rules.findIndex(
24-
(rule) => rule.test?.toString() === "/\\.css$/"
38+
(rule) => rule.test?.toString() === '/\\.css$/'
2539
);
2640

2741
// map over the 'use' array of the css rule and set the 'module' option to true
2842
currentConfig.module.rules[ruleCssIndex].use.map((item) => {
29-
if (item.loader && item.loader.includes("/css-loader/")) {
43+
if (item.loader && item.loader.includes('/css-loader/')) {
3044
item.options.modules = {
31-
mode: "local",
45+
// The library ships default.css as a plain global stylesheet
46+
// (consumers import it directly), so its class names must NOT be
47+
// scoped/hashed or component className strings won't match. Keep
48+
// everything else as local CSS modules.
49+
mode: (resourcePath: string) =>
50+
resourcePath.replace(/\\/g, '/').endsWith('styles/default.css')
51+
? 'global'
52+
: 'local',
3253
localIdentName:
33-
configType === "PRODUCTION"
34-
? "[local]__[hash:base64:5]"
35-
: "[name]__[local]__[hash:base64:5]",
54+
configType === 'PRODUCTION'
55+
? '[local]__[hash:base64:5]'
56+
: '[name]__[local]__[hash:base64:5]',
3657
};
3758
}
3859

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { FC, useState } from 'react';
2+
import { isWebauthnSupported } from '@authorizerdev/authorizer-js';
3+
4+
import '../styles/default.css';
5+
import { ButtonAppearance, MessageType } from '../constants';
6+
import {
7+
IconAuthenticator,
8+
IconEmail,
9+
IconPasskey,
10+
IconPhone,
11+
} from '../icons/mfa';
12+
import { StyledButton } from '../styledComponents';
13+
import { Message } from './Message';
14+
import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner';
15+
import { AuthorizerPasskeyRegister } from './AuthorizerPasskeyRegister';
16+
17+
const BackLink: FC<{ onClick: () => void }> = ({ onClick }) => (
18+
<button
19+
type="button"
20+
className="mfa-icon-button"
21+
onClick={onClick}
22+
style={{ border: 'none', background: 'none', padding: '4px 0' }}
23+
>
24+
&larr; All methods
25+
</button>
26+
);
27+
28+
export type MfaMethod = 'totp' | 'passkey' | 'email_otp' | 'sms_otp';
29+
30+
// Which MFA methods the server offers. This shape is designed to map 1:1
31+
// onto the public `meta` query the server will expose next:
32+
//
33+
// totp <- meta.is_totp_mfa_enabled
34+
// passkey <- meta.is_webauthn_enabled (also gated at runtime by the
35+
// browser via isWebauthnSupported())
36+
// emailOtp <- meta.is_email_otp_mfa_enabled
37+
// smsOtp <- meta.is_sms_otp_mfa_enabled
38+
//
39+
// Until those fields land, drive it via this prop. An omitted / false value
40+
// hides the method entirely, so the user only ever sees real options.
41+
export type AvailableMfaMethods = {
42+
totp?: boolean;
43+
passkey?: boolean;
44+
emailOtp?: boolean;
45+
smsOtp?: boolean;
46+
};
47+
48+
type TotpEnrollment = {
49+
authenticator_scanner_image: string;
50+
authenticator_secret: string;
51+
authenticator_recovery_codes: string[];
52+
};
53+
54+
// AuthorizerMFASetup is the hub where a signed-in user opts into the MFA
55+
// methods the server supports. TOTP and passkey have complete in-component
56+
// enrolment flows; email- and SMS-OTP are enabled server-side, so they are
57+
// delegated to the host via onSetupMethod.
58+
export const AuthorizerMFASetup: FC<{
59+
availableMfaMethods: AvailableMfaMethods;
60+
// Enrolment payload for the authenticator-app flow. When present, choosing
61+
// "Set up" for TOTP renders the scanner inline. When absent, onSetupMethod
62+
// is called so the host can fetch it (the server returns the QR + secret +
63+
// recovery codes) and re-render with this prop populated.
64+
totpEnrollment?: TotpEnrollment;
65+
// Fired when a method is chosen that this component can't complete on its
66+
// own (email/SMS OTP, or TOTP without an enrolment payload).
67+
onSetupMethod?: (method: MfaMethod) => void;
68+
heading?: string;
69+
}> = ({
70+
availableMfaMethods,
71+
totpEnrollment,
72+
onSetupMethod,
73+
heading = 'Add a second step to sign in',
74+
}) => {
75+
const [selected, setSelected] = useState<MfaMethod | null>(null);
76+
const [notice, setNotice] = useState('');
77+
78+
const passkeySupported = isWebauthnSupported();
79+
80+
const methods: {
81+
key: MfaMethod;
82+
available: boolean;
83+
icon: JSX.Element;
84+
title: string;
85+
description: string;
86+
disabled?: boolean;
87+
disabledReason?: string;
88+
}[] = [
89+
{
90+
key: 'totp',
91+
available: !!availableMfaMethods.totp,
92+
icon: <IconAuthenticator />,
93+
title: 'Authenticator app',
94+
description: 'Use a TOTP app like Google Authenticator or Authy.',
95+
},
96+
{
97+
key: 'passkey',
98+
available: !!availableMfaMethods.passkey,
99+
icon: <IconPasskey />,
100+
title: 'Passkey',
101+
description: 'Sign in with your fingerprint, face, or device PIN.',
102+
disabled: !passkeySupported,
103+
disabledReason: 'Not supported on this browser or device.',
104+
},
105+
{
106+
key: 'email_otp',
107+
available: !!availableMfaMethods.emailOtp,
108+
icon: <IconEmail />,
109+
title: 'Email one-time code',
110+
description: 'Get a single-use code by email each time you sign in.',
111+
},
112+
{
113+
key: 'sms_otp',
114+
available: !!availableMfaMethods.smsOtp,
115+
icon: <IconPhone />,
116+
title: 'SMS one-time code',
117+
description:
118+
'Get a single-use code by text message each time you sign in.',
119+
},
120+
];
121+
122+
const visibleMethods = methods.filter((m) => m.available);
123+
124+
const handleSetup = (method: MfaMethod) => {
125+
setNotice('');
126+
if (method === 'totp') {
127+
if (totpEnrollment) {
128+
setSelected('totp');
129+
} else {
130+
onSetupMethod?.('totp');
131+
}
132+
return;
133+
}
134+
if (method === 'passkey') {
135+
setSelected('passkey');
136+
return;
137+
}
138+
// email_otp / sms_otp are enabled on the server; hand off to the host.
139+
onSetupMethod?.(method);
140+
setNotice(
141+
method === 'email_otp'
142+
? 'Email one-time codes will be sent the next time you sign in.'
143+
: 'SMS one-time codes will be sent the next time you sign in.'
144+
);
145+
};
146+
147+
const backToList = () => setSelected(null);
148+
149+
if (selected === 'totp' && totpEnrollment) {
150+
return (
151+
<>
152+
<BackLink onClick={backToList} />
153+
<AuthorizerTOTPScanner
154+
{...totpEnrollment}
155+
setView={() => setSelected(null)}
156+
onLogin={() => setSelected(null)}
157+
/>
158+
</>
159+
);
160+
}
161+
162+
if (selected === 'passkey') {
163+
return (
164+
<>
165+
<BackLink onClick={backToList} />
166+
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>Add a passkey</p>
167+
<AuthorizerPasskeyRegister onSuccess={backToList} />
168+
</>
169+
);
170+
}
171+
172+
return (
173+
<>
174+
<p style={{ margin: '10px 0px', fontWeight: 'bold' }}>{heading}</p>
175+
{notice && (
176+
<Message
177+
type={MessageType.Success}
178+
text={notice}
179+
onClose={() => setNotice('')}
180+
/>
181+
)}
182+
{visibleMethods.length === 0 ? (
183+
<Message
184+
type={MessageType.Info}
185+
text="No additional sign-in methods are available right now."
186+
extraStyles={{ color: 'var(--authorizer-text-color)' }}
187+
/>
188+
) : (
189+
<ul className="mfa-list" aria-label="Available multi-factor methods">
190+
{visibleMethods.map((m) => (
191+
<li key={m.key} className="mfa-method">
192+
<span className="mfa-method-icon">{m.icon}</span>
193+
<div className="mfa-method-body">
194+
<p className="mfa-method-title">{m.title}</p>
195+
<p className="mfa-method-desc">
196+
{m.disabled && m.disabledReason
197+
? m.disabledReason
198+
: m.description}
199+
</p>
200+
</div>
201+
<div className="mfa-method-action">
202+
<StyledButton
203+
type="button"
204+
appearance={ButtonAppearance.Default}
205+
disabled={m.disabled}
206+
onClick={() => handleSetup(m.key)}
207+
style={{ width: 'auto' }}
208+
>
209+
Set up
210+
</StyledButton>
211+
</div>
212+
</li>
213+
))}
214+
</ul>
215+
)}
216+
</>
217+
);
218+
};

0 commit comments

Comments
 (0)