From 4d2a5e423e0038728344c4fef860b88591e1777b Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 2 Oct 2026 20:56:04 +0200 Subject: [PATCH] feat: display passkey setup page for users coming from the login page --- frontend/src/routes/settings/+layout.ts | 21 ++++++++--- frontend/src/routes/settings/account/+page.ts | 11 ++---- frontend/src/routes/signup/+page.svelte | 4 +-- .../routes/signup/add-passkey/+page.svelte | 9 ++++- frontend/src/routes/signup/setup/+page.svelte | 4 +-- tests/specs/one-time-access-token.spec.ts | 25 +++++++++++++ tests/specs/user-signup.spec.ts | 36 +++++++++++++++---- 7 files changed, 86 insertions(+), 24 deletions(-) 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 }) => {