mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Notification shows who canceled an appointment (#317)
* Notification shows who canceled an appointment -wip * Added solution arcitecture for decrypting notification payload * Use encrypted tunnel key to decrypt encrpyted notification payload * Fix tests --------- Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
co-authored by
Karl Ludwig Weise
parent
de83771843
commit
9e5a5ab722
@@ -366,6 +366,7 @@
|
|||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"select": "Auswählen",
|
"select": "Auswählen",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
|
"unkown": "Unbekannt",
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Übersicht",
|
"title": "Übersicht",
|
||||||
"hello": "Hallo",
|
"hello": "Hallo",
|
||||||
@@ -490,6 +491,7 @@
|
|||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"title": "Kanäle",
|
"title": "Kanäle",
|
||||||
|
"singular": "Kanal",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "Bisher keine Kanäle",
|
"title": "Bisher keine Kanäle",
|
||||||
"description": "Kanäle sind Ihre Möglichkeit, verschiedene Arten von Terminen zu sammeln"
|
"description": "Kanäle sind Ihre Möglichkeit, verschiedene Arten von Terminen zu sammeln"
|
||||||
@@ -1130,16 +1132,13 @@
|
|||||||
"empty": "Keine Benachrichtigungen vorhanden",
|
"empty": "Keine Benachrichtigungen vorhanden",
|
||||||
"types": {
|
"types": {
|
||||||
"APPOINTMENT_REQUESTED": {
|
"APPOINTMENT_REQUESTED": {
|
||||||
"title": "Neue Terminanfrage",
|
"title": "Neue Terminanfrage"
|
||||||
"description": "Ein neuer Termin wurde in {channel} angefragt."
|
|
||||||
},
|
},
|
||||||
"APPOINTMENT_CONFIRMED": {
|
"APPOINTMENT_CONFIRMED": {
|
||||||
"title": "Termin bestätigt",
|
"title": "Termin bestätigt"
|
||||||
"description": "Terminanfrage in {channel} wurde bestätigt."
|
|
||||||
},
|
},
|
||||||
"APPOINTMENT_CANCELLED": {
|
"APPOINTMENT_CANCELLED": {
|
||||||
"title": "Termin abgesagt",
|
"title": "Termin abgesagt"
|
||||||
"description": "Der Klient hat den Termin in {channel} abgesagt."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -373,6 +373,8 @@
|
|||||||
"pause": "Deactivate",
|
"pause": "Deactivate",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
|
"close": "Close",
|
||||||
|
"unkown": "Unkown",
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"hello": "Hello",
|
"hello": "Hello",
|
||||||
@@ -432,7 +434,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"close": "Close",
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"title": "Agents",
|
"title": "Agents",
|
||||||
"empty": {
|
"empty": {
|
||||||
@@ -498,6 +499,7 @@
|
|||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"title": "Channels",
|
"title": "Channels",
|
||||||
|
"singular": "Channel",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No channels yet",
|
"title": "No channels yet",
|
||||||
"description": "Channels are your way to collect different types of appointments"
|
"description": "Channels are your way to collect different types of appointments"
|
||||||
@@ -1138,16 +1140,13 @@
|
|||||||
"empty": "No notifications available",
|
"empty": "No notifications available",
|
||||||
"types": {
|
"types": {
|
||||||
"APPOINTMENT_REQUESTED": {
|
"APPOINTMENT_REQUESTED": {
|
||||||
"title": "New appointment request",
|
"title": "New appointment request"
|
||||||
"description": "A new appointment has been requested in {channel}."
|
|
||||||
},
|
},
|
||||||
"APPOINTMENT_CONFIRMED": {
|
"APPOINTMENT_CONFIRMED": {
|
||||||
"title": "Appointment confirmed",
|
"title": "Appointment confirmed"
|
||||||
"description": "Appointment in {channel} was confirmed."
|
|
||||||
},
|
},
|
||||||
"APPOINTMENT_CANCELLED": {
|
"APPOINTMENT_CANCELLED": {
|
||||||
"title": "Appointment cancelled",
|
"title": "Appointment cancelled"
|
||||||
"description": "Client cancelled appointment in {channel}."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,13 @@
|
|||||||
|
|
||||||
import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment";
|
import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment";
|
||||||
import { OptimizedArgon2 } from "$lib/crypto/hashing";
|
import { OptimizedArgon2 } from "$lib/crypto/hashing";
|
||||||
import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
|
import {
|
||||||
|
AESCrypto,
|
||||||
|
BufferUtils,
|
||||||
|
encryptedDataToString,
|
||||||
|
KyberCrypto,
|
||||||
|
ShamirSecretSharing,
|
||||||
|
} from "$lib/crypto/utils";
|
||||||
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
|
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
|
||||||
import { pinThrottleStore } from "$lib/stores/pin-throttle";
|
import { pinThrottleStore } from "$lib/stores/pin-throttle";
|
||||||
|
|
||||||
@@ -64,7 +70,7 @@ interface StaffKeyPair {
|
|||||||
privateKey: Uint8Array;
|
privateKey: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EncryptedData {
|
export interface EncryptedData {
|
||||||
encryptedPayload: string;
|
encryptedPayload: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
authTag: string;
|
authTag: string;
|
||||||
@@ -90,6 +96,7 @@ interface StaffPublicKey {
|
|||||||
|
|
||||||
export interface DecryptedAppointment {
|
export interface DecryptedAppointment {
|
||||||
id: string;
|
id: string;
|
||||||
|
channelId?: string;
|
||||||
appointmentDate: string;
|
appointmentDate: string;
|
||||||
status: string;
|
status: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -103,6 +110,7 @@ interface MyAppointmentsResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
appointmentDate: string;
|
appointmentDate: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
channelId: string;
|
||||||
encryptedData: EncryptedData;
|
encryptedData: EncryptedData;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -246,7 +254,9 @@ export class UnifiedAppointmentCrypto {
|
|||||||
async cancelAppointmentByClient(opts: {
|
async cancelAppointmentByClient(opts: {
|
||||||
tenant: string;
|
tenant: string;
|
||||||
appointment: string;
|
appointment: string;
|
||||||
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
phone?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (this.pin === null) {
|
if (this.pin === null) {
|
||||||
@@ -305,6 +315,13 @@ export class UnifiedAppointmentCrypto {
|
|||||||
emailHash: this.emailHash,
|
emailHash: this.emailHash,
|
||||||
challengeId: challengeData.challengeId,
|
challengeId: challengeData.challengeId,
|
||||||
challengeResponse: decryptedChallenge,
|
challengeResponse: decryptedChallenge,
|
||||||
|
encryptedPayload: encryptedDataToString(
|
||||||
|
await this.encryptData({
|
||||||
|
name: opts.name,
|
||||||
|
email: opts.email,
|
||||||
|
phone: opts.phone,
|
||||||
|
}),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -709,6 +726,7 @@ export class UnifiedAppointmentCrypto {
|
|||||||
id: encryptedAppt.id,
|
id: encryptedAppt.id,
|
||||||
appointmentDate: encryptedAppt.appointmentDate,
|
appointmentDate: encryptedAppt.appointmentDate,
|
||||||
status: encryptedAppt.status,
|
status: encryptedAppt.status,
|
||||||
|
channelId: encryptedAppt.channelId,
|
||||||
...decryptedData,
|
...decryptedData,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -787,6 +805,16 @@ export class UnifiedAppointmentCrypto {
|
|||||||
encryptedAppointment: EncryptedData;
|
encryptedAppointment: EncryptedData;
|
||||||
staffKeyShare: string;
|
staffKeyShare: string;
|
||||||
}): Promise<AppointmentData> {
|
}): Promise<AppointmentData> {
|
||||||
|
return this.decryptStaff<AppointmentData>({
|
||||||
|
data: encryptedData.encryptedAppointment,
|
||||||
|
staffKeyShare: encryptedData.staffKeyShare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt appointment data for staff members
|
||||||
|
*/
|
||||||
|
async decryptStaff<T>(encryptedData: { data: EncryptedData; staffKeyShare: string }): Promise<T> {
|
||||||
if (!this.staffAuthenticated || !this.staffKeyPair) {
|
if (!this.staffAuthenticated || !this.staffKeyPair) {
|
||||||
throw new Error("Staff not authenticated");
|
throw new Error("Staff not authenticated");
|
||||||
}
|
}
|
||||||
@@ -848,9 +876,9 @@ export class UnifiedAppointmentCrypto {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 5. Now decrypt the actual appointment data
|
// 5. Now decrypt the actual appointment data
|
||||||
const appointmentIv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
|
const appointmentIv = this.hexToUint8Array(encryptedData.data.iv);
|
||||||
const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload);
|
const ciphertext = this.hexToUint8Array(encryptedData.data.encryptedPayload);
|
||||||
const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag);
|
const authTag = this.hexToUint8Array(encryptedData.data.authTag);
|
||||||
|
|
||||||
// Combine ciphertext and auth tag for Web Crypto API
|
// Combine ciphertext and auth tag for Web Crypto API
|
||||||
const encrypted = new Uint8Array(ciphertext.length + authTag.length);
|
const encrypted = new Uint8Array(ciphertext.length + authTag.length);
|
||||||
@@ -1137,6 +1165,13 @@ export class UnifiedAppointmentCrypto {
|
|||||||
data: AppointmentData | AppointmentDataByStaff,
|
data: AppointmentData | AppointmentDataByStaff,
|
||||||
tunnelKey?: CryptoKey,
|
tunnelKey?: CryptoKey,
|
||||||
): Promise<EncryptedData> {
|
): Promise<EncryptedData> {
|
||||||
|
return this.encryptData<AppointmentData | AppointmentDataByStaff>(data, tunnelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data with the tunnel key
|
||||||
|
*/
|
||||||
|
private async encryptData<T>(data: T, tunnelKey?: CryptoKey): Promise<EncryptedData> {
|
||||||
const usedTunnelKey = tunnelKey ?? this.tunnelKey;
|
const usedTunnelKey = tunnelKey ?? this.tunnelKey;
|
||||||
if (!usedTunnelKey) throw new Error("No tunnel key available");
|
if (!usedTunnelKey) throw new Error("No tunnel key available");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import logger from "$lib/logger";
|
||||||
|
import type { TStaffKeyShare } from "$lib/types/staff";
|
||||||
|
|
||||||
|
export const getStaffKeyShares = async (
|
||||||
|
tenantId: string,
|
||||||
|
tunnelId: string,
|
||||||
|
): Promise<TStaffKeyShare[]> => {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/tenants/${tenantId}/appointments/tunnels/${tunnelId}/staff-key-shares`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to parse staff key shares response", { tenantId, tunnelId, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return data?.keyShares || [];
|
||||||
|
};
|
||||||
@@ -3,11 +3,15 @@
|
|||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { m } from "$i18n/messages";
|
import { m } from "$i18n/messages";
|
||||||
|
import { getStaffKeyShares } from "$lib/client/crypto";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { Text } from "$lib/components/ui/typography";
|
import { Text } from "$lib/components/ui/typography";
|
||||||
import { ROUTES } from "$lib/const/routes";
|
import { ROUTES } from "$lib/const/routes";
|
||||||
|
import { stringToEncryptedData } from "$lib/crypto/utils";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { channels as channelsStore } from "$lib/stores/channels";
|
import { channels as channelsStore } from "$lib/stores/channels";
|
||||||
|
import { staffCrypto } from "$lib/stores/staff-crypto";
|
||||||
|
import { tenants } from "$lib/stores/tenants";
|
||||||
import type { TAppointment } from "$lib/types/appointments";
|
import type { TAppointment } from "$lib/types/appointments";
|
||||||
import type { TNotification } from "$lib/types/notification";
|
import type { TNotification } from "$lib/types/notification";
|
||||||
import { toDisplayDateTime } from "$lib/utils/datetime";
|
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||||
@@ -26,7 +30,9 @@
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const channels = $derived($channelsStore.channels);
|
const channels = $derived($channelsStore.channels);
|
||||||
|
const tenant = $derived($tenants.currentTenant);
|
||||||
let appointment: TAppointment | undefined = $state();
|
let appointment: TAppointment | undefined = $state();
|
||||||
|
let decrypted: { name?: string; email?: string; phone?: string } | undefined = $state();
|
||||||
|
|
||||||
const getAppointment = async (id: string) => {
|
const getAppointment = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -46,16 +52,57 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decryptPayload = async () => {
|
||||||
|
if (item.metaData?.encryptedPayload) {
|
||||||
|
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
|
||||||
|
const maxWaitTime = 5000; // 5 seconds
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
|
||||||
|
console.error("Staff crypto not initialized after waiting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = stringToEncryptedData(item.metaData?.encryptedPayload);
|
||||||
|
if (data) {
|
||||||
|
if (!item.metaData?.tunnelId) {
|
||||||
|
console.error("Missing tunnelId in notification metadata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant === null) {
|
||||||
|
console.error("Missing tenant to decrypt encrypted notification payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffKeyShares = await getStaffKeyShares(tenant!.id, item.metaData.tunnelId);
|
||||||
|
|
||||||
|
// TODO: When multiple staffKeyShares are supported, select the one currently in use
|
||||||
|
const staffKeyShare = staffKeyShares[0];
|
||||||
|
|
||||||
|
decrypted = await $staffCrypto.crypto.decryptStaff({
|
||||||
|
data,
|
||||||
|
staffKeyShare: staffKeyShare.encryptedTunnelKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (item.metaData?.appointmentId) {
|
if (item.metaData?.appointmentId) {
|
||||||
getAppointment(item.metaData.appointmentId);
|
getAppointment(item.metaData.appointmentId);
|
||||||
|
decryptPayload();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class=" flex h-auto w-full shrink flex-col items-start gap-1 rounded-none px-3 last:rounded-b-md focus:ring-inset"
|
class="flex h-auto w-full shrink flex-col items-start gap-1 rounded-none px-3 last:rounded-b-md focus:ring-inset"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (appointment) {
|
if (appointment) {
|
||||||
closePopover();
|
closePopover();
|
||||||
@@ -96,6 +143,21 @@
|
|||||||
timeZoneName: "short",
|
timeZoneName: "short",
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
{:else if item.metaData?.appointmentDate}
|
||||||
|
<Text
|
||||||
|
style="xs"
|
||||||
|
class="text-muted-foreground -mb-1 flex pr-8 text-start font-light whitespace-break-spaces"
|
||||||
|
>
|
||||||
|
{toDisplayDateTime(new Date(item.metaData?.appointmentDate), {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: getLocalTimeZone(),
|
||||||
|
timeZoneName: "short",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
{/if}
|
{/if}
|
||||||
<Text style="md" class="flex pr-8 text-start whitespace-break-spaces">
|
<Text style="md" class="flex pr-8 text-start whitespace-break-spaces">
|
||||||
{#if item.type === "APPOINTMENT_REQUESTED"}
|
{#if item.type === "APPOINTMENT_REQUESTED"}
|
||||||
@@ -106,23 +168,29 @@
|
|||||||
{m["notifications.types.APPOINTMENT_CANCELLED.title"]()}
|
{m["notifications.types.APPOINTMENT_CANCELLED.title"]()}
|
||||||
{/if}
|
{/if}
|
||||||
</Text>
|
</Text>
|
||||||
{#if appointment}
|
{@const possibleChannelIds = [item.metaData?.channelId, appointment?.channelId]}
|
||||||
<Text style="sm" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
|
{@const channel = channels.find((c) => possibleChannelIds.includes(c.id))}
|
||||||
{#if appointment?.channelId}
|
{#if channel}
|
||||||
{@const channel = channels.find((c) => c.id === appointment?.channelId)}
|
{@const channelName = getCurrentTranlslation(channel.names)}
|
||||||
{@const channelName = getCurrentTranlslation(channel?.names)}
|
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
|
||||||
{#if channelName}
|
{m["channels.singular"]()}: {channelName}
|
||||||
{#if item.type === "APPOINTMENT_REQUESTED"}
|
</Text>
|
||||||
{m["notifications.types.APPOINTMENT_REQUESTED.description"]({
|
{/if}
|
||||||
channel: channelName,
|
{#if decrypted?.name}
|
||||||
})}
|
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
|
||||||
{/if}
|
{m["form.name"]()}: {decrypted.name || m["unkown"]()}
|
||||||
{:else if item.type === "APPOINTMENT_CONFIRMED"}
|
</Text>
|
||||||
{m["notifications.types.APPOINTMENT_CONFIRMED.description"]({
|
{/if}
|
||||||
channel: channelName,
|
{#if decrypted?.email}
|
||||||
})}
|
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
|
||||||
{/if}
|
{m["form.email"]()}:
|
||||||
{/if}
|
<a href={`mailto:${decrypted.email}`} class="underline">{decrypted.email}</a>
|
||||||
|
</Text>
|
||||||
|
{/if}
|
||||||
|
{#if decrypted?.phone}
|
||||||
|
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
|
||||||
|
{m["form.phone"]()}:
|
||||||
|
<a href={`tel:${decrypted.phone}`} class="underline">{decrypted.phone}</a>
|
||||||
</Text>
|
</Text>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Text } from "$lib/components/ui/typography";
|
import { Text } from "$lib/components/ui/typography";
|
||||||
import { languageSwitchLocales } from "$lib/const/locales";
|
import { languageSwitchLocales } from "$lib/const/locales";
|
||||||
import { toDisplayDateTime } from "$lib/utils/datetime";
|
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||||
import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte";
|
import { Calendar, Languages, Mail, Phone, Split, User, UserStar } from "@lucide/svelte";
|
||||||
import type { AppointmentDetailItems } from ".";
|
import type { AppointmentDetailItems } from ".";
|
||||||
import CopyButton from "./copy-button.svelte";
|
import CopyButton from "./copy-button.svelte";
|
||||||
|
|
||||||
@@ -18,7 +18,12 @@
|
|||||||
{#each items as item (item.type)}
|
{#each items as item (item.type)}
|
||||||
{#if item.value}
|
{#if item.value}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if item.type === "agent"}
|
{#if item.type === "channel"}
|
||||||
|
<Split class="size-4 shrink-0" />
|
||||||
|
<Text style="sm">
|
||||||
|
{item.value}
|
||||||
|
</Text>
|
||||||
|
{:else if item.type === "agent"}
|
||||||
<UserStar class="size-4 shrink-0" />
|
<UserStar class="size-4 shrink-0" />
|
||||||
<Text style="sm">
|
<Text style="sm">
|
||||||
{item.value}
|
{item.value}
|
||||||
|
|||||||
@@ -29,4 +29,8 @@ export type AppointmentDetailItem =
|
|||||||
| {
|
| {
|
||||||
type: "agent";
|
type: "agent";
|
||||||
value: string | undefined;
|
value: string | undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "channel";
|
||||||
|
value: string | undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ml_kem768 } from "@noble/post-quantum/ml-kem";
|
import { ml_kem768 } from "@noble/post-quantum/ml-kem";
|
||||||
import { randomBytes } from "@noble/hashes/utils";
|
import { randomBytes } from "@noble/hashes/utils";
|
||||||
|
import type { EncryptedData } from "$lib/client/appointment-crypto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type alias for cryptographic buffer operations
|
* Type alias for cryptographic buffer operations
|
||||||
@@ -588,3 +589,43 @@ export class ShamirSecretSharing {
|
|||||||
return length;
|
return length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const encryptedDataToString = (data: EncryptedData): string => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(JSON.stringify(data));
|
||||||
|
|
||||||
|
const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join("");
|
||||||
|
const base64 = btoa(binString);
|
||||||
|
|
||||||
|
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stringToEncryptedData = (str: string): EncryptedData | null => {
|
||||||
|
try {
|
||||||
|
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
|
||||||
|
const binString = atob(base64);
|
||||||
|
const bytes = Uint8Array.from(binString, (char) => char.codePointAt(0)!);
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const parsed = JSON.parse(decoder.decode(bytes));
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed.encryptedPayload === "string" &&
|
||||||
|
typeof parsed.iv === "string" &&
|
||||||
|
typeof parsed.authTag === "string"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
encryptedPayload: parsed.encryptedPayload,
|
||||||
|
iv: parsed.iv,
|
||||||
|
authTag: parsed.authTag,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.error("Hydrated encrypted data does not match expected object type");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to hydrate encrypted data from string", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -554,26 +554,6 @@ export class AppointmentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create notifications for all staff members in the channel
|
|
||||||
const notificationService = await NotificationService.forTenant(this.tenantId);
|
|
||||||
|
|
||||||
// Create notifications (async, don't wait)
|
|
||||||
notificationService
|
|
||||||
.createNotification({
|
|
||||||
channelId,
|
|
||||||
type: "APPOINTMENT_CANCELLED",
|
|
||||||
metaData: {
|
|
||||||
appointmentId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
log.error("Failed to create channel notifications", {
|
|
||||||
appointmentId,
|
|
||||||
channelId,
|
|
||||||
error: String(error),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info("Appointment deleted by staff successfully", {
|
log.info("Appointment deleted by staff successfully", {
|
||||||
appointmentId,
|
appointmentId,
|
||||||
channelId,
|
channelId,
|
||||||
@@ -1251,6 +1231,7 @@ export class AppointmentService {
|
|||||||
emailHash: string,
|
emailHash: string,
|
||||||
challengeId: string,
|
challengeId: string,
|
||||||
challengeResponse: string,
|
challengeResponse: string,
|
||||||
|
encryptedPayload?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const log = logger.setContext("AppointmentService");
|
const log = logger.setContext("AppointmentService");
|
||||||
log.debug("Client deleting appointment with authentication", {
|
log.debug("Client deleting appointment with authentication", {
|
||||||
@@ -1367,6 +1348,10 @@ export class AppointmentService {
|
|||||||
type: "APPOINTMENT_CANCELLED",
|
type: "APPOINTMENT_CANCELLED",
|
||||||
metaData: {
|
metaData: {
|
||||||
appointmentId,
|
appointmentId,
|
||||||
|
channelId: appointment.channelId,
|
||||||
|
appointmentDate: appointment.appointmentDate,
|
||||||
|
tunnelId: appointment.tunnelId,
|
||||||
|
encryptedPayload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
import type { SelectUser } from "$lib/server/db/central-schema";
|
import type { SelectUser } from "$lib/server/db/central-schema";
|
||||||
|
|
||||||
export type TStaff = Pick<SelectUser, "id" | "name" | "role" | "email">;
|
export type TStaff = Pick<SelectUser, "id" | "name" | "role" | "email">;
|
||||||
|
|
||||||
|
export type TStaffKeyShare = {
|
||||||
|
tunnelId: string;
|
||||||
|
encryptedTunnelKey: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,21 +3,24 @@
|
|||||||
import { m } from "$i18n/messages.js";
|
import { m } from "$i18n/messages.js";
|
||||||
import type { DecryptedAppointment } from "$lib/client/appointment-crypto";
|
import type { DecryptedAppointment } from "$lib/client/appointment-crypto";
|
||||||
import * as Alert from "$lib/components/ui/alert";
|
import * as Alert from "$lib/components/ui/alert";
|
||||||
|
import { AppointmentDetails } from "$lib/components/ui/appointment-details";
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
import { Badge } from "$lib/components/ui/badge";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
import * as Item from "$lib/components/ui/item";
|
import * as Item from "$lib/components/ui/item";
|
||||||
|
import { LocalizedText } from "$lib/components/ui/public";
|
||||||
import { openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
import { openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
||||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||||
import { Headline, Text } from "$lib/components/ui/typography";
|
import { Headline } from "$lib/components/ui/typography";
|
||||||
import { ROUTES } from "$lib/const/routes";
|
import { ROUTES } from "$lib/const/routes";
|
||||||
import { publicStore } from "$lib/stores/public";
|
import { publicStore } from "$lib/stores/public";
|
||||||
import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime";
|
import { toDisplayDateTime } from "$lib/utils/datetime";
|
||||||
import { getLocalTimeZone } from "@internationalized/date";
|
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
||||||
import { Calendar, CircleAlert } from "@lucide/svelte";
|
import { CircleAlert } from "@lucide/svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { appointmentStatusToBadge } from "./utils";
|
import { appointmentStatusToBadge } from "./utils";
|
||||||
|
|
||||||
|
let channels = $derived($publicStore.channels);
|
||||||
let tenant = $derived($publicStore.tenant);
|
let tenant = $derived($publicStore.tenant);
|
||||||
let step: "loading" | "list" | "error" = $state("loading");
|
let step: "loading" | "list" | "error" = $state("loading");
|
||||||
let appointments: DecryptedAppointment[] = $state([]);
|
let appointments: DecryptedAppointment[] = $state([]);
|
||||||
@@ -43,6 +46,8 @@
|
|||||||
?.cancelAppointmentByClient({
|
?.cancelAppointmentByClient({
|
||||||
appointment: cancelling.id,
|
appointment: cancelling.id,
|
||||||
tenant: tenant.id,
|
tenant: tenant.id,
|
||||||
|
name: cancelling.name,
|
||||||
|
phone: cancelling.phone,
|
||||||
email: cancelling.email,
|
email: cancelling.email,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -72,10 +77,13 @@
|
|||||||
{#if appointments.length > 0}
|
{#if appointments.length > 0}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{#each appointments as item (item)}
|
{#each appointments as item (item)}
|
||||||
|
{@const channel = channels?.find((it) => it.id === item.channelId)}
|
||||||
<Item.Root variant="outline">
|
<Item.Root variant="outline">
|
||||||
<Item.Content>
|
<Item.Content>
|
||||||
<Item.Title>
|
<Item.Title>
|
||||||
<!-- TODO: Add channel and agent; ids needed -->
|
{#if channel}
|
||||||
|
<LocalizedText translations={channel.names} />
|
||||||
|
{/if}
|
||||||
{@const badge = appointmentStatusToBadge(item.status)}
|
{@const badge = appointmentStatusToBadge(item.status)}
|
||||||
{#if badge}
|
{#if badge}
|
||||||
<Badge>{badge.label.toLocaleUpperCase()}</Badge>
|
<Badge>{badge.label.toLocaleUpperCase()}</Badge>
|
||||||
@@ -118,26 +126,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if cancelling}
|
{#if cancelling}
|
||||||
|
{@const channel = channels?.find((it) => it.id === cancelling?.channelId)}
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog
|
||||||
id="cancel-appointment"
|
id="cancel-appointment"
|
||||||
title={m["clients.appointments.cancel.title"]()}
|
title={m["clients.appointments.cancel.title"]()}
|
||||||
description={m["clients.appointments.cancel.description"]()}
|
description={m["clients.appointments.cancel.description"]()}
|
||||||
triggerHidden={true}
|
triggerHidden={true}
|
||||||
>
|
>
|
||||||
<div class="flex gap-2 p-1">
|
<AppointmentDetails
|
||||||
<Calendar class="size-4 " />
|
items={[
|
||||||
<Text style="sm">
|
{
|
||||||
{toDisplayDateTime(utcToLocalWithoutDST(new Date(cancelling.appointmentDate)), {
|
type: "channel",
|
||||||
year: "numeric",
|
value: channel && getCurrentTranlslation(channel.names),
|
||||||
month: "long",
|
},
|
||||||
day: "numeric",
|
{
|
||||||
weekday: "short",
|
type: "date",
|
||||||
hour: "2-digit",
|
value: new Date(cancelling.appointmentDate),
|
||||||
minute: "2-digit",
|
},
|
||||||
timeZone: getLocalTimeZone().toString(),
|
]}
|
||||||
})}
|
/>
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
|||||||
+4
-1
@@ -17,6 +17,7 @@ const requestSchema = z.object({
|
|||||||
emailHash: z.string().min(1),
|
emailHash: z.string().min(1),
|
||||||
challengeId: z.string().min(1),
|
challengeId: z.string().min(1),
|
||||||
challengeResponse: z.string().min(1),
|
challengeResponse: z.string().min(1),
|
||||||
|
encryptedPayload: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register OpenAPI documentation for DELETE
|
// Register OpenAPI documentation for DELETE
|
||||||
@@ -131,7 +132,8 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { emailHash, challengeId, challengeResponse } = requestSchema.parse(body);
|
const { emailHash, challengeId, challengeResponse, encryptedPayload } =
|
||||||
|
requestSchema.parse(body);
|
||||||
|
|
||||||
log.debug("Client deleting appointment", {
|
log.debug("Client deleting appointment", {
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -146,6 +148,7 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
|
|||||||
emailHash,
|
emailHash,
|
||||||
challengeId,
|
challengeId,
|
||||||
challengeResponse,
|
challengeResponse,
|
||||||
|
encryptedPayload,
|
||||||
);
|
);
|
||||||
|
|
||||||
log.debug("Appointment deleted successfully by client", {
|
log.debug("Appointment deleted successfully by client", {
|
||||||
|
|||||||
+3
@@ -26,6 +26,7 @@ const mockAppointmentId = "appointment-456";
|
|||||||
const mockEmailHash = "email-hash-abc123";
|
const mockEmailHash = "email-hash-abc123";
|
||||||
const mockChallengeId = "challenge-789";
|
const mockChallengeId = "challenge-789";
|
||||||
const mockChallengeResponse = "decrypted-challenge";
|
const mockChallengeResponse = "decrypted-challenge";
|
||||||
|
const mockEncryptedPayload = "encrypted-payload-string";
|
||||||
|
|
||||||
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client", () => {
|
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -40,6 +41,7 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client
|
|||||||
emailHash: mockEmailHash,
|
emailHash: mockEmailHash,
|
||||||
challengeId: mockChallengeId,
|
challengeId: mockChallengeId,
|
||||||
challengeResponse: mockChallengeResponse,
|
challengeResponse: mockChallengeResponse,
|
||||||
|
encryptedPayload: mockEncryptedPayload,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
} as any,
|
} as any,
|
||||||
@@ -66,6 +68,7 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client
|
|||||||
mockEmailHash,
|
mockEmailHash,
|
||||||
mockChallengeId,
|
mockChallengeId,
|
||||||
mockChallengeResponse,
|
mockChallengeResponse,
|
||||||
|
mockEncryptedPayload,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
import { logger } from "$lib/logger";
|
||||||
|
import { centralDb, getTenantDb } from "$lib/server/db";
|
||||||
|
import { user } from "$lib/server/db/central-schema";
|
||||||
|
import { clientTunnelStaffKeyShare } from "$lib/server/db/tenant-schema";
|
||||||
|
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||||
|
import {
|
||||||
|
AuthenticationError,
|
||||||
|
AuthorizationError,
|
||||||
|
BackendError,
|
||||||
|
InternalError,
|
||||||
|
logError,
|
||||||
|
ValidationError,
|
||||||
|
} from "$lib/server/utils/errors";
|
||||||
|
import { checkPermission } from "$lib/server/utils/permissions";
|
||||||
|
import type { RequestHandler } from "@sveltejs/kit";
|
||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Register OpenAPI documentation for GET
|
||||||
|
registerOpenAPIRoute("/tenants/{id}/appointments/tunnels/{tunnelId}/staff-key-shares", "GET", {
|
||||||
|
summary: "Get staff key shares for a specific tunnel",
|
||||||
|
description:
|
||||||
|
"Get staff key shares for a specific tunnel. This is used when a staff member needs access to decrypt a specific client tunnel.",
|
||||||
|
tags: ["Appointments", "Tunnels", "Staff"],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", format: "uuid" },
|
||||||
|
description: "Tenant ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tunnelId",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", format: "uuid" },
|
||||||
|
description: "Tunnel ID",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Staff key shares retrieved successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
keyShares: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
encryptedTunnelKey: { type: "string", description: "Encrypted tunnel key" },
|
||||||
|
tunnelId: { type: "string", format: "uuid", description: "Tunnel ID" },
|
||||||
|
},
|
||||||
|
required: ["encryptedTunnelKey", "tunnelId"],
|
||||||
|
},
|
||||||
|
description: "Retrieved key shares",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["keyShares"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
description: "Authentication required",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
description: "Forbidden",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const log = logger.setContext("API.GetStaffKeyShares");
|
||||||
|
const tenantId = params.id;
|
||||||
|
const tunnelId = params.tunnelId;
|
||||||
|
const userId = locals.user?.id;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new ValidationError("Tenant ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tunnelId) {
|
||||||
|
throw new ValidationError("Tunnel ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new AuthenticationError("User ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
checkPermission(locals, tenantId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("Getting staff key shares for tunnel", {
|
||||||
|
tenantId,
|
||||||
|
tunnelId,
|
||||||
|
userId: locals.user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const staffUser = await centralDb
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
isActive: user.isActive,
|
||||||
|
role: user.role,
|
||||||
|
confirmationState: user.confirmationState,
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (staffUser.length === 0) {
|
||||||
|
throw new AuthenticationError("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffUser[0].tenantId !== tenantId) {
|
||||||
|
throw new AuthorizationError("Staff user does not belong to this tenant");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!staffUser[0].isActive) {
|
||||||
|
throw new AuthorizationError("Staff user is inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getTenantDb(tenantId);
|
||||||
|
|
||||||
|
const tunnelAccess = await db
|
||||||
|
.select({
|
||||||
|
tunnelId: clientTunnelStaffKeyShare.tunnelId,
|
||||||
|
})
|
||||||
|
.from(clientTunnelStaffKeyShare)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientTunnelStaffKeyShare.userId, userId),
|
||||||
|
eq(clientTunnelStaffKeyShare.tunnelId, tunnelId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (tunnelAccess.length === 0) {
|
||||||
|
throw new AuthorizationError(
|
||||||
|
"User does not have access to this tunnel or tunnel does not exist",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyShares = await db
|
||||||
|
.select({
|
||||||
|
tunnelId: clientTunnelStaffKeyShare.tunnelId,
|
||||||
|
encryptedTunnelKey: clientTunnelStaffKeyShare.encryptedTunnelKey,
|
||||||
|
})
|
||||||
|
.from(clientTunnelStaffKeyShare)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientTunnelStaffKeyShare.userId, userId),
|
||||||
|
eq(clientTunnelStaffKeyShare.tunnelId, tunnelId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
keyShares,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(log)("Error getting staff key shares", error, locals.user?.id, tenantId);
|
||||||
|
|
||||||
|
if (error instanceof BackendError) {
|
||||||
|
return error.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InternalError().toJson();
|
||||||
|
}
|
||||||
|
};
|
||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockCheckPermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogger = vi.hoisted(() => ({
|
||||||
|
setContext: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCentralDb = vi.hoisted(() => ({
|
||||||
|
select: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetTenantDb = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("$lib/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
...mockLogger,
|
||||||
|
setContext: vi.fn().mockImplementation(() => mockLogger),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/server/openapi", () => ({
|
||||||
|
registerOpenAPIRoute: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/server/db", () => ({
|
||||||
|
centralDb: mockCentralDb,
|
||||||
|
getTenantDb: mockGetTenantDb,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/server/db/central-schema", () => ({
|
||||||
|
user: {
|
||||||
|
id: "id",
|
||||||
|
tenantId: "tenantId",
|
||||||
|
isActive: "isActive",
|
||||||
|
role: "role",
|
||||||
|
confirmationState: "confirmationState",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/server/db/tenant-schema", () => ({
|
||||||
|
clientTunnelStaffKeyShare: {
|
||||||
|
userId: "userId",
|
||||||
|
tunnelId: "tunnelId",
|
||||||
|
encryptedTunnelKey: "encryptedTunnelKey",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$lib/server/utils/permissions", () => ({
|
||||||
|
checkPermission: mockCheckPermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GET } from "../+server";
|
||||||
|
|
||||||
|
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
|
||||||
|
const mockStaffUserId = "456e7890-e89b-12d3-a456-426614174001";
|
||||||
|
const mockTunnelId = "789e0123-e89b-12d3-a456-426614174002";
|
||||||
|
|
||||||
|
const createSelectChain = (result: unknown) => ({
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockResolvedValue(result),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTenantDbResult = (rows: unknown[]) => ({
|
||||||
|
select: vi.fn().mockReturnValue({
|
||||||
|
from: vi.fn().mockReturnValue({
|
||||||
|
where: vi.fn().mockReturnValue({
|
||||||
|
limit: vi.fn().mockResolvedValue(rows),
|
||||||
|
then: (resolve: (value: unknown[]) => unknown) => resolve(rows),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildEvent = (locals: Record<string, unknown>, tenantId?: string, tunnelId?: string) =>
|
||||||
|
({
|
||||||
|
params: { id: tenantId, tunnelId },
|
||||||
|
locals,
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
describe("Get Staff Key Shares API", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCheckPermission.mockImplementation(() => undefined);
|
||||||
|
mockLogger.setContext.mockImplementation(() => mockLogger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the staff key shares for the current staff user and tunnel", async () => {
|
||||||
|
const mockStaffUser = {
|
||||||
|
id: mockStaffUserId,
|
||||||
|
tenantId: mockTenantId,
|
||||||
|
isActive: true,
|
||||||
|
role: "STAFF",
|
||||||
|
confirmationState: "ACCESS_GRANTED",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockCentralDb.select.mockReturnValue(createSelectChain([mockStaffUser]));
|
||||||
|
|
||||||
|
mockGetTenantDb.mockResolvedValue(
|
||||||
|
createTenantDbResult([
|
||||||
|
{
|
||||||
|
tunnelId: mockTunnelId,
|
||||||
|
encryptedTunnelKey: "encrypted-key-share",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({
|
||||||
|
keyShares: [{ tunnelId: mockTunnelId, encryptedTunnelKey: "encrypted-key-share" }],
|
||||||
|
});
|
||||||
|
expect(mockCheckPermission).toHaveBeenCalledWith(
|
||||||
|
{ user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" } },
|
||||||
|
mockTenantId,
|
||||||
|
);
|
||||||
|
expect(mockGetTenantDb).toHaveBeenCalledWith(mockTenantId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the staff user does not have access to the requested tunnel", async () => {
|
||||||
|
const mockStaffUser = {
|
||||||
|
id: mockStaffUserId,
|
||||||
|
tenantId: mockTenantId,
|
||||||
|
isActive: true,
|
||||||
|
role: "STAFF",
|
||||||
|
confirmationState: "ACCESS_GRANTED",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockCentralDb.select.mockReturnValue(createSelectChain([mockStaffUser]));
|
||||||
|
mockGetTenantDb.mockResolvedValue(createTenantDbResult([]));
|
||||||
|
|
||||||
|
const response = await GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({
|
||||||
|
error: "User does not have access to this tunnel or tunnel does not exist",
|
||||||
|
message: "User does not have access to this tunnel or tunnel does not exist",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when tenant id is missing", async () => {
|
||||||
|
await expect(
|
||||||
|
GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Tenant ID is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when tunnel id is missing", async () => {
|
||||||
|
await expect(
|
||||||
|
GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Tunnel ID is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the current user is not in locals", async () => {
|
||||||
|
await expect(
|
||||||
|
GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: null,
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("User ID is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the staff user record is not found in the central database", async () => {
|
||||||
|
mockCentralDb.select.mockReturnValue(createSelectChain([]));
|
||||||
|
|
||||||
|
const response = await GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({ error: "User not found", message: "User not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the staff user belongs to a different tenant", async () => {
|
||||||
|
mockCentralDb.select.mockReturnValue(
|
||||||
|
createSelectChain([
|
||||||
|
{
|
||||||
|
id: mockStaffUserId,
|
||||||
|
tenantId: "different-tenant",
|
||||||
|
isActive: true,
|
||||||
|
role: "STAFF",
|
||||||
|
confirmationState: "ACCESS_GRANTED",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({
|
||||||
|
error: "Staff user does not belong to this tenant",
|
||||||
|
message: "Staff user does not belong to this tenant",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the staff user is inactive", async () => {
|
||||||
|
mockCentralDb.select.mockReturnValue(
|
||||||
|
createSelectChain([
|
||||||
|
{
|
||||||
|
id: mockStaffUserId,
|
||||||
|
tenantId: mockTenantId,
|
||||||
|
isActive: false,
|
||||||
|
role: "STAFF",
|
||||||
|
confirmationState: "ACCESS_GRANTED",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({ error: "Staff user is inactive", message: "Staff user is inactive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a 500 response when the tenant database query fails unexpectedly", async () => {
|
||||||
|
mockCentralDb.select.mockReturnValue(
|
||||||
|
createSelectChain([
|
||||||
|
{
|
||||||
|
id: mockStaffUserId,
|
||||||
|
tenantId: mockTenantId,
|
||||||
|
isActive: true,
|
||||||
|
role: "STAFF",
|
||||||
|
confirmationState: "ACCESS_GRANTED",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
mockGetTenantDb.mockRejectedValue(new Error("database unavailable"));
|
||||||
|
|
||||||
|
const response = await GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({ error: "Internal server error", message: "Internal server error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates permission failures before any database access occurs", async () => {
|
||||||
|
mockCheckPermission.mockImplementation(() => {
|
||||||
|
throw new Error("permission denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
GET(
|
||||||
|
buildEvent(
|
||||||
|
{
|
||||||
|
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
|
||||||
|
},
|
||||||
|
mockTenantId,
|
||||||
|
mockTunnelId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("permission denied");
|
||||||
|
|
||||||
|
expect(mockCentralDb.select).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user