diff --git a/frontend/src/routes/settings/+layout.ts b/frontend/src/routes/settings/+layout.ts
index 385e7799..5e72d1e5 100644
--- a/frontend/src/routes/settings/+layout.ts
+++ b/frontend/src/routes/settings/+layout.ts
@@ -1,16 +1,21 @@
import StorageService from '#lib/services/storage-service.ts';
import VersionService from '#lib/services/version-service.ts';
+import WebAuthnService from '#lib/services/webauthn-service.ts';
import type { AppVersionInformation } from '#lib/types/application-configuration.type.ts';
+import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
-export const load: LayoutLoad = async () => {
+export const load: LayoutLoad = async ({ url }) => {
const versionService = new VersionService();
const storageService = new StorageService();
+ const webauthnService = new WebAuthnService();
+
const currentVersion = versionService.getCurrentVersion();
- const [newestVersion, sqliteStorageWarning] = await Promise.all([
+ const [newestVersion, sqliteStorageWarning, passkeys] = await Promise.all([
versionService.getNewestVersion().catch(() => null),
- storageService.getSqliteStorageWarning().catch(() => false)
+ storageService.getSqliteStorageWarning().catch(() => false),
+ webauthnService.listCredentials()
]);
// If newestVersion is empty, it means the check is disabled or failed.
@@ -24,8 +29,16 @@ export const load: LayoutLoad = async () => {
isUpToDate
};
+ const skipPasskeySetup =
+ parseInt(localStorage.getItem('skip-passkey-setup-until') ?? '0') > Date.now();
+
+ if (!skipPasskeySetup && passkeys.length === 0 && url.pathname !== '/signup/add-passkey') {
+ redirect(303, '/signup/add-passkey');
+ }
+
return {
versionInformation,
- sqliteStorageWarning
+ sqliteStorageWarning,
+ passkeys
};
};
diff --git a/frontend/src/routes/settings/account/+page.ts b/frontend/src/routes/settings/account/+page.ts
index 375c58ce..613ec5b0 100644
--- a/frontend/src/routes/settings/account/+page.ts
+++ b/frontend/src/routes/settings/account/+page.ts
@@ -1,18 +1,11 @@
import UserService from '#lib/services/user-service.ts';
-import WebAuthnService from '#lib/services/webauthn-service.ts';
import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
- const webauthnService = new WebAuthnService();
const userService = new UserService();
-
- const [account, passkeys] = await Promise.all([
- userService.getCurrent(),
- webauthnService.listCredentials()
- ]);
+ const account = await userService.getCurrent();
return {
- account,
- passkeys
+ account
};
};
diff --git a/frontend/src/routes/signup/+page.svelte b/frontend/src/routes/signup/+page.svelte
index 72a3d9bf..1e7f32b3 100644
--- a/frontend/src/routes/signup/+page.svelte
+++ b/frontend/src/routes/signup/+page.svelte
@@ -1,5 +1,4 @@
diff --git a/frontend/src/routes/signup/setup/+page.svelte b/frontend/src/routes/signup/setup/+page.svelte
index 1f76a461..b72c04c4 100644
--- a/frontend/src/routes/signup/setup/+page.svelte
+++ b/frontend/src/routes/signup/setup/+page.svelte
@@ -1,5 +1,4 @@
diff --git a/tests/specs/one-time-access-token.spec.ts b/tests/specs/one-time-access-token.spec.ts
index 4e7347b9..ed0796f2 100644
--- a/tests/specs/one-time-access-token.spec.ts
+++ b/tests/specs/one-time-access-token.spec.ts
@@ -2,6 +2,7 @@ import test, { expect, type Browser } from '@playwright/test';
import { oneTimeAccessTokens } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import { pathFromRoot } from '../utils/fs.util';
+import passkeyUtil from '../utils/passkey.util';
test.beforeEach(async () => await cleanupBackend());
@@ -13,6 +14,30 @@ test('Sign in with login code', async ({ page }) => {
await page.goto(`/lc/${token.token}`);
await page.waitForURL('/settings/account');
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).not.toBeVisible();
+});
+
+test('Sign in with login code without a passkey shows passkey setup', async ({ page }) => {
+ // Remove the existing passkey so the login code is the user's only way to sign in
+ await page.goto('/login');
+ await (await passkeyUtil.init(page)).addPasskey();
+ await page.getByRole('button', { name: 'Authenticate' }).click();
+ await page.waitForURL('/settings/account');
+ await page.getByLabel('Delete').first().click();
+ await page.getByLabel('Delete Passkey').getByRole('button', { name: 'Delete' }).click();
+ await expect(page.locator('[data-type="success"]')).toHaveText('Passkey deleted successfully');
+
+ // Sign in through the real login-code flow to verify the missing-passkey redirect
+ await page.context().clearCookies();
+ const token = oneTimeAccessTokens.find((t) => !t.expired)!;
+ await page.goto(`/lc/${token.token}`);
+ await page.waitForURL('/signup/add-passkey');
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
+
+ // Opening another settings page must still require passkey setup
+ await page.goto('/settings/apps');
+ await expect(page).toHaveURL('/signup/add-passkey');
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
});
test('Sign in with expired login code fails', async ({ page }) => {
diff --git a/tests/specs/user-signup.spec.ts b/tests/specs/user-signup.spec.ts
index 1442b94d..6af65338 100644
--- a/tests/specs/user-signup.spec.ts
+++ b/tests/specs/user-signup.spec.ts
@@ -78,7 +78,7 @@ test.describe('Initial User Signup', () => {
await page.getByLabel('Email').fill('jane.smith@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
- await expect(page.getByText('Set up your passkey')).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
});
test('Initial Signup - concurrent requests create one administrator', async ({ request }) => {
@@ -145,7 +145,7 @@ test.describe('User Signup', () => {
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
- await expect(page.getByText('Set up your passkey')).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
const response = await page.request.get('/api/users/me').then((res) => res.json());
expect(response.userGroups.map((g) => g.id)).toContain(userGroups.developers.id);
@@ -189,7 +189,7 @@ test.describe('User Signup', () => {
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
- await expect(page.getByText('Set up your passkey')).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
});
test('Open signup - validation errors', async ({ page }) => {
@@ -251,7 +251,7 @@ test.describe('User Signup', () => {
await expect(page.getByText('Single Passkey Configured')).toBeVisible();
});
- test('Skip passkey creation during signup', async ({ page }) => {
+ test('Dismiss passkey setup during signup for one day', async ({ page }) => {
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
@@ -262,14 +262,38 @@ test.describe('User Signup', () => {
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
+ // Cancelling the confirmation must keep passkey setup open
await page.getByRole('button', { name: 'Skip for now' }).click();
+ const dialog = page.getByRole('alertdialog', { name: 'Skip Passkey Setup' });
+ await dialog.getByRole('button', { name: 'Cancel' }).click();
+ await page.reload();
+ await expect(page).toHaveURL('/signup/add-passkey');
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
- await expect(page.getByText('Skip Passkey Setup')).toBeVisible();
- await page.getByRole('button', { name: 'Skip for now' }).nth(1).click();
+ // Confirming dismissal allows settings access without creating a passkey
+ const dismissedAt = Date.now();
+ await page.clock.setFixedTime(dismissedAt);
+ await page.getByRole('button', { name: 'Skip for now' }).click();
+ await dialog.getByRole('button', { name: 'Skip for now' }).click();
await page.waitForURL('/settings/account');
await expect(page.getByText('Passkey missing')).toBeVisible();
+ await page.reload();
+ await expect(page).toHaveURL('/settings/account');
+ await expect(page.getByText('Passkey missing')).toBeVisible();
+
+ // Dismissal persists across settings pages until the one-day grace period expires
+ await page.clock.setFixedTime(dismissedAt + 24 * 60 * 60 * 1000 - 1000);
+ await page.goto('/settings/apps');
+ await expect(page).toHaveURL('/settings/apps');
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).not.toBeVisible();
+
+ await page.clock.setFixedTime(dismissedAt + 24 * 60 * 60 * 1000);
+ await page.reload();
+ await expect(page).toHaveURL('/signup/add-passkey');
+ await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
});
test('Token usage limit is enforced', async ({ page }) => {