diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/edit-passkey-form.svelte b/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/edit-passkey-form.svelte
new file mode 100644
index 0000000..0c64b61
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/edit-passkey-form.svelte
@@ -0,0 +1,63 @@
+
+
+
+
+
+ {#snippet children({ props })}
+
+ {/snippet}
+
+
+
+
+
+ {#snippet children({ props })}
+ {m["form.name"]()}
+
+ {/snippet}
+
+
+
+
+
+ {m["account.passkeys.edit.action"]()}
+
+
+
diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/index.ts b/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/index.ts
new file mode 100644
index 0000000..6e71fdb
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/index.ts
@@ -0,0 +1,5 @@
+import EditPasskeyForm from "./edit-passkey-form.svelte";
+
+export { EditPasskeyForm };
+export { formSchema } from "./schema";
+export type { FormSchema } from "./schema";
diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/schema.ts b/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/schema.ts
new file mode 100644
index 0000000..91d1e00
--- /dev/null
+++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/edit-passkey-form/schema.ts
@@ -0,0 +1,9 @@
+import { m } from "$i18n/messages";
+import { z } from "zod";
+
+export const formSchema = z.object({
+ passkeyId: z.string(),
+ deviceName: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()),
+});
+
+export type FormSchema = typeof formSchema;
diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts b/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts
index 64acb3d..967a26d 100644
--- a/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts
+++ b/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts
@@ -1,7 +1,10 @@
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";
+import { fail, redirect, type Actions } from "@sveltejs/kit";
+import { superValidate } from "sveltekit-superforms";
+import { zod4 as zod } from "sveltekit-superforms/adapters";
+import { formSchema as editFormSchema } from "./(components)/edit-passkey-form";
const log = logger.setContext(import.meta.filename);
@@ -47,3 +50,49 @@ export const load = async (event) => {
},
};
};
+
+export const actions: Actions = {
+ edit: async (event) => {
+ const form = await superValidate(event, zod(editFormSchema));
+
+ if (!form.valid) {
+ log.error("Edit passkey form is not valid", { errors: form.errors });
+ return fail(400, {
+ form: { ...form, data: { ...form.data } },
+ error: "Form is not valid",
+ });
+ }
+
+ if (!form.data.passkeyId) {
+ log.error("User trying to edit a passkey, but has no passkeyId");
+ redirect(302, ROUTES.LOGOUT);
+ }
+
+ const resp = await event.fetch(`/api/auth/passkeys/${form.data.passkeyId}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "same-origin",
+ body: JSON.stringify({
+ deviceName: form.data.deviceName,
+ }),
+ });
+
+ if (resp.status < 400) {
+ return { form };
+ } else {
+ let error = "Unknown error";
+ try {
+ const body = await resp.json();
+ error = body.error;
+ } catch (e) {
+ log.error("Failed to parse edit passkey error response", { error: e });
+ }
+ return fail(400, {
+ form: { ...form, data: { ...form.data } },
+ error,
+ });
+ }
+ },
+};
diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte
index 81c28ef..a8f916f 100644
--- a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte
+++ b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte
@@ -4,13 +4,14 @@
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
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 { closeDialog, 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";
+ import { EditPasskeyForm } from "./(components)/edit-passkey-form";
const { data } = $props();
let curItem: RedactedPasskeyHydrated | null = $state(null);
@@ -116,7 +117,13 @@
triggerHidden={true}
>
{#if curItem}
- Edit
+ {
+ closeDialog("edit");
+ curItem = null;
+ }}
+ />
{/if}
p.id === passkeyId);
+
+ if (!passkeyToRename) {
+ throw new NotFoundError("Passkey not found or does not belong to you");
+ }
+
+ const body = await request.json();
+ if (!body.deviceName) {
+ throw new ValidationError("deviceName is required");
+ }
+
+ // Update the passkey
+ const updatedPasskey = await UserService.updatePasskey(passkeyId, {
+ deviceName: body.deviceName,
+ });
+
+ if (!updatedPasskey) {
+ throw new NotFoundError("Failed to update passkey");
+ }
+
+ log.info("Passkey updated successfully", {
+ passkeyId,
+ userId,
+ });
+
+ return json({
+ deviceName: updatedPasskey.deviceName,
+ });
+ } catch (error) {
+ logError(log)("Failed to update passkey", error, locals.user?.id, params.passkeyId);
+
+ if (error instanceof BackendError) {
+ return error.toJson();
+ }
+
+ return new InternalError().toJson();
+ }
+}
+
export async function DELETE({ params, locals }: RequestEvent) {
const log = logger.setContext("API.DeletePasskey");
const { passkeyId } = params;
diff --git a/src/routes/api/auth/passkeys/[passkeyId]/__tests__/edit-passkey.test.ts b/src/routes/api/auth/passkeys/[passkeyId]/__tests__/edit-passkey.test.ts
new file mode 100644
index 0000000..cda317d
--- /dev/null
+++ b/src/routes/api/auth/passkeys/[passkeyId]/__tests__/edit-passkey.test.ts
@@ -0,0 +1,179 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { RequestEvent } from "@sveltejs/kit";
+
+const mockLogger = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+};
+
+vi.mock("$lib/server/auth/webauthn-service", () => ({
+ WebAuthnService: {
+ getUserPasskeys: vi.fn(),
+ },
+}));
+
+vi.mock("$lib/server/services/user-service", () => ({
+ UserService: {
+ updatePasskey: vi.fn(),
+ },
+}));
+
+vi.mock("$lib/server/services/staff-crypto.service", () => ({
+ StaffCryptoService: vi.fn(),
+}));
+
+vi.mock("$lib/server/openapi", () => ({
+ registerOpenAPIRoute: vi.fn(),
+}));
+
+vi.mock("$lib/logger", () => ({
+ default: {
+ setContext: vi.fn(() => mockLogger),
+ },
+}));
+
+import { PUT } from "../+server";
+import { UserService } from "$lib/server/services/user-service";
+import { WebAuthnService } from "$lib/server/auth/webauthn-service";
+import type { SelectUserPasskey } from "$lib/server/db/central-schema";
+
+describe("Edit Passkey API", () => {
+ const mockUserId = "456e7890-e89b-12d3-a456-426614174001";
+ const mockPasskeyId = "passkey_abc123def456";
+ const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
+
+ const mockOwnedPasskey: SelectUserPasskey = {
+ id: mockPasskeyId,
+ userId: mockUserId,
+ deviceName: "Original Device",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ lastUsedAt: new Date(),
+ counter: 0,
+ publicKey: "public-key",
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(WebAuthnService.getUserPasskeys).mockResolvedValue([mockOwnedPasskey]);
+ vi.mocked(UserService.updatePasskey).mockResolvedValue({
+ ...mockOwnedPasskey,
+ deviceName: "Renamed Device",
+ });
+ });
+
+ function createMockRequestEvent(overrides: Partial = {}): RequestEvent {
+ return {
+ params: { passkeyId: mockPasskeyId },
+ locals: {
+ user: {
+ id: mockUserId,
+ tenantId: mockTenantId,
+ passkeyId: mockPasskeyId,
+ },
+ },
+ request: {
+ json: vi.fn().mockResolvedValue({
+ deviceName: "Renamed Device",
+ }),
+ } as any,
+ ...overrides,
+ } as RequestEvent;
+ }
+
+ describe("PUT /api/auth/passkeys/[passkeyId]", () => {
+ it("updates a passkey device name for an authenticated user who owns it", async () => {
+ const event = createMockRequestEvent();
+
+ const response = await PUT(event);
+ const data = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(data).toEqual({ deviceName: "Renamed Device" });
+ expect(WebAuthnService.getUserPasskeys).toHaveBeenCalledWith(mockUserId);
+ expect(UserService.updatePasskey).toHaveBeenCalledWith(mockPasskeyId, {
+ deviceName: "Renamed Device",
+ });
+ });
+
+ it("rejects unauthenticated requests with authentication required", async () => {
+ const event = createMockRequestEvent({
+ locals: { user: null } as any,
+ });
+
+ const response = await PUT(event);
+ const data = await response.json();
+
+ expect(response.status).toBe(403);
+ expect(data.error).toBe("Authentication required");
+ expect(UserService.updatePasskey).not.toHaveBeenCalled();
+ });
+
+ it("rejects requests that do not include a passkeyId path parameter", async () => {
+ const event = createMockRequestEvent({
+ params: { passkeyId: undefined as unknown as string },
+ });
+
+ const response = await PUT(event);
+ const data = await response.json();
+
+ expect(response.status).toBe(422);
+ expect(data.error).toBe("Passkey ID is required");
+ });
+
+ it("rejects requests where deviceName is missing from the body", async () => {
+ const event = createMockRequestEvent({
+ request: {
+ json: vi.fn().mockResolvedValue({}),
+ } as any,
+ });
+
+ const response = await PUT(event);
+ const data = await response.json();
+
+ expect(response.status).toBe(422);
+ expect(data.error).toBe("deviceName is required");
+ expect(UserService.updatePasskey).not.toHaveBeenCalled();
+ });
+
+ it("returns 404 when the passkey does not belong to the authenticated user", async () => {
+ vi.mocked(WebAuthnService.getUserPasskeys).mockResolvedValue([]);
+
+ const event = createMockRequestEvent();
+
+ const response = await PUT(event);
+ const data = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(data.error).toBe("Passkey not found or does not belong to you");
+ expect(UserService.updatePasskey).not.toHaveBeenCalled();
+ });
+
+ it("returns 404 when the update service cannot persist the rename", async () => {
+ vi.mocked(UserService.updatePasskey).mockResolvedValue(null as any);
+
+ const event = createMockRequestEvent();
+
+ const response = await PUT(event);
+ const data = await response.json();
+
+ expect(response.status).toBe(404);
+ expect(data.error).toBe("Failed to update passkey");
+ });
+
+ it("returns 500 when an unexpected error occurs during the update flow", async () => {
+ vi.mocked(WebAuthnService.getUserPasskeys).mockRejectedValue(new Error("db unavailable"));
+
+ const event = createMockRequestEvent();
+
+ const response = await PUT(event);
+ const data = await response.json();
+
+ expect(response.status).toBe(500);
+ expect(data.error).toBe("Internal server error");
+ });
+ });
+});