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 <ludwig@ludwigweise.de>
Co-authored-by: Hendrik Belitz <hendrik@innovation-through-understanding.de>
This commit is contained in:
Karl Ludwig Weise
2026-02-26 17:29:27 +01:00
committed by GitHub
co-authored by Karl Ludwig Weise Hendrik Belitz
parent 7b1a4da662
commit 513b706877
13 changed files with 390 additions and 66 deletions
+12 -3
View File
@@ -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",
+12 -3
View File
@@ -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",
+95 -22
View File
@@ -77,6 +77,10 @@ export interface AppointmentData {
locale?: string;
}
export type AppointmentDataByStaff = Omit<AppointmentData, "email"> & {
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<string> => {
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<boolean> {
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<void> {
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<void> {
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<string> {
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<EncryptedData> {
private async encryptAppointmentData(
data: AppointmentData | AppointmentDataByStaff,
): Promise<EncryptedData> {
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<StaffPublicKey[]> {
async fetchStaffPublicKeys(tenantId: string): Promise<StaffPublicKey[]> {
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<Array<{ userId: string; encryptedTunnelKey: string }>> {
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<CryptoKey> {
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<string> {
if (!this.serverPrivateKeyShare) throw new Error("Server private key share not available");
@@ -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", () => {
+20 -2
View File
@@ -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<ClientTunnelResponse[]> {
public async getClientTunnels(staffUserId?: string): Promise<ClientTunnelResponse[]> {
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<string, string>();
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(),
}));
@@ -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 @@
};
</script>
{#if error}
{#if error === errors.missingKeyShare}
<div class="text-destructive flex h-full items-center gap-1 px-1">
<Text style="xs" class="leading-none text-(--channel-color-contrast)" title={error}>
⚠️ {m["calendar.decryptingErrorNoKeyShare"]()}
</Text>
</div>
{:else if error}
<div class="text-destructive flex h-full items-center gap-1 px-1">
<Text style="xs" class="leading-none text-(--channel-color-contrast)" title={error}>
⚠️ {m["calendar.decryptingError"]()}
@@ -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"]());
}
};
</script>
@@ -42,21 +103,34 @@
Icon={StopIcon}
size="sm"
/>
{:else}
<div class="mt-6 flex flex-col gap-4">
{#if isSubmitting}
<CenterLoadingState label={m["staff.access.loading"]({ success: 0, total: 1 })} />
{/if}
<Button
size="lg"
type="button"
onclick={grantAccess}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["staff.access.action"]()}
</Button>
</div>
{:else if step === "init"}
<Button
size="lg"
type="button"
onclick={grantAccess}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["staff.access.action"]()}
</Button>
{:else if step === "fetch-tunnels"}
<CenterLoadingState label={m["staff.access.loadingTunnels"]()} />
{:else if step === "add-staff-key-shares"}
<CenterLoadingState label={m["staff.access.loadingAddingKeyShares"]()} />
{:else if step === "error-no-public-key"}
<CenterState
headline={m["staff.access.errorNoPublicKey.title"]()}
description={m["staff.access.errorNoPublicKey.description"]()}
Icon={TriangleAlert}
size="sm"
/>
{:else if step === "error"}
<CenterState
headline={m["staff.access.error.title"]()}
description={m["staff.access.error.description"]()}
Icon={TriangleAlert}
size="sm"
/>
{/if}
</div>
@@ -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;
};
@@ -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",
@@ -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}
@@ -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();
@@ -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", () => {
@@ -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,