1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 15:39:45 +00:00

feat: add qrcode representation of one time link (#424) (#436)

Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Grégory Paul
2025-04-14 15:16:46 +02:00
committed by GitHub
parent 57cb8f8795
commit abf17f6211
9 changed files with 5643 additions and 5285 deletions

10665
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +1,62 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.46.0", "version": "0.46.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 3000", "preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.8.2", "axios": "^1.8.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto": "^1.0.1", "crypto": "^1.0.1",
"formsnap": "^1.0.1", "formsnap": "^1.0.1",
"jose": "^5.9.6", "jose": "^5.9.6",
"lucide-svelte": "^0.487.0", "lucide-svelte": "^0.487.0",
"mode-watcher": "^0.5.1", "mode-watcher": "^0.5.1",
"svelte-sonner": "^0.3.28", "qrcode": "^1.5.4",
"sveltekit-superforms": "^2.23.1", "svelte-sonner": "^0.3.28",
"tailwind-merge": "^2.6.0", "sveltekit-superforms": "^2.23.1",
"tailwind-variants": "^0.3.1", "tailwind-merge": "^2.6.0",
"zod": "^3.24.1" "tailwind-variants": "^0.3.1",
}, "zod": "^3.24.1"
"devDependencies": { },
"@inlang/paraglide-js": "^2.0.0", "devDependencies": {
"@inlang/plugin-m-function-matcher": "^2.0.7", "@inlang/paraglide-js": "^2.0.0",
"@inlang/plugin-message-format": "^4.0.0", "@inlang/plugin-m-function-matcher": "^2.0.7",
"@internationalized/date": "^3.7.0", "@inlang/plugin-message-format": "^4.0.0",
"@playwright/test": "^1.50.0", "@internationalized/date": "^3.7.0",
"@sveltejs/adapter-auto": "^4.0.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.1", "@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/kit": "^2.16.1",
"@types/eslint": "^9.6.1", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/node": "^22.10.10", "@types/eslint": "^9.6.1",
"bits-ui": "^0.22.0", "@types/node": "^22.10.10",
"cmdk-sv": "^0.0.19", "@types/qrcode": "^1.5.5",
"eslint": "^9.19.0", "bits-ui": "^0.22.0",
"eslint-config-prettier": "^10.0.1", "cmdk-sv": "^0.0.19",
"eslint-plugin-svelte": "^2.46.1", "eslint": "^9.19.0",
"globals": "^15.14.0", "eslint-config-prettier": "^10.0.1",
"prettier": "^3.4.2", "eslint-plugin-svelte": "^2.46.1",
"prettier-plugin-svelte": "^3.3.3", "globals": "^15.14.0",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier": "^3.4.2",
"svelte": "^5.19.3", "prettier-plugin-svelte": "^3.3.3",
"svelte-check": "^4.1.4", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.0", "svelte": "^5.19.3",
"tslib": "^2.8.1", "svelte-check": "^4.1.4",
"typescript": "^5.7.3", "tailwindcss": "^4.0.0",
"typescript-eslint": "^8.21.0", "tslib": "^2.8.1",
"vite": "^6.2.6" "typescript": "^5.7.3",
} "typescript-eslint": "^8.21.0",
"vite": "^6.2.6"
}
} }

View File

@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js'; import * as Select from '$lib/components/ui/select/index.js';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
let { let {
userId = $bindable() userId = $bindable()
@@ -18,6 +21,7 @@
const userService = new UserService(); const userService = new UserService();
let oneTimeLink: string | null = $state(null); let oneTimeLink: string | null = $state(null);
let code: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour()); let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour());
let availableExpirations = { let availableExpirations = {
@@ -31,8 +35,8 @@
async function createOneTimeAccessToken() { async function createOneTimeAccessToken() {
try { try {
const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000); const expiration = new Date(Date.now() + availableExpirations[selectedExpiration] * 1000);
const token = await userService.createOneTimeAccessToken(expiration, userId!); code = await userService.createOneTimeAccessToken(expiration, userId!);
oneTimeLink = `${page.url.origin}/lc/${token}`; oneTimeLink = `${page.url.origin}/lc/${code}`;
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -41,6 +45,7 @@
function onOpenChange(open: boolean) { function onOpenChange(open: boolean) {
if (!open) { if (!open) {
oneTimeLink = null; oneTimeLink = null;
code = null;
userId = null; userId = null;
} }
} }
@@ -54,6 +59,7 @@
>{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description >{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description
> >
</Dialog.Header> </Dialog.Header>
{#if oneTimeLink === null} {#if oneTimeLink === null}
<div> <div>
<Label for="expiration">{m.expiration()}</Label> <Label for="expiration">{m.expiration()}</Label>
@@ -65,7 +71,7 @@
onSelectedChange={(v) => onSelectedChange={(v) =>
(selectedExpiration = v!.value as keyof typeof availableExpirations)} (selectedExpiration = v!.value as keyof typeof availableExpirations)}
> >
<Select.Trigger class="h-9 "> <Select.Trigger class="h-9 w-full">
<Select.Value>{selectedExpiration}</Select.Value> <Select.Value>{selectedExpiration}</Select.Value>
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
@@ -75,12 +81,36 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</div> </div>
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}> <Button
onclick={() => createOneTimeAccessToken()}
disabled={!selectedExpiration}
class="mt-2 w-full"
>
{m.generate_code()} {m.generate_code()}
</Button> </Button>
{:else} {:else}
<Label for="login-code" class="sr-only">{m.login_code()}</Label> <div class="flex flex-col items-center gap-2">
<Input id="login-code" value={oneTimeLink} readonly /> <CopyToClipboard value={code!}>
<p class="text-3xl font-semibold">{code}</p>
</CopyToClipboard>
<div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<Separator />
<p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator />
</div>
<Qrcode
class="mb-2"
value={oneTimeLink}
size={180}
color={$mode === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={$mode === 'dark' ? '#000000' : '#FFFFFF'}
/>
<CopyToClipboard value={oneTimeLink!}>
<p data-testId="login-code-link">{oneTimeLink!}</p>
</CopyToClipboard>
</div>
{/if} {/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { cn } from '$lib/utils/style';
import QRCode from 'qrcode';
import { onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let canvasEl: HTMLCanvasElement | null;
let {
value,
size = 200,
color = '#000000',
backgroundColor = '#FFFFFF',
...restProps
}: HTMLAttributes<HTMLCanvasElement> & {
value: string | null;
size?: number;
color?: string;
backgroundColor?: string;
} = $props();
onMount(() => {
if (value && canvasEl) {
// Convert "transparent" to a valid value for the QR code library
const lightColor = backgroundColor === 'transparent' ? '#00000000' : backgroundColor;
const options = {
width: size,
margin: 0,
color: {
dark: color,
light: lightColor
}
};
QRCode.toCanvas(canvasEl, value, options).catch((error: Error) => {
console.error('Error generating QR Code:', error);
});
}
});
</script>
<canvas {...restProps} bind:this={canvasEl} class={cn('rounded-lg', restProps.class)}></canvas>

View File

@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import Qrcode from '$lib/components/qrcode/qrcode.svelte';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages'; import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { mode } from 'mode-watcher';
let { let {
show = $bindable() show = $bindable()
@@ -16,13 +18,17 @@
const userService = new UserService(); const userService = new UserService();
let code: string | null = $state(null); let code: string | null = $state(null);
let loginCodeLink: string | null = $state(null);
$effect(() => { $effect(() => {
if (show) { if (show) {
const expiration = new Date(Date.now() + 15 * 60 * 1000); const expiration = new Date(Date.now() + 15 * 60 * 1000);
userService userService
.createOneTimeAccessToken(expiration, 'me') .createOneTimeAccessToken(expiration, 'me')
.then((c) => (code = c)) .then((c) => {
code = c;
loginCodeLink = page.url.origin + '/lc/' + code;
})
.catch((e) => axiosErrorToast(e)); .catch((e) => axiosErrorToast(e));
} }
}); });
@@ -48,16 +54,22 @@
<CopyToClipboard value={code!}> <CopyToClipboard value={code!}>
<p class="text-3xl font-semibold">{code}</p> <p class="text-3xl font-semibold">{code}</p>
</CopyToClipboard> </CopyToClipboard>
<div class="text-muted-foreground flex items-center justify-center gap-3"> <div class="text-muted-foreground my-2 flex items-center justify-center gap-3">
<Separator /> <Separator />
<p class="text-nowrap text-xs">{m.or_visit()}</p> <p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator /> <Separator />
</div> </div>
<div>
<CopyToClipboard value={page.url.origin + '/lc/' + code!}> <Qrcode
<p data-testId="login-code-link">{page.url.origin + '/lc/' + code!}</p> class="mb-2"
</CopyToClipboard> value={loginCodeLink}
</div> size={180}
color={$mode === 'dark' ? '#FFFFFF' : '#000000'}
backgroundColor={$mode === 'dark' ? '#000000' : '#FFFFFF'}
/>
<CopyToClipboard value={loginCodeLink!}>
<p data-testId="login-code-link">{loginCodeLink!}</p>
</CopyToClipboard>
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -3,14 +3,13 @@
import { openConfirmDialog } from '$lib/components/confirm-dialog/'; import { openConfirmDialog } from '$lib/components/confirm-dialog/';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service'; import OIDCService from '$lib/services/oidc-service';
import type { OidcClient } from '$lib/types/oidc.type'; import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from 'lucide-svelte'; import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
clients = $bindable(), clients = $bindable(),
@@ -20,8 +19,6 @@
requestOptions: SearchPaginationSortRequest; requestOptions: SearchPaginationSortRequest;
} = $props(); } = $props();
let oneTimeLink = $state<string | null>(null);
const oidcService = new OIDCService(); const oidcService = new OIDCService();
async function deleteClient(client: OidcClient) { async function deleteClient(client: OidcClient) {
@@ -86,5 +83,3 @@
</Table.Cell> </Table.Cell>
{/snippet} {/snippet}
</AdvancedTable> </AdvancedTable>
<OneTimeLinkModal {oneTimeLink} />

View File

@@ -1,8 +1,8 @@
import test, { expect } from '@playwright/test'; import test, { expect } from '@playwright/test';
import { users } from './data'; import { users } from './data';
import authUtil from './utils/auth.util';
import { cleanupBackend } from './utils/cleanup.util'; import { cleanupBackend } from './utils/cleanup.util';
import passkeyUtil from './utils/passkey.util'; import passkeyUtil from './utils/passkey.util';
import authUtil from './utils/auth.util';
test.beforeEach(cleanupBackend); test.beforeEach(cleanupBackend);

View File

@@ -5,7 +5,7 @@ import { cleanupBackend } from './utils/cleanup.util';
test.describe('API Key Management', () => { test.describe('API Key Management', () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await cleanupBackend() await cleanupBackend();
await page.goto('/settings/admin/api-keys'); await page.goto('/settings/admin/api-keys');
}); });

View File

@@ -50,11 +50,13 @@ test('Create user fails with already taken username', async ({ page }) => {
await expect(page.getByRole('status')).toHaveText('Username is already in use'); await expect(page.getByRole('status')).toHaveText('Username is already in use');
}); });
test('Create one time access token', async ({ page }) => { test('Create one time access token', async ({ page, context }) => {
await page.goto('/settings/admin/users'); await page.goto('/settings/admin/users');
await page await page
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }) .getByRole('row', {
name: `${users.craig.firstname} ${users.craig.lastname}`
})
.getByRole('button') .getByRole('button')
.click(); .click();
@@ -64,16 +66,20 @@ test('Create one time access token', async ({ page }) => {
await page.getByRole('option', { name: '12 hours' }).click(); await page.getByRole('option', { name: '12 hours' }).click();
await page.getByRole('button', { name: 'Generate Code' }).click(); await page.getByRole('button', { name: 'Generate Code' }).click();
await expect(page.getByRole('textbox', { name: 'Login Code' })).toHaveValue( const link = await page.getByTestId('login-code-link').textContent();
/http:\/\/localhost\/lc\/.*/ await context.clearCookies();
);
await page.goto(link!);
await page.waitForURL('/settings/account');
}); });
test('Delete user', async ({ page }) => { test('Delete user', async ({ page }) => {
await page.goto('/settings/admin/users'); await page.goto('/settings/admin/users');
await page await page
.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }) .getByRole('row', {
name: `${users.craig.firstname} ${users.craig.lastname}`
})
.getByRole('button') .getByRole('button')
.click(); .click();
await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('menuitem', { name: 'Delete' }).click();
@@ -81,7 +87,9 @@ test('Delete user', async ({ page }) => {
await expect(page.getByRole('status')).toHaveText('User deleted successfully'); await expect(page.getByRole('status')).toHaveText('User deleted successfully');
await expect( await expect(
page.getByRole('row', { name: `${users.craig.firstname} ${users.craig.lastname}` }) page.getByRole('row', {
name: `${users.craig.firstname} ${users.craig.lastname}`
})
).not.toBeVisible(); ).not.toBeVisible();
}); });