mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-28 19:54:50 +02:00
Added removal of passkeys -wip
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+82
@@ -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}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import DeletePasskeyForm from "./delete-passkey-form.svelte";
|
||||
|
||||
export { DeletePasskeyForm };
|
||||
export { formSchema } from "./schema";
|
||||
export type { FormSchema } from "./schema";
|
||||
+8
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user