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}
+
+
+
+
+
+ {#snippet children({ props })}
+
+ {/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
-
- - Cannot delete last passkey (check be for this as well)
- -
- Delete
-
- - Remove from db
- - Go through tunnels and remove this passkey from it
-
- -
- On creation of a new client tunnel, also sve user passkeyId in
- client_tunnel_staff_key_share `// TODO: passkeyId`
-
- - migration of already existing passkeys
- - Delete from client_tunnel_staff_key_share where userId and passkeyId
-
-
-
-
+ {
+ 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,