Merge branch 'main' of github.com:open-reception/appointment-booking-software into 148-provide-apis-for-cancelling-appointments-staff-and-client-wise

This commit is contained in:
Hendrik Belitz
2026-01-22 11:03:24 +01:00
68 changed files with 883 additions and 800 deletions
+48 -3
View File
@@ -6,7 +6,24 @@ on:
types: [opened, synchronize, reopened]
jobs:
test-and-build:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
check:
runs-on: ubuntu-latest
steps:
@@ -24,9 +41,21 @@ jobs:
- name: Run type checking
run: npm run check
tests:
runs-on: ubuntu-latest
- name: Run linting
run: npm run lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit -- --run
@@ -41,6 +70,22 @@ jobs:
SMTP_FROM_EMAIL: "noreply@example.com"
JWT_SECRET: "test"
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
env:
+491 -641
View File
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -38,38 +38,38 @@
"db:clean": "npm run docker:dev:down && npm run db:drop && npm run docker:dev:up"
},
"devDependencies": {
"@eslint/compat": "^1.3.0",
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.3.2",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.544.0",
"@playwright/test": "^1.56.1",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.10",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.8",
"@eslint/compat": "^2.0.1",
"@eslint/js": "^9.39.2",
"@inlang/paraglide-js": "2.9.1",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.562.0",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.11.4",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.3",
"eslint-plugin-svelte": "^3.14.0",
"formsnap": "^2.0.1",
"globals": "^16.2.0",
"jsdom": "^26.1.0",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.13",
"svelte": "^5.34.7",
"svelte-check": "^4.2.2",
"svelte-sonner": "^1.0.5",
"sveltekit-superforms": "^2.28.0",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.4",
"svelte-check": "^4.3.5",
"svelte-sonner": "^1.0.7",
"sveltekit-superforms": "^2.29.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.10",
@@ -84,7 +84,7 @@
"@noble/hashes": "^1.8.0",
"@noble/post-quantum": "^0.4.1",
"@simplewebauthn/server": "^11.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-node": "^5.5.1",
"argon2": "^0.43.0",
"argon2-browser": "^1.18.0",
"cropperjs": "^2.0.1",
+19 -1
View File
@@ -1 +1,19 @@
cache
# IF GIT SHOWED THAT THIS FILE CHANGED
#
# 1. RUN THE FOLLOWING COMMAND
#
# ---
# git rm --cached '**/*.inlang/.gitignore'
# ---
#
# 2. COMMIT THE CHANGE
#
# ---
# git commit -m "fix: remove tracked .gitignore from inlang project"
# ---
#
# Inlang handles the gitignore itself starting with version ^2.5.
#
# everything is ignored except settings.json
*
!settings.json
+2 -1
View File
@@ -844,7 +844,8 @@
"description": "Nur Sie und {name} können diese Informationen sehen. Die Daten sind Ende-zu-Ende verschlüsselt."
},
"email": {
"description": "Wir senden Ihnen eine Bestätigungs-E-Mail mit den Termindetails."
"notifyMe": "Benachrichtigen Sie mich über Änderungen per E-Mail",
"tooltip": "Wenn aktiviert, wird Ihre Mail vorübergehend im Logbuch des Mailservers auftauchen. Wenn deaktiviert müssen Sie sich auf dieser Seite Einloggen, um Änderungen einzusehen."
},
"phone": {
"description": "Format: +491234567890. {name} kontaktiert Sie ggf. telefonisch, falls es Fragen/Änderungen zu Ihrem Termin gibt.",
+2 -1
View File
@@ -853,7 +853,8 @@
"description": "Only you and {name} will be able to access this information. It is end-to-end encrypted."
},
"email": {
"description": "We will use this to send you the appointment details."
"notifyMe": "Notify me of changes by e-mail",
"tooltip": "If enabled, your e-mail address will appear temporarily in server logs of the mailserver. If disabled, you will have to login on this page to check for updates."
},
"phone": {
"description": "Format: +1234567890. {name} may contact you by phone, if there are questions or changes regarding your appointment.",
+27
View File
@@ -3,6 +3,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handle } from "./hooks.server";
import { mockCookies } from "$lib/tests/const";
// Mock the sequence function to avoid the request store issue
vi.mock("@sveltejs/kit/hooks", () => ({
sequence: (...handlers: any[]) => {
// Return a simple sequential handler that doesn't require request store
return async ({ event, resolve }: any) => {
let currentResolve = resolve;
// Chain handlers in reverse order
for (let i = handlers.length - 1; i >= 0; i--) {
const handler = handlers[i];
const previousResolve = currentResolve;
currentResolve = async (event: any) => {
return handler({ event, resolve: previousResolve });
};
}
return currentResolve(event);
};
},
}));
// Mock the startup service
vi.mock("$lib/server/services/startup-service", () => ({
StartupService: {
@@ -72,6 +93,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
};
};
@@ -142,6 +165,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
@@ -189,6 +214,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
+3 -2
View File
@@ -71,6 +71,7 @@ interface EncryptedData {
export interface AppointmentData {
name: string;
email: string;
shareEmail: boolean;
phone?: string;
}
@@ -319,7 +320,7 @@ export class UnifiedAppointmentCrypto {
appointmentDate,
duration,
emailHash: this.emailHash,
clientEmail: appointmentData.email,
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
clientLanguage,
clientPublicKey: this.clientKeyPair?.publicKey,
privateKeyShare: await this.getPrivateKeyShare(),
@@ -336,7 +337,7 @@ export class UnifiedAppointmentCrypto {
channelId,
appointmentDate,
duration,
clientEmail: appointmentData.email,
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
clientLanguage,
encryptedAppointment,
};
@@ -66,6 +66,7 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
{#snippet child({ props })}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.url} {...props}>
<item.icon />
<Text style="md" class="ml-2">{item.title}</Text>
@@ -42,6 +42,7 @@
{#if $auth.user && item.roles.includes($auth.user?.role)}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
<!-- eslint-disable svelte/no-navigation-without-resolve -->
{#snippet child({ props })}
<a
href={item.url}
@@ -55,6 +56,7 @@
{/if}
</a>
{/snippet}
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
@@ -12,6 +12,7 @@
import { nameToAvatarFallback } from "$lib/utils/name";
import { LanguageSwitch } from "$lib/components/templates/language-switch";
import { m } from "$i18n/messages";
import { resolve } from "$app/paths";
const sidebar = useSidebar();
</script>
@@ -61,13 +62,13 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<LanguageSwitch class="w-full" triggerClass="w-[100%] [&>svg]:ml-auto" />
<DropdownMenu.Item onclick={() => goto(ROUTES.DASHBOARD.ACCOUNT.MAIN)}>
<DropdownMenu.Item onclick={() => goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}>
<AccountIcon />
{m["nav.account"]()}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto(ROUTES.LOGOUT)}>
<DropdownMenu.Item onclick={() => goto(resolve(ROUTES.LOGOUT))}>
<LogOutIcon />
{m["nav.logout"]()}
</DropdownMenu.Item>
@@ -14,6 +14,7 @@
import UnknownTenantIcon from "@lucide/svelte/icons/landmark";
import Loader from "@lucide/svelte/icons/loader-2";
import { cn } from "$lib/utils";
import { resolve } from "$app/paths";
const sidebar = useSidebar();
@@ -116,7 +117,10 @@
{/each}
{#if $tenants?.tenants.length > maxTenantsToShow}
<DropdownMenu.Separator />
<DropdownMenu.Item class="gap-2 p-2" onclick={() => goto(ROUTES.DASHBOARD.TENANTS)}>
<DropdownMenu.Item
class="gap-2 p-2"
onclick={() => goto(resolve(ROUTES.DASHBOARD.TENANTS))}
>
<div
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
>
@@ -33,6 +33,7 @@
size?: VariantProps<typeof variants>["size"];
} = $props();
// svelte-ignore state_referenced_locally
const isSmaller = size === "sm";
</script>
@@ -58,6 +58,7 @@
</script>
{#if href}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
bind:this={ref}
data-slot="button"
@@ -70,6 +71,7 @@
>
{@render children?.()}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
<button
bind:this={ref}
@@ -1,31 +1,50 @@
<script lang="ts">
import { Checkbox } from "../checkbox";
import * as Popover from "$lib/components/ui/popover/index.js";
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
import { Text } from "../typography";
import type { OnChangeFn } from "vaul-svelte";
import { Checkbox } from "../checkbox";
import { Text } from "../typography";
import { Info } from "@lucide/svelte/icons";
import { buttonVariants } from "../button";
let {
class: className,
value = $bindable(false),
label,
tooltip,
onCheckedChange,
...restProps
}: HTMLAttributes<HTMLButtonElement> & {
value: boolean;
label: string;
tooltip?: string;
id?: string;
onCheckedChange?: OnChangeFn<boolean>;
} = $props();
</script>
<label class={cn("flex items-start gap-2", className)}>
<Checkbox
{...restProps}
checked={value}
value={value ? "true" : "false"}
{onCheckedChange}
class="mt-0.5"
/>
<Text style="sm" class="font-normal select-none">{label}</Text>
<label class={cn("flex items-center gap-2", className)}>
<div class="flex items-start gap-2">
<Checkbox
{...restProps}
checked={value}
value={value ? "true" : "false"}
{onCheckedChange}
class="mt-0.5"
/>
<Text style="sm" class="font-normal select-none">{label}</Text>
</div>
{#if tooltip}
<Popover.Root>
<Popover.Trigger class={cn(buttonVariants({ variant: "ghost", size: "xs" }), "p-1!")}>
<Info size="sm" />
</Popover.Trigger>
<Popover.Content class="w-65 leading-1">
<Text style="xs">
{tooltip}
</Text>
</Popover.Content>
</Popover.Root>
{/if}
</label>
@@ -22,7 +22,7 @@
)}
{...restProps}
>
{cell.char}
{cell.char ? "*" : null}
{#if cell.hasFakeCaret}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000"></div>
@@ -7,7 +7,13 @@
let { value = $bindable(), ...restProps }: Props = $props();
</script>
<InputOTP.Root {...restProps} maxlength={6} bind:value>
<InputOTP.Root
{...restProps}
maxlength={6}
type="password"
pushPasswordManagerStrategy="none"
bind:value
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
+5 -3
View File
@@ -1,17 +1,19 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
import Portal from "./popover-portal.svelte";
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -1,6 +1,8 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
@@ -10,20 +12,20 @@
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
</PopoverPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
@@ -5,7 +5,7 @@
export function openDialog(id: string) {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
newState.set(id, true);
return newState;
});
@@ -13,7 +13,7 @@
export function closeDialog(id: string) {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
if (newState.has(id)) {
newState.set(id, false);
}
@@ -34,7 +34,7 @@
import * as Drawer from "$lib/components/ui/drawer";
import { onDestroy, type Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { MediaQuery } from "svelte/reactivity";
import { MediaQuery, SvelteMap } from "svelte/reactivity";
import { HorizontalPagePadding } from "../page";
import { ScrollArea } from "../scroll-area";
import { cn } from "$lib/utils";
@@ -80,7 +80,7 @@
onDestroy(() => {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
newState.delete(id);
return newState;
});
@@ -77,6 +77,7 @@
return segments;
}
// svelte-ignore state_referenced_locally
const segments = processTranslation(translation, interpolations);
</script>
+1 -1
View File
@@ -28,4 +28,4 @@ export const ROUTES = {
CHANGE_PASSPHRASE: "/dashboard/account/change-passphrase",
},
},
};
} as const;
+3 -1
View File
@@ -35,7 +35,9 @@
cancelUrl: string;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -35,7 +35,9 @@
cancelUrl: string;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -32,7 +32,9 @@
};
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -19,7 +19,9 @@
expirationMinutes: number;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -20,7 +20,9 @@
loginUrl: string;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
+3 -1
View File
@@ -22,7 +22,9 @@
expirationMinutes: number;
} = $props();
setLocale(locale);
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
@@ -3,6 +3,7 @@
</script>
<div class="button-container">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a class="button" {href}>{@render children?.()}</a>
</div>
+15 -13
View File
@@ -25,7 +25,7 @@ export interface ClientTunnelData {
appointmentDate: string;
duration: number;
emailHash: string;
clientEmail: string;
clientEmail?: string;
clientLanguage?: string;
clientPublicKey: string;
privateKeyShare: string;
@@ -543,19 +543,21 @@ export class AppointmentService {
});
// Send email notification to client (async, don't wait)
this.sendAppointmentNotification(
result.appointment.id,
clientData.channelId,
clientData.clientEmail,
clientData.clientLanguage || "de",
result.requiresConfirmation,
).catch((error) => {
log.error("Failed to send appointment notification", {
tunnelId: clientData.tunnelId,
appointmentId: result.appointment.id,
error: String(error),
if (clientData.clientEmail) {
this.sendAppointmentNotification(
result.appointment.id,
clientData.channelId,
clientData.clientEmail,
clientData.clientLanguage || "de",
result.requiresConfirmation,
).catch((error) => {
log.error("Failed to send appointment notification", {
tunnelId: clientData.tunnelId,
appointmentId: result.appointment.id,
error: String(error),
});
});
});
}
return response;
}
+4 -3
View File
@@ -9,6 +9,7 @@ import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { agents } from "./agents";
import { channels } from "./channels";
import { resolve } from "$app/paths";
const log = logger.setContext("TenantsStore");
@@ -54,7 +55,7 @@ const createTenantsStore = () => {
// Redirect to dashboard main if tenant changed to avaoid showing data from previous tenant
if (tenantId !== curTenant) {
goto(ROUTES.DASHBOARD.MAIN);
goto(resolve(ROUTES.DASHBOARD.MAIN));
}
},
reload: async () => {
@@ -81,7 +82,7 @@ const createTenantsStore = () => {
description: m["dashboard.onboarding.notification.ongoing.description"](),
action: {
label: m["dashboard.onboarding.notification.ongoing.action"](),
onClick: () => goto(ROUTES.DASHBOARD.MAIN),
onClick: () => goto(resolve(ROUTES.DASHBOARD.MAIN)),
},
});
} else {
@@ -92,7 +93,7 @@ const createTenantsStore = () => {
description: m["dashboard.onboarding.notification.done.description"](),
action: {
label: m["dashboard.onboarding.notification.done.action"](),
onClick: () => goto(ROUTES.MAIN),
onClick: () => goto(resolve(ROUTES.MAIN)),
},
});
}
+1
View File
@@ -40,6 +40,7 @@ export type TPublicAppointment = {
};
data?: {
name: string;
shareEmail: boolean;
email: string;
phone?: string;
};
+3 -2
View File
@@ -1,4 +1,5 @@
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
@@ -19,7 +20,7 @@ export const refreshSession = async () => {
if (!response.ok) {
if (response.status === 401) {
auth.setRefreshing(false);
goto(ROUTES.LOGOUT);
goto(resolve(ROUTES.LOGOUT));
return;
}
}
@@ -42,7 +43,7 @@ export const refreshUserData = async () => {
if (!response.ok) {
if (response.status === 401) {
goto(ROUTES.LOGOUT);
goto(resolve(ROUTES.LOGOUT));
return;
}
}
@@ -1,16 +1,17 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import * as Alert from "$lib/components/ui/alert";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { Text } from "$lib/components/ui/typography";
import { publicStore } from "$lib/stores/public";
import type { TPublicAppointment } from "$lib/types/public";
import { Lock } from "@lucide/svelte";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { Lock } from "@lucide/svelte";
import { publicStore } from "$lib/stores/public";
import { createFormSchema } from "./schema";
import { get } from "svelte/store";
const { proceed }: { proceed: (a: Partial<TPublicAppointment>) => void } = $props();
const tenant = $derived($publicStore.tenant);
@@ -19,6 +20,7 @@
const form = superForm(
{
name: "",
shareEmail: false,
email: "",
phone: get(publicStore).tenant?.requirePhone ? "" : undefined,
},
@@ -36,6 +38,7 @@
...appointment,
data: {
name: validation.data.name,
shareEmail: validation.data.shareEmail,
email: validation.data.email,
phone: validation.data.phone,
},
@@ -74,18 +77,33 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description class="mt-1">
{m["public.steps.data.email.description"]()}
</Form.Description>
</Form.Field>
<div>
<Form.Field {form} name="email">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="shareEmail">
<Form.Control>
{#snippet children({ props })}
<CheckboxWithLabel
{...props}
bind:value={$formData.shareEmail}
label={m["public.steps.data.email.notifyMe"]()}
tooltip={m["public.steps.data.email.tooltip"]()}
onCheckedChange={(v) => {
$formData.shareEmail = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<Form.Field {form} name="phone">
<Form.Control>
{#snippet children({ props })}
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button";
@@ -39,7 +40,7 @@
)
.then(() => {
const isRequest = channel.requiresConfirmation;
goto(ROUTES.APPOINTMENT_BOOKED, { state: { isRequest } });
goto(resolve(ROUTES.APPOINTMENT_BOOKED), { state: { isRequest } });
})
.catch(() => {
toast.error(m["public.steps.summary.error"]());
@@ -1,5 +1,6 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import { publicStore } from "$lib/stores/public";
import type { TPublicAppointment, TPublicSchedule } from "$lib/types/public";
@@ -74,7 +75,7 @@ export const proceed = (
newAppointment: updatedAppointment,
}));
}
goto(`${ROUTES.BOOK_APPOINTMENT}/${newAppointment.channel}`);
goto(resolve(`${ROUTES.BOOK_APPOINTMENT}/${newAppointment.channel}`));
return { ...newAppointment, step: "SELECT_AGENT" };
}
default:
@@ -83,7 +84,7 @@ export const proceed = (
};
export const resest = () => {
goto(ROUTES.BOOK_APPOINTMENT, { invalidateAll: true });
goto(resolve(ROUTES.BOOK_APPOINTMENT), { invalidateAll: true });
};
export const fetchSchedule = async (opts: {
@@ -9,6 +9,7 @@
import Check from "@lucide/svelte/icons/check";
import { ROUTES } from "$lib/const/routes";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
const { data } = $props();
</script>
@@ -65,7 +66,7 @@
size="lg"
class="w-full"
onclick={() =>
goto(ROUTES.SETUP_PASSKEY, {
goto(resolve(ROUTES.SETUP_PASSKEY), {
state: {
id: confirmation.id,
email: confirmation.email,
@@ -15,6 +15,7 @@
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
// svelte-ignore state_referenced_locally
const form = superForm(data.form, {
validators: zodClient(formSchema),
onResult: async (event) => {
@@ -23,6 +23,7 @@
import { formSchema, type FormSchema } from "./schema";
import { onMount } from "svelte";
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import { resolve } from "$app/paths";
let {
data,
@@ -37,6 +38,7 @@
let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state();
let registrationChallenge: string | undefined = $state();
// svelte-ignore state_referenced_locally
const form = superForm(data.form, {
validators: zodClient(formSchema),
onChange: (event) => {
@@ -48,7 +50,7 @@
if (event.result.type === "success") {
toast.success(m["setupPasskey.success"]());
await storeStaffKeyPair();
await goto(ROUTES.LOGIN);
await goto(resolve(ROUTES.LOGIN));
} else {
toast.error(m["setupPasskey.error"]());
}
+6 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { Button } from "$lib/components/ui/button";
@@ -72,7 +73,7 @@
{
label: m["dashboard.tenants.setup.sections.tenants.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.TENANTS, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.TENANTS), { state: { action: "add" } });
},
},
],
@@ -103,7 +104,7 @@
{
label: m["dashboard.onboarding.sections.settings.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.SETTINGS);
goto(resolve(ROUTES.DASHBOARD.SETTINGS));
},
},
],
@@ -116,7 +117,7 @@
{
label: m["dashboard.onboarding.sections.agents.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.AGENTS, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.AGENTS), { state: { action: "add" } });
},
},
],
@@ -130,7 +131,7 @@
{
label: m["dashboard.onboarding.sections.channels.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.CHANNELS, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.CHANNELS), { state: { action: "add" } });
},
},
],
@@ -144,7 +145,7 @@
{
label: m["dashboard.onboarding.sections.staff.action"](),
onClick: () => {
goto(ROUTES.DASHBOARD.STAFF, { state: { action: "add" } });
goto(resolve(ROUTES.DASHBOARD.STAFF), { state: { action: "add" } });
},
},
],
@@ -14,6 +14,7 @@
const agents = $derived($agentsStore.agents ?? []);
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, agent: entity.agentId },
{
@@ -18,6 +18,8 @@
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
const agents = $derived($agentsStore.agents ?? []);
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -43,8 +45,10 @@
);
let isSubmitting = $state(false);
let startDate = toInputDateTime(entity.startDate);
let endDate = toInputDateTime(entity.endDate);
// svelte-ignore state_referenced_locally
let startDate = $state(toInputDateTime(entity.startDate));
// svelte-ignore state_referenced_locally
let endDate = $state(toInputDateTime(entity.endDate));
let isAllDay = $state(
startDate.hour === 0 && startDate.minute === 0 && endDate.hour === 0 && endDate.minute === 0,
);
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
@@ -22,7 +23,7 @@
<Item.Group class="gap-2">
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.GENERAL} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.GENERAL)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.general.title"]()}</Text>
@@ -39,7 +40,7 @@
</Item.Root>
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.CHANGE_EMAIL} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_EMAIL)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.change-email.title"]()}</Text>
@@ -56,7 +57,7 @@
</Item.Root>
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.PASSKEYS} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.PASSKEYS)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.passkeys.title"]()}</Text>
@@ -74,7 +75,7 @@
{#if $auth.user?.role === "GLOBAL_ADMIN"}
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={ROUTES.DASHBOARD.ACCOUNT.CHANGE_PASSPHRASE} {...props}>
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_PASSPHRASE)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.change-passphrase.title"]()}</Text>
@@ -14,6 +14,7 @@
let { entity, done }: { entity: TAgent; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, name: "" },
{
@@ -17,8 +17,10 @@
let { entity, done }: { entity: TAgent; done: () => void } = $props();
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
// svelte-ignore state_referenced_locally
const form = superForm(
{
$state.snapshot({
id: entity.id,
name: entity.name,
descriptions: tenantLocales.reduce(
@@ -26,7 +28,7 @@
{} as { [key: string]: string },
),
image: entity.image ?? "",
},
}),
{
dataType: "json",
validators: zodClient(formSchema),
@@ -1,5 +1,6 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
import { toCalendarDateTime, toZoned, type CalendarDate } from "@internationalized/date";
@@ -32,7 +33,7 @@ export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarD
}
} else {
if (res.status === 401) {
goto(ROUTES.LOGIN);
goto(resolve(ROUTES.LOGIN));
} else {
console.error("Unable to fetch calendar", res.status, res.statusText);
}
@@ -15,6 +15,7 @@
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, name: "" },
{
@@ -23,6 +23,8 @@
const agents = $derived($agentsStore.agents ?? []);
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -14,6 +14,7 @@
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, pause: !entity.pause },
{
@@ -30,6 +30,8 @@
key: it,
label: translatedLocales[it as keyof typeof translatedLocales],
}));
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -14,6 +14,7 @@
let { entity, done }: { entity: TStaff; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, email: "", confirmationState: entity.confirmationState },
{
@@ -14,6 +14,8 @@
let { entity, done }: { entity: TStaff; done: () => void } = $props();
const availableRoles = $derived(roles.filter((it) => it.value !== "GLOBAL_ADMIN"));
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
@@ -12,7 +12,8 @@
let { data, done }: { done: () => void; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
// svelte-ignore state_referenced_locally
const form = superForm($state.snapshot(data.form), {
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
@@ -17,6 +17,7 @@
let { entity, done }: { entity: TTenant; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, shortname: "" },
{
@@ -10,6 +10,7 @@
let { entity, done }: { entity: TTenant; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, shortName: entity.shortName },
{
+10 -9
View File
@@ -5,20 +5,21 @@
import * as Form from "$lib/components/ui/form";
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Passkey } from "$lib/components/ui/passkey";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import logger from "$lib/logger";
import { auth } from "$lib/stores/auth";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import { writable, type Writable } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { baseSchema, formSchema } from "./schema";
import { onMount } from "svelte";
import { Passkey } from "$lib/components/ui/passkey";
import { Text } from "$lib/components/ui/typography";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { Label } from "$lib/components/ui/label";
import { auth } from "$lib/stores/auth";
import logger from "$lib/logger";
import { resolve } from "$app/paths";
let { formId, onEvent }: { formId: string; onEvent: EventReporter } = $props();
@@ -42,7 +43,7 @@
onResult: async (event) => {
if (event.result.type === "success") {
auth.setUser(event.result.data?.user);
await goto(ROUTES.DASHBOARD.MAIN);
await goto(resolve(ROUTES.DASHBOARD.MAIN));
} else {
toast.error(m["login.error"]());
}
@@ -9,6 +9,7 @@ export const load = async (event) => {
// Check if global admin exists
const adminExists = await UserService.adminExists();
// @ts-expect-error any string should be tested against blocklist
if (blocklist.includes(cleanedId) && adminExists) {
redirect(302, ROUTES.LOGIN);
}
@@ -14,13 +14,16 @@
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { Input } from "$lib/components/ui/input";
import { resolve } from "$app/paths";
let { data }: { data: { form: SuperValidated<Infer<FormSchema>> } } = $props();
const formId = "resend-confirmation-email";
let email = $state("");
let isSubmitting = $state(false);
const form = superForm(data.form, {
// svelte-ignore state_referenced_locally
const form = superForm($state.snapshot(data.form), {
resetForm: false,
validators: zodClient(formSchema),
onResult: (event) => {
@@ -41,7 +44,7 @@
// This page is only of help, if you have just created an admin account
if (!email) {
await goto(ROUTES.SETUP.CREATE_ADMIN_ACCOUNT);
await goto(resolve(ROUTES.SETUP.CREATE_ADMIN_ACCOUNT));
}
// Set email from previous step
@@ -20,6 +20,7 @@
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { arrayBufferToBase64, fetchChallenge, generatePasskey } from "$lib/utils/passkey";
import logger from "$lib/logger";
import { resolve } from "$app/paths";
let {
data,
@@ -28,7 +29,8 @@
}: { formId: string; onEvent: EventReporter; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
const form = superForm(data.form, {
// svelte-ignore state_referenced_locally
const form = superForm($state.snapshot(data.form), {
validators: zodClient(formSchema),
onChange: (event) => {
if (event.paths.includes("email")) {
@@ -38,7 +40,7 @@
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["setup.create_admin_account.success"]());
await goto(ROUTES.SETUP.CHECK_EMAIL, {
await goto(resolve(ROUTES.SETUP.CHECK_EMAIL), {
state: { email: event.result.data?.form.data.email },
});
}
+5 -2
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { browser } from "$app/environment";
import { setLocale } from "$i18n/runtime";
import { Toaster } from "$lib/components/ui/sonner/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { ModeWatcher, setMode } from "mode-watcher";
import "../app.css";
import { setLocale } from "$i18n/runtime";
let { data, children } = $props();
@@ -27,4 +28,6 @@
<ModeWatcher track={false} />
<Toaster richColors />
{@render children()}
<Tooltip.Provider delayDuration={0}>
{@render children()}
</Tooltip.Provider>
@@ -146,6 +146,8 @@ describe("POST /api/admin/tenant", () => {
isSubRequest: false,
fetch: fetch,
setHeaders: vi.fn(),
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
@@ -8,7 +8,7 @@ import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
const requestSchema = z.object({
clientEmail: z.string().email().optional(),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
});
@@ -27,7 +27,7 @@ const requestSchema = z.object({
agentId: z.string(),
appointmentDate: z.string(),
duration: z.number().int().positive(),
clientEmail: z.string().email(),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
encryptedAppointment: z.object({
encryptedPayload: z.string(),
@@ -121,7 +121,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/add-to-tunnel", "POST", {
"tunnelId",
"channelId",
"appointmentDate",
"clientEmail",
"encryptedAppointment",
],
},
@@ -304,12 +303,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
if (!tenant) {
logger.warn("Cannot send email: Tenant not found", { tenantId });
} else {
// Create client data object from request
const clientData = {
email: validatedData.clientEmail,
language: validatedData.clientLanguage,
};
// Get channel title for the email
const channelTitle = await getChannelTitle(
tenantId,
@@ -318,20 +311,28 @@ export const POST: RequestHandler = async ({ request, params }) => {
);
// Send appropriate email based on whether confirmation is required
if (requiresConfirmation) {
await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment request email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
} else {
await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment confirmation email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
if (validatedData.clientEmail) {
// Create client data object from request
const clientData = {
email: validatedData.clientEmail,
language: validatedData.clientLanguage,
};
if (requiresConfirmation) {
await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment request email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
} else {
await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment confirmation email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
}
}
}
} catch (emailError) {
@@ -12,7 +12,7 @@ const requestSchema = z.object({
appointmentDate: z.string(),
duration: z.number().int().positive(),
emailHash: z.string(),
clientEmail: z.string().email(),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
clientPublicKey: z.string(),
privateKeyShare: z.string(),
@@ -74,7 +74,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
clientEmail: {
type: "string",
format: "email",
description: "Client email address for sending confirmation",
description: "Client email address for sending confirmation. Optional.",
example: "client@example.com",
},
clientLanguage: {
@@ -138,7 +138,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
"channelId",
"appointmentDate",
"emailHash",
"clientEmail",
"clientPublicKey",
"privateKeyShare",
"encryptedAppointment",
+1
View File
@@ -2,6 +2,7 @@ import type { RequestEvent } from "@sveltejs/kit";
const ACCESS_TOKEN_COOKIE_NAME = "access_token";
export const getAccessToken = (
// @ts-expect-error exact route names are not relevant here
event: RequestEvent<Partial<Record<string, string>>, string | null>,
): string | null => {
let accessToken: string | null = null;