diff --git a/.changeset/totp-svelte-ui.md b/.changeset/totp-svelte-ui.md new file mode 100644 index 0000000..f73c9e7 --- /dev/null +++ b/.changeset/totp-svelte-ui.md @@ -0,0 +1,13 @@ +--- +'@seamless-auth/svelte': minor +--- + +The bundled screens cover authenticator apps (TOTP). + +- `SaRegisterAuthenticator` (`/register-authenticator`) adds an authenticator app: a QR code drawn in the page with no extra dependency, the setup key with a copy button and an `otpauth://` link, and the first code to confirm it. It ends on a success state, then continues where passkey enrolment does. +- `SaAuthenticatorCode` (`/authenticator-code`) signs in with a code from the app, and explains a wrong or reused code, a lockout and a rate limit. +- `SaLogin` offers "Use an authenticator app" with the other sign-in options. `SaRegisterPasskey` offers it when the device cannot register a passkey (unless the application signs in with passkeys only, where the API would refuse the app), and always to a recovery session, which can no longer skip enrolment. Enrolling an authenticator app lifts the recovery restriction. +- `SaAuthenticatorAppSettings` is a router-free settings section: status, set up, and turn off with a current code. +- `authRoutePaths` gains `registerAuthenticator` and `authenticatorCode`, and the package re-exports the `TotpStatus` type. + +Mount the two new screens at their paths (or pass `paths` to `createKitNavigator`). Needs a server adapter that proxies the auth API's `/totp/*` routes. diff --git a/packages/svelte/README.md b/packages/svelte/README.md index 14aab4e..6f5048b 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -175,16 +175,27 @@ Put `requireGuest` only on the screens that start a sign-in (`login`, `passkey-l Mount each at its route. The paths below are what the screens navigate between by default; pass `paths` to `createKitNavigator` if you mount them elsewhere. -| Default path | Component | Purpose | -| ------------------- | ----------------------------- | ----------------------------------------------------------- | -| `/login` | `SaLogin` | Sign in with an email or phone number, or create an account | -| `/passkey-login` | `SaPasskeyLogin` | Sign in with a passkey alone | -| `/verify-email-otp` | `SaVerifyOtp channel="email"` | Check an email code | -| `/verify-phone-otp` | `SaVerifyOtp channel="phone"` | Check a text message code | -| `/magic-link-sent` | `SaMagicLinkSent` | Wait for an emailed link to be used | -| `/verify-magiclink` | `SaVerifyMagicLink` | Where the emailed link lands. The auth API builds this URL | -| `/oauth/callback` | `SaOAuthCallback` | The OAuth redirect URI to register with providers | -| `/register-passkey` | `SaRegisterPasskey` | Offer passkey enrolment after sign-up | +| Default path | Component | Purpose | +| ------------------------- | ----------------------------- | ----------------------------------------------------------- | +| `/login` | `SaLogin` | Sign in with an email or phone number, or create an account | +| `/passkey-login` | `SaPasskeyLogin` | Sign in with a passkey alone | +| `/verify-email-otp` | `SaVerifyOtp channel="email"` | Check an email code | +| `/verify-phone-otp` | `SaVerifyOtp channel="phone"` | Check a text message code | +| `/magic-link-sent` | `SaMagicLinkSent` | Wait for an emailed link to be used | +| `/verify-magiclink` | `SaVerifyMagicLink` | Where the emailed link lands. The auth API builds this URL | +| `/oauth/callback` | `SaOAuthCallback` | The OAuth redirect URI to register with providers | +| `/register-passkey` | `SaRegisterPasskey` | Offer passkey enrolment after sign-up | +| `/register-authenticator` | `SaRegisterAuthenticator` | Add an authenticator app instead of a passkey | +| `/authenticator-code` | `SaAuthenticatorCode` | Sign in with a code from an authenticator app | + +The sign-in screen always offers "Use an authenticator app" among the other ways in, since `/login` +never lists it. `SaRegisterPasskey` offers it when the device cannot register a passkey (unless the application signs in with passkeys only, where the API would refuse the app), and always +to a recovery session, which cannot skip enrolment: adding either factor lifts the recovery +restriction. + +`SaAuthenticatorAppSettings` is a router-free settings section for a signed-in page: it shows +whether an authenticator app is set up, adds one (QR code, setup key with a copy button, first code) +and turns it off with a current code. The building blocks are exported too: `SaOtpInput` (bind with `bind:value`), `SaFallbackOptions`, `SaOAuthProviderButtons`, `SaAuthLayout`, and `authRoutePaths`. @@ -204,7 +215,8 @@ The screens never import a router. They navigate through an `AuthNavigator` set Every screen is a thin view over a flow in `@seamless-auth/client` (`beginSignIn`, `registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, -`confirmMagicLinkSignIn`, `startOAuthSignIn`, `completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the +`confirmMagicLinkSignIn`, `startOAuthSignIn`, `completeOAuthCallback`, `enrollPasskey`, +`signInWithTotp`, `qrCode`, and helpers). A custom screen calls the same flows with the session as its actions: ```ts diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 0864333..3338ad5 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -15,6 +15,8 @@ export { export { authRoutePaths } from './navigation.js'; export type { AuthNavigator, AuthScreen } from './navigation.js'; +export { default as SaAuthenticatorAppSettings } from './screens/AuthenticatorAppSettings.svelte'; +export { default as SaAuthenticatorCode } from './screens/AuthenticatorCode.svelte'; export { default as SaAuthLayout } from './screens/AuthLayout.svelte'; export { default as SaFallbackOptions } from './screens/FallbackOptions.svelte'; export { default as SaLogin } from './screens/Login.svelte'; @@ -23,6 +25,7 @@ export { default as SaOAuthCallback } from './screens/OAuthCallback.svelte'; export { default as SaOAuthProviderButtons } from './screens/OAuthProviderButtons.svelte'; export { default as SaOtpInput } from './screens/OtpInput.svelte'; export { default as SaPasskeyLogin } from './screens/PasskeyLogin.svelte'; +export { default as SaRegisterAuthenticator } from './screens/RegisterAuthenticator.svelte'; export { default as SaRegisterPasskey } from './screens/RegisterPasskey.svelte'; export { default as SaVerifyMagicLink } from './screens/VerifyMagicLink.svelte'; export { default as SaVerifyOtp } from './screens/VerifyOtp.svelte'; @@ -52,5 +55,6 @@ export type { SeamlessAuthClient, SeamlessAuthResult, StepUpStatus, + TotpStatus, User, } from '@seamless-auth/client'; diff --git a/packages/svelte/src/navigation.ts b/packages/svelte/src/navigation.ts index 5e01f33..c99bb9d 100644 --- a/packages/svelte/src/navigation.ts +++ b/packages/svelte/src/navigation.ts @@ -19,6 +19,8 @@ export const authRoutePaths = { verifyMagicLink: '/verify-magiclink', oauthCallback: '/oauth/callback', registerPasskey: '/register-passkey', + registerAuthenticator: '/register-authenticator', + authenticatorCode: '/authenticator-code', magicLinkSent: '/magic-link-sent', } as const; diff --git a/packages/svelte/src/screens/AuthenticatorAppSettings.svelte b/packages/svelte/src/screens/AuthenticatorAppSettings.svelte new file mode 100644 index 0000000..0c45771 --- /dev/null +++ b/packages/svelte/src/screens/AuthenticatorAppSettings.svelte @@ -0,0 +1,140 @@ + + + + + +
+

Authenticator app

+

Sign in with a code from an app such as 1Password or Google Authenticator.

+ + {#if notice} +

{notice}

+ {/if} + + {#if loadError} + + + {:else if !status} +
+ + Loading... +
+ {:else if mode === 'enroll'} + + + {:else if mode === 'disable'} +
+ Enter a current code from your authenticator app to turn it off + + {#if error} + + {/if} + + + + {:else if status.enabled} +

+ On{status.verifiedAt ? `, added ${formatDate(status.verifiedAt)}` : ''}{status.lastUsedAt + ? `, last used ${formatDate(status.lastUsedAt)}` + : ''}. +

+ + {:else} +

Not set up.

+ + {/if} +
+
diff --git a/packages/svelte/src/screens/AuthenticatorCode.svelte b/packages/svelte/src/screens/AuthenticatorCode.svelte new file mode 100644 index 0000000..9c28f19 --- /dev/null +++ b/packages/svelte/src/screens/AuthenticatorCode.svelte @@ -0,0 +1,71 @@ + + + + + +

Enter your authenticator code

+

Open your authenticator app and enter the code it shows for this account.

+ + {#if error} + + {/if} + +
+
+ Authenticator Code + +
+ + + + +
+
diff --git a/packages/svelte/src/screens/FallbackOptions.svelte b/packages/svelte/src/screens/FallbackOptions.svelte index bad4592..cf87b09 100644 --- a/packages/svelte/src/screens/FallbackOptions.svelte +++ b/packages/svelte/src/screens/FallbackOptions.svelte @@ -18,6 +18,7 @@ onEmailOtp, onPhoneOtp, onPasskeyRetry, + onAuthenticatorApp, }: { identifier: string; /** Null while the methods are unknown; the options then stay permissive. */ @@ -26,6 +27,11 @@ onEmailOtp?: () => void; onPhoneOtp: () => void; onPasskeyRetry?: () => void; + /** + * Offered whenever it is passed: `/login` never lists the authenticator + * app, and the API answers a code for any identifier alike. + */ + onAuthenticatorApp?: () => void; } = $props(); const options = $derived( @@ -36,7 +42,7 @@ ); -{#if hasFallbackSignInOption(options)} +{#if hasFallbackSignInOption(options) || onAuthenticatorApp}
Choose a sign-in method

Choose another secure sign-in method.

@@ -60,6 +66,12 @@ Receive a one-time code via SMS {/if} + {#if onAuthenticatorApp} + + {/if}
{#if options.passkeyRetry} diff --git a/packages/svelte/src/screens/Login.svelte b/packages/svelte/src/screens/Login.svelte index c7e4167..f3e404b 100644 --- a/packages/svelte/src/screens/Login.svelte +++ b/packages/svelte/src/screens/Login.svelte @@ -203,6 +203,7 @@ onEmailOtp={sendEmailOtp} onPhoneOtp={sendPhoneOtp} onPasskeyRetry={auth.passkeySupported ? retryPasskey : undefined} + onAuthenticatorApp={() => navigator.toScreen('authenticatorCode')} /> {/if} diff --git a/packages/svelte/src/screens/RegisterAuthenticator.svelte b/packages/svelte/src/screens/RegisterAuthenticator.svelte new file mode 100644 index 0000000..567337c --- /dev/null +++ b/packages/svelte/src/screens/RegisterAuthenticator.svelte @@ -0,0 +1,49 @@ + + + + + + {#if enrolled} +

Authenticator app added

+

+ You can now sign in with a code from your authenticator app. +

+ + {:else} +

Set up an authenticator app

+ { + enrolled = true; + }} + /> + + {/if} +
diff --git a/packages/svelte/src/screens/RegisterPasskey.svelte b/packages/svelte/src/screens/RegisterPasskey.svelte index 8d0cc44..acb356e 100644 --- a/packages/svelte/src/screens/RegisterPasskey.svelte +++ b/packages/svelte/src/screens/RegisterPasskey.svelte @@ -32,8 +32,11 @@ let message = $state(''); // With passkey as the only enabled method, a user who skipped would have no - // way back into the account they just made. - const canSkip = $derived(hasNonPasskeyLoginMethod(auth.loginMethods)); + // way back into the account they just made. A recovery session stays + // restricted until it adds a factor, so it cannot skip either. + const canSkip = $derived( + !auth.recoverySession && hasNonPasskeyLoginMethod(auth.loginMethods) + ); const busy = $derived(status === 'loading'); onMount(() => { @@ -60,6 +63,12 @@ await finish(); } + const useAuthenticator = () => + navigator.toScreen( + 'registerAuthenticator', + typeof returnTo === 'string' ? { returnTo } : undefined + ); + async function finishWithoutPasskey() { await auth.refreshSession(); await finish(); @@ -75,10 +84,17 @@ {:else if !auth.passkeySupported}

Passkeys are not available here

- {canSkip - ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' - : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'} + {auth.recoverySession + ? 'This device does not support passkeys. Set up an authenticator app to finish securing your account.' + : canSkip + ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' + : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'}

+ {#if auth.recoverySession || canSkip} + + {/if} {#if canSkip} {/if} @@ -98,6 +114,11 @@ > Use a security key instead + {#if auth.recoverySession} + + {/if} {#if message}

{message}

{/if} diff --git a/packages/svelte/src/screens/TotpEnrollment.svelte b/packages/svelte/src/screens/TotpEnrollment.svelte new file mode 100644 index 0000000..82d1a16 --- /dev/null +++ b/packages/svelte/src/screens/TotpEnrollment.svelte @@ -0,0 +1,148 @@ + + + + +{#if starting} +
+ + Preparing your authenticator app... +
+{:else if !enrollment || !qr} + + +{:else} +

+ Scan this QR code with your authenticator app, or enter the setup key by hand. +

+
+ + + + +
+ + Setup key +

{formatTotpSecret(enrollment.secret)}

+ + {#if copyMessage} +

{copyMessage}

+ {/if} + {#if appLink} +

+ Open in an authenticator app on this device +

+ {/if} + +
+ Enter the code your authenticator app shows + + {#if error} + + {/if} + + +{/if} diff --git a/packages/svelte/tests/Harness.svelte b/packages/svelte/tests/Harness.svelte index 28ec945..832c9ff 100644 --- a/packages/svelte/tests/Harness.svelte +++ b/packages/svelte/tests/Harness.svelte @@ -9,10 +9,13 @@ import { setAuthNavigator, setSeamlessAuth, type SeamlessAuth } from '../src'; import type { AuthNavigator } from '../src/navigation'; import { + SaAuthenticatorAppSettings, + SaAuthenticatorCode, SaLogin, SaMagicLinkSent, SaOAuthCallback, SaPasskeyLogin, + SaRegisterAuthenticator, SaRegisterPasskey, SaVerifyMagicLink, SaVerifyOtp, @@ -52,5 +55,11 @@ {:else if nav.current === 'registerPasskey'} + {:else if nav.current === 'registerAuthenticator'} + + {:else if nav.current === 'authenticatorCode'} + + {:else if nav.current === 'settings'} + {/if} {/key} diff --git a/packages/svelte/tests/fakeNavigator.svelte.ts b/packages/svelte/tests/fakeNavigator.svelte.ts index 27f7773..f7f325e 100644 --- a/packages/svelte/tests/fakeNavigator.svelte.ts +++ b/packages/svelte/tests/fakeNavigator.svelte.ts @@ -11,11 +11,11 @@ import type { AuthNavigator, AuthScreen } from '../src/navigation'; * `location` is what the address bar would show. */ export function createFakeNavigator(start: { - screen: AuthScreen | 'app'; + screen: AuthScreen | 'app' | 'settings'; query?: string; }) { const nav = $state({ - current: start.screen as AuthScreen | 'app', + current: start.screen as AuthScreen | 'app' | 'settings', path: '/', state: {} as Record, query: new URLSearchParams(start.query ?? ''), diff --git a/packages/svelte/tests/screens.test.ts b/packages/svelte/tests/screens.test.ts index fa9e5d4..ab0454e 100644 --- a/packages/svelte/tests/screens.test.ts +++ b/packages/svelte/tests/screens.test.ts @@ -31,7 +31,7 @@ const ok = { body: { message: 'Success' } }; let nav: ReturnType['nav']; async function open( - start: AuthScreen, + start: AuthScreen | 'settings', routes: Record, options: { passkeys?: boolean; @@ -632,6 +632,336 @@ describe('passkey screens', () => { }); }); +const enrollment = { + body: { + message: 'Success', + secret: 'JBSWY3DPEHPK3PXPJBSWY3DP', + otpauthUrl: + 'otpauth://totp/Example:ada%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DP', + issuer: 'Example', + accountName: 'ada@example.com', + algorithm: 'SHA1', + digits: 6, + period: 30, + }, +}; +const totpRefused = { status: 401, body: { error: 'totp_verification_failed' } }; + +describe('authenticator app sign-in', () => { + it('is offered with the other ways in, even though /login never lists it', async () => { + localStorage.setItem('seamlessauth_seen', 'true'); + await open('login', { + 'POST /login': { body: { loginMethods: ['passkey'] } }, + 'GET /system-config/public': { body: { loginMethods: ['passkey'] } }, + }); + + await type('#identifier', 'ada@example.com'); + await submit(); + + expect(text()).toContain('Choose a sign-in method'); + expect(text()).not.toContain('Email Magic Link'); + await click(/Use an authenticator app/); + expect(nav.current).toBe('authenticatorCode'); + expect(heading()).toBe('Enter your authenticator code'); + }); + + it('signs in with a code', async () => { + let session = signedOut; + const { adapter } = await open('authenticatorCode', { + 'GET /users/me': () => session, + 'POST /totp/verify-login': () => { + session = signedIn; + return ok; + }, + }); + + await typeCode('123456'); + await submit(); + + expect(bodyOf(adapter, 'POST', '/totp/verify-login')).toEqual({ code: '123456' }); + expect(nav.current).toBe('app'); + }); + + it('asks for a whole code before sending one', async () => { + const { adapter } = await open('authenticatorCode', {}); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Enter the 6-digit code'); + expect(adapter.called('POST', '/totp/verify-login')).toHaveLength(0); + }); + + it('explains a wrong or reused code, a lockout and a rate limit', async () => { + let reply: AdapterReply = totpRefused; + await open('authenticatorCode', { 'POST /totp/verify-login': () => reply }); + + await typeCode('123456'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Each code works once.'); + expect(nav.current).toBe('authenticatorCode'); + + reply = { status: 423, body: { error: 'account_locked', retryAfterSeconds: 120 } }; + await typeCode('234567'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Try again in 2 minutes.'); + + reply = { status: 429, body: { error: 'too_many_requests' } }; + await typeCode('345678'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Too many attempts.'); + + await click('Back to login'); + expect(nav.current).toBe('login'); + }); +}); + +describe('authenticator app enrollment', () => { + let clipboard: { writeText: ReturnType }; + + beforeEach(() => { + clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: clipboard, + configurable: true, + }); + }); + + it('shows a QR code and setup key, verifies the first code, and continues', async () => { + const { adapter } = await open( + 'registerAuthenticator', + { 'POST /totp/enroll/start': enrollment, 'POST /totp/enroll/verify': ok }, + { state: { returnTo: '/settings' } } + ); + await waitSettled(); + + expect(heading()).toBe('Set up an authenticator app'); + const qr = screen.getByRole('img', { name: 'QR code for your authenticator app' }); + expect(qr.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff'); + expect(qr.querySelector('path')?.getAttribute('d')).toMatch(/^M/); + expect(text()).toContain('JBSW Y3DP EHPK 3PXP JBSW Y3DP'); + expect( + screen.getByRole('link', { name: /Open in an authenticator app/ }) + ).toHaveAttribute('href', enrollment.body.otpauthUrl); + + await click('Copy setup key'); + expect(clipboard.writeText).toHaveBeenCalledWith('JBSWY3DPEHPK3PXPJBSWY3DP'); + expect(text()).toContain('Setup key copied.'); + + await typeCode('123456'); + await submit(); + + expect(bodyOf(adapter, 'POST', '/totp/enroll/verify')).toEqual({ code: '123456' }); + expect(heading()).toBe('Authenticator app added'); + expect(screen.getByRole('status').textContent).toContain('sign in with a code'); + + await click('Continue'); + expect(nav.current).toBe('app'); + expect(nav.path).toBe('/settings'); + }); + + it('reports a refused code and a failed copy, and lets the user go back', async () => { + clipboard.writeText.mockRejectedValue(new Error('denied')); + await open( + 'registerAuthenticator', + { 'POST /totp/enroll/start': enrollment, 'POST /totp/enroll/verify': totpRefused }, + { state: { returnTo: '/settings' } } + ); + await waitSettled(); + + await click('Copy setup key'); + expect(text()).toContain('Could not copy.'); + + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Enter the 6-digit code'); + + await typeCode('123456'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('That code did not work.'); + expect(heading()).toBe('Set up an authenticator app'); + + await click('Back'); + expect(nav.current).toBe('registerPasskey'); + expect(nav.state).toEqual({ returnTo: '/settings' }); + }); + + it('explains an enrollment that cannot start, and retries it', async () => { + let reply: AdapterReply = { status: 409, body: { error: 'secret_required' } }; + await open('registerAuthenticator', { 'POST /totp/enroll/start': () => reply }); + await waitSettled(); + + expect(screen.getByRole('alert').textContent).toContain( + 'Authenticator apps are not available' + ); + + reply = { + body: { ...enrollment.body, otpauthUrl: 'javascript:alert(1)' }, + }; + await click('Try again'); + expect(screen.getByRole('img', { name: /QR code/ })).toBeInTheDocument(); + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('is offered when the device cannot register a passkey', async () => { + await open('registerPasskey', { + 'GET /system-config/public': { body: { loginMethods: ['passkey', 'email_otp'] } }, + }); + await waitSettled(); + + expect(text()).toContain('You can continue without one'); + await click('Use an authenticator app'); + expect(nav.current).toBe('registerAuthenticator'); + }); + + // A passkey-only application refuses authenticator sign-in, so an app set up + // here could never sign the user in. + it('is not offered when the application requires a passkey', async () => { + await open('registerPasskey', { + 'GET /system-config/public': { body: { loginMethods: ['passkey'] } }, + }); + await waitSettled(); + + expect(text()).toContain('requires one to sign in'); + expect(screen.queryByRole('button', { name: 'Use an authenticator app' })).toBeNull(); + }); + + it('lets a recovery session finish with an authenticator app, and never skip', async () => { + let recovery = true; + const { auth } = await open( + 'registerPasskey', + { + 'GET /users/me': () => ({ body: { ...(signedIn.body as object), recovery } }), + 'GET /system-config/public': { body: { loginMethods: ['passkey', 'email_otp'] } }, + 'POST /totp/enroll/start': enrollment, + 'POST /totp/enroll/verify': () => { + recovery = false; + return ok; + }, + 'POST /refresh': ok, + }, + { passkeys: true } + ); + await waitSettled(); + + expect(auth.recoverySession).toBe(true); + expect(heading()).toBe('Secure Your Account with a Passkey'); + expect(screen.queryByRole('button', { name: 'Skip for now' })).toBeNull(); + + await click('Use an authenticator app'); + await waitSettled(); + await typeCode('123456'); + await submit(); + + expect(heading()).toBe('Authenticator app added'); + expect(auth.recoverySession).toBe(false); + }); + + it('gives a recovery session on a device without passkeys a way forward', async () => { + await open('registerPasskey', { + 'GET /users/me': { body: { ...(signedIn.body as object), recovery: true } }, + 'GET /system-config/public': { body: { loginMethods: ['passkey', 'email_otp'] } }, + }); + await waitSettled(); + + expect(text()).toContain( + 'Set up an authenticator app to finish securing your account.' + ); + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull(); + expect(button('Use an authenticator app')).toBeInTheDocument(); + }); +}); + +describe('authenticator app settings', () => { + const status = (enabled: boolean) => ({ + body: { + enabled, + verifiedAt: enabled ? '2026-10-01T12:00:00.000Z' : null, + lastUsedAt: enabled ? '2026-10-20T12:00:00.000Z' : null, + }, + }); + + it('sets one up', async () => { + let enabled = false; + await open('settings', { + 'GET /users/me': signedIn, + 'GET /totp/status': () => status(enabled), + 'POST /totp/enroll/start': enrollment, + 'POST /totp/enroll/verify': () => { + enabled = true; + return ok; + }, + }); + await waitSettled(); + + expect(heading()).toBe('Authenticator app'); + expect(text()).toContain('Not set up.'); + + await click('Set up authenticator app'); + await waitSettled(); + expect(screen.getByRole('img', { name: /QR code/ })).toBeInTheDocument(); + + await click('Cancel'); + expect(text()).toContain('Not set up.'); + + await click('Set up authenticator app'); + await waitSettled(); + await typeCode('123456'); + await submit(); + + expect(screen.getByRole('status').textContent).toContain('Authenticator app added.'); + expect(text()).toContain('On, added '); + expect(text()).toContain('last used '); + }); + + it('turns one off with a current code', async () => { + let enabled = true; + let reply: AdapterReply = totpRefused; + const { adapter } = await open('settings', { + 'GET /users/me': signedIn, + 'GET /totp/status': () => status(enabled), + 'POST /totp/disable': () => { + if (reply === ok) enabled = false; + return reply; + }, + }); + await waitSettled(); + + await click('Turn off'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Enter the 6-digit code'); + expect(adapter.called('POST', '/totp/disable')).toHaveLength(0); + + await typeCode('123456'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('That code did not work.'); + + reply = ok; + await typeCode('234567'); + await submit(); + + expect(bodyOf(adapter, 'POST', '/totp/disable')).toEqual({ code: '123456' }); + expect(adapter.called('POST', '/totp/disable')).toHaveLength(2); + expect(screen.getByRole('status').textContent).toContain( + 'Authenticator app turned off.' + ); + expect(text()).toContain('Not set up.'); + }); + + it('retries a status that will not load', async () => { + let reply: AdapterReply = { status: 500 }; + await open('settings', { + 'GET /users/me': signedIn, + 'GET /totp/status': () => reply, + }); + await waitSettled(); + + expect(screen.getByRole('alert').textContent).toContain( + 'Could not load your authenticator app.' + ); + + reply = status(false); + await click('Try again'); + expect(text()).toContain('Not set up.'); + }); +}); + describe('stylesheet', () => { it('adds the stylesheet with a nonce, or not at all', async () => { await open('passkeyLogin', {}, { config: { cspNonce: 'n0nce' } });