mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-27 03:14:48 +02:00
Merge pull request #326 from open-reception/feat/resolve-missing-staff-crypto-situations
Resolve missing staff crypto situations
This commit is contained in:
Generated
+4
-4
@@ -47,7 +47,7 @@
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"bits-ui": "^2.15.4",
|
||||
"bits-ui": "^2.19.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.29.0",
|
||||
@@ -3803,9 +3803,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bits-ui": {
|
||||
"version": "2.18.1",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
|
||||
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.0.tgz",
|
||||
"integrity": "sha512-dHDm5Jw2NZJgLRvHAwPeuR4BnZij+AqTIq33OqVfo+qiMXontoC/yIvGT5TZaviv7OuBdJ2QUy2JPQ2yKBvF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"bits-ui": "^2.15.4",
|
||||
"bits-ui": "^2.19.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.29.0",
|
||||
|
||||
@@ -458,6 +458,18 @@
|
||||
}
|
||||
],
|
||||
"error": "Fehler beim Senden"
|
||||
},
|
||||
"missingCryptoKeys": {
|
||||
"title": "Kryptografische Schlüssel fehlen",
|
||||
"description": "Dieser Tab enthält nicht die kryptografischen Schlüssel zum Entschlüsseln und Verschlüsseln von Daten. Lade die Schlüssel, um Termine/Daten anzuzeigen und hinzuzufügen.",
|
||||
"cancel": "Ich will nur Einstellungen ändern",
|
||||
"success": "Kryptografische Schlüssel geladen",
|
||||
"error": "Fehler beim Laden der kryptografischen Schlüssel"
|
||||
},
|
||||
"inactiveSession": {
|
||||
"title": "Bist du noch da?",
|
||||
"description": "Du wirst in Kürze automatisch abgemeldet.",
|
||||
"cancel": "Ich bin noch da"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -466,6 +466,18 @@
|
||||
}
|
||||
],
|
||||
"error": "Failed to send reminders"
|
||||
},
|
||||
"missingCryptoKeys": {
|
||||
"title": "Cryptography Keys Missing",
|
||||
"description": "This tab is missing the cryptographic keys to decrypt and encrypt data. Load keys to view and add appointments/data.",
|
||||
"cancel": "I just want to change settings",
|
||||
"success": "Cryptography keys loaded",
|
||||
"error": "Failed to load cryptography keys"
|
||||
},
|
||||
"inactiveSession": {
|
||||
"title": "Are you still here?",
|
||||
"description": "You will be automatically logged out soon.",
|
||||
"cancel": "I am still here"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -153,9 +153,7 @@ export class UnifiedAppointmentCrypto {
|
||||
// Staff-specific properties
|
||||
private staffKeyPair: StaffKeyPair | null = null;
|
||||
private staffId: string | null = null;
|
||||
private tenantId: string | null = null;
|
||||
private staffAuthenticated: boolean = false;
|
||||
private keyExpiry: number | null = null;
|
||||
|
||||
// Shared crypto utilities
|
||||
private kyberCrypto: KyberCrypto = new KyberCrypto();
|
||||
@@ -786,9 +784,7 @@ export class UnifiedAppointmentCrypto {
|
||||
};
|
||||
|
||||
this.staffId = staffId;
|
||||
this.tenantId = tenantId;
|
||||
this.staffAuthenticated = true;
|
||||
this.keyExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
console.log("✅ Staff authentication successful with PRF");
|
||||
} catch (error) {
|
||||
@@ -820,10 +816,6 @@ export class UnifiedAppointmentCrypto {
|
||||
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(encryptedData.staffKeyShare);
|
||||
@@ -958,9 +950,7 @@ export class UnifiedAppointmentCrypto {
|
||||
logoutStaff(): void {
|
||||
this.staffKeyPair = null;
|
||||
this.staffId = null;
|
||||
this.tenantId = null;
|
||||
this.staffAuthenticated = false;
|
||||
this.keyExpiry = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1727,10 +1717,6 @@ export class UnifiedAppointmentCrypto {
|
||||
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);
|
||||
|
||||
@@ -52,10 +52,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const decryptPayload = async () => {
|
||||
const decryptPayload = async (retry = true) => {
|
||||
if (item.metaData?.encryptedPayload) {
|
||||
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
|
||||
const maxWaitTime = 5000; // 5 seconds
|
||||
const maxWaitTime = 5000;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
|
||||
@@ -63,6 +63,13 @@
|
||||
}
|
||||
|
||||
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
|
||||
const user = $auth.user;
|
||||
if (retry && user && user.tenantId) {
|
||||
console.warn("Staff crypto not initialized, retrying notification item decryption...");
|
||||
await staffCrypto.authenticateAndWait(user.id, user.tenantId);
|
||||
decryptPayload(false);
|
||||
return;
|
||||
}
|
||||
console.error("Staff crypto not initialized after waiting");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
triggerHidden = false,
|
||||
triggerVariant = "default",
|
||||
isActionLoading = false,
|
||||
isDismissable = true,
|
||||
actions,
|
||||
children,
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
@@ -62,6 +63,7 @@
|
||||
description?: string;
|
||||
triggerVariant?: ButtonVariant;
|
||||
isActionLoading?: boolean;
|
||||
isDismissable?: boolean;
|
||||
actions?: ListItemAction[];
|
||||
} = $props();
|
||||
|
||||
@@ -150,40 +152,49 @@
|
||||
{/if}
|
||||
</Dialog.Trigger>
|
||||
{/if}
|
||||
<Dialog.Content
|
||||
class={cn(
|
||||
"max-h-[95vh] sm:max-w-106.25",
|
||||
actions && actions.length > 0 && "[&>button:last-child]:hidden", // hides default close button
|
||||
)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Header class="flex flex-row items-start justify-between gap-2">
|
||||
<div class="flex flex-col gap-1 text-left">
|
||||
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
|
||||
{#if description}
|
||||
<Dialog.Description>
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</div>
|
||||
{#if actions && actions.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
{@render actionsSnippet?.()}
|
||||
<Dialog.Close>
|
||||
<X class="size-4" />
|
||||
</Dialog.Close>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class={cn(isDismissable === true ? "" : "z-60!")} />
|
||||
<Dialog.Content
|
||||
class={cn(
|
||||
"max-h-[95vh] sm:max-w-106.25",
|
||||
// pull non-dismissable dialogs to the top
|
||||
isDismissable === true ? "" : "z-70!",
|
||||
// hides default close button
|
||||
actions && actions.length > 0 ? "[&>button:last-child]:hidden" : "",
|
||||
)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
|
||||
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
|
||||
showCloseButton={isDismissable === true}
|
||||
>
|
||||
<Dialog.Header class="flex flex-row items-start justify-between gap-2">
|
||||
<div class="flex flex-col gap-1 text-left">
|
||||
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
|
||||
{#if description}
|
||||
<Dialog.Description>
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
<ScrollArea class="-mx-1 max-h-[75vh] overflow-hidden">
|
||||
<div class="px-1 pt-2 pb-3">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Dialog.Content>
|
||||
{#if actions && actions.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
{@render actionsSnippet?.()}
|
||||
<Dialog.Close>
|
||||
<X class="size-4" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
<ScrollArea class="-mx-1 max-h-[75vh] overflow-hidden">
|
||||
<div class="px-1 pt-2 pb-3">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
{:else}
|
||||
<Drawer.Root bind:open>
|
||||
<Drawer.Root bind:open dismissible={isDismissable}>
|
||||
{#if !triggerHidden}
|
||||
<Drawer.Trigger class={buttonVariants({ variant: triggerVariant })}>
|
||||
{#if typeof triggerLabel === "string"}
|
||||
@@ -193,29 +204,40 @@
|
||||
{/if}
|
||||
</Drawer.Trigger>
|
||||
{/if}
|
||||
<Drawer.Content
|
||||
class="data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Drawer.Header class="flex flex-row justify-between gap-2 text-left">
|
||||
<div>
|
||||
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
|
||||
{#if description}
|
||||
<Drawer.Description>
|
||||
{description}
|
||||
</Drawer.Description>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{@render actionsSnippet?.()}
|
||||
</div>
|
||||
</Drawer.Header>
|
||||
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
|
||||
{@render children?.()}
|
||||
</HorizontalPagePadding>
|
||||
<Drawer.Footer class="pt-2">
|
||||
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay class={cn(isDismissable === true ? "" : "z-60!")} />
|
||||
<Drawer.Content
|
||||
class={cn(
|
||||
"data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]",
|
||||
// pull non-dismissable dialogs to the top
|
||||
isDismissable === true ? "" : "z-70!",
|
||||
)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
|
||||
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
|
||||
>
|
||||
<Drawer.Header class="flex flex-row justify-between gap-2 text-left">
|
||||
<div>
|
||||
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
|
||||
{#if description}
|
||||
<Drawer.Description>
|
||||
{description}
|
||||
</Drawer.Description>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{@render actionsSnippet?.()}
|
||||
</div>
|
||||
</Drawer.Header>
|
||||
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
|
||||
{@render children?.()}
|
||||
</HorizontalPagePadding>
|
||||
{#if isDismissable !== false}
|
||||
<Drawer.Footer class="pt-2">
|
||||
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
|
||||
</Drawer.Footer>
|
||||
{/if}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
{/if}
|
||||
|
||||
+28
-1
@@ -1,7 +1,10 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import type { SupportedLocale } from "$lib/const/locales";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import type { UserRole } from "$lib/server/auth/authorization-service";
|
||||
import { writable } from "svelte/store";
|
||||
import { get, writable } from "svelte/store";
|
||||
|
||||
export interface PasskeyAuthData {
|
||||
authenticatorData: string;
|
||||
@@ -15,6 +18,8 @@ export interface AuthState {
|
||||
refreshPromise: Promise<Response> | null;
|
||||
user?: AuthStateUser;
|
||||
passkeyAuthData?: PasskeyAuthData;
|
||||
lastActive?: Date | undefined;
|
||||
isInactive?: boolean;
|
||||
}
|
||||
|
||||
export type AuthStateUser = {
|
||||
@@ -31,6 +36,7 @@ function createAuthStore() {
|
||||
const store = writable<AuthState>({
|
||||
isAuthenticated: false,
|
||||
refreshPromise: null,
|
||||
isInactive: false,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -64,6 +70,7 @@ function createAuthStore() {
|
||||
refreshPromise: null,
|
||||
user: undefined,
|
||||
passkeyAuthData: undefined,
|
||||
lastActive: undefined,
|
||||
});
|
||||
},
|
||||
setPasskeyAuthData: (data: PasskeyAuthData) => {
|
||||
@@ -98,6 +105,26 @@ function createAuthStore() {
|
||||
unsubscribe();
|
||||
return authState!.user?.tenantId || null;
|
||||
},
|
||||
refreshLastActive: () => {
|
||||
store.update((state) => ({ ...state, lastActive: new Date(), isInactive: false }));
|
||||
},
|
||||
checkLastActive: () => {
|
||||
const lastActive = get(store).lastActive;
|
||||
if (!lastActive) return;
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - lastActive.getTime();
|
||||
|
||||
// Logs user out after 15 minutes of inactivity
|
||||
if (diff > 15 * 60 * 1000) {
|
||||
goto(resolve(ROUTES.LOGOUT));
|
||||
}
|
||||
|
||||
// Triggers inactivity modal after 10 minutes
|
||||
if (diff > 10 * 60 * 1000) {
|
||||
store.update((state) => ({ ...state, isInactive: true }));
|
||||
}
|
||||
},
|
||||
waitForRefresh: async () => {
|
||||
let authState: AuthState;
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { get, writable } from "svelte/store";
|
||||
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
|
||||
import { auth } from "./auth";
|
||||
|
||||
interface StaffCryptoState {
|
||||
crypto: UnifiedAppointmentCrypto | null;
|
||||
isAuthenticating: boolean;
|
||||
isAuthenticated: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -11,6 +12,7 @@ interface StaffCryptoState {
|
||||
const createStaffCryptoStore = () => {
|
||||
const store = writable<StaffCryptoState>({
|
||||
crypto: null,
|
||||
isAuthenticating: false,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
@@ -37,11 +39,18 @@ const createStaffCryptoStore = () => {
|
||||
*/
|
||||
async authenticate(staffId: string, tenantId: string): Promise<boolean> {
|
||||
try {
|
||||
store.set({
|
||||
...get(store),
|
||||
isAuthenticating: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const crypto = new UnifiedAppointmentCrypto();
|
||||
await crypto.authenticateStaff(staffId, tenantId);
|
||||
|
||||
store.set({
|
||||
crypto,
|
||||
isAuthenticating: false,
|
||||
isAuthenticated: true,
|
||||
error: null,
|
||||
});
|
||||
@@ -51,6 +60,7 @@ const createStaffCryptoStore = () => {
|
||||
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
|
||||
store.set({
|
||||
crypto: null,
|
||||
isAuthenticating: false,
|
||||
isAuthenticated: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
@@ -59,6 +69,24 @@ const createStaffCryptoStore = () => {
|
||||
}
|
||||
},
|
||||
|
||||
async authenticateAndWait(staffId: string, tenantId: string): Promise<boolean> {
|
||||
const isAuthenticating = get(store).isAuthenticating;
|
||||
if (isAuthenticating) {
|
||||
// Wait for the ongoing authentication to complete
|
||||
return new Promise((resolve) => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
if (!state.isAuthenticating) {
|
||||
unsubscribe();
|
||||
resolve(state.isAuthenticated);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Start a new authentication process
|
||||
return await this.authenticate(staffId, tenantId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the staff crypto state and auth data
|
||||
*/
|
||||
@@ -66,6 +94,7 @@ const createStaffCryptoStore = () => {
|
||||
auth.clearPasskeyAuthData();
|
||||
store.set({
|
||||
crypto: null,
|
||||
isAuthenticating: false,
|
||||
isAuthenticated: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { getNextAppointments, sendAppointmentReminders } from "./utils";
|
||||
import { m } from "$i18n/messages";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let status: "init" | "loading" | "sending" | "success" | "error" = $state("init");
|
||||
let appointments: TEmailAppointmentReminder[] = $state([]);
|
||||
@@ -27,6 +28,8 @@
|
||||
// Also gives staff crypto time to load, if not already loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
await auth.waitForRefresh();
|
||||
|
||||
const allAppointments = await getNextAppointments(tenantId);
|
||||
const decryptPromises = allAppointments.map(async (appointment) => {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import { m } from "$i18n/messages";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { openDialog, closeDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
$effect(() => {
|
||||
if ($auth.isInactive) {
|
||||
openDialog("inactivity-warning");
|
||||
} else {
|
||||
closeDialog("inactivity-warning");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ResponsiveDialog
|
||||
id="inactivity-warning"
|
||||
title={m["dashboard.inactiveSession.title"]()}
|
||||
description={m["dashboard.inactiveSession.description"]()}
|
||||
triggerHidden={true}
|
||||
isDismissable={false}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button onclick={() => auth.refreshLastActive()} class="w-full">
|
||||
{m["dashboard.inactiveSession.cancel"]()}
|
||||
</Button>
|
||||
<Button href={resolve(ROUTES.LOGOUT)} variant="link" class="w-full">
|
||||
{m["logout.title"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveDialog>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Passkey } from "$lib/components/ui/passkey";
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import { closeDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
||||
import logger from "$lib/logger";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { staffCrypto } from "$lib/stores/staff-crypto";
|
||||
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
const passkeyLoading: Writable<PasskeyState> = writable("initial");
|
||||
const onSetPasskey = async () => {
|
||||
$passkeyLoading = "loading";
|
||||
|
||||
if (!$auth.user?.email || !$auth.user.tenantId) {
|
||||
$passkeyLoading = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
const challenge = await fetchChallenge($auth.user.email);
|
||||
|
||||
if (!challenge) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to fetch challenge", { email: $auth.user.email });
|
||||
} else {
|
||||
$passkeyLoading = "user";
|
||||
|
||||
// Call WebAuthn with PRF enabled (uses email as salt for multi-passkey support)
|
||||
const credentialResp = await getCredential({
|
||||
...challenge,
|
||||
email: $auth.user.email,
|
||||
enablePRF: true,
|
||||
}).catch((error) => {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Failed to get credential", { ...challenge, error });
|
||||
});
|
||||
|
||||
if (!credentialResp) {
|
||||
$passkeyLoading = "error";
|
||||
logger.error("Credential response is falsy");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data with passkey info
|
||||
const authenticatorDataBase64 = arrayBufferToBase64(
|
||||
// @ts-expect-error response type needs to be fixed
|
||||
credentialResp.response.authenticatorData,
|
||||
);
|
||||
|
||||
// Store authenticatorData and PRF output for later key reconstruction
|
||||
const passkeyId = credentialResp.id;
|
||||
|
||||
// Extract PRF output from WebAuthn response (if PRF was enabled)
|
||||
let prfOutputBase64: string | undefined;
|
||||
if (credentialResp.prfOutput) {
|
||||
prfOutputBase64 = arrayBufferToBase64(credentialResp.prfOutput);
|
||||
logger.info("PRF output retrieved from login", {
|
||||
prfOutputLength: credentialResp.prfOutput.byteLength,
|
||||
});
|
||||
} else {
|
||||
logger.warn("No PRF output in login response - crypto features may not work", {
|
||||
email: $auth.user.email,
|
||||
});
|
||||
}
|
||||
|
||||
auth.setPasskeyAuthData({
|
||||
authenticatorData: authenticatorDataBase64,
|
||||
passkeyId,
|
||||
email: $auth.user.email,
|
||||
prfOutput: prfOutputBase64,
|
||||
});
|
||||
|
||||
await staffCrypto.authenticate($auth.user.id, $auth.user.tenantId);
|
||||
|
||||
// Update UI to show passkey is ready
|
||||
$passkeyLoading = "success";
|
||||
closeDialog("missing-staff-crypto");
|
||||
toast.success(m["dashboard.missingCryptoKeys.success"]());
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<ResponsiveDialog
|
||||
id="missing-staff-crypto"
|
||||
title={m["dashboard.missingCryptoKeys.title"]()}
|
||||
description={m["dashboard.missingCryptoKeys.description"]()}
|
||||
triggerHidden={true}
|
||||
isDismissable={false}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Passkey.State state="click" onclick={onSetPasskey} class="justify-center" />
|
||||
<Button onclick={() => closeDialog("missing-staff-crypto")} variant="link" class="w-full">
|
||||
{m["dashboard.missingCryptoKeys.cancel"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveDialog>
|
||||
@@ -9,6 +9,11 @@
|
||||
import { staff } from "$lib/stores/staff";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
|
||||
import { browser } from "$app/environment";
|
||||
import { page } from "$app/state";
|
||||
import MissingStaffCrypto from "./(components)/MissingStaffCrypto.svelte";
|
||||
import { openDialog } from "$lib/components/ui/responsive-dialog";
|
||||
import { clock } from "$lib/stores/time";
|
||||
import InactivityWarning from "./(components)/InactivityWarning.svelte";
|
||||
|
||||
let { data, children }: LayoutProps = $props();
|
||||
|
||||
@@ -59,6 +64,18 @@
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (page.url.pathname) {
|
||||
auth.refreshLastActive();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($clock) {
|
||||
auth.checkLastActive();
|
||||
}
|
||||
});
|
||||
|
||||
const updateStores = async () => {
|
||||
staff.load();
|
||||
notifications.load();
|
||||
@@ -80,6 +97,7 @@
|
||||
if (success) {
|
||||
console.log("✅ Staff crypto initialized successfully");
|
||||
} else {
|
||||
openDialog("missing-staff-crypto");
|
||||
console.log("ℹ️ Staff crypto not initialized (no session data or error)");
|
||||
}
|
||||
}
|
||||
@@ -88,4 +106,6 @@
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{@render children()}
|
||||
<MissingStaffCrypto />
|
||||
<InactivityWarning />
|
||||
</QueryClientProvider>
|
||||
|
||||
+4
-2
@@ -6,8 +6,11 @@
|
||||
import { InputDateTime } from "$lib/components/ui/input-date-time";
|
||||
import RadioCards from "$lib/components/ui/radio-cards/radio-cards.svelte";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { times } from "$lib/components/ui/slot-template/utils";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { cn } from "$lib/utils";
|
||||
import {
|
||||
getDefaultEndTime,
|
||||
getDefaultStartTime,
|
||||
@@ -24,8 +27,6 @@
|
||||
import type { AbsenceType } from "../types";
|
||||
import { reasons, types } from "../utils";
|
||||
import { formSchema } from "./schema";
|
||||
import { cn } from "$lib/utils";
|
||||
import { times } from "$lib/components/ui/slot-template/utils";
|
||||
|
||||
let { done }: { done: () => void } = $props();
|
||||
|
||||
@@ -46,6 +47,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["absences.add.success"]());
|
||||
done();
|
||||
|
||||
+2
@@ -4,6 +4,7 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { TAbsence } from "$lib/types/absence";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
@@ -20,6 +21,7 @@
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["absences.delete.success"]());
|
||||
done();
|
||||
|
||||
+10
-8
@@ -4,14 +4,13 @@
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { InputDateTime } from "$lib/components/ui/input-date-time";
|
||||
import { RadioCards } from "$lib/components/ui/radio-cards";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { times } from "$lib/components/ui/slot-template/utils";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { TAbsence } from "$lib/types/absence";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { reasons, types } from "../utils";
|
||||
import { cn } from "$lib/utils";
|
||||
import {
|
||||
timeLocalWithoutOffsetToUTC,
|
||||
timeUTCToLocalWithoutOffset,
|
||||
@@ -19,10 +18,12 @@
|
||||
toWeekdaysLabel,
|
||||
weekdays,
|
||||
} from "$lib/utils/datetime";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { SvelteDate } from "svelte/reactivity";
|
||||
import { times } from "$lib/components/ui/slot-template/utils";
|
||||
import { RadioCards } from "$lib/components/ui/radio-cards";
|
||||
import { cn } from "$lib/utils";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { reasons, types } from "../utils";
|
||||
|
||||
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
|
||||
|
||||
@@ -45,6 +46,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["absences.edit.success"]());
|
||||
done();
|
||||
|
||||
+1
@@ -19,6 +19,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["account.change-passphrase.success"]());
|
||||
} else if (event.result.type === "failure") {
|
||||
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["account.general.success"]());
|
||||
} else if (event.result.type === "failure") {
|
||||
|
||||
+3
@@ -38,6 +38,9 @@
|
||||
},
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: () => {
|
||||
auth.refreshLastActive();
|
||||
},
|
||||
onChange: (event) => {
|
||||
if (event.paths.includes("email")) {
|
||||
setProperPasskeyState();
|
||||
|
||||
+2
@@ -5,6 +5,7 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
@@ -29,6 +30,7 @@
|
||||
),
|
||||
),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["account.passkeys.delete.success"]());
|
||||
done();
|
||||
|
||||
+2
@@ -2,6 +2,7 @@
|
||||
import { m } from "$i18n/messages.js";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { RedactedPasskeyHydrated } from "$lib/types/passkeys";
|
||||
import { untrack } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -20,6 +21,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["account.passkeys.edit.success"]());
|
||||
done();
|
||||
|
||||
+4
-2
@@ -5,13 +5,14 @@
|
||||
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
|
||||
import { LanguageTabs } from "$lib/components/ui/language-tabs";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import ItemIcon from "@lucide/svelte/icons/user-star";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
let { done }: { done: () => void } = $props();
|
||||
|
||||
@@ -29,6 +30,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["agents.add.success"]());
|
||||
done();
|
||||
|
||||
+2
@@ -11,6 +11,7 @@
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { z } from "zod";
|
||||
import { formSchema } from ".";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let { entity, done }: { entity: TAgent; done: () => void } = $props();
|
||||
|
||||
@@ -30,6 +31,7 @@
|
||||
),
|
||||
),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["agents.delete.success"]());
|
||||
done();
|
||||
|
||||
+4
-2
@@ -5,14 +5,15 @@
|
||||
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
|
||||
import { LanguageTabs } from "$lib/components/ui/language-tabs";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import type { TAgent } from "$lib/types/agent";
|
||||
import ItemIcon from "@lucide/svelte/icons/user-star";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
let { entity, done }: { entity: TAgent; done: () => void } = $props();
|
||||
|
||||
@@ -33,6 +34,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["agents.edit.success"]());
|
||||
done();
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
||||
import { type SupportedLocale } from "$lib/const/locales";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { type CurAppointmentItem } from "$lib/stores/calendar";
|
||||
import { channels as channelsStore } from "$lib/stores/channels";
|
||||
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
|
||||
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
||||
import { CalendarPlus, Move, Trash2 } from "@lucide/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { cancelAppointment, confirmAppointment, denyAppointment } from "./utils";
|
||||
|
||||
@@ -41,6 +43,7 @@
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const denyItem = async () => {
|
||||
auth.refreshLastActive();
|
||||
const proceed = confirm(
|
||||
`${m["calendar.notificationHint"]()} ${m["calendar.denyAppointment.confirm"]()}`,
|
||||
);
|
||||
@@ -64,6 +67,7 @@
|
||||
};
|
||||
|
||||
const confirmItem = async () => {
|
||||
auth.refreshLastActive();
|
||||
isConfirming = true;
|
||||
const success = await confirmAppointment({
|
||||
tenant: tenantId,
|
||||
@@ -80,6 +84,10 @@
|
||||
}
|
||||
isConfirming = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
auth.refreshLastActive();
|
||||
});
|
||||
</script>
|
||||
|
||||
<ResponsiveDialog
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import { staffCrypto } from "$lib/stores/staff-crypto";
|
||||
import { type TCalendarItem } from "$lib/types/calendar";
|
||||
import Loader2Icon from "@lucide/svelte/icons/loader-2";
|
||||
import { onMount } from "svelte";
|
||||
import { calendarStore } from "$lib/stores/calendar";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { m } from "$i18n/messages";
|
||||
import { cn } from "$lib/utils";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let {
|
||||
item,
|
||||
@@ -21,15 +21,23 @@
|
||||
let decrypted = $state<AppointmentData | undefined>();
|
||||
let error = $state<string | undefined>();
|
||||
|
||||
onMount(() => {
|
||||
decrypt();
|
||||
$effect(() => {
|
||||
if (!decrypted) {
|
||||
if ($staffCrypto.isAuthenticated) {
|
||||
decrypt();
|
||||
} else {
|
||||
error = "Keys missing";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const errors = {
|
||||
missingKeyShare: "Missing key share",
|
||||
};
|
||||
|
||||
const decrypt = async () => {
|
||||
const decrypt = async (retry = true) => {
|
||||
error = undefined;
|
||||
|
||||
if (!item.appointment) {
|
||||
console.error("Unable to decrypt appointment data - no appointment data in item.", item.id);
|
||||
error = "Missing data";
|
||||
@@ -52,7 +60,7 @@
|
||||
|
||||
// Wait for crypto to be initialized (max 5 seconds)
|
||||
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
|
||||
const maxWaitTime = 5000; // 5 seconds
|
||||
const maxWaitTime = 5000;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
|
||||
@@ -60,6 +68,13 @@
|
||||
}
|
||||
|
||||
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
|
||||
const user = $auth.user;
|
||||
if (retry && user && user.tenantId) {
|
||||
console.warn("Staff crypto not initialized, retrying decryption...");
|
||||
await staffCrypto.authenticateAndWait(user.id, user.tenantId);
|
||||
decrypt(false);
|
||||
return;
|
||||
}
|
||||
error = "Crypto not initialized";
|
||||
console.error("Staff crypto not initialized after waiting");
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import * as Sidebar from "$lib/components/ui/sidebar";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { channels as channelsStore } from "$lib/stores/channels";
|
||||
import { sidebar as sidebarStore } from "$lib/stores/sidebar";
|
||||
import type { TAppointmentFilter } from "$lib/types/calendar";
|
||||
@@ -52,6 +53,7 @@
|
||||
let agents = $derived($agentsStore.agents.filter((x) => !x.archived));
|
||||
|
||||
const zoom = (direction: number) => {
|
||||
auth.refreshLastActive();
|
||||
const currentIndex = CALENDAR_ZOOM_STEPS.indexOf(scale);
|
||||
if (currentIndex === -1) return;
|
||||
const nextIndex = currentIndex + direction;
|
||||
@@ -60,11 +62,13 @@
|
||||
};
|
||||
|
||||
const changeView = (newView: CalendarView) => {
|
||||
auth.refreshLastActive();
|
||||
view = newView;
|
||||
document.cookie = `calendarView=${newView}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict`;
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
auth.refreshLastActive();
|
||||
shownAppointments = "all";
|
||||
shownChannels = [];
|
||||
shownAgents = [];
|
||||
@@ -239,6 +243,7 @@
|
||||
{shownAgents}
|
||||
{shownChannels}
|
||||
onSelectDay={(v: DateValue | undefined) => {
|
||||
auth.refreshLastActive();
|
||||
sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded);
|
||||
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
|
||||
changeView("week");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
|
||||
import { cn } from "$lib/utils";
|
||||
import {
|
||||
@@ -37,6 +38,7 @@
|
||||
let isWeekView = $derived(view !== "day");
|
||||
|
||||
const prev = () => {
|
||||
auth.refreshLastActive();
|
||||
const nextDate = new CalendarDate(
|
||||
selectedDate.year,
|
||||
selectedDate.month,
|
||||
@@ -48,6 +50,7 @@
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
auth.refreshLastActive();
|
||||
const nextDate = new CalendarDate(selectedDate.year, selectedDate.month, selectedDate.day).add({
|
||||
days: isWeekView ? 7 : 1,
|
||||
});
|
||||
@@ -55,10 +58,12 @@
|
||||
};
|
||||
|
||||
const setToToday = () => {
|
||||
auth.refreshLastActive();
|
||||
selectedDate = today(getLocalTimeZone());
|
||||
};
|
||||
|
||||
const onSelectDay = (v: DateValue | undefined) => {
|
||||
auth.refreshLastActive();
|
||||
open = false;
|
||||
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
|
||||
view = "week";
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { channels as channelsStore } from "$lib/stores/channels";
|
||||
import type { TCalendarModeMove, TCalendarSlot } from "$lib/types/calendar";
|
||||
import { utcToLocalWithoutDST } from "$lib/utils/datetime";
|
||||
@@ -66,15 +67,15 @@
|
||||
});
|
||||
if (success) {
|
||||
toast.success(m["calendar.moveAppointment.success"]());
|
||||
isSubmitting = false;
|
||||
updateCalendar();
|
||||
} else {
|
||||
toast.error(m["calendar.moveAppointment.error"]());
|
||||
}
|
||||
isSubmitting = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
console.log("item.start", item.start);
|
||||
auth.refreshLastActive();
|
||||
if (mode.agentId && item.availableAgents) {
|
||||
const availableAgents = item.availableAgents.map((it) => it.id);
|
||||
if (availableAgents.includes(mode.agentId)) {
|
||||
|
||||
+3
@@ -16,6 +16,7 @@
|
||||
import SelectAgent from "./SelectAgent.svelte";
|
||||
import Summary from "./Summary.svelte";
|
||||
import type { TAddAppointment, TAddAppointmentStep } from "./types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let {
|
||||
tenantId,
|
||||
@@ -46,6 +47,7 @@
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
const proceed = (data: TAddAppointment) => {
|
||||
auth.refreshLastActive();
|
||||
switch (true) {
|
||||
case !data.agentId: {
|
||||
newAppointment = {
|
||||
@@ -77,6 +79,7 @@
|
||||
};
|
||||
|
||||
const addAppointment = async () => {
|
||||
auth.refreshLastActive();
|
||||
if (
|
||||
newAppointment.name &&
|
||||
newAppointment.agentId &&
|
||||
|
||||
+2
@@ -10,6 +10,7 @@
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { Headline, Text } from "$lib/components/ui/typography";
|
||||
import { agents as agentsStore } from "$lib/stores/agents";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { staff as staffStore } from "$lib/stores/staff";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -44,6 +45,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["channels.add.success"]());
|
||||
done();
|
||||
|
||||
+4
-2
@@ -5,13 +5,14 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { TChannelWithFullAgents } from "$lib/types/channel";
|
||||
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { z } from "zod";
|
||||
import { formSchema } from ".";
|
||||
import type { TChannelWithFullAgents } from "$lib/types/channel";
|
||||
import { getCurrentTranlslation } from "$lib/utils/localizations";
|
||||
|
||||
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
|
||||
|
||||
@@ -31,6 +32,7 @@
|
||||
),
|
||||
),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["channels.delete.success"]());
|
||||
done();
|
||||
|
||||
+2
@@ -19,6 +19,7 @@
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { DEFAULT_SLOT_TEMPLATE } from "../utils";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
|
||||
const agents = $derived($agentsStore.agents ?? []);
|
||||
@@ -47,6 +48,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["channels.edit.success"]());
|
||||
done();
|
||||
|
||||
+2
@@ -12,6 +12,7 @@
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { Checkbox } from "$lib/components/ui/checkbox";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
|
||||
|
||||
@@ -21,6 +22,7 @@
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(
|
||||
$formData.pause ? m["channels.pause.success"]() : m["channels.unpause.success"](),
|
||||
|
||||
+3
-1
@@ -9,13 +9,14 @@
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
import { supportedLocales, translatedLocales } from "$lib/const/locales";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import type { TTenantSettings } from "$lib/types/tenant";
|
||||
import DefaultOrgIcon from "@lucide/svelte/icons/landmark";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from "./schema";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
|
||||
let { entity }: { entity: TTenantSettings } = $props();
|
||||
|
||||
@@ -57,6 +58,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
tenants.reload();
|
||||
toast.success(m["tenants.edit.success"]());
|
||||
|
||||
+2
@@ -4,6 +4,7 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { supportedLocales, translatedLocales } from "$lib/const/locales";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import type { TStaff } from "$lib/types/users";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -29,6 +30,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["staff.add.success"]());
|
||||
done();
|
||||
|
||||
+3
-1
@@ -5,12 +5,13 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { TStaff } from "$lib/types/users";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { z } from "zod";
|
||||
import { formSchema } from ".";
|
||||
import type { TStaff } from "$lib/types/users";
|
||||
|
||||
let { entity, done }: { entity: TStaff; done: () => void } = $props();
|
||||
|
||||
@@ -30,6 +31,7 @@
|
||||
),
|
||||
),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["staff.delete.success"]());
|
||||
done();
|
||||
|
||||
+2
@@ -10,6 +10,7 @@
|
||||
import { formSchema } from ".";
|
||||
import RolePermissions from "../role-permissions.svelte";
|
||||
import { roles } from "../utils";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let { entity, done }: { entity: TStaff; done: () => void } = $props();
|
||||
|
||||
@@ -27,6 +28,7 @@
|
||||
dataType: "json",
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["staff.edit.success"]());
|
||||
done();
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
let tunnels: ClientTunnelResponse[] = $state([]);
|
||||
|
||||
const grantAccess = async () => {
|
||||
auth.refreshLastActive();
|
||||
if (!tenantId || !$staffCrypto.crypto) return;
|
||||
const cryptoClient = $staffCrypto.crypto;
|
||||
|
||||
|
||||
+2
@@ -5,6 +5,7 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { TStaff } from "$lib/types/users";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
@@ -19,6 +20,7 @@
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["staff.resendInvite.success"]());
|
||||
done();
|
||||
|
||||
+5
-3
@@ -1,16 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { m } from "$i18n/messages.js";
|
||||
import CheckboxWithLabel from "$lib/components/ui/checkbox-with-label/checkbox-with-label.svelte";
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { ERRORS } from "$lib/errors";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
|
||||
import { ERRORS } from "$lib/errors";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema } from ".";
|
||||
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
|
||||
import { page } from "$app/state";
|
||||
|
||||
let { done }: { done: () => void } = $props();
|
||||
|
||||
@@ -25,6 +26,7 @@
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["tenants.add.success"]());
|
||||
done();
|
||||
|
||||
+1
@@ -33,6 +33,7 @@
|
||||
),
|
||||
),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["tenants.delete.success"]());
|
||||
done();
|
||||
|
||||
+2
@@ -10,6 +10,7 @@
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
|
||||
import { page } from "$app/state";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
let { entity, done }: { entity: TTenant; done: () => void } = $props();
|
||||
|
||||
@@ -19,6 +20,7 @@
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
auth.refreshLastActive();
|
||||
if (event.result.type === "success") {
|
||||
toast.success(m["tenants.edit.success"]());
|
||||
done();
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
onResult: async (event) => {
|
||||
if (event.result.type === "success") {
|
||||
auth.setUser(event.result.data?.user);
|
||||
|
||||
// Wait for cookies to be set before navigating to dashboard
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
await goto(resolve(ROUTES.DASHBOARD.MAIN));
|
||||
} else {
|
||||
if ($formData.type === "passkey" && $formData.id === "") {
|
||||
|
||||
+1
-130
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { DELETE, PUT } from "../+server";
|
||||
import { PUT } from "../+server";
|
||||
import type { RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -242,133 +242,4 @@ describe("Appointment Detail API Routes", () => {
|
||||
expect(result.error).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]", () => {
|
||||
it("should delete appointment for tenant admin", async () => {
|
||||
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
|
||||
|
||||
const event = createMockRequestEvent();
|
||||
const response = await DELETE(event);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.message).toBe("Appointment deleted successfully");
|
||||
expect(mockAppointmentService.deleteAppointment).toHaveBeenCalledWith(mockAppointmentId);
|
||||
});
|
||||
|
||||
it("should allow global admin to delete any tenant's appointments", async () => {
|
||||
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
|
||||
|
||||
const event = createMockRequestEvent({
|
||||
locals: {
|
||||
user: {
|
||||
userId: "user123",
|
||||
role: "GLOBAL_ADMIN",
|
||||
tenantId: "different-tenant",
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const response = await DELETE(event);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.message).toBe("Appointment deleted successfully");
|
||||
});
|
||||
|
||||
it("should return 403 for staff users trying to delete appointments", async () => {
|
||||
vi.mocked(checkPermission).mockImplementationOnce(() => {
|
||||
throw new AuthorizationError("Insufficient permissions");
|
||||
});
|
||||
|
||||
const event = createMockRequestEvent({
|
||||
locals: {
|
||||
user: {
|
||||
userId: "user123",
|
||||
role: "STAFF",
|
||||
tenantId: mockTenantId,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const response = await DELETE(event);
|
||||
const result = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(result.error).toBe("Insufficient permissions");
|
||||
expect(mockAppointmentService.deleteAppointment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return 401 for unauthenticated requests", async () => {
|
||||
vi.mocked(checkPermission).mockImplementationOnce(() => {
|
||||
throw new AuthenticationError("Authentication required");
|
||||
});
|
||||
|
||||
const event = createMockRequestEvent({ locals: { user: null } as any });
|
||||
const response = await DELETE(event);
|
||||
const result = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(result.error).toBe("Authentication required");
|
||||
});
|
||||
|
||||
it("should handle missing tenant ID", async () => {
|
||||
const event = createMockRequestEvent({
|
||||
params: { id: undefined, appointmentId: mockAppointmentId },
|
||||
});
|
||||
|
||||
const response = await DELETE(event);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toBe("Tenant ID and appointment ID are required");
|
||||
});
|
||||
|
||||
it("should handle missing appointment ID", async () => {
|
||||
const event = createMockRequestEvent({
|
||||
params: { id: mockTenantId, appointmentId: undefined },
|
||||
});
|
||||
|
||||
const response = await DELETE(event);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toBe("Tenant ID and appointment ID are required");
|
||||
});
|
||||
|
||||
it("should handle appointment not found", async () => {
|
||||
mockAppointmentService.deleteAppointment.mockResolvedValue(false);
|
||||
|
||||
const event = createMockRequestEvent();
|
||||
const response = await DELETE(event);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.error).toBe("Appointment not found");
|
||||
});
|
||||
|
||||
it("should handle service errors", async () => {
|
||||
mockAppointmentService.deleteAppointment.mockRejectedValue(
|
||||
new NotFoundError("Appointment not found"),
|
||||
);
|
||||
|
||||
const event = createMockRequestEvent();
|
||||
const response = await DELETE(event);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.error).toBe("Appointment not found");
|
||||
});
|
||||
|
||||
it("should handle internal server errors", async () => {
|
||||
mockAppointmentService.deleteAppointment.mockRejectedValue(new Error("Database error"));
|
||||
|
||||
const event = createMockRequestEvent();
|
||||
const response = await DELETE(event);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.error).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user