Resolve missing staff crypto situations -wip

This commit is contained in:
Karl Ludwig Weise
2026-09-04 14:53:29 +02:00
parent 75e9c8cfda
commit fe05c55ffb
8 changed files with 149 additions and 12 deletions
+4 -4
View File
@@ -47,7 +47,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.15.4",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
@@ -3803,9 +3803,9 @@
"license": "MIT"
},
"node_modules/bits-ui": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.0.tgz",
"integrity": "sha512-dHDm5Jw2NZJgLRvHAwPeuR4BnZij+AqTIq33OqVfo+qiMXontoC/yIvGT5TZaviv7OuBdJ2QUy2JPQ2yKBvF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -57,7 +57,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.15.4",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
+7
View File
@@ -458,6 +458,13 @@
}
],
"error": "Fehler beim Senden"
},
"missingCryptoKeys": {
"title": "Kryptografische Schlüssel fehlen",
"description": "Dieser Tab enthält nicht die kryptografischen Schlüssel zum Entschlüsseln und Verschlüsseln von Daten. Lade die Schlüssel, um Termine/Daten anzuzeigen und hinzuzufügen.",
"cancel": "Ich will nur Einstellungen ändern",
"success": "Kryptografische Schlüssel geladen",
"error": "Fehler beim Laden der kryptografischen Schlüssel"
}
},
"agents": {
+7
View File
@@ -466,6 +466,13 @@
}
],
"error": "Failed to send reminders"
},
"missingCryptoKeys": {
"title": "Cryptography Keys Missing",
"description": "This tab is missing the cryptographic keys to decrypt and encrypt data. Load keys to view and add appointments/data.",
"cancel": "I just want to change settings",
"success": "Cryptography keys loaded",
"error": "Failed to load cryptography keys"
}
},
"agents": {
@@ -52,6 +52,7 @@
triggerHidden = false,
triggerVariant = "default",
isActionLoading = false,
isDismissable = true,
actions,
children,
}: HTMLAttributes<HTMLDivElement> & {
@@ -62,6 +63,7 @@
description?: string;
triggerVariant?: ButtonVariant;
isActionLoading?: boolean;
isDismissable?: boolean;
actions?: ListItemAction[];
} = $props();
@@ -153,9 +155,13 @@
<Dialog.Content
class={cn(
"max-h-[95vh] sm:max-w-106.25",
actions && actions.length > 0 && "[&>button:last-child]:hidden", // hides default close button
// hides default close button
actions && actions.length > 0 ? "[&>button:last-child]:hidden" : "",
)}
onOpenAutoFocus={(e) => e.preventDefault()}
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
showCloseButton={isDismissable === true}
>
<Dialog.Header class="flex flex-row items-start justify-between gap-2">
<div class="flex flex-col gap-1 text-left">
@@ -183,7 +189,7 @@
</Dialog.Content>
</Dialog.Root>
{:else}
<Drawer.Root bind:open>
<Drawer.Root bind:open dismissible={isDismissable}>
{#if !triggerHidden}
<Drawer.Trigger class={buttonVariants({ variant: triggerVariant })}>
{#if typeof triggerLabel === "string"}
@@ -196,6 +202,8 @@
<Drawer.Content
class="data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]"
onOpenAutoFocus={(e) => e.preventDefault()}
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
>
<Drawer.Header class="flex flex-row justify-between gap-2 text-left">
<div>
@@ -213,9 +221,11 @@
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
{@render children?.()}
</HorizontalPagePadding>
<Drawer.Footer class="pt-2">
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
</Drawer.Footer>
{#if isDismissable !== false}
<Drawer.Footer class="pt-2">
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
</Drawer.Footer>
{/if}
</Drawer.Content>
</Drawer.Root>
{/if}
@@ -0,0 +1,100 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { Button } from "$lib/components/ui/button";
import { Passkey } from "$lib/components/ui/passkey";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { closeDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import logger from "$lib/logger";
import { auth } from "$lib/stores/auth";
import { staffCrypto } from "$lib/stores/staff-crypto";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { toast } from "svelte-sonner";
import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
const passkeyLoading: Writable<PasskeyState> = writable("initial");
const onSetPasskey = async () => {
$passkeyLoading = "loading";
if (!$auth.user?.email || !$auth.user.tenantId) {
$passkeyLoading = "error";
return;
}
const challenge = await fetchChallenge($auth.user.email);
if (!challenge) {
$passkeyLoading = "error";
logger.error("Failed to fetch challenge", { email: $auth.user.email });
} else {
$passkeyLoading = "user";
// Call WebAuthn with PRF enabled (uses email as salt for multi-passkey support)
const credentialResp = await getCredential({
...challenge,
email: $auth.user.email,
enablePRF: true,
}).catch((error) => {
$passkeyLoading = "error";
logger.error("Failed to get credential", { ...challenge, error });
});
if (!credentialResp) {
$passkeyLoading = "error";
logger.error("Credential response is falsy");
return;
}
// Update form data with passkey info
const authenticatorDataBase64 = arrayBufferToBase64(
// @ts-expect-error response type needs to be fixed
credentialResp.response.authenticatorData,
);
// Store authenticatorData and PRF output for later key reconstruction
const passkeyId = credentialResp.id;
// Extract PRF output from WebAuthn response (if PRF was enabled)
let prfOutputBase64: string | undefined;
if (credentialResp.prfOutput) {
prfOutputBase64 = arrayBufferToBase64(credentialResp.prfOutput);
logger.info("PRF output retrieved from login", {
prfOutputLength: credentialResp.prfOutput.byteLength,
});
} else {
logger.warn("No PRF output in login response - crypto features may not work", {
email: $auth.user.email,
});
}
auth.setPasskeyAuthData({
authenticatorData: authenticatorDataBase64,
passkeyId,
email: $auth.user.email,
prfOutput: prfOutputBase64,
});
await staffCrypto.authenticate($auth.user.id, $auth.user.tenantId);
// Update UI to show passkey is ready
$passkeyLoading = "success";
closeDialog("missing-staff-crypto");
toast.success(m["dashboard.missingCryptoKeys.success"]());
}
};
</script>
<ResponsiveDialog
id="missing-staff-crypto"
title={m["dashboard.missingCryptoKeys.title"]()}
description={m["dashboard.missingCryptoKeys.description"]()}
triggerHidden={true}
isDismissable={false}
>
<div class="flex flex-col gap-2">
<Passkey.State state="click" onclick={onSetPasskey} class="justify-center" />
<Button onclick={() => closeDialog("missing-staff-crypto")} variant="link" class="w-full">
{m["dashboard.missingCryptoKeys.cancel"]()}
</Button>
</div>
</ResponsiveDialog>
@@ -9,6 +9,8 @@
import { staff } from "$lib/stores/staff";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { browser } from "$app/environment";
import MissingStaffCrypto from "./(components)/MissingStaffCrypto.svelte";
import { openDialog } from "$lib/components/ui/responsive-dialog";
let { data, children }: LayoutProps = $props();
@@ -80,6 +82,7 @@
if (success) {
console.log("✅ Staff crypto initialized successfully");
} else {
openDialog("missing-staff-crypto");
console.log("ℹ️ Staff crypto not initialized (no session data or error)");
}
}
@@ -88,4 +91,5 @@
<QueryClientProvider client={queryClient}>
{@render children()}
<MissingStaffCrypto />
</QueryClientProvider>
@@ -9,6 +9,7 @@
import { Button } from "$lib/components/ui/button";
import { m } from "$i18n/messages";
import { cn } from "$lib/utils";
import { auth } from "$lib/stores/auth";
let {
item,
@@ -29,7 +30,7 @@
missingKeyShare: "Missing key share",
};
const decrypt = async () => {
const decrypt = async (retry = true) => {
if (!item.appointment) {
console.error("Unable to decrypt appointment data - no appointment data in item.", item.id);
error = "Missing data";
@@ -52,7 +53,7 @@
// Wait for crypto to be initialized (max 5 seconds)
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const maxWaitTime = 5000; // 5 seconds
const maxWaitTime = 2000;
const startTime = Date.now();
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
@@ -60,6 +61,14 @@
}
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const user = $auth.user;
if (retry && user && user.tenantId) {
console.warn("Staff crypto not initialized, retrying decryption...");
// TODO: This should only be done once, not in every instance
await staffCrypto.authenticate(user.id, user.tenantId);
decrypt(false);
return;
}
error = "Crypto not initialized";
console.error("Staff crypto not initialized after waiting");
return;