diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 7962cf7..59ecbdc 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -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", diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index f22498b..3b74c86 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -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", diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte index 46fa6ec..e9743d7 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte @@ -62,12 +62,9 @@ - goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))} - disabled={true} - > + goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}> - {m["nav.account"]()} (coming soon) + {m["nav.account"]()} diff --git a/src/lib/types/passkeys.ts b/src/lib/types/passkeys.ts new file mode 100644 index 0000000..ac3a42f --- /dev/null +++ b/src/lib/types/passkeys.ts @@ -0,0 +1,11 @@ +export type RedactedPasskey = { + id: string; + deviceName: string | null; + createdAt: string | null; + lastUsedAt: string | null; +}; + +export type RedactedPasskeyHydrated = Pick & { + createdAt: Date | null; + lastUsedAt: Date | null; +}; diff --git a/src/routes/(pages)/dashboard/account/+page.svelte b/src/routes/(pages)/dashboard/account/+page.svelte index 222c238..f6983b0 100644 --- a/src/routes/(pages)/dashboard/account/+page.svelte +++ b/src/routes/(pages)/dashboard/account/+page.svelte @@ -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 @@ {/snippet} - + {#snippet child({ props })} diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts b/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts new file mode 100644 index 0000000..64acb3d --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts @@ -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, + }, + }; +}; diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte index 0552bb2..f376f54 100644 --- a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte @@ -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); + + {m["account.passkeys.title"]()} - OpenReception + + {m["account.passkeys.title"]()} + {#await data.streamed.list} + + {:then items} +
+ + {#snippet triggerLabel()} + {m["account.passkeys.add.title"]()} + {/snippet} + SetupPasskeyForm? + + + {#if items.length > 0} + + {#each items as item (item.id)} + { + curItem = item; + openDialog("edit"); + }, + }, + { + type: "divider", + }, + { + type: "action", + icon: Trash2, + label: m["delete"](), + isDestructive: true, + onClick: () => { + curItem = item; + openDialog("delete"); + }, + }, + ]} + /> + {/each} + + + {#if curItem} + Edit + {/if} + + + {#if curItem} + Delete + {/if} + + {:else} + No passkeys found + {/if} +
+ {/await}
diff --git a/src/routes/api/auth/passkeys/+server.ts b/src/routes/api/auth/passkeys/+server.ts index ca52cae..5863f7a 100644 --- a/src/routes/api/auth/passkeys/+server.ts +++ b/src/routes/api/auth/passkeys/+server.ts @@ -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");