mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-27 11:14:49 +02:00
Resolve missing staff crypto situations -wip
This commit is contained in:
Generated
+4
-4
@@ -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
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user