mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-26 19:04:50 +02:00
Allow dashboard users to add passkey -wip
This commit is contained in:
@@ -85,7 +85,9 @@
|
||||
"pin": "PIN",
|
||||
"pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager.",
|
||||
"locale": "Sprache",
|
||||
"localePlaceholder": "Sprache wählen"
|
||||
"localePlaceholder": "Sprache wählen",
|
||||
"deviceName": "Passkey Name",
|
||||
"deviceNameHint": "Something you will recognize"
|
||||
},
|
||||
"login": {
|
||||
"or": "Oder",
|
||||
|
||||
@@ -101,7 +101,9 @@
|
||||
"pin": "PIN",
|
||||
"pinHint": "Save your PIN in a secure place, like a password-manager.",
|
||||
"locale": "Language",
|
||||
"localePlaceholder": "Select Language"
|
||||
"localePlaceholder": "Select Language",
|
||||
"deviceName": "Passkey Name",
|
||||
"deviceNameHint": "Etwas, das Du wiedererkennen wirst"
|
||||
},
|
||||
"login": {
|
||||
"or": "Or",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { m } from "$i18n/messages";
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import logger from "$lib/logger";
|
||||
import { normalizeEmail } from "$lib/utils";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
type WebAuthnAllowCredential = {
|
||||
id: string;
|
||||
@@ -349,3 +352,125 @@ export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer)
|
||||
// Counter is at offset 33, 4 bytes, big-endian
|
||||
return view.getUint32(33, false); // false = big-endian
|
||||
};
|
||||
|
||||
export const getPasskeyFormData = async ({
|
||||
email,
|
||||
userId,
|
||||
setPasskeyFieldState,
|
||||
}: {
|
||||
email: string;
|
||||
userId: string;
|
||||
setPasskeyFieldState: (newState: PasskeyState) => void;
|
||||
}) => {
|
||||
setPasskeyFieldState("loading");
|
||||
|
||||
// Generate Kyber keypair BEFORE passkey registration
|
||||
// This keypair will be used to create the dbShard after PRF output is available
|
||||
const { KyberCrypto } = await import("$lib/crypto/utils");
|
||||
const kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined =
|
||||
KyberCrypto.generateKeyPair();
|
||||
|
||||
const challenge = await fetchChallenge(email, userId);
|
||||
let registrationChallenge: string | undefined;
|
||||
|
||||
if (!challenge) {
|
||||
logger.error("Failed to fetch challenge", { email: email });
|
||||
setPasskeyFieldState("error");
|
||||
} else {
|
||||
// Store the registration challenge - will be sent with form to avoid cookie overwrite by PRF challenge
|
||||
registrationChallenge = challenge.challenge;
|
||||
|
||||
setPasskeyFieldState("user");
|
||||
const passkeyResp = await generatePasskey({
|
||||
...challenge,
|
||||
email: email,
|
||||
enablePRF: true, // CRITICAL: Enable PRF extension for zero-knowledge key derivation
|
||||
}).catch((error) => {
|
||||
setPasskeyFieldState("error");
|
||||
logger.error("Failed to generate passkey", { ...challenge, error });
|
||||
});
|
||||
|
||||
if (!passkeyResp) {
|
||||
setPasskeyFieldState("error");
|
||||
logger.error("Passkey response is falsy");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify PRF extension is enabled
|
||||
const extensionResults = passkeyResp.getClientExtensionResults();
|
||||
if (!extensionResults.prf?.enabled) {
|
||||
setPasskeyFieldState("error");
|
||||
logger.error("PRF extension not enabled - passkey rejected", {
|
||||
email: email,
|
||||
extensions: extensionResults,
|
||||
});
|
||||
toast.warning(m["setupPasskey.errorAuthenticatorNotSupported"]());
|
||||
return;
|
||||
}
|
||||
|
||||
// Get attestationObject and clientDataJSON for @simplewebauthn/server verification
|
||||
const attestationObjectResp = passkeyResp.response.attestationObject;
|
||||
const clientDataJSONResp = passkeyResp.response.clientDataJSON;
|
||||
|
||||
// UX Primer for second passkey UI
|
||||
const isConfirmed = confirm(m["setupPasskey.confirmPrfRetrival"]());
|
||||
if (!isConfirmed) {
|
||||
setPasskeyFieldState("error");
|
||||
toast.error(m["setupPasskey.errorPrfOutputNotTriggered"]());
|
||||
logger.warn("PRF challenge primer not confirmed");
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Get PRF output immediately after passkey creation
|
||||
// This is the only time we can retrieve the PRF output
|
||||
// Uses email as salt for multi-passkey support
|
||||
let prfOutput: ArrayBuffer | undefined;
|
||||
try {
|
||||
const prfChallenge = await fetchChallenge(email, userId);
|
||||
if (!prfChallenge) {
|
||||
throw new Error("Failed to fetch PRF challenge");
|
||||
}
|
||||
|
||||
const prfOutputResp = await getPRFOutputAfterRegistration({
|
||||
passkeyId: passkeyResp.id,
|
||||
rpId: prfChallenge.id,
|
||||
challengeBase64: prfChallenge.challenge,
|
||||
email: email, // Use email as PRF salt for multi-passkey support
|
||||
});
|
||||
|
||||
prfOutput = prfOutputResp;
|
||||
logger.info("PRF output retrieved successfully", {
|
||||
prfOutputLength: prfOutputResp.byteLength,
|
||||
});
|
||||
} catch (error) {
|
||||
setPasskeyFieldState("error");
|
||||
logger.error("Failed to get PRF output", {
|
||||
email: email,
|
||||
error,
|
||||
});
|
||||
toast.error(m["setupPasskey.errorGettingPrfOutput"]());
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data with passkey info - send full attestation for proper COSE key extraction
|
||||
const attestationObjectBase64 = arrayBufferToBase64(attestationObjectResp);
|
||||
const clientDataJSONBase64 = arrayBufferToBase64(clientDataJSONResp);
|
||||
|
||||
// Update UI to show passkey is ready
|
||||
setPasskeyFieldState("success");
|
||||
|
||||
return {
|
||||
passkeyId: passkeyResp.id,
|
||||
prfOutput: prfOutput,
|
||||
kyberKeyPair,
|
||||
formData: {
|
||||
email,
|
||||
userId,
|
||||
id: passkeyResp.id,
|
||||
attestationObjectBase64,
|
||||
clientDataJSONBase64,
|
||||
challenge: registrationChallenge!, // Send original registration challenge (not PRF challenge)
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import logger from "$lib/logger";
|
||||
import {
|
||||
arrayBufferToBase64,
|
||||
fetchChallenge,
|
||||
generatePasskey,
|
||||
getPRFOutputAfterRegistration,
|
||||
} from "$lib/utils/passkey";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { type Infer, type SuperValidated } from "sveltekit-superforms";
|
||||
@@ -24,6 +18,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
|
||||
import { resolve } from "$app/paths";
|
||||
import { getPasskeyFormData } from "$lib/utils/passkey";
|
||||
|
||||
let {
|
||||
data,
|
||||
@@ -36,7 +31,6 @@
|
||||
let passkeyId: string | undefined = $state();
|
||||
let prfOutput: ArrayBuffer | undefined = $state();
|
||||
let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state();
|
||||
let registrationChallenge: string | undefined = $state();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const form = superForm(data.form, {
|
||||
@@ -73,109 +67,23 @@
|
||||
};
|
||||
|
||||
const onSetPasskey = async () => {
|
||||
$passkeyLoading = "loading";
|
||||
const data = await getPasskeyFormData({
|
||||
email: $formData.email,
|
||||
userId: $formData.userId,
|
||||
setPasskeyFieldState: (v) => ($passkeyLoading = v),
|
||||
});
|
||||
|
||||
// Generate Kyber keypair BEFORE passkey registration
|
||||
// This keypair will be used to create the dbShard after PRF output is available
|
||||
const { KyberCrypto } = await import("$lib/crypto/utils");
|
||||
kyberKeyPair = KyberCrypto.generateKeyPair();
|
||||
|
||||
const challenge = await fetchChallenge($formData.email, $formData.userId);
|
||||
|
||||
if (!challenge) {
|
||||
logger.error("Failed to fetch challenge", { email: $formData.email });
|
||||
$passkeyLoading = "error";
|
||||
if (!data) {
|
||||
console.error("Unable to getPasskeyFormData");
|
||||
} else {
|
||||
// Store the registration challenge - will be sent with form to avoid cookie overwrite by PRF challenge
|
||||
registrationChallenge = challenge.challenge;
|
||||
passkeyId = data.passkeyId;
|
||||
kyberKeyPair = data.kyberKeyPair;
|
||||
prfOutput = data.prfOutput;
|
||||
|
||||
$passkeyLoading = "user";
|
||||
const passkeyResp = await generatePasskey({
|
||||
...challenge,
|
||||
email: $formData.email,
|
||||
enablePRF: true, // CRITICAL: Enable PRF extension for zero-knowledge key derivation
|
||||
}).catch((error) => {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to generate passkey", { ...challenge, error });
|
||||
});
|
||||
|
||||
if (!passkeyResp) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Passkey response is falsy");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify PRF extension is enabled
|
||||
const extensionResults = passkeyResp.getClientExtensionResults();
|
||||
if (!extensionResults.prf?.enabled) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("PRF extension not enabled - passkey rejected", {
|
||||
email: $formData.email,
|
||||
extensions: extensionResults,
|
||||
});
|
||||
toast.warning(m["setupPasskey.errorAuthenticatorNotSupported"]());
|
||||
return;
|
||||
}
|
||||
|
||||
// Get attestationObject and clientDataJSON for @simplewebauthn/server verification
|
||||
const attestationObjectResp = passkeyResp.response.attestationObject;
|
||||
const clientDataJSONResp = passkeyResp.response.clientDataJSON;
|
||||
|
||||
// UX Primer for second passkey UI
|
||||
const isConfirmed = confirm(m["setupPasskey.confirmPrfRetrival"]());
|
||||
if (!isConfirmed) {
|
||||
$passkeyLoading = "error";
|
||||
toast.error(m["setupPasskey.errorPrfOutputNotTriggered"]());
|
||||
logger.warn("PRF challenge primer not confirmed");
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL: Get PRF output immediately after passkey creation
|
||||
// This is the only time we can retrieve the PRF output
|
||||
// Uses email as salt for multi-passkey support
|
||||
try {
|
||||
const prfChallenge = await fetchChallenge($formData.email, $formData.userId);
|
||||
if (!prfChallenge) {
|
||||
throw new Error("Failed to fetch PRF challenge");
|
||||
}
|
||||
|
||||
const prfOutputResp = await getPRFOutputAfterRegistration({
|
||||
passkeyId: passkeyResp.id,
|
||||
rpId: prfChallenge.id,
|
||||
challengeBase64: prfChallenge.challenge,
|
||||
email: $formData.email, // Use email as PRF salt for multi-passkey support
|
||||
});
|
||||
|
||||
prfOutput = prfOutputResp;
|
||||
logger.info("PRF output retrieved successfully", {
|
||||
prfOutputLength: prfOutputResp.byteLength,
|
||||
});
|
||||
} catch (error) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to get PRF output", {
|
||||
email: $formData.email,
|
||||
error,
|
||||
});
|
||||
toast.error(m["setupPasskey.errorGettingPrfOutput"]());
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data with passkey info - send full attestation for proper COSE key extraction
|
||||
const attestationObjectBase64 = arrayBufferToBase64(attestationObjectResp);
|
||||
const clientDataJSONBase64 = arrayBufferToBase64(clientDataJSONResp);
|
||||
$formData = {
|
||||
...$formData,
|
||||
id: passkeyResp.id,
|
||||
attestationObjectBase64,
|
||||
clientDataJSONBase64,
|
||||
challenge: registrationChallenge!, // Send original registration challenge (not PRF challenge)
|
||||
};
|
||||
|
||||
// Set for later use
|
||||
passkeyId = passkeyResp.id;
|
||||
|
||||
// Update UI to show passkey is ready
|
||||
$passkeyLoading = "success";
|
||||
$formData.id = data.formData.id;
|
||||
$formData.attestationObjectBase64 = data.formData.attestationObjectBase64;
|
||||
$formData.clientDataJSONBase64 = data.formData.clientDataJSONBase64;
|
||||
$formData.challenge = data.formData.challenge;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { invalidate } from "$app/navigation";
|
||||
import { m } from "$i18n/messages.js";
|
||||
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Passkey } from "$lib/components/ui/passkey";
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import logger from "$lib/logger";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { getPasskeyFormData } from "$lib/utils/passkey";
|
||||
import { untrack } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { superForm } from "sveltekit-superforms/client";
|
||||
import { formSchema } from "./schema";
|
||||
|
||||
let account = $derived($auth.user);
|
||||
let tenantId: string | undefined = $state();
|
||||
let passkeyId: string | undefined = $state();
|
||||
let prfOutput: ArrayBuffer | undefined = $state();
|
||||
let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state();
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const form = superForm(
|
||||
{
|
||||
deviceName: "",
|
||||
email: untrack(() => account?.email || ""),
|
||||
userId: untrack(() => account?.id || ""),
|
||||
id: "",
|
||||
attestationObjectBase64: "",
|
||||
clientDataJSONBase64: "",
|
||||
challenge: "",
|
||||
},
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onChange: (event) => {
|
||||
if (event.paths.includes("email")) {
|
||||
setProperPasskeyState();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
const passkeyLoading: Writable<PasskeyState> = writable("click");
|
||||
|
||||
const setProperPasskeyState = () => {
|
||||
const isOk = formSchema.shape.email.safeParse($formData.email).success;
|
||||
if (isOk) {
|
||||
$passkeyLoading = "click";
|
||||
} else {
|
||||
$passkeyLoading = "initial";
|
||||
}
|
||||
};
|
||||
|
||||
const onSetPasskey = async () => {
|
||||
const data = await getPasskeyFormData({
|
||||
email: $formData.email,
|
||||
userId: $formData.userId,
|
||||
setPasskeyFieldState: (v) => ($passkeyLoading = v),
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
console.error("Unable to getPasskeyFormData");
|
||||
} else {
|
||||
passkeyId = data.passkeyId;
|
||||
kyberKeyPair = data.kyberKeyPair;
|
||||
prfOutput = data.prfOutput;
|
||||
|
||||
$formData.id = data.formData.id;
|
||||
$formData.attestationObjectBase64 = data.formData.attestationObjectBase64;
|
||||
$formData.clientDataJSONBase64 = data.formData.clientDataJSONBase64;
|
||||
$formData.challenge = data.formData.challenge;
|
||||
}
|
||||
};
|
||||
|
||||
const storeStaffKeyPairForNewPasskey = async () => {
|
||||
if (tenantId && passkeyId && prfOutput && kyberKeyPair) {
|
||||
const crypto = new UnifiedAppointmentCrypto();
|
||||
return await crypto
|
||||
.storeStaffKeyPairForNewPasskey(
|
||||
tenantId,
|
||||
$formData.userId,
|
||||
passkeyId,
|
||||
prfOutput,
|
||||
kyberKeyPair,
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(m["setupPasskey.successKeyPairSaved"]());
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(m["setupPasskey.errorKeyPairNotSaved"]());
|
||||
logger.error("Failed to store staff key pair", {
|
||||
tenantId,
|
||||
userId: $formData.userId,
|
||||
passkeyId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.error("Failed to store staff key pair - missing required data", {
|
||||
tenantId,
|
||||
userId: $formData.userId,
|
||||
passkeyId,
|
||||
hasPrfOutput: !!prfOutput,
|
||||
hasKyberKeyPair: !!kyberKeyPair,
|
||||
});
|
||||
toast.error(m["setupPasskey.errorKeyPairDataMissing"]());
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
isSubmitting = false;
|
||||
try {
|
||||
const resp = await fetch(`/api/auth/passkeys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
passkey: {
|
||||
id: $formData.id,
|
||||
attestationObject: $formData.attestationObjectBase64,
|
||||
clientDataJSON: $formData.clientDataJSONBase64,
|
||||
deviceName: $formData.deviceName,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (resp.status >= 400) {
|
||||
throw Error(`Adding passkey failed. Error: ${await resp.text()}`);
|
||||
}
|
||||
|
||||
const respBody = await resp.json();
|
||||
console.log("respBody", respBody);
|
||||
|
||||
await storeStaffKeyPairForNewPasskey();
|
||||
|
||||
// TODO: Add later with respBody
|
||||
// const crypto = $staffCrypto.crypto;
|
||||
// if (crypto) {
|
||||
// crypto.rewrapAllTunnelsForNewPasskey();
|
||||
// } else {
|
||||
// throw Error(`Adding passkey failed. Error: staffCrypto is undefined`);
|
||||
// }
|
||||
toast.success(m["setupPasskey.success"]());
|
||||
invalidate("app:account-passkeys");
|
||||
} catch (error) {
|
||||
console.error("Unable to add passkey", error);
|
||||
toast.error(m["setupPasskey.error"]());
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Form.Root {enhance}>
|
||||
<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" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description>
|
||||
{m["form.deviceNameHint"]()}
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
<div>
|
||||
<Form.Field {form} name="email">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.email} type="email" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="userId" class="">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.userId} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="id" class="">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.id} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="attestationObjectBase64" class="">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.attestationObjectBase64} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="clientDataJSONBase64" class="">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.clientDataJSONBase64} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="challenge" class="">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.challenge} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Label class="mb-2">{m["form.passkey"]()}</Label>
|
||||
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
|
||||
</div>
|
||||
<Form.Button
|
||||
type="button"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
onclick={onSubmit}
|
||||
isLoading={isSubmitting}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{m["setupPasskey.action"]()}
|
||||
</Form.Button>
|
||||
</Form.Root>
|
||||
@@ -0,0 +1,5 @@
|
||||
import AddPasskeyForm from "./add-passkey-form.svelte";
|
||||
|
||||
export { AddPasskeyForm };
|
||||
export { formSchema } from "./schema";
|
||||
export type { FormSchema } from "./schema";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { m } from "$i18n/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
deviceName: z.string().min(3),
|
||||
userId: z.string().min(3),
|
||||
id: z.string().min(3),
|
||||
email: z.string().email(m["form.errors.email"]()),
|
||||
attestationObjectBase64: z.string().base64(),
|
||||
clientDataJSONBase64: z.string().base64(),
|
||||
challenge: z.string().min(20), // Original registration challenge (before PRF challenge overwrites cookie)
|
||||
});
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
@@ -9,6 +9,8 @@ import { formSchema as editFormSchema } from "./(components)/edit-passkey-form";
|
||||
const log = logger.setContext(import.meta.filename);
|
||||
|
||||
export const load = async (event) => {
|
||||
event.depends(`app:account-passkeys`);
|
||||
|
||||
const user = event.locals.user;
|
||||
if (!user) {
|
||||
log.error("User trying to access their passkeys, but has no user");
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { getLocalTimeZone } from "@internationalized/date";
|
||||
import { UserKey, Pen, PlusIcon, Trash2 } from "@lucide/svelte";
|
||||
import { EditPasskeyForm } from "./(components)/edit-passkey-form";
|
||||
import { AddPasskeyForm } from "./(components)/add-passkey-form";
|
||||
|
||||
const { data } = $props();
|
||||
let curItem: RedactedPasskeyHydrated | null = $state(null);
|
||||
@@ -50,11 +51,7 @@
|
||||
{#snippet triggerLabel()}
|
||||
<PlusIcon /> {m["account.passkeys.add.title"]()}
|
||||
{/snippet}
|
||||
<ul>
|
||||
<li>Do more or less what's done in setup passkey form</li>
|
||||
<li>send it to POST /api/auth/passkeys</li>
|
||||
<li>do more or less what grant access form does</li>
|
||||
</ul>
|
||||
<AddPasskeyForm />
|
||||
</ResponsiveDialog>
|
||||
|
||||
{#if items.length > 0}
|
||||
|
||||
Reference in New Issue
Block a user