- Scan the QR code or enter the secret key into your authenticator app.
+ Set up your authenticator app
-
+
+
+ Open your authenticator app (Google Authenticator, Authy,
+ 1Password…).
+
+ Scan the QR code below, or enter the setup key manually.
+ Enter the 6-digit code the app generates to finish.
+
+
+
-
-
- If you are unable to scan the QR code, please enter the secret key
- manually.
-
-
- {authenticator_secret}
-
-
-
- If you lose access to your authenticator app, you can use the recovery
- codes below to regain access to your account. Please save these codes
- safely and do not share them with anyone.
+
+
+
+ Can't scan it? Enter this setup key manually:
-
- {authenticator_recovery_codes.map((code, index) => {
- return {code} ;
- })}
-
-
- Continue
-
+
+
+ {authenticator_secret}
+
+ handleCopy(authenticator_secret, 'secret')}
+ aria-label="Copy setup key"
+ >
+ {copied === 'secret' ? : }
+ {copied === 'secret' ? 'Copied' : 'Copy'}
+
+
+
+ {hasRecoveryCodes && (
+ <>
+
+
+ Save your recovery codes
+
+
+ ⚠
+
+ Save these codes now. If you lose your authenticator device, they
+ are the only way back into your account. Each code works once, and
+ they won't be shown again.
+
+
+
+ {recoveryCodes.map((code) => (
+
+ {code}
+
+ ))}
+
+
+ handleCopy(recoveryCodes.join('\n'), 'codes')}
+ aria-label="Copy all recovery codes"
+ >
+ {copied === 'codes' ? : }
+ {copied === 'codes' ? 'Copied' : 'Copy all'}
+
+ downloadRecoveryCodes(recoveryCodes)}
+ aria-label="Download recovery codes as a text file"
+ >
+
+ Download
+
+ printRecoveryCodes(recoveryCodes)}
+ aria-label="Print recovery codes"
+ >
+
+ Print
+
+
+
+ {copied === 'codes' ? 'Recovery codes copied to clipboard.' : ''}
+ {copied === 'secret' ? 'Setup key copied to clipboard.' : ''}
+
+ >
+ )}
+
+
+
+ Continue
+
+
>
);
};
diff --git a/src/icons/mfa.tsx b/src/icons/mfa.tsx
new file mode 100644
index 0000000..d0e2449
--- /dev/null
+++ b/src/icons/mfa.tsx
@@ -0,0 +1,141 @@
+import { FC } from 'react';
+
+// Shared MFA icons. All use `currentColor` so they inherit the surrounding
+// text color and stay theme-consistent. Decorative by default
+// (aria-hidden) - the accessible label lives on the button/element around
+// them.
+type IconProps = {
+ size?: number;
+};
+
+const base = (size: number) => ({
+ width: `${size}px`,
+ height: `${size}px`,
+ display: 'block',
+});
+
+export const IconAuthenticator: FC = ({ size = 22 }) => (
+
+
+
+
+);
+
+export const IconPasskey: FC = ({ size = 22 }) => (
+
+
+
+
+);
+
+export const IconEmail: FC = ({ size = 22 }) => (
+
+
+
+
+);
+
+export const IconPhone: FC = ({ size = 22 }) => (
+
+
+
+
+);
+
+export const IconCopy: FC = ({ size = 15 }) => (
+
+
+
+
+);
+
+export const IconDownload: FC = ({ size = 15 }) => (
+
+
+
+);
+
+export const IconPrint: FC = ({ size = 15 }) => (
+
+
+
+
+);
+
+export const IconCheck: FC = ({ size = 15 }) => (
+
+
+
+);
diff --git a/src/index.tsx b/src/index.tsx
index 0c375b3..acf598c 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -15,6 +15,12 @@ import { AuthorizerVerifyOtp } from './components/AuthorizerVerifyOtp';
import { AuthorizerRoot as Authorizer } from './components/AuthorizerRoot';
import { AuthorizerTOTPScanner } from './components/AuthorizerTOTPScanner';
import { AuthorizerPasskeyLogin } from './components/AuthorizerPasskeyLogin';
+import { AuthorizerPasskeyRegister } from './components/AuthorizerPasskeyRegister';
+import {
+ AuthorizerMFASetup,
+ type AvailableMfaMethods,
+ type MfaMethod,
+} from './components/AuthorizerMFASetup';
export {
useAuthorizer,
@@ -29,4 +35,8 @@ export {
AuthorizerVerifyOtp,
AuthorizerTOTPScanner,
AuthorizerPasskeyLogin,
+ AuthorizerPasskeyRegister,
+ AuthorizerMFASetup,
};
+
+export type { AvailableMfaMethods, MfaMethod };
diff --git a/src/stories/AuthorizerMFASetup.stories.tsx b/src/stories/AuthorizerMFASetup.stories.tsx
new file mode 100644
index 0000000..69a5f47
--- /dev/null
+++ b/src/stories/AuthorizerMFASetup.stories.tsx
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { fn } from '@storybook/test';
+
+import { AuthorizerMFASetup } from '../components/AuthorizerMFASetup';
+import { withWebauthnSupport } from './decorators';
+import {
+ sampleQrImage,
+ sampleRecoveryCodes,
+ sampleSecret,
+} from './mfaFixtures';
+
+const totpEnrollment = {
+ authenticator_scanner_image: sampleQrImage,
+ authenticator_secret: sampleSecret,
+ authenticator_recovery_codes: sampleRecoveryCodes,
+};
+
+const meta: Meta = {
+ title: 'MFA/AuthorizerMFASetup',
+ component: AuthorizerMFASetup,
+ parameters: { layout: 'centered' },
+ args: { onSetupMethod: fn(), totpEnrollment },
+ decorators: [withWebauthnSupport(true)],
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Every method the server can offer. Click "Set up" on Authenticator app to
+// enter the TOTP flow, or Passkey to enter enrolment.
+export const AllMethods: Story = {
+ args: {
+ availableMfaMethods: {
+ totp: true,
+ passkey: true,
+ emailOtp: true,
+ smsOtp: true,
+ },
+ },
+};
+
+export const TotpOnly: Story = {
+ args: { availableMfaMethods: { totp: true } },
+};
+
+export const PasskeyOnly: Story = {
+ args: { availableMfaMethods: { passkey: true } },
+};
+
+export const OtpMethodsOnly: Story = {
+ args: { availableMfaMethods: { emailOtp: true, smsOtp: true } },
+};
+
+// Passkey offered by the server but the browser can't run WebAuthn: the row
+// stays visible but disabled with an explanation.
+export const PasskeyUnsupported: Story = {
+ args: { availableMfaMethods: { totp: true, passkey: true } },
+ decorators: [withWebauthnSupport(false)],
+};
+
+// TOTP offered but no enrolment payload yet: "Set up" calls onSetupMethod so
+// the host can fetch the QR + secret + recovery codes (check the Actions tab).
+export const TotpWithoutEnrollment: Story = {
+ args: {
+ availableMfaMethods: { totp: true, passkey: true },
+ totpEnrollment: undefined,
+ },
+};
+
+// Nothing enabled server-side: a friendly empty state.
+export const NoMethods: Story = {
+ args: { availableMfaMethods: {} },
+};
diff --git a/src/stories/AuthorizerPasskeyRegister.stories.tsx b/src/stories/AuthorizerPasskeyRegister.stories.tsx
new file mode 100644
index 0000000..315e6f7
--- /dev/null
+++ b/src/stories/AuthorizerPasskeyRegister.stories.tsx
@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { AuthorizerPasskeyRegister } from '../components/AuthorizerPasskeyRegister';
+import { withWebauthnSupport } from './decorators';
+
+const meta: Meta = {
+ title: 'MFA/AuthorizerPasskeyRegister',
+ component: AuthorizerPasskeyRegister,
+ parameters: { layout: 'centered' },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Supported: shows the optional name field + "Add a passkey" button. Clicking
+// it starts a real WebAuthn ceremony against the configured server, so the
+// success / error Messages appear at runtime.
+export const Supported: Story = {
+ decorators: [withWebauthnSupport(true)],
+};
+
+// A fixed credential name hides the inline name field.
+export const SupportedWithFixedName: Story = {
+ args: { name: 'MacBook Touch ID' },
+ decorators: [withWebauthnSupport(true)],
+};
+
+// Unsupported browser / device: renders an informational notice instead of
+// the button.
+export const Unsupported: Story = {
+ decorators: [withWebauthnSupport(false)],
+};
diff --git a/src/stories/AuthorizerTOTPScanner.stories.tsx b/src/stories/AuthorizerTOTPScanner.stories.tsx
new file mode 100644
index 0000000..743f0e9
--- /dev/null
+++ b/src/stories/AuthorizerTOTPScanner.stories.tsx
@@ -0,0 +1,51 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { AuthorizerTOTPScanner } from '../components/AuthorizerTOTPScanner';
+import StyledWrapper from '../styledComponents/StyledWrapper';
+import {
+ sampleQrImage,
+ sampleRecoveryCodes,
+ sampleSecret,
+} from './mfaFixtures';
+
+const meta: Meta = {
+ title: 'MFA/AuthorizerTOTPScanner',
+ component: AuthorizerTOTPScanner,
+ parameters: { layout: 'centered' },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const WithRecoveryCodes: Story = {
+ args: {
+ authenticator_scanner_image: sampleQrImage,
+ authenticator_secret: sampleSecret,
+ authenticator_recovery_codes: sampleRecoveryCodes,
+ },
+};
+
+export const WithoutRecoveryCodes: Story = {
+ args: {
+ authenticator_scanner_image: sampleQrImage,
+ authenticator_secret: sampleSecret,
+ authenticator_recovery_codes: [],
+ },
+};
+
+export const LongSecret: Story = {
+ args: {
+ authenticator_scanner_image: sampleQrImage,
+ authenticator_secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXPJBSWY3DP',
+ authenticator_recovery_codes: sampleRecoveryCodes,
+ },
+};
diff --git a/src/stories/decorators.tsx b/src/stories/decorators.tsx
new file mode 100644
index 0000000..bb80eda
--- /dev/null
+++ b/src/stories/decorators.tsx
@@ -0,0 +1,27 @@
+import type { Decorator } from '@storybook/react';
+
+import StyledWrapper from '../styledComponents/StyledWrapper';
+
+// isWebauthnSupported() (from the SDK) keys off window.PublicKeyCredential and
+// its JSON helpers. Stub / remove it so passkey states render deterministically
+// inside Storybook regardless of the host browser.
+export const withWebauthnSupport =
+ (supported: boolean): Decorator =>
+ (Story) => {
+ if (supported) {
+ (window as any).PublicKeyCredential = {
+ parseCreationOptionsFromJSON: () => undefined,
+ parseRequestOptionsFromJSON: () => undefined,
+ prototype: { toJSON: () => undefined },
+ };
+ } else {
+ delete (window as any).PublicKeyCredential;
+ }
+ return (
+
+
+
+
+
+ );
+ };
diff --git a/src/stories/mfaFixtures.ts b/src/stories/mfaFixtures.ts
new file mode 100644
index 0000000..49ea648
--- /dev/null
+++ b/src/stories/mfaFixtures.ts
@@ -0,0 +1,18 @@
+// Shared fixtures for the MFA Storybook stories. The QR value is a
+// placeholder JPEG (base64) that renders as a QR-like image so the layout
+// can be reviewed without a running server.
+export const sampleQrImage =
+ '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';
+
+export const sampleSecret = 'JBSWY3DPEHPK3PXP';
+
+export const sampleRecoveryCodes = [
+ '3f9a-1c22',
+ '8b04-77de',
+ 'a1c5-90f2',
+ '6e2d-4b81',
+ 'cc70-15a9',
+ '02f8-e3b7',
+ '9d4a-6612',
+ 'be31-08c4',
+];
diff --git a/src/styles/default.css b/src/styles/default.css
index 693af80..22a6589 100644
--- a/src/styles/default.css
+++ b/src/styles/default.css
@@ -138,3 +138,169 @@
*:after {
box-sizing: inherit;
}
+
+/* ---------- MFA setup ---------- */
+.mfa-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ width: 100%;
+ margin: 10px 0;
+ padding: 0;
+ list-style: none;
+}
+.mfa-method {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--authorizer-gray-color);
+ border-radius: var(--authorizer-radius-card);
+ background-color: var(--authorizer-white-color);
+}
+.mfa-method-icon {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--authorizer-radius-card);
+ background-color: var(--authorizer-slate-color);
+ color: var(--authorizer-primary-color);
+}
+.mfa-method-body {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+.mfa-method-title {
+ margin: 0;
+ font-weight: 600;
+}
+.mfa-method-desc {
+ margin: 2px 0 0;
+ font-size: var(--authorizer-fonts-small-text);
+ color: var(--authorizer-text-color);
+ opacity: 0.75;
+}
+.mfa-method-action {
+ flex: 0 0 auto;
+}
+.mfa-method-action button {
+ width: auto;
+ padding: 8px 16px !important;
+ white-space: nowrap;
+}
+.mfa-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ border-radius: 999px;
+ font-size: var(--authorizer-fonts-tiny-text);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background-color: var(--authorizer-success-color);
+ color: var(--authorizer-white-color);
+}
+
+/* ---------- TOTP scanner ---------- */
+.mfa-steps {
+ margin: 10px 0;
+ padding-left: 18px;
+}
+.mfa-steps li {
+ margin: 4px 0;
+}
+.mfa-qr {
+ display: flex;
+ justify-content: center;
+ padding: 12px;
+ border: 1px solid var(--authorizer-gray-color);
+ border-radius: var(--authorizer-radius-card);
+ background-color: var(--authorizer-white-color);
+}
+.mfa-qr img {
+ display: block;
+ width: 180px;
+ height: 180px;
+}
+.mfa-secret-row {
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+ margin: 10px 0;
+}
+.mfa-secret-value {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 10px 12px;
+ border: 1px solid var(--authorizer-gray-color);
+ border-radius: var(--authorizer-radius-input);
+ background-color: var(--authorizer-slate-color);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: var(--authorizer-fonts-medium-text);
+ letter-spacing: 0.06em;
+ word-break: break-all;
+}
+.mfa-icon-button {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border: 1px solid var(--authorizer-gray-color);
+ border-radius: var(--authorizer-radius-button);
+ background-color: var(--authorizer-white-color);
+ color: var(--authorizer-text-color);
+ font-size: var(--authorizer-fonts-small-text);
+ cursor: pointer;
+}
+.mfa-icon-button:hover {
+ border-color: var(--authorizer-primary-color);
+ color: var(--authorizer-primary-color);
+}
+.mfa-icon-button:focus-visible {
+ outline: 2px solid var(--authorizer-primary-color);
+ outline-offset: 2px;
+}
+
+/* ---------- recovery codes ---------- */
+.mfa-warning {
+ display: flex;
+ gap: 8px;
+ padding: 10px 12px;
+ margin: 10px 0;
+ border-radius: var(--authorizer-radius-card);
+ background-color: #fef3c7;
+ color: #92400e;
+ font-size: var(--authorizer-fonts-small-text);
+}
+.mfa-recovery-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 8px;
+ margin: 10px 0;
+ padding: 12px;
+ border: 1px dashed var(--authorizer-gray-color);
+ border-radius: var(--authorizer-radius-card);
+ background-color: var(--authorizer-slate-color);
+}
+.mfa-recovery-code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: var(--authorizer-fonts-medium-text);
+ text-align: center;
+ letter-spacing: 0.04em;
+}
+.mfa-recovery-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 10px 0;
+}
+.mfa-status {
+ margin: 6px 0;
+ font-size: var(--authorizer-fonts-small-text);
+ color: var(--authorizer-success-color);
+}
diff --git a/src/utils/common.ts b/src/utils/common.ts
index 852e9d5..8f33ec6 100644
--- a/src/utils/common.ts
+++ b/src/utils/common.ts
@@ -27,6 +27,31 @@ export const createRandomString = () => {
return random;
};
+export const copyToClipboard = async (text: string): Promise => {
+ try {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch {
+ // fall through to the legacy path below
+ }
+ // Legacy fallback for non-secure contexts / older browsers.
+ if (!hasWindow()) {
+ return false;
+ }
+ const el = document.createElement('textarea');
+ el.value = text;
+ el.setAttribute('readonly', '');
+ el.style.position = 'absolute';
+ el.style.left = '-9999px';
+ document.body.appendChild(el);
+ el.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(el);
+ return ok;
+};
+
export const createQueryParams = (params: any) => {
return Object.keys(params)
.filter((k) => typeof params[k] !== 'undefined')