diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 643097f..2513c4e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -22,9 +22,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Security audit - run: npm audit --audit-level=high - - name: Run linting run: npm run lint check: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c37478..b9e7ad7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build Docker image - run: docker build -t openreception/open-reception:${{ github.ref_name }} . + run: docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:${{ github.ref_name }} . - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master diff --git a/Dockerfile b/Dockerfile index aec12d0..bdb274c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,9 @@ COPY package*.json ./ RUN npm ci --ignore-scripts && \ npm cache clean --force +# Set node env +ENV NODE_ENV=production + # Copy source and build COPY . . RUN npm run build diff --git a/package.json b/package.json index 49e9cf2..c17fac7 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "docker:dev:down": "docker compose -f docker-compose.dev.yml down", "docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f", "docker:dev:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans", - "docker:build": "docker build -t openreception/open-reception:latest .", + "docker:build": "docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:latest .", "docker:build:tag": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version", "docker:push": "docker push openreception/open-reception:$npm_package_version && docker push openreception/open-reception:latest", "docker:build-and-push": "npm run docker:build && npm run docker:build:tag && npm run docker:push", diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 7e0fc0a..eaecd38 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -924,8 +924,9 @@ } }, "calendar": { + "title": "Kalender", "today": "Heute", - "loading": "Loading calendar", + "loading": "Kalender wird geladen", "decrypting": "wird entschlüsselt...", "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.", "decryptingErrorNoKeyShare": "Entschlüsselung fehlgeschlagen. Bitten Sie ein Teammitglied, Ihnen Zugriff zu gewähren.", @@ -957,11 +958,14 @@ "error": "Fehler beim Bestätigen des Termins" }, "addAppointment": { + "title": "Neuen Termin erstellen", "preview": "Neuer Termin {time} Uhr {channel}", "steps": { "selectClient": { "hasNoEmail": "Klient hat keine E-Mail Adresse", - "action": "Klient suchen" + "action": "Klient suchen", + "confirmNewClient": "Dieser Klient hat noch kein Konto. Möchten Sie für ihn ein Konto erstellen?", + "proceedExistingClient": "Klient ausgewählt." }, "agents": { "title": "Akteur auswählen" diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index bd746e7..f2c0ee5 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -933,6 +933,7 @@ } }, "calendar": { + "title": "Calendar", "today": "Today", "loading": "Loading calendar", "decrypting": "decrypting...", @@ -966,11 +967,14 @@ "error": "Failed to confirm appointment" }, "addAppointment": { + "title": "Add Appointment", "preview": "Add {time} {channel}", "steps": { "selectClient": { "hasNoEmail": "Client has no e-mail", - "action": "Search Client" + "action": "Search Client", + "confirmNewClient": "This client does not have an account yet. Do you want to create an account for them?", + "proceedExistingClient": "Client selected." }, "agents": { "title": "Select an Agent" diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 42b2fd6..af79ce0 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -48,8 +48,9 @@ */ import { OptimizedArgon2 } from "$lib/crypto/hashing"; +import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils"; +import type { ClientTunnelResponse } from "$lib/server/services/appointment-service"; import { pinThrottleStore } from "$lib/stores/pin-throttle"; -import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils"; // Type definitions for unified cryptography interface ClientKeyPair { @@ -104,6 +105,17 @@ interface MyAppointmentsResponse { encryptedData: EncryptedData; }>; } + +type StaffKeyShares = Array<{ userId: string; encryptedTunnelKey: string }>; + +type EncryptableTunnelConfig = { + emailHash: string; + decryptedTunnelKey: CryptoKey; + staffKeyShares: StaffKeyShares; + clientPublicKey: string; + tunnelId: string; +}; + /** * Generate deterministic SHA-256 hash of email for privacy-preserving lookups */ @@ -483,6 +495,143 @@ export class UnifiedAppointmentCrypto { } } + private createTunnelForNewClient = async (params: { + tenantId: string; + email: string; + }): Promise => { + const usedPin = crypto.randomUUID().slice(0, 6); + + // Create tunnel + await this.initNewClient(params.email, usedPin, params.tenantId); + + if (!this.tunnelId) { + throw new Error("Failed to use initialized client tunnel"); + } + + if (!this.tunnelKey) { + throw new Error("Failed to use initialized client tunnel key"); + } + + if (!this.clientKeyPair?.publicKey) { + throw new Error("Failed to use initialized client public key"); + } + + return { + tunnelId: this.tunnelId, + emailHash: await hashEmail(params.email), + clientPublicKey: this.clientKeyPair?.publicKey, + decryptedTunnelKey: this.tunnelKey, + staffKeyShares: await this.getStaffKeyShares(params.tenantId), + }; + }; + + private useTunnelForExistingClient = async (params: { + tunnel: ClientTunnelResponse; + tenantId: string; + email: string; + }): Promise => { + const decryptedTunnelKey = await this.decryptTunnelKeyByStaff( + params.tunnel.currentStaffEncryptedTunnelKey!, + ); + return { + tunnelId: params.tunnel.id, + emailHash: await hashEmail(params.email), + clientPublicKey: params.tunnel.clientPublicKey, + decryptedTunnelKey, + staffKeyShares: await this.getStaffKeyShares(params.tenantId, decryptedTunnelKey), + }; + }; + + /** + * Creates a new encrypted appointment that is created by a staff member + */ + async createAppointmentByStaff(params: { + appointmentData: AppointmentDataByStaff; + appointmentDate: Date; + agentId: string; + channelId: string; + duration: number; + tenantId: string; + email?: string; + hasNoEmail: boolean; + tunnel: ClientTunnelResponse | undefined; + }): Promise { + if (!this.staffAuthenticated || !this.staffKeyPair) { + throw new Error("Staff Member not authenticated"); + } + + try { + // If new client, create new tunnel and then get it + const usedEmail = params.email ?? `${crypto.randomUUID()}@client.noemail`; + const tunnelConfig = !params.tunnel + ? await this.createTunnelForNewClient({ tenantId: params.tenantId, email: usedEmail }) + : await this.useTunnelForExistingClient({ + tunnel: params.tunnel, + tenantId: params.tenantId, + email: usedEmail, + }); + + // Encrypt appointment data + const encryptedAppointment = await this.encryptAppointmentData( + params.appointmentData, + tunnelConfig.decryptedTunnelKey, + ); + + // Set tunnelKey & clientKeyPair for encryptTunnelKeyForClient + this.tunnelKey = tunnelConfig.decryptedTunnelKey; + this.clientKeyPair = { + publicKey: tunnelConfig.clientPublicKey, + privateKey: "", // Not needed here + }; + + // Call endpoint + const sendEmail = params.appointmentData.shareEmail && Boolean(params.email); + const requestData = { + clientEmail: params.appointmentData.shareEmail ? usedEmail : undefined, + hasNoEmail: params.hasNoEmail, + emailHash: await hashEmail(usedEmail), + appointmentDate: params.appointmentDate.toISOString(), + duration: params.duration, + agentId: params.agentId, + channelId: params.channelId, + encryptedAppointment, + sendEmail, + clientLanguage: params.appointmentData.locale, + tunnelId: tunnelConfig.tunnelId, + clientPublicKey: tunnelConfig.clientPublicKey, + staffKeyShares: tunnelConfig.staffKeyShares, + privateKeyShare: await this.getClientKeyShare(), + clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(), + }; + const response = await fetch(`/api/tenants/${params.tenantId}/appointments/staff-create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + throw new Error("Appointment could not be created"); + } + + const result = await response.json(); + + console.log("✅ Encrypted appointment created:", result.id); + return result.id; + } catch (error) { + console.error("❌ Error creating appointment:", error); + throw error; + } finally { + // Clear sensitive data from class properties to prevent leaks on reuse + this.tunnelKey = null; + this.clientKeyPair = null; + this.emailHash = null; + this.tunnelId = null; + this.serverPrivateKeyShare = null; + this.clientAuthenticated = false; + this.pin = null; + } + } + /** * Allows to check, if a client is authenticated */ @@ -947,8 +1096,10 @@ export class UnifiedAppointmentCrypto { */ private async encryptAppointmentData( data: AppointmentData | AppointmentDataByStaff, + tunnelKey?: CryptoKey, ): Promise { - if (!this.tunnelKey) throw new Error("No tunnel key available"); + const usedTunnelKey = tunnelKey ?? this.tunnelKey; + if (!usedTunnelKey) throw new Error("No tunnel key available"); const encoder = new TextEncoder(); const plaintext = encoder.encode(JSON.stringify(data)); @@ -956,7 +1107,7 @@ export class UnifiedAppointmentCrypto { const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, - this.tunnelKey, + usedTunnelKey, plaintext, ); @@ -1089,35 +1240,12 @@ export class UnifiedAppointmentCrypto { // Public key is stored as Base64, not Hex const staffPublicKeyBytes = this.base64ToUint8Array(staff.publicKey); - console.log("🔐 Encrypting tunnel key for staff:", { - userId: staff.userId, - publicKeyLength: staffPublicKeyBytes.length, - publicKeyHex: - Array.from(staffPublicKeyBytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") - .substring(0, 64) + "...", - tunnelKeyLength: tunnelKeyArray.length, - }); - // Kyber encapsulation creates a shared secret const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(staffPublicKeyBytes); - console.log("🔑 Kyber encapsulation done:", { - sharedSecretLength: sharedSecret.length, - encapsulatedSecretLength: encapsulatedSecret.length, - }); - // Use the first 32 bytes of shared secret as AES key (same as decryption) const aesKeyBytes = sharedSecret.slice(0, 32); - console.log( - "🔑 AES key for encryption (hex):", - Array.from(aesKeyBytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""), - ); - // Import as CryptoKey for Web Crypto API const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [ "encrypt", @@ -1126,19 +1254,6 @@ export class UnifiedAppointmentCrypto { // Generate IV for AES-GCM const iv = BufferUtils.randomBytes(12); - console.log( - "📍 IV for encryption (hex):", - Array.from(iv) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""), - ); - console.log( - "🔒 Tunnel key to encrypt (hex):", - Array.from(tunnelKeyArray) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""), - ); - // Encrypt tunnel key with AES-GCM const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, @@ -1149,13 +1264,6 @@ export class UnifiedAppointmentCrypto { // encrypted contains ciphertext + 16-byte auth tag const encryptedArray = new Uint8Array(encrypted); - console.log( - "🔒 Encrypted tunnel key (hex):", - Array.from(encryptedArray) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""), - ); - // Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag) const combined = new Uint8Array( encapsulatedSecret.length + iv.length + encryptedArray.length, @@ -1386,10 +1494,11 @@ export class UnifiedAppointmentCrypto { private async getStaffKeyShares( tenantId: string, - ): Promise> { + decryptedTunnelKey?: CryptoKey, + ): Promise { // Fetch and encrypt tunnel key for all staff members const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId); - return await this.encryptTunnelKeyForStaff(staffPublicKeys); + return await this.encryptTunnelKeyForStaff(staffPublicKeys, decryptedTunnelKey); } private async getClientKeyShare(): Promise { diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 6be65e3..327c424 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -348,7 +348,7 @@ export class AppointmentService { */ public async deleteAppointmentByStaff( appointmentId: string, - clientEmail: string, + clientEmail: string | undefined, clientLanguage: string = "de", ): Promise { const log = logger.setContext("AppointmentService"); @@ -396,18 +396,22 @@ export class AppointmentService { const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage); // Send cancellation email to client (async, don't wait) - const clientData = { - email: clientEmail, - language: clientLanguage, - }; + if (clientEmail) { + const clientData = { + email: clientEmail, + language: clientLanguage, + }; - sendAppointmentCancelledEmail(clientData, tenant, appointment, channelTitle).catch((error) => { - log.error("Failed to send appointment cancellation email", { - appointmentId, - clientEmail, - error: String(error), - }); - }); + sendAppointmentCancelledEmail(clientData, tenant, appointment, channelTitle).catch( + (error) => { + log.error("Failed to send appointment cancellation email", { + appointmentId, + clientEmail, + error: String(error), + }); + }, + ); + } // Create notifications for all staff members in the channel const notificationService = await NotificationService.forTenant(this.tenantId); diff --git a/src/lib/utils/localizations.ts b/src/lib/utils/localizations.ts index 0094bbb..868abca 100644 --- a/src/lib/utils/localizations.ts +++ b/src/lib/utils/localizations.ts @@ -1,6 +1,7 @@ import { getLocale } from "$i18n/runtime"; import type { supportedLocales } from "$lib/const/locales"; import type { TPublicTenant } from "$lib/types/public"; +import type { TTenant } from "$lib/types/tenant"; export const removeEmptyTranslations = (object: { [key: string]: string } | undefined) => { if (!object) return object; @@ -25,3 +26,16 @@ export const getPublicLocale = (tenant: TPublicTenant): string => { } return tenant.defaultLanguage as unknown as string; }; + +export const getDefaultAppointmentLocale = (tenant: TTenant | null): string => { + const locale = getLocale(); + if (!tenant) return locale; + + const availableLanguages = tenant.languages; + if (availableLanguages.includes(locale)) { + return locale; + } + + // TODO: Not ideal + return availableLanguages[0]; +}; diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 2c65d89..63da6d2 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -28,7 +28,7 @@ } if (!intervalSession) { refreshSession(); - intervalSession = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes + intervalSession = setInterval(refreshSession, 3 * 60 * 1000); // 3 minutes } const unsubscribe = () => { diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte index a4b77a2..c0636cc 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte @@ -87,33 +87,37 @@ {#if item.appointment.appointment} -
+
{item.decrypted.name}
-
- - {#if item.decrypted.phone} - - {/if} -
+ {#if item.decrypted.email || item.decrypted.phone} +
+ {#if item.decrypted.email} + + {/if} + {#if item.decrypted.phone} + + {/if} +
+ {/if}
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte index 5f01406..81a1259 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte @@ -1,15 +1,20 @@ - + {#if step === "email"} {:else if step === "agent" && item.availableAgents} {:else if step === "summary"} - {:else if step === "client"} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte index 013027b..8aa5260 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte @@ -4,18 +4,24 @@ import { Text } from "$lib/components/ui/typography"; import { agents as agentsStore } from "$lib/stores/agents"; import { toDisplayDateTime } from "$lib/utils/datetime"; - import { Calendar, Mail, Phone, User, UserStar } from "@lucide/svelte"; + import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte"; import type { TAddAppointment, TAddAppointmentStep } from "./types"; + import * as Select from "$lib/components/ui/select"; + import { tenants } from "$lib/stores/tenants"; + import { languageSwitchLocales } from "$lib/const/locales"; let { step, newAppointment, + onChangeLocale, }: { step: TAddAppointmentStep; newAppointment: TAddAppointment; + onChangeLocale: (locale: string) => void; } = $props(); let agent = $derived($agentsStore.agents.find((a) => a.id === newAppointment.agentId)); + let languages = $derived($tenants.currentTenant?.languages);
@@ -41,8 +47,29 @@
{/if} {#if newAppointment.email} +
+ + + + {@const locale = newAppointment.locale + ? languageSwitchLocales[newAppointment.locale as keyof typeof languageSwitchLocales] + : undefined} + {locale ? locale.label : "Select language"} + + + {#each languages as language (language)} + + {languageSwitchLocales[language as keyof typeof languageSwitchLocales].label} + + {/each} + + +