mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-13 12:47:39 +02:00
Passkeys can now be renamed
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages.js";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import type { RedactedPasskeyHydrated } from "$lib/types/passkeys";
|
||||
import { untrack } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
|
||||
let { entity, done }: { entity: RedactedPasskeyHydrated; done: () => void } = $props();
|
||||
|
||||
const form = superForm(
|
||||
{
|
||||
passkeyId: untrack(() => entity.id),
|
||||
deviceName: untrack(() => entity.deviceName || ""),
|
||||
},
|
||||
{
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["account.passkeys.edit.success"]());
|
||||
done();
|
||||
} else if (event.result.type === "failure") {
|
||||
toast.error(m["account.passkeys.edit.error"]());
|
||||
}
|
||||
isSubmitting = false;
|
||||
},
|
||||
onSubmit: () => (isSubmitting = true),
|
||||
},
|
||||
);
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
<Form.Root {enhance} action="?/edit">
|
||||
<Form.Field {form} name="passkeyId" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.passkeyId} type="passkeyId" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="deviceName">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.name"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.deviceName} type="deviceName" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<div class="mt-6 flex flex-col gap-4">
|
||||
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
|
||||
{m["account.passkeys.edit.action"]()}
|
||||
</Form.Button>
|
||||
</div>
|
||||
</Form.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
import EditPasskeyForm from "./edit-passkey-form.svelte";
|
||||
|
||||
export { EditPasskeyForm };
|
||||
export { formSchema } from "./schema";
|
||||
export type { FormSchema } from "./schema";
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
<EditPasskeyForm
|
||||
entity={curItem}
|
||||
done={() => {
|
||||
closeDialog("edit");
|
||||
curItem = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</ResponsiveDialog>
|
||||
<ResponsiveDialog
|
||||
|
||||
@@ -14,6 +14,102 @@ import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import logger from "$lib/logger";
|
||||
|
||||
// Register OpenAPI documentation for DELETE
|
||||
registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "PUT", {
|
||||
summary: "Update WebAuthn passkey",
|
||||
description:
|
||||
"Allows authenticated users to change the deviceName of one of their WebAuthn passkeys.",
|
||||
tags: ["Authentication"],
|
||||
parameters: [
|
||||
{
|
||||
name: "passkeyId",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "The ID of the passkey",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
deviceName: {
|
||||
type: "string",
|
||||
description: "New recognizable name for the passkey",
|
||||
},
|
||||
},
|
||||
required: ["deviceName"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Passkey updated successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
deviceName: { type: "string", description: "New name" },
|
||||
},
|
||||
required: ["deviceName"],
|
||||
},
|
||||
example: {
|
||||
deviceName: "My new name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Cannot update passkey",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Cannot update passkey" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": {
|
||||
description: "Authentication required",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Authentication required" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Not authorized to update this passkey",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "You can only update your own passkeys" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Passkey not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Passkey not found" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"500": {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Internal server error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "DELETE", {
|
||||
summary: "Delete a WebAuthn passkey from user account",
|
||||
description:
|
||||
@@ -96,6 +192,70 @@ registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "DELETE", {
|
||||
},
|
||||
});
|
||||
|
||||
export async function PUT({ params, request, locals }: RequestEvent) {
|
||||
const log = logger.setContext("API.PutPasskey");
|
||||
const { passkeyId } = params;
|
||||
|
||||
try {
|
||||
// Check authentication
|
||||
if (!locals.user) {
|
||||
throw new AuthorizationError("Authentication required");
|
||||
}
|
||||
|
||||
if (!passkeyId) {
|
||||
throw new ValidationError("Passkey ID is required");
|
||||
}
|
||||
|
||||
const userId = locals.user.id;
|
||||
|
||||
log.debug("Attempting to update passkey", {
|
||||
passkeyId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Get all passkeys for the user
|
||||
const userPasskeys = await WebAuthnService.getUserPasskeys(userId);
|
||||
|
||||
// Check if passkey exists and belongs to the user
|
||||
const passkeyToRename = userPasskeys.find((p) => 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;
|
||||
|
||||
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user