From 513b706877facdd6ea36c9d27d7402002e064b46 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Thu, 26 Feb 2026 17:29:27 +0100 Subject: [PATCH] Staff: Grant access to existing clients front-end implementation -wip (#201) * Staff: Grant access to existing clients front-end implementation -wip * WIP * Use correct keyhsare when decrypting tunnels for new users. * Set ACCESS_GRANTED even when all tunnel keys are already there * Finalized granting access to staff members * Fix lint * Re-added logs --------- Co-authored-by: Karl Ludwig Weise Co-authored-by: Hendrik Belitz --- project.inlang/messages/de.json | 15 ++- project.inlang/messages/en.json | 15 ++- src/lib/client/appointment-crypto.ts | 117 +++++++++++++---- .../__tests__/appointment-service.test.ts | 64 ++++++++++ .../server/services/appointment-service.ts | 22 +++- .../(components)/AppointmentPreview.svelte | 14 +- .../grant-access-form.svelte | 120 ++++++++++++++---- .../dashboard/staff/(components)/utils.ts | 37 ++++++ .../(pages)/dashboard/staff/+page.server.ts | 7 +- .../(pages)/dashboard/staff/+page.svelte | 3 +- .../[id]/appointments/tunnels/+server.ts | 18 ++- .../tunnels/__tests__/tunnels.test.ts | 3 + .../tunnels/add-staff-key-shares/+server.ts | 21 ++- 13 files changed, 390 insertions(+), 66 deletions(-) diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index d4c766a..7e0fc0a 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -802,9 +802,17 @@ "title": "Zugriff auf Klientendaten gewähren", "description": "Willst Du {name} Zugriff auf alle Klientendaten geben?", "action": "Zugriff gewähren", - "loading": "Zugriff wird gewährt ({success}/{total} Klienten)", - "success": "Zugriff gewährt", - "error": "Konnte Zugriff nicht für alle Klienten gewähren. Bitte erneut versuchen.", + "loadingTunnels": "Lade Liste aller Klienten", + "loadingAddingKeyShares": "Gewähre Zugriff", + "success": "Zugriff gewährt. {name} kann jetzt Termine entschlüsseln.", + "errorNoPublicKey": { + "title": "Zugriff gewähren fehlgeschlagen", + "description": "Diese Person muss zuerst die Einladung annehmen und ihren Passkey hinzufügen." + }, + "error": { + "title": "Zugriff gewähren fehlgeschlagen", + "description": "Du kannst es jederzeit erneut probieren." + }, "unavailable": { "title": "Du kannst keinen Zugriff gewähren", "description": "Nur Mitarbeiter:innen und Mandanten-Admins können Zugriff gewähren." @@ -920,6 +928,7 @@ "loading": "Loading calendar", "decrypting": "wird entschlüsselt...", "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.", + "decryptingErrorNoKeyShare": "Entschlüsselung fehlgeschlagen. Bitten Sie ein Teammitglied, Ihnen Zugriff zu gewähren.", "notificationHint": "Klienten werden per E-Mail benachrichtigt, wenn diese Benachrichtigungen aktiviert haben.", "shownAppointments": { "title": "Termine", diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 4252e9a..bd746e7 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -811,9 +811,17 @@ "title": "Grant access to appointments", "description": "Do you want to grant {name} access to all client data?", "action": "Grant access", - "loading": "Granting access ({success}/{total} clients)", - "success": "Access granted", - "error": "Granting access to all clients failed. You can retry anytime.", + "loadingTunnels": "Loading client list", + "loadingAddingKeyShares": "Grandting access", + "success": "Access granted. {name} is now ready to decrypt appointments.", + "errorNoPublicKey": { + "title": "Granting access failed", + "description": "This person needs to accept the invitation and add their Passkey first." + }, + "error": { + "title": "Granting access failed", + "description": "You can retry anytime." + }, "unavailable": { "title": "You can't grant access", "description": "Only Appointment Managers and Tenant Admins can be granted access to client data." @@ -929,6 +937,7 @@ "loading": "Loading calendar", "decrypting": "decrypting...", "decryptingError": "Unable to decrypt data. Please log-in again", + "decryptingErrorNoKeyShare": "Unable to decrypt data. Get a team member to grant you access.", "notificationHint": "Clients will be notified via e-mail, if they have enabled e-mail notifications.", "shownAppointments": { "title": "Appointments", diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index d11eb37..42b2fd6 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -77,6 +77,10 @@ export interface AppointmentData { locale?: string; } +export type AppointmentDataByStaff = Omit & { + email?: string; +}; + interface StaffPublicKey { userId: string; publicKey: string; @@ -100,6 +104,18 @@ interface MyAppointmentsResponse { encryptedData: EncryptedData; }>; } +/** + * Generate deterministic SHA-256 hash of email for privacy-preserving lookups + */ +export const hashEmail = async (email: string): Promise => { + const emailNormalized = email.toLowerCase().trim(); + const encoder = new TextEncoder(); + const data = encoder.encode(emailNormalized); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +}; export class UnifiedAppointmentCrypto { // Client-specific properties @@ -130,7 +146,7 @@ export class UnifiedAppointmentCrypto { */ async preCheck(email: string, tenantId: string): Promise { try { - const emailHash = await this.hashEmail(email); + const emailHash = await hashEmail(email); const response = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, { method: "POST", @@ -151,7 +167,7 @@ export class UnifiedAppointmentCrypto { async initNewClient(email: string, pin: string, tenantId: string): Promise { try { // 1. Generate email hash for privacy-preserving lookup - this.emailHash = await this.hashEmail(email); + this.emailHash = await hashEmail(email); // 2. Generate tunnel ID this.tunnelId = this.generateTunnelId(); @@ -209,7 +225,7 @@ export class UnifiedAppointmentCrypto { } // 1. Generate email hash - this.emailHash = await this.hashEmail(opts.email); + this.emailHash = await hashEmail(opts.email); // 2. Request challenge from server const challengeResponse = await fetch(`/api/tenants/${opts.tenant}/appointments/challenge`, { @@ -297,7 +313,7 @@ export class UnifiedAppointmentCrypto { async loginExistingClient(email: string, pin: string, tenantId: string): Promise { try { // 1. Generate email hash - this.emailHash = await this.hashEmail(email); + this.emailHash = await hashEmail(email); // 2. Request challenge from server const challengeResponse = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, { @@ -901,19 +917,6 @@ export class UnifiedAppointmentCrypto { return new Uint8Array(keyMaterial); } - /** - * Generate deterministic SHA-256 hash of email for privacy-preserving lookups - */ - async hashEmail(email: string): Promise { - const emailNormalized = email.toLowerCase().trim(); - const encoder = new TextEncoder(); - const data = encoder.encode(emailNormalized); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(hashBuffer)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } - /** * Generate ML-KEM-768 keypair for clients */ @@ -942,7 +945,9 @@ export class UnifiedAppointmentCrypto { /** * Encrypt appointment data with the tunnel key */ - private async encryptAppointmentData(data: AppointmentData): Promise { + private async encryptAppointmentData( + data: AppointmentData | AppointmentDataByStaff, + ): Promise { if (!this.tunnelKey) throw new Error("No tunnel key available"); const encoder = new TextEncoder(); @@ -1051,7 +1056,7 @@ export class UnifiedAppointmentCrypto { return this.uint8ArrayToHex(shares[1].y); } - private async fetchStaffPublicKeys(tenantId: string): Promise { + async fetchStaffPublicKeys(tenantId: string): Promise { const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-public-keys`, { method: "GET", headers: { "Content-Type": "application/json" }, @@ -1067,13 +1072,15 @@ export class UnifiedAppointmentCrypto { // getTenantId method removed - tenantId is now always passed explicitly - private async encryptTunnelKeyForStaff( + async encryptTunnelKeyForStaff( staffKeys: StaffPublicKey[], + externalTunnelKey?: CryptoKey, ): Promise> { - if (!this.tunnelKey) throw new Error("No tunnel key available"); + const usedKey = externalTunnelKey ?? this.tunnelKey; + if (!usedKey) throw new Error("No tunnel key available"); // Export tunnel key as raw bytes - const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey); + const tunnelKeyBytes = await crypto.subtle.exportKey("raw", usedKey); const tunnelKeyArray = new Uint8Array(tunnelKeyBytes); const results = []; @@ -1305,6 +1312,72 @@ export class UnifiedAppointmentCrypto { ); } + async decryptTunnelKeyByStaff(staffKeyShare: string): Promise { + if (!this.staffAuthenticated || !this.staffKeyPair) { + throw new Error("Staff not authenticated"); + } + + if (this.keyExpiry && Date.now() > this.keyExpiry) { + throw new Error("Staff session expired - please authenticate again"); + } + + try { + // Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey + const staffKeyShareBytes = this.hexToUint8Array(staffKeyShare); + + // ML-KEM-768 encapsulated secret is 1088 bytes + const ENCAPSULATED_SECRET_LENGTH = 1088; + const IV_LENGTH = 12; + + if (staffKeyShareBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) { + throw new Error( + `staffKeyShare too short: ${staffKeyShareBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`, + ); + } + + const encapsulatedSecret = staffKeyShareBytes.slice(0, ENCAPSULATED_SECRET_LENGTH); + const iv = staffKeyShareBytes.slice( + ENCAPSULATED_SECRET_LENGTH, + ENCAPSULATED_SECRET_LENGTH + IV_LENGTH, + ); + const encryptedTunnelKey = staffKeyShareBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH); + + // 1. Decapsulate to get shared secret + const sharedSecret = KyberCrypto.decapsulate( + this.staffKeyPair.privateKey, + encapsulatedSecret, + ); + + // 2. Use first 32 bytes of shared secret as AES key + const aesKeyBytes = sharedSecret.slice(0, 32); + + // Import as CryptoKey for Web Crypto API + const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [ + "decrypt", + ]); + + // 3. Decrypt the tunnel key + const decryptedTunnelKey = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + aesKey, + encryptedTunnelKey, + ); + + // 4. Import and return tunnel key as CryptoKey + return await crypto.subtle.importKey( + "raw", + new Uint8Array(decryptedTunnelKey), + { name: "AES-GCM" }, + true, + ["encrypt", "decrypt"], + ); + } catch (error) { + throw new Error( + `Decryption failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + // Helper methods for completing the API private async getPrivateKeyShare(): Promise { if (!this.serverPrivateKeyShare) throw new Error("Server private key share not available"); diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 6c25f63..33d8b44 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -146,6 +146,70 @@ describe("AppointmentService", () => { expect(result).toEqual([]); }); + + it("should include current staff encrypted tunnel key when staffUserId is provided", async () => { + const { getTenantDb } = await import("../../db"); + + const mockSelect = vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([mockClientTunnel]), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { + tunnelId: "tunnel-123", + encryptedTunnelKey: "staff-encrypted-tunnel-key", + }, + ]), + }), + }); + + const mockDb = { + select: mockSelect, + }; + + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + const result = await service.getClientTunnels("staff-123"); + + expect(result).toHaveLength(1); + expect(result[0].currentStaffEncryptedTunnelKey).toBe("staff-encrypted-tunnel-key"); + expect(result[0]).not.toHaveProperty("clientEncryptedTunnelKey"); + }); + + it("should set current staff encrypted tunnel key to undefined when no share exists", async () => { + const { getTenantDb } = await import("../../db"); + + const mockSelect = vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([mockClientTunnel]), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); + + const mockDb = { + select: mockSelect, + }; + + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + const result = await service.getClientTunnels("staff-123"); + + expect(result).toHaveLength(1); + expect(result[0].currentStaffEncryptedTunnelKey).toBeUndefined(); + }); }); describe("createNewClientWithAppointment", () => { diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 3033143..9606414 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -46,6 +46,7 @@ export interface ClientTunnelResponse { id: string; emailHash: string; clientPublicKey: string; + currentStaffEncryptedTunnelKey?: string; createdAt?: string; updatedAt?: string; } @@ -430,9 +431,9 @@ export class AppointmentService { /** * Get all client tunnels for the tenant */ - public async getClientTunnels(): Promise { + public async getClientTunnels(staffUserId?: string): Promise { const log = logger.setContext("AppointmentService"); - log.debug("Fetching client tunnels", { tenantId: this.tenantId }); + log.debug("Fetching client tunnels", { tenantId: this.tenantId, staffUserId }); const db = await this.getDb(); const tunnels = await db @@ -446,6 +447,22 @@ export class AppointmentService { .from(tenantSchema.clientAppointmentTunnel) .orderBy(tenantSchema.clientAppointmentTunnel.createdAt); + let encryptedTunnelKeyByTunnelId = new Map(); + + if (staffUserId) { + const staffKeyShares = await db + .select({ + tunnelId: tenantSchema.clientTunnelStaffKeyShare.tunnelId, + encryptedTunnelKey: tenantSchema.clientTunnelStaffKeyShare.encryptedTunnelKey, + }) + .from(tenantSchema.clientTunnelStaffKeyShare) + .where(eq(tenantSchema.clientTunnelStaffKeyShare.userId, staffUserId)); + + encryptedTunnelKeyByTunnelId = new Map( + staffKeyShares.map((share) => [share.tunnelId, share.encryptedTunnelKey]), + ); + } + log.debug("Client tunnels retrieved successfully", { tenantId: this.tenantId, tunnelCount: tunnels.length, @@ -455,6 +472,7 @@ export class AppointmentService { id: tunnel.id, emailHash: tunnel.emailHash, clientPublicKey: tunnel.clientPublicKey, + currentStaffEncryptedTunnelKey: encryptedTunnelKeyByTunnelId.get(tunnel.id), createdAt: tunnel.createdAt?.toISOString(), updatedAt: tunnel.updatedAt?.toISOString(), })); diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte index fe85f8d..0a5e145 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte @@ -22,6 +22,10 @@ decrypt(); }); + const errors = { + missingKeyShare: "Missing key share", + }; + const decrypt = async () => { if (!item.appointment) { console.error("Unable to decrypt appointment data - no appointment data in item.", item.id); @@ -39,7 +43,7 @@ // Check if we have staffKeyShare if (!item.appointment.staffKeyShare) { console.error("Unable to decrypt appointment data - missing staffKeyShare.", item.id); - error = "Missing key share"; + error = errors.missingKeyShare; return; } @@ -82,7 +86,13 @@ }; -{#if error} +{#if error === errors.missingKeyShare} +
+ + ⚠️ {m["calendar.decryptingErrorNoKeyShare"]()} + +
+{:else if error}
⚠️ {m["calendar.decryptingError"]()} diff --git a/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte index 8a1ec95..5d969ec 100644 --- a/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte +++ b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte @@ -5,26 +5,87 @@ import { InlineCode } from "$lib/components/ui/inline-code"; import { TranslationWithComponent } from "$lib/components/ui/translation-with-component"; import { Text } from "$lib/components/ui/typography"; + import type { ClientTunnelResponse } from "$lib/server/services/appointment-service"; import { auth } from "$lib/stores/auth"; + import { staffCrypto } from "$lib/stores/staff-crypto"; import type { TStaff } from "$lib/types/users"; + import { TriangleAlert } from "@lucide/svelte"; import StopIcon from "@lucide/svelte/icons/octagon-x"; import { toast } from "svelte-sonner"; + import { addStaffKeyShares, fetchClientTunnels } from "../utils"; let { entity, done }: { entity: TStaff; done: () => void } = $props(); const myUserRole = $derived($auth.user?.role); + const tenantId = $derived($auth.user?.tenantId); + let step: "init" | "fetch-tunnels" | "add-staff-key-shares" | "error-no-public-key" | "error" = + $state("init"); let isSubmitting = $state(false); + let tunnels: ClientTunnelResponse[] = $state([]); + + const grantAccess = async () => { + if (!tenantId || !$staffCrypto.crypto) return; + const cryptoClient = $staffCrypto.crypto; - const grantAccess = () => { isSubmitting = true; + step = "fetch-tunnels"; - // Simulate an async operation - setTimeout(() => { + try { + // Fetching all tunnels + tunnels = await fetchClientTunnels(tenantId); + step = "add-staff-key-shares"; + + // For each tunnel: decrypt currentStaffEncryptedTunnelKey with current user private key + const allPublicKeys = await cryptoClient.fetchStaffPublicKeys(tenantId); + const newUserPublicKeys = allPublicKeys.filter((x) => x.userId === entity.id); + + if (newUserPublicKeys.length === 0) { + step = "error-no-public-key"; + return; + } + + const keyShares = await Promise.all( + tunnels.map(async (tunnel) => { + if (!tunnel.currentStaffEncryptedTunnelKey) { + throw new Error(`Missing current staff key share for tunnel ${tunnel.id}`); + } + + const decryptedTunnelKey = await cryptoClient.decryptTunnelKeyByStaff( + tunnel.currentStaffEncryptedTunnelKey, + ); + + const encryptedForNewStaff = await cryptoClient.encryptTunnelKeyForStaff( + newUserPublicKeys, + decryptedTunnelKey, + ); + + if (encryptedForNewStaff.length === 0) { + throw new Error(`Failed to encrypt tunnel key for new staff on tunnel ${tunnel.id}`); + } + + return { + tunnelId: tunnel.id, + encryptedTunnelKey: encryptedForNewStaff[0].encryptedTunnelKey, + }; + }), + ); + + // Setting staff key shares + const isOk = await addStaffKeyShares(tenantId, entity.id, keyShares); + if (isOk) { + isSubmitting = false; + toast.success(m["staff.access.success"]({ name: entity.name })); + done(); + } else { + step = "error"; + toast.error(m["staff.access.error.title"]()); + isSubmitting = false; + } + } catch (error) { + console.error(error); + step = "error"; + toast.error(m["staff.access.error.title"]()); isSubmitting = false; - toast.success(m["staff.access.success"]()); - done(); - }, 2000); - - // toast.error(m["staff.access.error"]()); + } }; @@ -42,21 +103,34 @@ Icon={StopIcon} size="sm" /> - {:else} -
- {#if isSubmitting} - - {/if} - -
+ {:else if step === "init"} + + {:else if step === "fetch-tunnels"} + + {:else if step === "add-staff-key-shares"} + + {:else if step === "error-no-public-key"} + + {:else if step === "error"} + {/if}
diff --git a/src/routes/(pages)/dashboard/staff/(components)/utils.ts b/src/routes/(pages)/dashboard/staff/(components)/utils.ts index 3b87796..0967f91 100644 --- a/src/routes/(pages)/dashboard/staff/(components)/utils.ts +++ b/src/routes/(pages)/dashboard/staff/(components)/utils.ts @@ -1,4 +1,5 @@ import { m } from "$i18n/messages"; +import type { ClientTunnelResponse } from "$lib/server/services/appointment-service"; import type { TStaff } from "$lib/types/users"; export const roles = [ @@ -15,3 +16,39 @@ export const permissions: { label: string; roles: TStaff["role"][] }[] = [ { label: m["staff.permissions.staff"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, { label: m["staff.permissions.appointments"](), roles: ["TENANT_ADMIN", "STAFF"] }, ]; + +export const fetchClientTunnels = async (tenantId: string) => { + const resp = await fetch(`/api/tenants/${tenantId}/appointments/tunnels`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!resp.ok) { + throw new Error("❌ Unable to fetch tunnels"); + } + + const tunnelsData: { tunnels: ClientTunnelResponse[] } = await resp.json(); + return tunnelsData.tunnels; +}; + +export const addStaffKeyShares = async ( + tenantId: string, + staffUserId: string, + keyShares: { tunnelId: string; encryptedTunnelKey: string }[], +) => { + const resp = await fetch(`/api/tenants/${tenantId}/appointments/tunnels/add-staff-key-shares`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + staffUserId, + keyShares, + }), + }); + + if (!resp.ok) { + throw new Error("❌ Unable to send staff key shares"); + } + + const body: { success: boolean; skipped: number } = await resp.json(); + return resp.status < 400 && body.success; +}; diff --git a/src/routes/(pages)/dashboard/staff/+page.server.ts b/src/routes/(pages)/dashboard/staff/+page.server.ts index 3b0a910..76be18d 100644 --- a/src/routes/(pages)/dashboard/staff/+page.server.ts +++ b/src/routes/(pages)/dashboard/staff/+page.server.ts @@ -11,13 +11,16 @@ import { formSchema as editFormSchema } from "./(components)/edit-staff-member-f const log = logger.setContext(import.meta.filename); export const load = async (event) => { - if (!event.locals.user?.tenantId) { + const tenantId = event.locals.user?.tenantId; + event.depends(`app:staff-${tenantId}`); + + if (!tenantId) { log.error("User trying to access staff, but has no tenantId"); redirect(302, ROUTES.LOGOUT); } const list = event - .fetch(`/api/tenants/${event.locals.user?.tenantId}/staff`, { + .fetch(`/api/tenants/${tenantId}/staff`, { method: "GET", headers: { "Content-Type": "application/json", diff --git a/src/routes/(pages)/dashboard/staff/+page.svelte b/src/routes/(pages)/dashboard/staff/+page.svelte index d1c78b8..8f35028 100644 --- a/src/routes/(pages)/dashboard/staff/+page.svelte +++ b/src/routes/(pages)/dashboard/staff/+page.svelte @@ -27,6 +27,7 @@ const { data } = $props(); let curItem: TStaff | null = $state(null); const myUserId = $derived($auth.user?.id); + const tenantId = $derived($auth.user?.tenantId); onMount(() => { if (history.state["sveltekit:states"]?.action === "add") { @@ -159,7 +160,7 @@ done={() => { closeDialog("access"); curItem = null; - invalidate(ROUTES.DASHBOARD.STAFF); + invalidate(`app:staff-${tenantId}`); }} /> {/if} diff --git a/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts b/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts index a01dc2e..8d3b080 100644 --- a/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/tunnels/+server.ts @@ -48,6 +48,11 @@ registerOpenAPIRoute("/tenants/{id}/appointments/tunnels", "GET", { type: "string", description: "Client's ML-KEM-768 public key", }, + currentStaffEncryptedTunnelKey: { + type: "string", + description: + "Tunnel key encrypted for the currently authenticated staff user", + }, createdAt: { type: "string", format: "date-time", @@ -58,9 +63,8 @@ registerOpenAPIRoute("/tenants/{id}/appointments/tunnels", "GET", { format: "date-time", description: "Last update timestamp", }, - isActive: { type: "boolean", description: "Whether tunnel is active" }, }, - required: ["id", "emailHash", "clientPublicKey", "isActive"], + required: ["id", "emailHash", "clientPublicKey"], }, }, }, @@ -114,21 +118,23 @@ export const GET: RequestHandler = async ({ params, locals }) => { checkPermission(locals, tenantId, false); + const requesterUserId = locals.user?.id; + try { - log.debug("Fetching client tunnels", { tenantId, requesterId: locals.user?.id }); + log.debug("Fetching client tunnels", { tenantId, requesterId: requesterUserId }); const appointmentService = await AppointmentService.forTenant(tenantId); - const tunnels = await appointmentService.getClientTunnels(); + const tunnels = await appointmentService.getClientTunnels(requesterUserId); log.debug("Client tunnels retrieved successfully", { tenantId, - requesterId: locals.user?.id, + requesterId: requesterUserId, tunnelCount: tunnels.length, }); return json({ tunnels }); } catch (error) { - logError(log)("Error fetching client tunnels", error, locals.user?.id, tenantId); + logError(log)("Error fetching client tunnels", error, requesterUserId, tenantId); if (error instanceof BackendError) { return error.toJson(); diff --git a/src/routes/api/tenants/[id]/appointments/tunnels/__tests__/tunnels.test.ts b/src/routes/api/tenants/[id]/appointments/tunnels/__tests__/tunnels.test.ts index e8ce0b6..a201443 100644 --- a/src/routes/api/tenants/[id]/appointments/tunnels/__tests__/tunnels.test.ts +++ b/src/routes/api/tenants/[id]/appointments/tunnels/__tests__/tunnels.test.ts @@ -17,6 +17,7 @@ describe("Client Tunnels API", () => { id: "tunnel-uuid-1", emailHash: "sha256-hash-of-client-email", clientPublicKey: "base64-encoded-ml-kem-768-key", + currentStaffEncryptedTunnelKey: "encrypted-key-for-current-staff", createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }, @@ -27,6 +28,8 @@ describe("Client Tunnels API", () => { expect(mockTunnelResponse.tunnels[0]).toHaveProperty("id"); expect(mockTunnelResponse.tunnels[0]).toHaveProperty("emailHash"); expect(mockTunnelResponse.tunnels[0]).toHaveProperty("clientPublicKey"); + expect(mockTunnelResponse.tunnels[0]).toHaveProperty("currentStaffEncryptedTunnelKey"); + expect(mockTunnelResponse.tunnels[0]).not.toHaveProperty("clientEncryptedTunnelKey"); }); it("should validate email hash format", () => { diff --git a/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts b/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts index 6951275..979e8d4 100644 --- a/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts @@ -15,7 +15,7 @@ import { checkPermission } from "$lib/server/utils/permissions"; import { getTenantDb, centralDb } from "$lib/server/db"; import { clientAppointmentTunnel, clientTunnelStaffKeyShare } from "$lib/server/db/tenant-schema"; import { user } from "$lib/server/db/central-schema"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { registerOpenAPIRoute } from "$lib/server/openapi"; // Register OpenAPI documentation for POST @@ -185,6 +185,7 @@ export const POST: RequestHandler = async ({ params, locals, request }) => { tenantId: user.tenantId, isActive: user.isActive, role: user.role, + confirmationState: user.confirmationState, }) .from(user) .where(eq(user.id, staffUserId)) @@ -234,16 +235,30 @@ export const POST: RequestHandler = async ({ params, locals, request }) => { const newKeyShares = keyShares.filter((ks) => !existingKeyShareTunnelIds.has(ks.tunnelId)); + const ensureAccessGranted = async () => { + await centralDb + .update(user) + .set({ confirmationState: "ACCESS_GRANTED" }) + .where(and(eq(user.id, staffUserId), eq(user.tenantId, tenantId))); + }; + if (newKeyShares.length === 0) { log.info("No new key shares to add - all already exist", { tenantId, staffUserId, totalRequested: keyShares.length, + previousConfirmationState: staffUser[0].confirmationState, }); + await ensureAccessGranted(); + + const wasAlreadyGranted = staffUser[0].confirmationState === "ACCESS_GRANTED"; + return json({ success: true, - message: "All key shares already exist", + message: wasAlreadyGranted + ? "All key shares already exist" + : "All key shares already existed; access has now been granted", added: 0, skipped: keyShares.length, }); @@ -267,6 +282,8 @@ export const POST: RequestHandler = async ({ params, locals, request }) => { return insertedKeyShares; }); + await ensureAccessGranted(); + log.info("Staff key shares added successfully", { tenantId, staffUserId,