From 9e5a5ab722fa41c3fb85bb2ed67a53da5980ac98 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Thu, 13 Aug 2026 10:54:01 +0200 Subject: [PATCH] Notification shows who canceled an appointment (#317) * Notification shows who canceled an appointment -wip * Added solution arcitecture for decrypting notification payload * Use encrypted tunnel key to decrypt encrpyted notification payload * Fix tests --------- Co-authored-by: Karl Ludwig Weise --- project.inlang/messages/de.json | 11 +- project.inlang/messages/en.json | 13 +- src/lib/client/appointment-crypto.ts | 45 ++- src/lib/client/crypto.ts | 26 ++ .../components/notification-item.svelte | 104 +++++- .../appointment-details.svelte | 9 +- .../ui/appointment-details/index.ts | 4 + src/lib/crypto/utils.ts | 41 +++ .../server/services/appointment-service.ts | 25 +- src/lib/types/staff.ts | 5 + .../(components)/appointment-list.svelte | 45 ++- .../delete-by-client/+server.ts | 5 +- .../__tests__/delete-by-client.test.ts | 3 + .../[tunnelId]/staff-key-shares/+server.ts | 191 ++++++++++ .../__tests__/get-staff-key-shares.test.ts | 331 ++++++++++++++++++ 15 files changed, 780 insertions(+), 78 deletions(-) create mode 100644 src/lib/client/crypto.ts create mode 100644 src/routes/api/tenants/[id]/appointments/tunnels/[tunnelId]/staff-key-shares/+server.ts create mode 100644 src/routes/api/tenants/[id]/appointments/tunnels/[tunnelId]/staff-key-shares/__tests__/get-staff-key-shares.test.ts diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index bfae38e..ee25b55 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -366,6 +366,7 @@ "delete": "Löschen", "select": "Auswählen", "close": "Schließen", + "unkown": "Unbekannt", "dashboard": { "title": "Übersicht", "hello": "Hallo", @@ -490,6 +491,7 @@ }, "channels": { "title": "Kanäle", + "singular": "Kanal", "empty": { "title": "Bisher keine Kanäle", "description": "Kanäle sind Ihre Möglichkeit, verschiedene Arten von Terminen zu sammeln" @@ -1130,16 +1132,13 @@ "empty": "Keine Benachrichtigungen vorhanden", "types": { "APPOINTMENT_REQUESTED": { - "title": "Neue Terminanfrage", - "description": "Ein neuer Termin wurde in {channel} angefragt." + "title": "Neue Terminanfrage" }, "APPOINTMENT_CONFIRMED": { - "title": "Termin bestätigt", - "description": "Terminanfrage in {channel} wurde bestätigt." + "title": "Termin bestätigt" }, "APPOINTMENT_CANCELLED": { - "title": "Termin abgesagt", - "description": "Der Klient hat den Termin in {channel} abgesagt." + "title": "Termin abgesagt" } } }, diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index bfd3b93..7a94d53 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -373,6 +373,8 @@ "pause": "Deactivate", "delete": "Delete", "select": "Select", + "close": "Close", + "unkown": "Unkown", "dashboard": { "title": "Dashboard", "hello": "Hello", @@ -432,7 +434,6 @@ } } }, - "close": "Close", "agents": { "title": "Agents", "empty": { @@ -498,6 +499,7 @@ }, "channels": { "title": "Channels", + "singular": "Channel", "empty": { "title": "No channels yet", "description": "Channels are your way to collect different types of appointments" @@ -1138,16 +1140,13 @@ "empty": "No notifications available", "types": { "APPOINTMENT_REQUESTED": { - "title": "New appointment request", - "description": "A new appointment has been requested in {channel}." + "title": "New appointment request" }, "APPOINTMENT_CONFIRMED": { - "title": "Appointment confirmed", - "description": "Appointment in {channel} was confirmed." + "title": "Appointment confirmed" }, "APPOINTMENT_CANCELLED": { - "title": "Appointment cancelled", - "description": "Client cancelled appointment in {channel}." + "title": "Appointment cancelled" } } }, diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 0cd70a5..abbbd78 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -49,7 +49,13 @@ import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment"; import { OptimizedArgon2 } from "$lib/crypto/hashing"; -import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils"; +import { + AESCrypto, + BufferUtils, + encryptedDataToString, + KyberCrypto, + ShamirSecretSharing, +} from "$lib/crypto/utils"; import type { ClientTunnelResponse } from "$lib/server/services/appointment-service"; import { pinThrottleStore } from "$lib/stores/pin-throttle"; @@ -64,7 +70,7 @@ interface StaffKeyPair { privateKey: Uint8Array; } -interface EncryptedData { +export interface EncryptedData { encryptedPayload: string; iv: string; authTag: string; @@ -90,6 +96,7 @@ interface StaffPublicKey { export interface DecryptedAppointment { id: string; + channelId?: string; appointmentDate: string; status: string; name: string; @@ -103,6 +110,7 @@ interface MyAppointmentsResponse { id: string; appointmentDate: string; status: string; + channelId: string; encryptedData: EncryptedData; }>; } @@ -246,7 +254,9 @@ export class UnifiedAppointmentCrypto { async cancelAppointmentByClient(opts: { tenant: string; appointment: string; + name: string; email: string; + phone?: string; }): Promise { try { if (this.pin === null) { @@ -305,6 +315,13 @@ export class UnifiedAppointmentCrypto { emailHash: this.emailHash, challengeId: challengeData.challengeId, challengeResponse: decryptedChallenge, + encryptedPayload: encryptedDataToString( + await this.encryptData({ + name: opts.name, + email: opts.email, + phone: opts.phone, + }), + ), }), }, ); @@ -709,6 +726,7 @@ export class UnifiedAppointmentCrypto { id: encryptedAppt.id, appointmentDate: encryptedAppt.appointmentDate, status: encryptedAppt.status, + channelId: encryptedAppt.channelId, ...decryptedData, }); } catch (error) { @@ -787,6 +805,16 @@ export class UnifiedAppointmentCrypto { encryptedAppointment: EncryptedData; staffKeyShare: string; }): Promise { + return this.decryptStaff({ + data: encryptedData.encryptedAppointment, + staffKeyShare: encryptedData.staffKeyShare, + }); + } + + /** + * Decrypt appointment data for staff members + */ + async decryptStaff(encryptedData: { data: EncryptedData; staffKeyShare: string }): Promise { if (!this.staffAuthenticated || !this.staffKeyPair) { throw new Error("Staff not authenticated"); } @@ -848,9 +876,9 @@ export class UnifiedAppointmentCrypto { ); // 5. Now decrypt the actual appointment data - const appointmentIv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv); - const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload); - const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag); + const appointmentIv = this.hexToUint8Array(encryptedData.data.iv); + const ciphertext = this.hexToUint8Array(encryptedData.data.encryptedPayload); + const authTag = this.hexToUint8Array(encryptedData.data.authTag); // Combine ciphertext and auth tag for Web Crypto API const encrypted = new Uint8Array(ciphertext.length + authTag.length); @@ -1137,6 +1165,13 @@ export class UnifiedAppointmentCrypto { data: AppointmentData | AppointmentDataByStaff, tunnelKey?: CryptoKey, ): Promise { + return this.encryptData(data, tunnelKey); + } + + /** + * Encrypt data with the tunnel key + */ + private async encryptData(data: T, tunnelKey?: CryptoKey): Promise { const usedTunnelKey = tunnelKey ?? this.tunnelKey; if (!usedTunnelKey) throw new Error("No tunnel key available"); diff --git a/src/lib/client/crypto.ts b/src/lib/client/crypto.ts new file mode 100644 index 0000000..7aad475 --- /dev/null +++ b/src/lib/client/crypto.ts @@ -0,0 +1,26 @@ +import logger from "$lib/logger"; +import type { TStaffKeyShare } from "$lib/types/staff"; + +export const getStaffKeyShares = async ( + tenantId: string, + tunnelId: string, +): Promise => { + const resp = await fetch( + `/api/tenants/${tenantId}/appointments/tunnels/${tunnelId}/staff-key-shares`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }, + ); + + let data; + try { + data = await resp.json(); + } catch (error) { + logger.error("Failed to parse staff key shares response", { tenantId, tunnelId, error }); + } + + return data?.keyShares || []; +}; diff --git a/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte b/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte index 637bc27..e816e61 100644 --- a/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte @@ -3,11 +3,15 @@ import { resolve } from "$app/paths"; import { page } from "$app/state"; import { m } from "$i18n/messages"; + import { getStaffKeyShares } from "$lib/client/crypto"; import { Button } from "$lib/components/ui/button"; import { Text } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; + import { stringToEncryptedData } from "$lib/crypto/utils"; import { auth } from "$lib/stores/auth"; import { channels as channelsStore } from "$lib/stores/channels"; + import { staffCrypto } from "$lib/stores/staff-crypto"; + import { tenants } from "$lib/stores/tenants"; import type { TAppointment } from "$lib/types/appointments"; import type { TNotification } from "$lib/types/notification"; import { toDisplayDateTime } from "$lib/utils/datetime"; @@ -26,7 +30,9 @@ const queryClient = useQueryClient(); const channels = $derived($channelsStore.channels); + const tenant = $derived($tenants.currentTenant); let appointment: TAppointment | undefined = $state(); + let decrypted: { name?: string; email?: string; phone?: string } | undefined = $state(); const getAppointment = async (id: string) => { try { @@ -46,16 +52,57 @@ } }; + const decryptPayload = async () => { + if (item.metaData?.encryptedPayload) { + if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) { + const maxWaitTime = 5000; // 5 seconds + const startTime = Date.now(); + + while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) { + console.error("Staff crypto not initialized after waiting"); + return; + } + } + + const data = stringToEncryptedData(item.metaData?.encryptedPayload); + if (data) { + if (!item.metaData?.tunnelId) { + console.error("Missing tunnelId in notification metadata"); + return; + } + + if (tenant === null) { + console.error("Missing tenant to decrypt encrypted notification payload"); + } + + const staffKeyShares = await getStaffKeyShares(tenant!.id, item.metaData.tunnelId); + + // TODO: When multiple staffKeyShares are supported, select the one currently in use + const staffKeyShare = staffKeyShares[0]; + + decrypted = await $staffCrypto.crypto.decryptStaff({ + data, + staffKeyShare: staffKeyShare.encryptedTunnelKey, + }); + } + } + }; + onMount(() => { if (item.metaData?.appointmentId) { getAppointment(item.metaData.appointmentId); + decryptPayload(); } }); diff --git a/src/lib/components/ui/appointment-details/appointment-details.svelte b/src/lib/components/ui/appointment-details/appointment-details.svelte index 34dda01..1406c06 100644 --- a/src/lib/components/ui/appointment-details/appointment-details.svelte +++ b/src/lib/components/ui/appointment-details/appointment-details.svelte @@ -2,7 +2,7 @@ import { Text } from "$lib/components/ui/typography"; import { languageSwitchLocales } from "$lib/const/locales"; import { toDisplayDateTime } from "$lib/utils/datetime"; - import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte"; + import { Calendar, Languages, Mail, Phone, Split, User, UserStar } from "@lucide/svelte"; import type { AppointmentDetailItems } from "."; import CopyButton from "./copy-button.svelte"; @@ -18,7 +18,12 @@ {#each items as item (item.type)} {#if item.value}
- {#if item.type === "agent"} + {#if item.type === "channel"} + + + {item.value} + + {:else if item.type === "agent"} {item.value} diff --git a/src/lib/components/ui/appointment-details/index.ts b/src/lib/components/ui/appointment-details/index.ts index 0e9589f..f460f7b 100644 --- a/src/lib/components/ui/appointment-details/index.ts +++ b/src/lib/components/ui/appointment-details/index.ts @@ -29,4 +29,8 @@ export type AppointmentDetailItem = | { type: "agent"; value: string | undefined; + } + | { + type: "channel"; + value: string | undefined; }; diff --git a/src/lib/crypto/utils.ts b/src/lib/crypto/utils.ts index 5d1c2ed..735378a 100644 --- a/src/lib/crypto/utils.ts +++ b/src/lib/crypto/utils.ts @@ -1,5 +1,6 @@ import { ml_kem768 } from "@noble/post-quantum/ml-kem"; import { randomBytes } from "@noble/hashes/utils"; +import type { EncryptedData } from "$lib/client/appointment-crypto"; /** * Type alias for cryptographic buffer operations @@ -588,3 +589,43 @@ export class ShamirSecretSharing { return length; } } + +export const encryptedDataToString = (data: EncryptedData): string => { + const encoder = new TextEncoder(); + const bytes = encoder.encode(JSON.stringify(data)); + + const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join(""); + const base64 = btoa(binString); + + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +export const stringToEncryptedData = (str: string): EncryptedData | null => { + try { + const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); + + const binString = atob(base64); + const bytes = Uint8Array.from(binString, (char) => char.codePointAt(0)!); + + const decoder = new TextDecoder(); + const parsed = JSON.parse(decoder.decode(bytes)); + + if ( + typeof parsed.encryptedPayload === "string" && + typeof parsed.iv === "string" && + typeof parsed.authTag === "string" + ) { + return { + encryptedPayload: parsed.encryptedPayload, + iv: parsed.iv, + authTag: parsed.authTag, + }; + } else { + console.error("Hydrated encrypted data does not match expected object type"); + return null; + } + } catch (error) { + console.error("Failed to hydrate encrypted data from string", error); + return null; + } +}; diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 5536916..efe8b27 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -554,26 +554,6 @@ export class AppointmentService { ); } - // Create notifications for all staff members in the channel - const notificationService = await NotificationService.forTenant(this.tenantId); - - // Create notifications (async, don't wait) - notificationService - .createNotification({ - channelId, - type: "APPOINTMENT_CANCELLED", - metaData: { - appointmentId, - }, - }) - .catch((error) => { - log.error("Failed to create channel notifications", { - appointmentId, - channelId, - error: String(error), - }); - }); - log.info("Appointment deleted by staff successfully", { appointmentId, channelId, @@ -1251,6 +1231,7 @@ export class AppointmentService { emailHash: string, challengeId: string, challengeResponse: string, + encryptedPayload?: string, ): Promise { const log = logger.setContext("AppointmentService"); log.debug("Client deleting appointment with authentication", { @@ -1367,6 +1348,10 @@ export class AppointmentService { type: "APPOINTMENT_CANCELLED", metaData: { appointmentId, + channelId: appointment.channelId, + appointmentDate: appointment.appointmentDate, + tunnelId: appointment.tunnelId, + encryptedPayload, }, }); diff --git a/src/lib/types/staff.ts b/src/lib/types/staff.ts index eeafa9a..b45220a 100644 --- a/src/lib/types/staff.ts +++ b/src/lib/types/staff.ts @@ -1,3 +1,8 @@ import type { SelectUser } from "$lib/server/db/central-schema"; export type TStaff = Pick; + +export type TStaffKeyShare = { + tunnelId: string; + encryptedTunnelKey: string; +}; diff --git a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte index 4564a28..57cfa5d 100644 --- a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte +++ b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte @@ -3,21 +3,24 @@ import { m } from "$i18n/messages.js"; import type { DecryptedAppointment } from "$lib/client/appointment-crypto"; import * as Alert from "$lib/components/ui/alert"; + import { AppointmentDetails } from "$lib/components/ui/appointment-details"; import { Badge } from "$lib/components/ui/badge"; import { Button } from "$lib/components/ui/button"; import * as Item from "$lib/components/ui/item"; + import { LocalizedText } from "$lib/components/ui/public"; import { openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog"; import { Skeleton } from "$lib/components/ui/skeleton"; - import { Headline, Text } from "$lib/components/ui/typography"; + import { Headline } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; import { publicStore } from "$lib/stores/public"; - import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime"; - import { getLocalTimeZone } from "@internationalized/date"; - import { Calendar, CircleAlert } from "@lucide/svelte"; + import { toDisplayDateTime } from "$lib/utils/datetime"; + import { getCurrentTranlslation } from "$lib/utils/localizations"; + import { CircleAlert } from "@lucide/svelte"; import { onMount } from "svelte"; import { toast } from "svelte-sonner"; import { appointmentStatusToBadge } from "./utils"; + let channels = $derived($publicStore.channels); let tenant = $derived($publicStore.tenant); let step: "loading" | "list" | "error" = $state("loading"); let appointments: DecryptedAppointment[] = $state([]); @@ -43,6 +46,8 @@ ?.cancelAppointmentByClient({ appointment: cancelling.id, tenant: tenant.id, + name: cancelling.name, + phone: cancelling.phone, email: cancelling.email, }) .then(() => { @@ -72,10 +77,13 @@ {#if appointments.length > 0}
{#each appointments as item (item)} + {@const channel = channels?.find((it) => it.id === item.channelId)} - + {#if channel} + + {/if} {@const badge = appointmentStatusToBadge(item.status)} {#if badge} {badge.label.toLocaleUpperCase()} @@ -118,26 +126,25 @@
{#if cancelling} + {@const channel = channels?.find((it) => it.id === cancelling?.channelId)} -
- - - {toDisplayDateTime(utcToLocalWithoutDST(new Date(cancelling.appointmentDate)), { - year: "numeric", - month: "long", - day: "numeric", - weekday: "short", - hour: "2-digit", - minute: "2-digit", - timeZone: getLocalTimeZone().toString(), - })} - -
+