mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
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:
co-authored by
Karl Ludwig Weise
Hendrik Belitz
parent
7b1a4da662
commit
513b706877
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]()}
|
||||
|
||||
+97
-23
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user