mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-03 16:29:03 +02:00
feat: display passkey setup page for users coming from the login page
This commit is contained in:
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '#lib/components/login-wrapper.svelte';
|
||||
import SignupForm from '#lib/components/signup/signup-form.svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
@@ -10,6 +9,7 @@
|
||||
import type { UserSignUp } from '#lib/types/user.type.ts';
|
||||
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
|
||||
import { tryCatch } from '#lib/utils/try-catch-util.ts';
|
||||
import { goto } from '$app/navigation';
|
||||
import { LucideChevronLeft } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
await userStore.setUser(result.data);
|
||||
|
||||
goto('/signup/add-passkey');
|
||||
goto('/');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { openConfirmDialog } from '#lib/components/confirm-dialog/index.ts';
|
||||
import SignInWrapper from '#lib/components/login-wrapper.svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
@@ -7,6 +6,7 @@
|
||||
import WebAuthnService from '#lib/services/webauthn-service.ts';
|
||||
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
|
||||
import { tryCatch } from '#lib/utils/try-catch-util.ts';
|
||||
import { goto } from '$app/navigation';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from '../../login/components/login-logo-error-success-indicator.svelte';
|
||||
@@ -41,6 +41,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
dismiss();
|
||||
goto('/settings/account');
|
||||
isLoading = false;
|
||||
}
|
||||
@@ -53,11 +54,17 @@
|
||||
label: m.skip_for_now(),
|
||||
destructive: true,
|
||||
action: () => {
|
||||
dismiss();
|
||||
goto('/settings/account');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
const inOneDay = Date.now() + 24 * 60 * 60 * 1000;
|
||||
localStorage.setItem('skip-passkey-setup-until', inOneDay.toString());
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '#lib/components/login-wrapper.svelte';
|
||||
import SignupForm from '#lib/components/signup/signup-form.svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
@@ -10,6 +9,7 @@
|
||||
import type { UserSignUp } from '#lib/types/user.type.ts';
|
||||
import { getAxiosErrorMessage } from '#lib/utils/error-util.ts';
|
||||
import { tryCatch } from '#lib/utils/try-catch-util.ts';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from '../../login/components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
await userStore.setUser(result.data);
|
||||
|
||||
goto('/signup/add-passkey');
|
||||
goto('/');
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user