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 })} + {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"); + }); + }); +});