mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-29 12:14:50 +02:00
Account: Added passkey list
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user