Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/totp-svelte-ui.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 23 additions & 11 deletions packages/svelte/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -52,5 +55,6 @@ export type {
SeamlessAuthClient,
SeamlessAuthResult,
StepUpStatus,
TotpStatus,
User,
} from '@seamless-auth/client';
2 changes: 2 additions & 0 deletions packages/svelte/src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
140 changes: 140 additions & 0 deletions packages/svelte/src/screens/AuthenticatorAppSettings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<!--
Copyright © 2026 Fells Code, LLC
Licensed under the Apache License, Version 2.0
See LICENSE file in the project root for full license information
-->
<!--
The signed-in user's authenticator app: whether one is set up, adding one,
and turning it off. Router-free, so it sits on any settings page.
-->
<script lang="ts">
import { TOTP_CODE_LENGTH, totpErrorMessage, type TotpStatus } from '@seamless-auth/client';
import { onMount } from 'svelte';

import { getSeamlessAuth } from '../context.js';
import AuthLayout from './AuthLayout.svelte';
import OtpInput from './OtpInput.svelte';
import TotpEnrollment from './TotpEnrollment.svelte';

const auth = getSeamlessAuth();

let status = $state.raw<TotpStatus | null>(null);
let loadError = $state('');
let mode = $state<'view' | 'enroll' | 'disable'>('view');
let notice = $state('');
let code = $state('');
let error = $state('');
let busy = $state(false);

const formatDate = (value: string) => new Date(value).toLocaleDateString();

onMount(() => {
void load();
});

async function load() {
loadError = '';
const { data, error: failure } = await auth.client.getTotpStatus();

if (failure) {
loadError = totpErrorMessage(failure, 'Could not load your authenticator app.');
return;
}

status = data;
}

function show(next: 'view' | 'enroll' | 'disable') {
mode = next;
notice = '';
error = '';
code = '';
}

async function enrolled() {
show('view');
notice = 'Authenticator app added.';
await load();
}

async function disable(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
error = '';

if (code.length !== TOTP_CODE_LENGTH) {
error = `Enter the ${TOTP_CODE_LENGTH}-digit code from your authenticator app.`;
return;
}

busy = true;

try {
const { error: failure } = await auth.client.disableTotp(code);

if (failure) {
error = totpErrorMessage(failure, 'Could not turn off the authenticator app.');
code = '';
return;
}

show('view');
notice = 'Authenticator app turned off.';
await load();
} finally {
busy = false;
}
}
</script>

<AuthLayout>
<section aria-labelledby="seamless-authenticator-heading">
<h2 id="seamless-authenticator-heading" class="sa-heading">Authenticator app</h2>
<p class="sa-description">Sign in with a code from an app such as 1Password or Google Authenticator.</p>

{#if notice}
<p class="sa-success" role="status">{notice}</p>
{/if}

{#if loadError}
<p class="sa-error" role="alert">{loadError}</p>
<button type="button" class="sa-secondary" onclick={load}>Try again</button>
{:else if !status}
<div class="sa-center">
<div class="sa-spinner" aria-hidden="true"></div>
<span>Loading...</span>
</div>
{:else if mode === 'enroll'}
<TotpEnrollment onEnrolled={enrolled} />
<button type="button" class="sa-link" onclick={() => show('view')}>Cancel</button>
{:else if mode === 'disable'}
<form onsubmit={disable}>
<span class="sa-label">Enter a current code from your authenticator app to turn it off</span>
<OtpInput bind:value={code} length={TOTP_CODE_LENGTH} mode="numeric" name="totp-disable" />
{#if error}
<p class="sa-error" role="alert">{error}</p>
{/if}
<button type="submit" class="sa-button" disabled={busy}>
{busy ? 'Turning off...' : 'Turn off authenticator app'}
</button>
<button type="button" class="sa-link" disabled={busy} onclick={() => show('view')}>
Cancel
</button>
</form>
{:else if status.enabled}
<p class="sa-helper-text">
On{status.verifiedAt ? `, added ${formatDate(status.verifiedAt)}` : ''}{status.lastUsedAt
? `, last used ${formatDate(status.lastUsedAt)}`
: ''}.
</p>
<button type="button" class="sa-secondary" onclick={() => show('disable')}>
Turn off
</button>
{:else}
<p class="sa-helper-text">Not set up.</p>
<button type="button" class="sa-button" onclick={() => show('enroll')}>
Set up authenticator app
</button>
{/if}
</section>
</AuthLayout>
71 changes: 71 additions & 0 deletions packages/svelte/src/screens/AuthenticatorCode.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<!--
Copyright © 2026 Fells Code, LLC
Licensed under the Apache License, Version 2.0
See LICENSE file in the project root for full license information
-->
<!-- Signs in with a code from an authenticator app, on the sign-in the login screen started. -->
<script lang="ts">
import { signInWithTotp, TOTP_CODE_LENGTH } from '@seamless-auth/client';

import { getAuthNavigator, getSeamlessAuth } from '../context.js';
import AuthLayout from './AuthLayout.svelte';
import OtpInput from './OtpInput.svelte';

const auth = getSeamlessAuth();
const navigator = getAuthNavigator();

let code = $state('');
let error = $state('');
let loading = $state(false);

async function submit(event: SubmitEvent) {
event.preventDefault();
if (loading) return;
error = '';
loading = true;

try {
const result = await signInWithTotp(
{ client: auth.client, refreshSession: auth.refreshSession },
code
);

if (result.error !== null) {
error = result.error;
code = '';
return;
}

await navigator.toApp();
} catch {
// Backstop for unexpected errors only.
error = 'Verification failed.';
} finally {
loading = false;
}
}
</script>

<AuthLayout>
<h2 class="sa-heading">Enter your authenticator code</h2>
<p class="sa-subtitle">Open your authenticator app and enter the code it shows for this account.</p>

{#if error}
<p class="sa-error" role="alert">{error}</p>
{/if}

<form onsubmit={submit}>
<div>
<span class="sa-label">Authenticator Code</span>
<OtpInput bind:value={code} length={TOTP_CODE_LENGTH} mode="numeric" name="totp" />
</div>

<button type="submit" class="sa-button" disabled={loading}>
{loading ? 'Verifying...' : 'Verify & Continue'}
</button>

<button type="button" class="sa-link" onclick={() => navigator.toScreen('login')}>
Back to login
</button>
</form>
</AuthLayout>
14 changes: 13 additions & 1 deletion packages/svelte/src/screens/FallbackOptions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
onEmailOtp,
onPhoneOtp,
onPasskeyRetry,
onAuthenticatorApp,
}: {
identifier: string;
/** Null while the methods are unknown; the options then stay permissive. */
Expand All @@ -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(
Expand All @@ -36,7 +42,7 @@
);
</script>

{#if hasFallbackSignInOption(options)}
{#if hasFallbackSignInOption(options) || onAuthenticatorApp}
<div class="sa-fallback">
<div class="sa-fallback-header">Choose a sign-in method</div>
<p class="sa-fallback-description">Choose another secure sign-in method.</p>
Expand All @@ -60,6 +66,12 @@
<span class="sa-action-subtext">Receive a one-time code via SMS</span>
</button>
{/if}
{#if onAuthenticatorApp}
<button type="button" class="sa-action" onclick={onAuthenticatorApp}>
<span class="sa-action-title">Use an authenticator app</span>
<span class="sa-action-subtext">Enter a code from your authenticator app</span>
</button>
{/if}
</div>

{#if options.passkeyRetry}
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/screens/Login.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@
onEmailOtp={sendEmailOtp}
onPhoneOtp={sendPhoneOtp}
onPasskeyRetry={auth.passkeySupported ? retryPasskey : undefined}
onAuthenticatorApp={() => navigator.toScreen('authenticatorCode')}
/>
{/if}

Expand Down
Loading
Loading