diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 8a56457..3e973de 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -810,7 +810,7 @@ }, "delete": { "title": "Passkey löschen", - "description": "Diese Aktion kann nicht rückgängig gemacht werden.", + "description": "Diese Aktion kann nicht rückgängig gemacht werden. Tippe {name} um den Passkey zu löschen.", "forbidden": "Du kannst den letzten Passkey in Deinem Account nicht löschen", "action": "Löschen", "success": "Passkey gelöscht", diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index f21a938..89f926a 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -818,7 +818,7 @@ }, "delete": { "title": "Delete Passkey", - "description": "This action cannot be undone.", + "description": "This action cannot be undone. Type {name} to delete this passkey.", "forbidden": "You cannot delete the last passkey in your account", "action": "Delete", "success": "Passkey deleted", diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/delete-passkey-form.svelte b/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/delete-passkey-form.svelte new file mode 100644 index 0000000..e1a5d9f --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/delete-passkey-form.svelte @@ -0,0 +1,82 @@ + + + + + + + + + {#snippet children({ props })} + {m["form.deviceName"]()} + + {/snippet} + + + + + +
+ + {m["account.passkeys.delete.action"]()} + +
+
+ +{#snippet inlineCode(value: string | number)} + {value} +{/snippet} diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/index.ts b/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/index.ts new file mode 100644 index 0000000..eded9f5 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/index.ts @@ -0,0 +1,5 @@ +import DeletePasskeyForm from "./delete-passkey-form.svelte"; + +export { DeletePasskeyForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/schema.ts b/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/schema.ts new file mode 100644 index 0000000..2fc5288 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/(components)/delete-passkey-form/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const formSchema = z.object({ + passkeyId: z.string(), + deviceName: z.string().min(3).max(100), +}); + +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 2d74d33..0c08068 100644 --- a/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.server.ts @@ -5,6 +5,7 @@ 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"; +import { formSchema as deleteFormSchema } from "./(components)/delete-passkey-form"; const log = logger.setContext(import.meta.filename); @@ -66,7 +67,10 @@ export const actions: Actions = { if (!form.data.passkeyId) { log.error("User trying to edit a passkey, but has no passkeyId"); - redirect(302, ROUTES.LOGOUT); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Missing passkeyId", + }); } const resp = await event.fetch(`/api/auth/passkeys/${form.data.passkeyId}`, { @@ -96,4 +100,53 @@ export const actions: Actions = { }); } }, + delete: async (event) => { + if (!event.locals.user) { + log.error("User trying to delete a passkeys, but has no user"); + redirect(302, ROUTES.LOGOUT); + } + + const form = await superValidate(event, zod(deleteFormSchema)); + + if (!form.valid) { + log.error("Delete 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 delete a passkey, but has no passkeyId"); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Missing passkeyId", + }); + } + + const resp = await event.fetch(`/api/auth/passkeys/${form.data.passkeyId}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ tenantId: event.locals.user.tenantId }), + credentials: "same-origin", + }); + + 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 delete passkey error response", { error: e }); + } + return fail(resp.status, { + form, + error, + }); + } + }, }; diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte index 73c61f6..24957e3 100644 --- a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte @@ -15,6 +15,8 @@ import { UserKey, Pen, PlusIcon, Trash2, OctagonX } from "@lucide/svelte"; import { EditPasskeyForm } from "./(components)/edit-passkey-form"; import { AddPasskeyForm } from "./(components)/add-passkey-form"; + import { DeletePasskeyForm } from "./(components)/delete-passkey-form"; + import { invalidate } from "$app/navigation"; const { data } = $props(); let curItem: RedactedPasskeyHydrated | null = $state(null); @@ -108,7 +110,7 @@ icon: Trash2, label: m["delete"](), isDestructive: true, - isDisabled: data.passkeyId === item.id, + isDisabled: data.passkeyId === item.id || items.lenght <= 1, onClick: () => { curItem = item; openDialog("delete"); @@ -145,29 +147,17 @@ {#if curItem} - Delete - + { + curItem = null; + invalidate("app:account-passkeys"); + }} + /> {/if} {:else} diff --git a/src/routes/api/auth/passkeys/[passkeyId]/+server.ts b/src/routes/api/auth/passkeys/[passkeyId]/+server.ts index a6ac61f..89eba32 100644 --- a/src/routes/api/auth/passkeys/[passkeyId]/+server.ts +++ b/src/routes/api/auth/passkeys/[passkeyId]/+server.ts @@ -12,6 +12,9 @@ import { } from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import logger from "$lib/logger"; +import { getTenantDb } from "$lib/server/db"; +import { clientTunnelStaffKeyShare } from "$lib/server/db/tenant-schema"; +import { and, eq } from "drizzle-orm"; // Register OpenAPI documentation for DELETE registerOpenAPIRoute("/auth/passkeys/{passkeyId}", "PUT", { @@ -256,7 +259,7 @@ export async function PUT({ params, request, locals }: RequestEvent) { } } -export async function DELETE({ params, locals }: RequestEvent) { +export async function DELETE({ params, locals, request }: RequestEvent) { const log = logger.setContext("API.DeletePasskey"); const { passkeyId } = params; @@ -270,10 +273,16 @@ export async function DELETE({ params, locals }: RequestEvent) { throw new ValidationError("Passkey ID is required"); } - const userId = locals.user.id; + const body = await request.json(); + const tenantId = body.tenantId; + if (!tenantId) { + throw new ValidationError("tenantId is required"); + } + const userId = locals.user.id; log.debug("Attempting to delete passkey", { passkeyId, + tenantId: body.tenantId, userId, }); @@ -306,6 +315,21 @@ export async function DELETE({ params, locals }: RequestEvent) { throw new NotFoundError("Failed to delete passkey"); } + // Delete tenant-specific data (client tunnel key shares) + const tenantDb = await getTenantDb(tenantId); + const keyShareDeletionResult = await tenantDb + .delete(clientTunnelStaffKeyShare) + .where( + and( + eq(clientTunnelStaffKeyShare.userId, userId), + eq(clientTunnelStaffKeyShare.passkeyId, passkeyId), + ), + ); + + if (!keyShareDeletionResult) { + throw new NotFoundError("Failed to delete passkey key shares"); + } + log.info("Passkey deleted successfully", { passkeyId, userId,