Account: Added passkey list

This commit is contained in:
Karl Ludwig Weise
2026-07-23 16:29:43 +02:00
parent c598afae2f
commit a98b278df2
8 changed files with 281 additions and 13 deletions
+32 -2
View File
@@ -757,7 +757,7 @@
"general": {
"navItem": "Einstellungen",
"title": "Kontoeinstellungen",
"description": "Ändere Deinen Namen."
"description": "Ändere Deinen Namen oder die Sprache für E-Mails."
},
"change-email": {
"navItem": "E-Mail Adresse",
@@ -767,7 +767,36 @@
"passkeys": {
"navItem": "Passkeys",
"title": "Passkeys verwalten",
"description": "Du kannst bis zu drei Passkeys für Dein Konto hinzufügen."
"description": "Du kannst bis zu drei Passkeys für Dein Konto hinzufügen.",
"list": {
"loading": "Lade Passkeys",
"unnamedPasskey": "Unbenannter Passkey",
"empty": "Keine Passkeys hinterlegt",
"createdAt": "Erstellt am: {createdAt}.",
"lastUsedAt": "Erstellt am {lastUsedAt}."
},
"add": {
"title": "Passkey hinzufügen",
"description": "Füge Deinem Account einen Passkey hinzu",
"action": "Hinzufügen",
"success": "Passkey hinzugefügt",
"error": "Passkey konnte nicht hinzugefügt werden"
},
"edit": {
"title": "Passkey umbenennen",
"description": "Gib' Deinem Passkey einen Namen, den Du wieder erkennst",
"action": "Umbenennen",
"success": "Passkey umbenannt",
"error": "Passkey konnte nicht umbenannt werden"
},
"delete": {
"title": "Passkey löschen",
"description": "Diese Aktion kann nicht rückgängig gemacht werden.",
"forbidden": "Du kannst den letzten Passkey in Deinem Account nicht löschen",
"action": "Löschen",
"success": "Passkey gelöscht",
"error": "Passkey konnte nicht gelöscht werden"
}
},
"change-passphrase": {
"navItem": "Passphrase",
@@ -864,6 +893,7 @@
},
"yes": "Ja",
"no": "Nein",
"unknown": "unbekannt",
"public": {
"links": {
"website": "Webseite aufrufen",
+32 -2
View File
@@ -765,7 +765,7 @@
"general": {
"navItem": "Settings",
"title": "Account Settings",
"description": "Change your name."
"description": "Change your name and e-mail language."
},
"change-email": {
"navItem": "E-Mail Address",
@@ -775,7 +775,36 @@
"passkeys": {
"navItem": "Passkeys",
"title": "Manage Passkeys",
"description": "You can add up to three passkeys."
"description": "You can add up to three passkeys.",
"list": {
"loading": "Loading Passkeys",
"unnamedPasskey": "Unnamed Passkey",
"empty": "No Passkeys set",
"createdAt": "Created at: {createdAt}.",
"lastUsedAt": "Last used at: {lastUsedAt}."
},
"add": {
"title": "Add Passkey",
"description": "Add a Passkey to your account",
"action": "Add Passkey",
"success": "Passkey added",
"error": "Passkey could not be added"
},
"edit": {
"title": "Rename Passkey",
"description": "Rename it to something you will recognize",
"action": "Rename",
"success": "Passkey renamed",
"error": "Passkey could not be renamed"
},
"delete": {
"title": "Delete Passkey",
"description": "This action cannot be undone.",
"forbidden": "You cannot delete the last passkey in your account",
"action": "Delete",
"success": "Passkey deleted",
"error": "Passkey could not be deleted"
}
},
"change-passphrase": {
"navItem": "Passphrase",
@@ -872,6 +901,7 @@
},
"yes": "Yes",
"no": "No",
"unknown": "unknown",
"public": {
"links": {
"website": "Go to Website",
@@ -62,12 +62,9 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<LanguageSwitch class="w-full" triggerClass="w-[100%] [&>svg]:ml-auto" />
<DropdownMenu.Item
onclick={() => goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}
disabled={true}
>
<DropdownMenu.Item onclick={() => goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}>
<AccountIcon />
{m["nav.account"]()} (coming soon)
{m["nav.account"]()}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
+11
View File
@@ -0,0 +1,11 @@
export type RedactedPasskey = {
id: string;
deviceName: string | null;
createdAt: string | null;
lastUsedAt: string | null;
};
export type RedactedPasskeyHydrated = Pick<RedactedPasskey, "id" | "deviceName"> & {
createdAt: Date | null;
lastUsedAt: Date | null;
};
@@ -3,7 +3,7 @@
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import * as Item from "$lib/components/ui/item/index.js";
import * as Item from "$lib/components/ui/item";
import { Headline, Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
@@ -38,7 +38,7 @@
</a>
{/snippet}
</Item.Root>
<Item.Root variant="outline">
<!-- <Item.Root variant="outline">
{#snippet child({ props })}
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_EMAIL)} {...props}>
<Item.Content>
@@ -54,7 +54,7 @@
</Item.Actions>
</a>
{/snippet}
</Item.Root>
</Item.Root> -->
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.PASSKEYS)} {...props}>
@@ -0,0 +1,49 @@
import { ROUTES } from "$lib/const/routes.js";
import logger from "$lib/logger";
import type { RedactedPasskey, RedactedPasskeyHydrated } from "$lib/types/passkeys.js";
import { redirect } from "@sveltejs/kit";
const log = logger.setContext(import.meta.filename);
export const load = async (event) => {
const tenantId = event.locals.user?.tenantId;
event.depends(`app:passkeys-${tenantId}`);
if (!tenantId) {
log.error("User trying to access their passkeys, but has no tenantId");
redirect(302, ROUTES.LOGOUT);
}
const list = event
.fetch(`/api/auth/passkeys`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (res) => {
// Logout if session expired
if (res.status === 401) {
redirect(302, ROUTES.LOGOUT);
}
try {
const body = await res.json();
return body.passkeys.map((it: RedactedPasskey) => ({
...it,
createdAt: it.createdAt ? new Date(it.createdAt) : null,
lastUsedAt: it.lastUsedAt ? new Date(it.lastUsedAt) : null,
})) as RedactedPasskeyHydrated[];
} catch (error) {
log.error("Failed to parse user passkeys response", { error });
return [];
}
});
return {
streamed: {
list,
},
};
};
@@ -2,10 +2,24 @@
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { Headline } from "$lib/components/ui/typography";
import { List, ListItem } from "$lib/components/templates/list";
import { LoadingList } from "$lib/components/templates/loading";
import { openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { Headline, Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import type { RedactedPasskeyHydrated } from "$lib/types/passkeys";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { getLocalTimeZone } from "@internationalized/date";
import { Pen, PlusIcon, Trash2 } from "@lucide/svelte";
const { data } = $props();
let curItem: RedactedPasskeyHydrated | null = $state(null);
</script>
<svelte:head>
<title>{m["account.passkeys.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout
breakcrumbs={[
{
@@ -20,5 +34,101 @@
>
<MaxPageWidth maxWidth="md" class="flex flex-col gap-6">
<Headline level="h1" style="h3">{m["account.passkeys.title"]()}</Headline>
{#await data.streamed.list}
<LoadingList title={m["account.passkeys.list.loading"]()} />
{:then items}
<div class="flex flex-col items-start gap-5">
<ResponsiveDialog
id="add"
title={m["account.passkeys.add.title"]()}
description={m["account.passkeys.add.description"]()}
triggerHidden={false}
>
{#snippet triggerLabel()}
<PlusIcon /> {m["account.passkeys.add.title"]()}
{/snippet}
SetupPasskeyForm?
</ResponsiveDialog>
{#if items.length > 0}
<List>
{#each items as item (item.id)}
<ListItem
title={item.deviceName || m["account.passkeys.list.unnamedPasskey"]()}
description={`${m["account.passkeys.list.createdAt"]({
createdAt: item.createdAt
? toDisplayDateTime(item.createdAt, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone(),
})
: m["unknown"](),
})} ${m["account.passkeys.list.lastUsedAt"]({
lastUsedAt: item.lastUsedAt
? toDisplayDateTime(item.lastUsedAt, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone(),
})
: m["unknown"](),
})}`}
actions={[
{
type: "action",
icon: Pen,
label: m["edit"](),
onClick: () => {
curItem = item;
openDialog("edit");
},
},
{
type: "divider",
},
{
type: "action",
icon: Trash2,
label: m["delete"](),
isDestructive: true,
onClick: () => {
curItem = item;
openDialog("delete");
},
},
]}
/>
{/each}
</List>
<ResponsiveDialog
id="edit"
title={m["account.passkeys.edit.title"]()}
description={m["account.passkeys.edit.description"]()}
triggerHidden={true}
>
{#if curItem}
Edit
{/if}
</ResponsiveDialog>
<ResponsiveDialog
id="delete"
title={m["account.passkeys.delete.title"]()}
description={m["account.passkeys.delete.description"]()}
triggerHidden={true}
>
{#if curItem}
Delete
{/if}
</ResponsiveDialog>
{:else}
<Text style="md">No passkeys found</Text>
{/if}
</div>
{/await}
</MaxPageWidth>
</SidebarLayout>
+41
View File
@@ -6,6 +6,7 @@ import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { checkPermission } from "$lib/server/utils/permissions";
import logger from "$lib/logger";
import type { RedactedPasskey } from "$lib/types/passkeys";
// Register OpenAPI documentation
registerOpenAPIRoute("/auth/passkeys", "POST", {
@@ -81,6 +82,46 @@ registerOpenAPIRoute("/auth/passkeys", "POST", {
},
});
export const GET: RequestHandler = async ({ locals }) => {
const log = logger.setContext("API");
try {
if (!locals.user) {
throw new AuthenticationError();
}
checkPermission(locals, locals.user.tenantId, false, false);
const passkeys = await WebAuthnService.getUserPasskeys(locals.user.id);
const redactedPasskeys: RedactedPasskey[] = passkeys.map((it) => ({
id: it.id,
deviceName: it.deviceName,
createdAt: it.createdAt ? it.createdAt.toISOString() : null,
lastUsedAt: it.lastUsedAt ? it.lastUsedAt.toISOString() : null,
}));
log.debug("Returning user passkeys", {
userId: locals.user.id,
passkeys: redactedPasskeys,
});
return json(
{
passkeys: redactedPasskeys,
},
{ status: 200 },
);
} catch (error) {
log.error("Getting passkey error:", JSON.stringify(error || "?"));
if (error instanceof BackendError) {
return error.toJson();
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
const log = logger.setContext("API");