Added removal of passkeys -wip

This commit is contained in:
Karl Ludwig Weise
2026-07-31 15:54:38 +02:00
parent c544b7f53f
commit 87d275cd63
8 changed files with 188 additions and 26 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -0,0 +1,82 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { InlineCode } from "$lib/components/ui/inline-code";
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { z } from "zod";
import { formSchema } from ".";
let { id, deviceName, done }: { id: string; deviceName: string; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ passkeyId: id, deviceName: "" },
{
validators: zodClient(
formSchema.merge(
z.object({
deviceName: z.string().refine((val) => val === deviceName, {
message: m["form.errors.deleteConfirmation"]({
expectedValue: deviceName,
}),
}),
}),
),
),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["account.passkeys.delete.success"]());
done();
} else if (event.result.type === "failure") {
toast.error(m["account.passkeys.delete.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root {enhance} action="?/delete">
<Text style="sm" class="text-muted-foreground -mt-2 font-normal">
<TranslationWithComponent
translation={m["account.passkeys.delete.description"]({ name: "{name}" })}
interpolations={[{ param: "{name}", value: deviceName, snippet: inlineCode }]}
/>
</Text>
<Form.Field {form} name="deviceName">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.deviceName"]()}</Form.Label>
<Input {...props} bind:value={$formData.deviceName} type="text" autocomplete="off" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="passkeyId" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.passkeyId} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<div class="mt-6 flex flex-col gap-4">
<Form.Button size="lg" type="submit" isLoading={isSubmitting} disabled={isSubmitting}>
{m["account.passkeys.delete.action"]()}
</Form.Button>
</div>
</Form.Root>
{#snippet inlineCode(value: string | number)}
<InlineCode>{value}</InlineCode>
{/snippet}
@@ -0,0 +1,5 @@
import DeletePasskeyForm from "./delete-passkey-form.svelte";
export { DeletePasskeyForm };
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -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;
@@ -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,
});
}
},
};
@@ -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 @@
<ResponsiveDialog
id="delete"
title={m["account.passkeys.delete.title"]()}
description={m["account.passkeys.delete.description"]()}
triggerHidden={true}
>
{#if curItem}
Delete
<ul>
<li>Cannot delete last passkey (check be for this as well)</li>
<li>
Delete
<ul>
<li>Remove from db</li>
<li>Go through tunnels and remove this passkey from it</li>
<ul>
<li>
On creation of a new client tunnel, also sve user passkeyId in
client_tunnel_staff_key_share `// TODO: passkeyId`
</li>
<li>migration of already existing passkeys</li>
<li>Delete from client_tunnel_staff_key_share where userId and passkeyId</li>
</ul>
</ul>
</li>
</ul>
<DeletePasskeyForm
id={curItem.id}
deviceName={curItem.deviceName || ""}
done={() => {
curItem = null;
invalidate("app:account-passkeys");
}}
/>
{/if}
</ResponsiveDialog>
{:else}
@@ -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,