diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 87fa40e..74bf10b 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -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: diff --git a/.gitignore b/.gitignore index 9fb1414..bbad229 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* CLAUDE.md -.claude/ \ No newline at end of file +.claude/ + +security-audit* \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e85a7d..a60a1e8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,7 +9,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${POSTGRES_PORT:-5433}:5432" volumes: - postgres_data_dev:/var/lib/postgresql/data - postgres_run_dev:/var/run/postgresql diff --git a/docs/email-system.md b/docs/email-system.md index f95ee08..4b6dc53 100644 --- a/docs/email-system.md +++ b/docs/email-system.md @@ -1,5 +1,7 @@ # Email System Documentation +> This doc is partially outdated. New templates can be viewed locally. See routes dir. Example: http://localhost:5173/local-only/e-mail-templates/user-invite Text versions are generated automatically and will be printed to console, when local page is opened. + The Open Reception appointment booking platform includes a comprehensive email system for automated communications with clients and staff members. This document provides technical details and administration guidance for managing the email system. ## Overview diff --git a/docs/staff-crypto-worker.md b/docs/staff-crypto-worker.md index b32c8b9..db7da8f 100644 --- a/docs/staff-crypto-worker.md +++ b/docs/staff-crypto-worker.md @@ -21,43 +21,66 @@ Browser (Staff App) ↔ StaffCryptoService API ↔ Tenant Database - **Browser-Based Cryptography**: All key generation and crypto operations in browser - **Split-Key Architecture**: Private keys split using XOR between database and passkey shards -- **WebAuthn Integration**: Hardware-backed deterministic key derivation from passkey data -- **Zero-Knowledge Server**: Server never sees complete private keys +- **WebAuthn PRF Extension**: Hardware-backed deterministic secret derivation using PRF (Pseudo-Random Function) +- **Zero-Knowledge Server**: Server never sees complete private keys or PRF outputs - **Per-Passkey Keys**: Each staff passkey has its own unique keypair - **ML-KEM-768 Encryption**: Post-quantum cryptography using Kyber +- **Modern Authenticator Required**: Requires CTAP 2.1+ authenticators with PRF support ## Browser Integration Workflow ### 1. Staff Passkey Registration & Key Generation -When a staff member registers a new passkey, the browser automatically generates crypto keys: +When a staff member registers a new passkey, the browser automatically generates crypto keys using the WebAuthn PRF extension: ```javascript import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; +import { generatePasskey, getPRFOutputAfterRegistration } from "$lib/utils/passkey"; -async function registerStaffPasskey(userId, tenantId) { - // 1. Complete WebAuthn passkey registration - const credential = await navigator.credentials.create({ - publicKey: registrationOptions, +async function registerStaffPasskey(userId, tenantId, email) { + // 1. Complete WebAuthn passkey registration WITH PRF extension + const credential = await generatePasskey({ + id: rpId, + challenge: challengeBase64, + email: email, + enablePRF: true, // CRITICAL: Enable PRF for zero-knowledge key derivation }); + // Verify PRF extension is enabled + const extensionResults = credential.getClientExtensionResults(); + if (!extensionResults.prf?.enabled) { + throw new Error( + "PRF extension not supported. Please use a modern authenticator " + + "(YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)", + ); + } + // 2. Generate Kyber keypair in browser const keyPair = KyberCrypto.generateKeyPair(); - // 3. Derive deterministic shard from WebAuthn authenticatorData + // 3. IMMEDIATELY retrieve PRF output after passkey creation + // This is the ONLY time we can get the PRF output for this passkey + const prfOutput = await getPRFOutputAfterRegistration({ + passkeyId: credential.id, + rpId: rpId, + challengeBase64: freshChallengeBase64, // Fresh challenge from server + email: email, + }); + + // 4. Derive deterministic shard from PRF output (SECRET, zero-knowledge!) const crypto = new UnifiedAppointmentCrypto(); - const passkeyBasedShard = await crypto.derivePasskeyBasedShard( - credential.id, - credential.response.authenticatorData, + const passkeyBasedShard = await crypto.derivePasskeyBasedShardWithPRF( + prfOutput, // 32-byte secret from PRF extension + userId, ); - // 4. Create database shard using XOR + // 5. Create database shard using XOR const dbShard = new Uint8Array(keyPair.privateKey.length); for (let i = 0; i < keyPair.privateKey.length; i++) { dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i]; } - // 5. Store via StaffCryptoService API + // 6. Store via StaffCryptoService API await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -68,50 +91,101 @@ async function registerStaffPasskey(userId, tenantId) { }), }); - console.log("Staff crypto keys generated and stored"); + console.log("Staff crypto keys generated and stored (PRF-based)"); } ``` +**SECURITY NOTE**: The PRF output is a **secret** 32-byte value that only the passkey owner can derive. Unlike the old `authenticatorData` approach (which used public `RP_ID_HASH`), PRF provides true zero-knowledge security - an attacker with database access **cannot** reconstruct the private key. + ### 2. Staff Authentication & Key Reconstruction -When a staff member authenticates, their private key is reconstructed from shards: +When a staff member authenticates, their private key is reconstructed from shards using the WebAuthn PRF extension: ```javascript async function authenticateStaff(userId, tenantId) { - // 1. Perform WebAuthn authentication - const assertion = await navigator.credentials.get({ - publicKey: authenticationOptions, + const crypto = new UnifiedAppointmentCrypto(); + + // This internally performs the following steps: + // 1. Perform WebAuthn authentication WITH PRF extension + // 2. Get database shard from API + // 3. Derive same passkey-based shard from PRF output + // 4. Reconstruct private key using XOR + await crypto.authenticateStaff(userId, tenantId); + + console.log("Staff authenticated and keys reconstructed (PRF-based)"); + return crypto; +} +``` + +**Internal PRF Authentication Flow** (handled by `UnifiedAppointmentCrypto.authenticateStaff()`): + +```javascript +async authenticateStaff(staffId, tenantId) { + // 1. Get passkey list from session storage + const availablePasskeys = this.getAvailablePasskeys(staffId); + const passkeyId = availablePasskeys[0]; // User can select which passkey to use + + // 2. Perform WebAuthn authentication with PRF extension + const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`); + + const credential = await navigator.credentials.get({ + publicKey: { + challenge: challengeBuffer, + rpId: rpId, + allowCredentials: [{ id: passkeyIdBuffer, type: "public-key" }], + userVerification: "required", + extensions: { + prf: { + eval: { first: prfSalt }, // Request PRF output + }, + }, + }, }); - // 2. Get database shard from API - const response = await fetch(`/api/tenants/${tenantId}/staff/${userId}/key-shard`); - const { publicKey, privateKeyShare, passkeyId } = await response.json(); + // 3. Extract PRF output from WebAuthn response + const extensionResults = credential.getClientExtensionResults(); + const prfOutput = extensionResults.prf?.results?.first; - // 3. Derive same passkey-based shard - const crypto = new UnifiedAppointmentCrypto(); - const passkeyBasedShard = await crypto.derivePasskeyBasedShard( - passkeyId, - assertion.response.authenticatorData, + if (!prfOutput) { + throw new Error("PRF extension not supported by this passkey"); + } + + // 4. Get database shard from API + const response = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passkeyId }), + }); + const { publicKey, privateKeyShare } = await response.json(); + + // 5. Derive passkey-based shard from PRF output (same as during registration) + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF( + prfOutput, // Secret 32-byte PRF output + staffId, ); - // 4. Reconstruct private key using XOR + // 6. Reconstruct private key using XOR const dbShard = base64ToBuffer(privateKeyShare); const privateKey = new Uint8Array(dbShard.length); for (let i = 0; i < dbShard.length; i++) { privateKey[i] = dbShard[i] ^ passkeyBasedShard[i]; } - // 5. Store reconstructed keypair for session - crypto.staffKeyPair = { + // 7. Store reconstructed keypair for session + this.staffKeyPair = { publicKey: base64ToBuffer(publicKey), privateKey: privateKey, }; - - console.log("Staff authenticated and keys reconstructed"); - return crypto; } ``` +**SECURITY NOTE**: The PRF output is derived fresh during each authentication using the same deterministic salt (`open-reception-prf:${email}`). This guarantees: + +- **Determinism**: Same passkey + same salt = same PRF output = same private key +- **Zero-Knowledge**: PRF output never leaves the browser, server cannot derive it +- **Hardware-Backed**: PRF computation happens inside the authenticator's secure element +- **Multi-Passkey Support**: Using email as salt allows all passkeys for the same user to work interchangeably + ## StaffCryptoService API The backend API provides these endpoints for managing staff cryptographic keys: diff --git a/migrations/0007_milky_wasp.sql b/migrations/0007_milky_wasp.sql new file mode 100644 index 0000000..cbadf5a --- /dev/null +++ b/migrations/0007_milky_wasp.sql @@ -0,0 +1,6 @@ +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DEFAULT 'SETTINGS'::text;--> statement-breakpoint +DROP TYPE "public"."setup_state";--> statement-breakpoint +CREATE TYPE "public"."setup_state" AS ENUM('SETTINGS', 'AGENTS', 'CHANNELS', 'STAFF', 'READY');--> statement-breakpoint +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DEFAULT 'SETTINGS'::"public"."setup_state";--> statement-breakpoint +ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DATA TYPE "public"."setup_state" USING "setup_state"::"public"."setup_state"; \ No newline at end of file diff --git a/migrations/0008_complete_photon.sql b/migrations/0008_complete_photon.sql new file mode 100644 index 0000000..b02477a --- /dev/null +++ b/migrations/0008_complete_photon.sql @@ -0,0 +1 @@ +ALTER TABLE "user_session" ADD COLUMN "passkey_id" text; \ No newline at end of file diff --git a/migrations/0008_wild_whirlwind.sql b/migrations/0008_wild_whirlwind.sql new file mode 100644 index 0000000..0ca17d2 --- /dev/null +++ b/migrations/0008_wild_whirlwind.sql @@ -0,0 +1,6 @@ +CREATE TABLE "challenge_throttle" ( + "id" text PRIMARY KEY NOT NULL, + "failed_attempts" integer DEFAULT 0 NOT NULL, + "last_attempt_at" timestamp DEFAULT now() NOT NULL, + "reset_at" timestamp NOT NULL +); diff --git a/migrations/0009_bizarre_saracen.sql b/migrations/0009_bizarre_saracen.sql new file mode 100644 index 0000000..b02477a --- /dev/null +++ b/migrations/0009_bizarre_saracen.sql @@ -0,0 +1 @@ +ALTER TABLE "user_session" ADD COLUMN "passkey_id" text; \ No newline at end of file diff --git a/migrations/meta/0007_snapshot.json b/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..3552f4a --- /dev/null +++ b/migrations/meta/0007_snapshot.json @@ -0,0 +1,793 @@ +{ + "id": "c06d9c90-44ff-4e2e-a834-8533afcfe755", + "prevId": "c7e97210-1d91-4d46-942d-157da03e44e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "defaultLanguage": { + "name": "defaultLanguage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "logo": { + "name": "logo", + "type": "varchar(100000)", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_state": { + "name": "setup_state", + "type": "setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SETTINGS'" + }, + "links": { + "name": "links", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_database_url_idx": { + "name": "tenant_database_url_idx", + "columns": [ + { + "expression": "database_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_short_name_unique": { + "name": "tenant_short_name_unique", + "nullsNotDistinct": false, + "columns": ["short_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_config": { + "name": "tenant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "config_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_config_tenant_name_idx": { + "name": "tenant_config_tenant_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_config_tenant_id_tenant_id_fk": { + "name": "tenant_config_tenant_id_tenant_id_fk", + "tableFrom": "tenant_config", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STAFF'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "confirmation_state": { + "name": "confirmation_state", + "type": "confirmation_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'INVITED'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "passphrase_hash": { + "name": "passphrase_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recovery_passphrase": { + "name": "recovery_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tenant_id_tenant_id_fk": { + "name": "user_tenant_id_tenant_id_fk", + "tableFrom": "user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_invite": { + "name": "user_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_code": { + "name": "invite_code", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_user_id": { + "name": "created_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invite_code_idx": { + "name": "user_invite_code_idx", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_email_idx": { + "name": "user_invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_tenant_idx": { + "name": "user_invite_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invite_tenant_id_tenant_id_fk": { + "name": "user_invite_tenant_id_tenant_id_fk", + "tableFrom": "user_invite", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_invited_by_user_id_fk": { + "name": "user_invite_invited_by_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_created_user_id_user_id_fk": { + "name": "user_invite_created_user_id_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_invite_invite_code_unique": { + "name": "user_invite_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["invite_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkey": { + "name": "user_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_passkey_user_idx": { + "name": "user_passkey_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkey_user_id_user_id_fk": { + "name": "user_passkey_user_id_user_id_fk", + "tableFrom": "user_passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session": { + "name": "user_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "user_session_user_idx": { + "name": "user_session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_session_token_idx": { + "name": "user_session_token_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_session_user_id_user_id_fk": { + "name": "user_session_user_id_user_id_fk", + "tableFrom": "user_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_session_session_token_unique": { + "name": "user_session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.config_type": { + "name": "config_type", + "schema": "public", + "values": ["BOOLEAN", "NUMBER", "STRING"] + }, + "public.confirmation_state": { + "name": "confirmation_state", + "schema": "public", + "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] + }, + "public.setup_state": { + "name": "setup_state", + "schema": "public", + "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/0008_snapshot.json b/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..23db82f --- /dev/null +++ b/migrations/meta/0008_snapshot.json @@ -0,0 +1,832 @@ +{ + "id": "6f7d3653-5075-4baf-ad67-77afa7ae10ff", + "prevId": "c06d9c90-44ff-4e2e-a834-8533afcfe755", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.challenge_throttle": { + "name": "challenge_throttle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reset_at": { + "name": "reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "defaultLanguage": { + "name": "defaultLanguage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "logo": { + "name": "logo", + "type": "varchar(100000)", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_state": { + "name": "setup_state", + "type": "setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SETTINGS'" + }, + "links": { + "name": "links", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_database_url_idx": { + "name": "tenant_database_url_idx", + "columns": [ + { + "expression": "database_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_short_name_unique": { + "name": "tenant_short_name_unique", + "nullsNotDistinct": false, + "columns": ["short_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_config": { + "name": "tenant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "config_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_config_tenant_name_idx": { + "name": "tenant_config_tenant_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_config_tenant_id_tenant_id_fk": { + "name": "tenant_config_tenant_id_tenant_id_fk", + "tableFrom": "tenant_config", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STAFF'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "confirmation_state": { + "name": "confirmation_state", + "type": "confirmation_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'INVITED'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "passphrase_hash": { + "name": "passphrase_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recovery_passphrase": { + "name": "recovery_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tenant_id_tenant_id_fk": { + "name": "user_tenant_id_tenant_id_fk", + "tableFrom": "user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_invite": { + "name": "user_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_code": { + "name": "invite_code", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_user_id": { + "name": "created_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invite_code_idx": { + "name": "user_invite_code_idx", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_email_idx": { + "name": "user_invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_tenant_idx": { + "name": "user_invite_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invite_tenant_id_tenant_id_fk": { + "name": "user_invite_tenant_id_tenant_id_fk", + "tableFrom": "user_invite", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_invited_by_user_id_fk": { + "name": "user_invite_invited_by_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_created_user_id_user_id_fk": { + "name": "user_invite_created_user_id_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_invite_invite_code_unique": { + "name": "user_invite_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["invite_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkey": { + "name": "user_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_passkey_user_idx": { + "name": "user_passkey_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkey_user_id_user_id_fk": { + "name": "user_passkey_user_id_user_id_fk", + "tableFrom": "user_passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session": { + "name": "user_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "user_session_user_idx": { + "name": "user_session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_session_token_idx": { + "name": "user_session_token_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_session_user_id_user_id_fk": { + "name": "user_session_user_id_user_id_fk", + "tableFrom": "user_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_session_session_token_unique": { + "name": "user_session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.config_type": { + "name": "config_type", + "schema": "public", + "values": ["BOOLEAN", "NUMBER", "STRING"] + }, + "public.confirmation_state": { + "name": "confirmation_state", + "schema": "public", + "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] + }, + "public.setup_state": { + "name": "setup_state", + "schema": "public", + "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/0009_snapshot.json b/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..56ada36 --- /dev/null +++ b/migrations/meta/0009_snapshot.json @@ -0,0 +1,838 @@ +{ + "id": "945e895d-77bd-4acb-a4d3-20f652924edf", + "prevId": "6f7d3653-5075-4baf-ad67-77afa7ae10ff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.challenge_throttle": { + "name": "challenge_throttle", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reset_at": { + "name": "reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "defaultLanguage": { + "name": "defaultLanguage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "logo": { + "name": "logo", + "type": "varchar(100000)", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_state": { + "name": "setup_state", + "type": "setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SETTINGS'" + }, + "links": { + "name": "links", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_database_url_idx": { + "name": "tenant_database_url_idx", + "columns": [ + { + "expression": "database_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_short_name_unique": { + "name": "tenant_short_name_unique", + "nullsNotDistinct": false, + "columns": ["short_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_config": { + "name": "tenant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "config_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_config_tenant_name_idx": { + "name": "tenant_config_tenant_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_config_tenant_id_tenant_id_fk": { + "name": "tenant_config_tenant_id_tenant_id_fk", + "tableFrom": "tenant_config", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STAFF'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "confirmation_state": { + "name": "confirmation_state", + "type": "confirmation_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'INVITED'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "passphrase_hash": { + "name": "passphrase_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recovery_passphrase": { + "name": "recovery_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tenant_id_tenant_id_fk": { + "name": "user_tenant_id_tenant_id_fk", + "tableFrom": "user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_invite": { + "name": "user_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_code": { + "name": "invite_code", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_user_id": { + "name": "created_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invite_code_idx": { + "name": "user_invite_code_idx", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_email_idx": { + "name": "user_invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_tenant_idx": { + "name": "user_invite_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invite_tenant_id_tenant_id_fk": { + "name": "user_invite_tenant_id_tenant_id_fk", + "tableFrom": "user_invite", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_invited_by_user_id_fk": { + "name": "user_invite_invited_by_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_created_user_id_user_id_fk": { + "name": "user_invite_created_user_id_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_invite_invite_code_unique": { + "name": "user_invite_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["invite_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkey": { + "name": "user_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_passkey_user_idx": { + "name": "user_passkey_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkey_user_id_user_id_fk": { + "name": "user_passkey_user_id_user_id_fk", + "tableFrom": "user_passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session": { + "name": "user_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "user_session_user_idx": { + "name": "user_session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_session_token_idx": { + "name": "user_session_token_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_session_user_id_user_id_fk": { + "name": "user_session_user_id_user_id_fk", + "tableFrom": "user_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_session_session_token_unique": { + "name": "user_session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.config_type": { + "name": "config_type", + "schema": "public", + "values": ["BOOLEAN", "NUMBER", "STRING"] + }, + "public.confirmation_state": { + "name": "confirmation_state", + "schema": "public", + "values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"] + }, + "public.setup_state": { + "name": "setup_state", + "schema": "public", + "values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 6880a1e..1ee04c1 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -50,6 +50,27 @@ "when": 1760360156572, "tag": "0006_legal_dracula", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1761303767674, + "tag": "0007_milky_wasp", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1767362589999, + "tag": "0008_wild_whirlwind", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1768324950524, + "tag": "0009_bizarre_saracen", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index c287cc0..9695647 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@noble/hashes": "^1.8.0", "@noble/post-quantum": "^0.4.1", - "@sveltejs/adapter-node": "^5.2.12", + "@simplewebauthn/server": "^11.0.0", + "@sveltejs/adapter-node": "^5.5.1", "argon2": "^0.43.0", "argon2-browser": "^1.18.0", "cropperjs": "^2.0.1", @@ -21,46 +22,46 @@ "drizzle-orm": "^0.44.2", "jose": "^6.0.11", "mode-watcher": "^1.0.8", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.11", "postgres": "^3.4.7", "secrets.js-34r7h": "^2.0.2", "uuidv7": "^1.0.2", "winston": "^3.17.0", - "zod": "^3.25.76" + "zod": "^4.1.12" }, "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.27.1", + "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", @@ -68,7 +69,7 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.34.1", "vaul-svelte": "^1.0.0-next.7", - "vite": "^6.3.5", + "vite": "^6.4.1", "vitest": "^3.2.4" } }, @@ -79,34 +80,21 @@ "dev": true, "license": "MIT" }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@ark/schema": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.46.0.tgz", - "integrity": "sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==", + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.0.tgz", + "integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@ark/util": "0.46.0" + "@ark/util": "0.56.0" } }, "node_modules/@ark/util": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.46.0.tgz", - "integrity": "sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==", + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.0.tgz", + "integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==", "dev": true, "license": "MIT", "optional": true @@ -1301,16 +1289,19 @@ } }, "node_modules/@eslint/compat": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.0.tgz", - "integrity": "sha512-ZBygRBqpDYiIHsN+d1WyHn3TYgzgpzLEcgJUxTATyiInQbKZz6wZb6+ljwdg8xeeOe4v03z6Uh6lELiw0/mVhQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.1.tgz", + "integrity": "sha512-yl/JsgplclzuvGFNqwNYV4XNPhP3l62ZOP9w/47atNAdmDtIFCx6X7CSk/SlWUuBGkT4Et/5+UD+WyvX2iiIWA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.0.1" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "peerDependencies": { - "eslint": "^9.10.0" + "eslint": "^8.40 || 9" }, "peerDependenciesMeta": { "eslint": { @@ -1318,6 +1309,19 @@ } } }, + "node_modules/@eslint/compat/node_modules/@eslint/core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.0.1.tgz", + "integrity": "sha512-r18fEAj9uCk+VjzGt2thsbOmychS+4kxI14spVNibUO2vqKX7obOG+ymZljAwuPZl+S3clPGwCwTDtrdqTiY6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/config-array": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", @@ -1394,9 +1398,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -1479,39 +1483,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@gcornut/valibot-json-schema": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@gcornut/valibot-json-schema/-/valibot-json-schema-0.42.0.tgz", - "integrity": "sha512-4Et4AN6wmqeA0PfU5Clkv/IS27wiefsWf6TemAZrb75uzkClYEFavim7SboeKwbll9Nbsn2Iv0LT/HS5H7orZg==", - "dev": true, - "optional": true, - "dependencies": { - "valibot": "~0.42.0" - }, - "bin": { - "valibot-json-schema": "bin/index.js" - }, - "optionalDependencies": { - "@types/json-schema": ">= 7.0.14", - "esbuild-runner": ">= 2.2.2" - } - }, - "node_modules/@gcornut/valibot-json-schema/node_modules/valibot": { - "version": "0.42.1", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.42.1.tgz", - "integrity": "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peerDependencies": { - "typescript": ">=5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -1531,6 +1502,12 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1598,14 +1575,14 @@ } }, "node_modules/@inlang/paraglide-js": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.3.2.tgz", - "integrity": "sha512-mF7ku1AaXQxa6fnbBczXiEAM7lxhYzaAH7FnDVvAbpzNtgRJESi11KN0bzpCH0YhsKLdimFrQlzjaSVlo9Uh+Q==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.9.1.tgz", + "integrity": "sha512-hciFnOKGVA10BmLixmFFIOZwNFqlyhKwvC8V+mEh/XP1WaEUtwijBlcIn2odwN9obuGxI8Fu05KHOUuETrpj+g==", "dev": true, "license": "MIT", "dependencies": { - "@inlang/recommend-sherlock": "0.2.1", - "@inlang/sdk": "2.4.9", + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "2.6.2", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", @@ -1627,9 +1604,9 @@ } }, "node_modules/@inlang/sdk": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.9.tgz", - "integrity": "sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==", + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.6.2.tgz", + "integrity": "sha512-eOgAX+eQpHvD/H4BMILc4tZ85XviTlwr/51RKkKUHozVVthj5avUPKP+4N4vcTUrqSscl2atTh9NbNTuvoBN0A==", "dev": true, "license": "MIT", "dependencies": { @@ -1637,35 +1614,22 @@ "@sinclair/typebox": "^0.31.17", "kysely": "^0.27.4", "sqlite-wasm-kysely": "0.3.0", - "uuid": "^10.0.0" + "uuid": "^13.0.0" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@internationalized/date": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz", - "integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", + "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -1680,6 +1644,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1699,9 +1673,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1714,6 +1688,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@lix-js/sdk": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz", @@ -1733,6 +1713,20 @@ "node": ">=18" } }, + "node_modules/@lix-js/sdk/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@lix-js/server-protocol-schema": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", @@ -1741,9 +1735,9 @@ "license": "Apache-2.0" }, "node_modules/@lucide/svelte": { - "version": "0.544.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.544.0.tgz", - "integrity": "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==", + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.562.0.tgz", + "integrity": "sha512-wDMULwtTFN2Sc/TFBm6gfuVCNb4Y5P9LDrwxNnUbV52+IEU7NXZmvxwXoz+vrrpad6Xupq+Hw5eUlqIHEGhouw==", "dev": true, "license": "ISC", "peerDependencies": { @@ -1812,13 +1806,63 @@ "node": ">= 8" } }, - "node_modules/@petamoriken/float16": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", - "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==", + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", "license": "MIT", - "optional": true, - "peer": true + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", + "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", + "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", + "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } }, "node_modules/@phc/format": { "version": "1.0.0", @@ -1830,13 +1874,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.1" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -2259,6 +2303,33 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/@simplewebauthn/server": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-11.0.0.tgz", + "integrity": "sha512-zu8dxKcPiRUNSN2kmrnNOzNbRI8VaR/rL4ENCHUfC6PEE7SAAdIql9g5GBOd/wOVZolIsaZz3ccFxuGoVP0iaw==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^11.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-11.0.0.tgz", + "integrity": "sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.31.28", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", @@ -2280,9 +2351,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.5", @@ -2294,9 +2363,9 @@ } }, "node_modules/@sveltejs/adapter-auto": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-6.1.0.tgz", - "integrity": "sha512-shOuLI5D2s+0zTv2ab5M5PqfknXqWbKi+0UwB9yLTRIdzsK1R93JOO8jNhIYSHdW+IYXIYnLniu+JZqXs7h9Wg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz", + "integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2304,9 +2373,9 @@ } }, "node_modules/@sveltejs/adapter-node": { - "version": "5.2.13", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.13.tgz", - "integrity": "sha512-yS2TVFmIrxjGhYaV5/iIUrJ3mJl6zjaYn0lBD70vTLnYvJeqf3cjvLXeXCUCuYinhSBoyF4DpfGla49BnIy7sQ==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.1.tgz", + "integrity": "sha512-VpZdPNRPQuZRtgfAMETPWWKpZx9JwXmUUsgz/+eSpw/Oh7+2O1uZHlsQTuyfxydJHPrRzjfu/ItcJjY4oscCiQ==", "license": "MIT", "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", @@ -2319,16 +2388,17 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.25.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.25.2.tgz", - "integrity": "sha512-aKfj82vqEINedoH9Pw4Ip16jj3w8soNq9F3nJqc56kxXW74TcEu/gdTAuLUI+gsl8i+KXfetRqg1F+gG/AZRVQ==", + "version": "2.50.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.0.tgz", + "integrity": "sha512-Hj8sR8O27p2zshFEIJzsvfhLzxga/hWw6tRLnBjMYw70m1aS9BSYCqAUtzDBjRREtX1EvLMYgaC0mYE3Hz4KWA==", "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.1.0", + "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -2344,47 +2414,56 @@ "node": ">=18.13" }, "peerDependencies": { + "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.0.tgz", - "integrity": "sha512-wojIS/7GYnJDYIg1higWj2ROA6sSRWvcR1PO/bqEyFr/5UZah26c8Cz4u0NaqjPeVltzsVpt2Tm8d2io0V+4Tw==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { "svelte": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.3.0 || ^7.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", "license": "MIT", "dependencies": { - "debug": "^4.3.7" + "obug": "^2.1.0" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", - "vite": "^6.0.0" + "vite": "^6.3.0 || ^7.0.0" } }, "node_modules/@swc/helpers": { @@ -2398,54 +2477,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.10.tgz", - "integrity": "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.30.1", - "magic-string": "^0.30.17", + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.10" + "tailwindcss": "4.1.18" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.10.tgz", - "integrity": "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.10", - "@tailwindcss/oxide-darwin-arm64": "4.1.10", - "@tailwindcss/oxide-darwin-x64": "4.1.10", - "@tailwindcss/oxide-freebsd-x64": "4.1.10", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", - "@tailwindcss/oxide-linux-x64-musl": "4.1.10", - "@tailwindcss/oxide-wasm32-wasi": "4.1.10", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz", - "integrity": "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], @@ -2460,9 +2534,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz", - "integrity": "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], @@ -2477,9 +2551,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz", - "integrity": "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], @@ -2494,9 +2568,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz", - "integrity": "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], @@ -2511,9 +2585,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz", - "integrity": "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], @@ -2528,9 +2602,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz", - "integrity": "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ "arm64" ], @@ -2545,9 +2619,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz", - "integrity": "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], @@ -2562,9 +2636,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", - "integrity": "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ "x64" ], @@ -2579,9 +2653,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", - "integrity": "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ "x64" ], @@ -2596,9 +2670,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz", - "integrity": "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2614,81 +2688,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.10", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" }, "engines": { "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.4.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.4.3", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.10", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.0", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz", - "integrity": "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ "arm64" ], @@ -2703,9 +2717,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz", - "integrity": "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ "x64" ], @@ -2720,15 +2734,12 @@ } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", - "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", "dev": true, "license": "MIT", "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { @@ -2736,18 +2747,18 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.10.tgz", - "integrity": "sha512-QWnD5HDY2IADv+vYR82lOhqOlS1jSCUUAmfem52cXAhRTKxpDh3ARX8TTXJTCCO7Rv7cD2Nlekabv02bwP3a2A==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.10", - "@tailwindcss/oxide": "4.1.10", - "tailwindcss": "4.1.10" + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" }, "peerDependencies": { - "vite": "^5.2.0 || ^6" + "vite": "^5.2.0 || ^6 || ^7" } }, "node_modules/@testing-library/dom": { @@ -2805,18 +2816,17 @@ "license": "MIT" }, "node_modules/@testing-library/jest-dom": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", - "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", - "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", + "picocolors": "^1.1.1", "redent": "^3.0.0" }, "engines": { @@ -2826,13 +2836,14 @@ } }, "node_modules/@testing-library/svelte": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.8.tgz", - "integrity": "sha512-ucQOtGsJhtawOEtUmbR4rRh53e6RbM1KUluJIXRmh6D4UzxR847iIqqjRtg9mHNFmGQ8Vkam9yVcR5d1mhIHKA==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", "dev": true, "license": "MIT", "dependencies": { - "@testing-library/dom": "9.x.x || 10.x.x" + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" }, "engines": { "node": ">= 10" @@ -2851,6 +2862,19 @@ } } }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2937,9 +2961,9 @@ "license": "MIT" }, "node_modules/@types/validator": { - "version": "13.15.2", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.2.tgz", - "integrity": "sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==", + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "dev": true, "license": "MIT", "optional": true @@ -3236,6 +3260,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@valibot/to-json-schema": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@valibot/to-json-schema/-/to-json-schema-1.5.0.tgz", + "integrity": "sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peerDependencies": { + "valibot": "^1.2.0" + } + }, "node_modules/@vinejs/compiler": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@vinejs/compiler/-/compiler-3.0.0.tgz", @@ -3495,16 +3530,28 @@ "node": ">= 0.4" } }, - "node_modules/arktype": { - "version": "2.1.20", - "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.1.20.tgz", - "integrity": "sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==", + "node_modules/arkregex": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.5.tgz", + "integrity": "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@ark/schema": "0.46.0", - "@ark/util": "0.46.0" + "@ark/util": "0.56.0" + } + }, + "node_modules/arktype": { + "version": "2.1.29", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.1.29.tgz", + "integrity": "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@ark/schema": "0.56.0", + "@ark/util": "0.56.0", + "arkregex": "0.0.5" } }, "node_modules/array-timsort": { @@ -3514,6 +3561,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3547,17 +3608,17 @@ "license": "MIT" }, "node_modules/bits-ui": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.11.4.tgz", - "integrity": "sha512-OlVBJhNUMDHbIAf8oDAyPchIrU8b1S5NAMm6enMZSKx5HKcf/QPI485/BL1r4EPlv4O3m45e59hBRCETtYFdxg==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.4.tgz", + "integrity": "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og==", "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", - "runed": "^0.31.1", - "svelte-toolbelt": "^0.10.4", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "engines": { @@ -3572,9 +3633,9 @@ } }, "node_modules/bits-ui/node_modules/runed": { - "version": "0.31.1", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.31.1.tgz", - "integrity": "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", "dev": true, "funding": [ "https://github.com/sponsors/huntabyte", @@ -3582,23 +3643,31 @@ ], "license": "MIT", "dependencies": { - "esm-env": "^1.0.0" + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" }, "peerDependencies": { + "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } } }, "node_modules/bits-ui/node_modules/svelte-toolbelt": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.5.tgz", - "integrity": "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", "dev": true, "funding": [ "https://github.com/sponsors/huntabyte" ], "dependencies": { "clsx": "^2.1.1", - "runed": "^0.29.0", + "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "engines": { @@ -3609,23 +3678,6 @@ "svelte": "^5.30.2" } }, - "node_modules/bits-ui/node_modules/svelte-toolbelt/node_modules/runed": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz", - "integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/huntabyte", - "https://github.com/sponsors/tglide" - ], - "license": "MIT", - "dependencies": { - "esm-env": "^1.0.0" - }, - "peerDependencies": { - "svelte": "^5.7.0" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3708,20 +3760,6 @@ "node": ">=12" } }, - "node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -3748,27 +3786,17 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/class-validator": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@types/validator": "^13.11.8", + "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", - "validator": "^13.9.0" + "validator": "^13.15.20" } }, "node_modules/clsx": { @@ -3920,6 +3948,15 @@ "@cropper/utils": "^2.0.1" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4005,6 +4042,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4087,9 +4125,9 @@ } }, "node_modules/devalue": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz", - "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "license": "MIT" }, "node_modules/dlv": { @@ -4276,9 +4314,9 @@ } }, "node_modules/effect": { - "version": "3.17.7", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.17.7.tgz", - "integrity": "sha512-dpt0ONUn3zzAuul6k4nC/coTTw27AL5nhkORXgTi6NfMPzqWYa1M05oKmOMTxpVSTKepqXVcW9vIwkuaaqx9zA==", + "version": "3.19.14", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.19.14.tgz", + "integrity": "sha512-3vwdq0zlvQOxXzXNKRIPKTqZNMyGCdaFUBfMPqpsyzZDre67kgC1EEHDV4EoQTovJ4w5fmJW756f86kkuz7WFA==", "dev": true, "license": "MIT", "optional": true, @@ -4294,9 +4332,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4320,20 +4358,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -4394,32 +4418,6 @@ "esbuild": ">=0.12 <1" } }, - "node_modules/esbuild-runner": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", - "integrity": "sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==", - "dev": true, - "license": "Apache License 2.0", - "optional": true, - "dependencies": { - "source-map-support": "0.5.21", - "tslib": "2.4.0" - }, - "bin": { - "esr": "bin/esr.js" - }, - "peerDependencies": { - "esbuild": "*" - } - }, - "node_modules/esbuild-runner/node_modules/tslib": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", - "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4511,9 +4509,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.9.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.9.3.tgz", - "integrity": "sha512-PlcyK80sqAZ43IITeZkgl3zPFWJytx/Joup9iKGqIOsXM2m3pWfPbWuXPr5PN3loXFEypqTY/JyZwNqlSpSvRw==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.14.0.tgz", + "integrity": "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==", "dev": true, "license": "MIT", "dependencies": { @@ -4526,7 +4524,7 @@ "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", - "svelte-eslint-parser": "^1.2.0" + "svelte-eslint-parser": "^1.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4574,6 +4572,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4643,9 +4654,9 @@ } }, "node_modules/esrap": { - "version": "1.4.9", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz", - "integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -4943,56 +4954,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gel": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/gel/-/gel-2.1.0.tgz", - "integrity": "sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@petamoriken/float16": "^3.8.7", - "debug": "^4.3.4", - "env-paths": "^3.0.0", - "semver": "^7.6.2", - "shell-quote": "^1.8.1", - "which": "^4.0.0" - }, - "bin": { - "gel": "dist/cli.mjs" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/gel/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - } - }, - "node_modules/gel/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -5120,9 +5081,9 @@ } }, "node_modules/human-id": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.1.tgz", - "integrity": "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz", + "integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==", "dev": true, "license": "MIT", "bin": { @@ -5297,9 +5258,9 @@ "license": "ISC" }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", "bin": { @@ -5345,9 +5306,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5503,17 +5464,17 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.12.12", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.12.tgz", - "integrity": "sha512-aWVR6xXYYRvnK0v/uIwkf5Lthq9Jpn0N8TISW/oDTWlYB2sOimuiLn9Q26aUw4KxkJoiT8ACdiw44Y8VwKFIfQ==", + "version": "1.12.33", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz", + "integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==", "dev": true, "license": "MIT", "optional": true }, "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", "dependencies": { @@ -5527,22 +5488,43 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", "cpu": [ "arm64" ], @@ -5560,9 +5542,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", "cpu": [ "x64" ], @@ -5580,9 +5562,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", "cpu": [ "x64" ], @@ -5600,9 +5582,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", "cpu": [ "arm" ], @@ -5620,9 +5602,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", "cpu": [ "arm64" ], @@ -5640,9 +5622,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", "cpu": [ "arm64" ], @@ -5660,9 +5642,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", "cpu": [ "x64" ], @@ -5680,9 +5662,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", "cpu": [ "x64" ], @@ -5700,9 +5682,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", "cpu": [ "arm64" ], @@ -5720,9 +5702,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", "cpu": [ "x64" ], @@ -5771,27 +5753,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5841,12 +5802,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/memoize-weak": { @@ -5916,45 +5877,6 @@ "node": "*" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mode-watcher": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.0.8.tgz", @@ -6026,6 +5948,48 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -6038,9 +6002,9 @@ } }, "node_modules/nodemailer": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz", - "integrity": "sha512-jGOaRznodf62TVzdyhKt/f1Q/c3kYynk8629sgJHpRzGZj01ezbgMMWJSAjHADcwTKxco3B68/R+KHJY2T5BaA==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -6067,6 +6031,16 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/one-time": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", @@ -6214,13 +6188,13 @@ } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -6233,9 +6207,9 @@ } }, "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6421,9 +6395,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", - "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz", + "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6432,16 +6406,18 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.13", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.13.tgz", - "integrity": "sha512-uQ0asli1+ic8xrrSmIOaElDu0FacR4x69GynTh2oZjFY10JUt6EEumTQl5tB4fMeD6I1naKd+4rXQQ7esT2i1g==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.21.3" + "node": ">=20.19" }, "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", @@ -6449,20 +6425,24 @@ "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", - "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", - "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, "peerDependenciesMeta": { "@ianvs/prettier-plugin-sort-imports": { "optional": true }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, "@prettier/plugin-pug": { "optional": true }, @@ -6481,9 +6461,6 @@ "prettier-plugin-css-order": { "optional": true }, - "prettier-plugin-import-sort": { - "optional": true - }, "prettier-plugin-jsdoc": { "optional": true }, @@ -6502,9 +6479,6 @@ "prettier-plugin-sort-imports": { "optional": true }, - "prettier-plugin-style-order": { - "optional": true - }, "prettier-plugin-svelte": { "optional": true } @@ -6574,6 +6548,24 @@ "license": "MIT", "optional": true }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6867,7 +6859,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6905,20 +6897,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7115,12 +7093,12 @@ } }, "node_modules/svelte": { - "version": "5.34.7", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.34.7.tgz", - "integrity": "sha512-5PEg+QQKce4t1qiOtVUhUS3AQRTtxJyGBTpxLcNWnr0Ve8q4r06bMo0Gv8uhtCPWlztZHoi3Ye7elLhu+PCTMg==", + "version": "5.46.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.4.tgz", + "integrity": "sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==", "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", + "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", @@ -7128,8 +7106,9 @@ "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.6.2", "esm-env": "^1.2.1", - "esrap": "^1.4.8", + "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -7140,9 +7119,9 @@ } }, "node_modules/svelte-check": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.2.2.tgz", - "integrity": "sha512-1+31EOYZ7NKN0YDMKusav2hhEoA51GD9Ws6o//0SphMT0ve9mBTsTUEX7OmDMadUP3KjNHsSKtJrqdSaD8CrGQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7164,9 +7143,9 @@ } }, "node_modules/svelte-eslint-parser": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.2.0.tgz", - "integrity": "sha512-mbPtajIeuiyU80BEyGvwAktBeTX7KCr5/0l+uRGLq1dafwRNrjfM5kHGJScEBlPG3ipu6dJqfW/k0/fujvIEVw==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", + "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", "dev": true, "license": "MIT", "dependencies": { @@ -7178,7 +7157,8 @@ "postcss-selector-parser": "^7.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.24.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" @@ -7193,9 +7173,9 @@ } }, "node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -7207,9 +7187,9 @@ } }, "node_modules/svelte-sonner": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", - "integrity": "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.7.tgz", + "integrity": "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==", "dev": true, "license": "MIT", "dependencies": { @@ -7272,9 +7252,9 @@ } }, "node_modules/sveltekit-superforms": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.27.1.tgz", - "integrity": "sha512-cvq2AevkZ0Zrk0w0gNM3kjcnJMtJ0jzu+2zqDoM9a+lZa+8bGpNl4YqxVkemiJNkGnFgNC8xr5xF5BlMzjookQ==", + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/sveltekit-superforms/-/sveltekit-superforms-2.29.1.tgz", + "integrity": "sha512-9Cv1beOVPgm8rb8NZBqLdlZ9cBqRBTk0+6/oHn7DWvHQoAFie1EPjh1e4NHO3Qouv1Zq9QTGrZNDbYcetkuOVw==", "dev": true, "funding": [ { @@ -7292,30 +7272,29 @@ ], "license": "MIT", "dependencies": { - "devalue": "^5.1.1", + "devalue": "^5.6.1", "memoize-weak": "^1.0.2", "ts-deepmerge": "^7.0.3" }, "optionalDependencies": { "@exodus/schemasafe": "^1.3.0", - "@gcornut/valibot-json-schema": "^0.42.0", - "@sinclair/typebox": "^0.34.35", "@typeschema/class-validator": "^0.3.0", + "@valibot/to-json-schema": "^1.5.0", "@vinejs/vine": "^3.0.1", - "arktype": "^2.1.20", - "class-validator": "^0.14.2", - "effect": "^3.16.7", + "arktype": "^2.1.29", + "class-validator": "^0.14.3", + "effect": "^3.19.12", "joi": "^17.13.3", "json-schema-to-ts": "^3.1.1", "superstruct": "^2.0.2", - "valibot": "^1.1.0", - "yup": "^1.6.1", - "zod": "^3.25.64", - "zod-to-json-schema": "^3.24.5" + "typebox": "^1.0.62", + "valibot": "^1.2.0", + "yup": "^1.7.1", + "zod": "^4.1.13", + "zod-v3-to-json-schema": "^4.0.0" }, "peerDependencies": { "@exodus/schemasafe": "^1.3.0", - "@sinclair/typebox": "^0.34.28", "@sveltejs/kit": "1.x || 2.x", "@typeschema/class-validator": "^0.3.0", "@vinejs/vine": "^1.8.0 || ^2.0.0 || ^3.0.0", @@ -7325,17 +7304,15 @@ "joi": "^17.13.1", "superstruct": "^2.0.2", "svelte": "3.x || 4.x || >=5.0.0-next.51", - "valibot": "^1.0.0", + "typebox": "^1.0.36", + "valibot": "^1.2.0", "yup": "^1.4.0", - "zod": "^3.25.0" + "zod": "^3.25.0 || ^4.0.0" }, "peerDependenciesMeta": { "@exodus/schemasafe": { "optional": true }, - "@sinclair/typebox": { - "optional": true - }, "@typeschema/class-validator": { "optional": true }, @@ -7357,6 +7334,9 @@ "superstruct": { "optional": true }, + "typebox": { + "optional": true + }, "valibot": { "optional": true }, @@ -7368,14 +7348,6 @@ } } }, - "node_modules/sveltekit-superforms/node_modules/@sinclair/typebox": { - "version": "0.34.40", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.40.tgz", - "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -7422,38 +7394,24 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", - "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" - } - }, - "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" }, - "engines": { - "node": ">=18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/text-hex": { @@ -7650,7 +7608,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tw-animate-css": { @@ -7690,11 +7647,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typebox": { + "version": "1.0.78", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.0.78.tgz", + "integrity": "sha512-xOPmaDSBCMa6vCHFguteJuOcq73HY2aKexdVUi+zE82dCcCp3FMKaM/SfT4fcNNgskLmjauuU8wr0/1fWNN1Og==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -7773,9 +7738,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -7783,7 +7748,7 @@ ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/uuidv7": { @@ -7796,9 +7761,9 @@ } }, "node_modules/valibot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", - "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", "dev": true, "license": "MIT", "optional": true, @@ -7812,9 +7777,9 @@ } }, "node_modules/validator": { - "version": "13.15.15", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", - "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", "dev": true, "license": "MIT", "optional": true, @@ -7857,9 +7822,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -7968,16 +7933,17 @@ } }, "node_modules/vitefu": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", - "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", "license": "MIT", "workspaces": [ "tests/deps/*", - "tests/projects/*" + "tests/projects/*", + "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "peerDependenciesMeta": { "vite": { @@ -8243,30 +8209,6 @@ "dev": true, "license": "MIT" }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8281,9 +8223,9 @@ } }, "node_modules/yup": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", - "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "dev": true, "license": "MIT", "optional": true, @@ -8301,23 +8243,23 @@ "license": "MIT" }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "node_modules/zod-v3-to-json-schema": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/zod-v3-to-json-schema/-/zod-v3-to-json-schema-4.0.0.tgz", + "integrity": "sha512-KixLrhX/uPmRFnDgsZrzrk4x5SSJA+PmaE5adbfID9+3KPJcdxqRobaHU397EfWBqfQircrjKqvEqZ/mW5QH6w==", "dev": true, "license": "ISC", "optional": true, "peerDependencies": { - "zod": "^3.24.1" + "zod": "^3.25 || ^4.0.14" } } } diff --git a/package.json b/package.json index cc6f7d4..49e9cf2 100644 --- a/package.json +++ b/package.json @@ -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.27.1", + "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", @@ -77,13 +77,14 @@ "typescript": "^5.8.3", "typescript-eslint": "^8.34.1", "vaul-svelte": "^1.0.0-next.7", - "vite": "^6.3.5", + "vite": "^6.4.1", "vitest": "^3.2.4" }, "dependencies": { "@noble/hashes": "^1.8.0", "@noble/post-quantum": "^0.4.1", - "@sveltejs/adapter-node": "^5.2.12", + "@simplewebauthn/server": "^11.0.0", + "@sveltejs/adapter-node": "^5.5.1", "argon2": "^0.43.0", "argon2-browser": "^1.18.0", "cropperjs": "^2.0.1", @@ -93,12 +94,12 @@ "drizzle-orm": "^0.44.2", "jose": "^6.0.11", "mode-watcher": "^1.0.8", - "nodemailer": "^7.0.7", + "nodemailer": "^7.0.11", "postgres": "^3.4.7", "secrets.js-34r7h": "^2.0.2", "uuidv7": "^1.0.2", "winston": "^3.17.0", - "zod": "^3.25.76" + "zod": "^4.1.12" }, "license": "AGPL-3.0" } diff --git a/project.inlang/.gitignore b/project.inlang/.gitignore index 5e46596..04df330 100644 --- a/project.inlang/.gitignore +++ b/project.inlang/.gitignore @@ -1 +1,19 @@ -cache \ No newline at end of file +# 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 \ No newline at end of file diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index f2ccb6e..765a8ac 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "welcome": "Willkommen bei OpenReception", + "home": "Startseite", "poweredBy": "Betrieben mit", "i18n": { "label": "Sprache wählen", @@ -61,10 +62,19 @@ "url": "Ungültige URL", "languages": "Wähle mindestens eine Sprache", "language": "Wähle eine Sprache", - "deleteAfterDays": "Wähle einen Zeitpunkt" + "deleteAfterDays": "Wähle einen Zeitpunkt", + "phoneNoRequired": "Telefonnummer ist erforderlich", + "phoneNoInvalid": "Format der Telefonnummer ist ungültig", + "pinLength": "PIN muss 6 Ziffern lang sein", + "pinDigitsOnly": "PIN darf nur Ziffern enthalten", + "pinInsecure": "Diese PIN ist zu unsicher" }, "passkey": "Passkey", - "name": "Name" + "salutation": "Anrede", + "name": "Name", + "phone": "Telefonnummer", + "pin": "PIN", + "pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager." }, "login": { "or": "Oder", @@ -77,9 +87,9 @@ }, "confirm": { "success": { - "title": "Du bist bereit!", - "description": "Du hast Dein Konto erfolgreich gesichert", - "action": "Weiter" + "title": "E-Mail Adresse bestätigt!", + "description": "Jetzt musst Du nur noch einen Passkey hinzufügen.", + "action": "Passkey hinzufügen" }, "error": { "title": "Bestätigung fehlgeschlagen", @@ -94,6 +104,20 @@ "success": "E-Mail gesendet" } }, + "setupPasskey": { + "title": "Passkey einrichten", + "description": "Erstelle Deinen Passkey, um Dein Konto zu sichern.", + "action": "Passkey setzen", + "success": "Passkey eingerichtet. Logge Dich damit jetzt ein.", + "successKeyPairSaved": "Schlüsselpaar erfolgreich gespeichert.", + "confirmPrfRetrival": "Jetzt verwende den neu erstellten Passkey, um damit die Verschlüsselung zu aktivieren.", + "error": "Passkey konnte nicht gesetzt werden", + "errorKeyPairDataMissing": "Konnte Schlüsselpaar nicht generieren. Fehlende Daten.", + "errorKeyPairNotSaved": "Schlüsselpaar konnte nicht gespeichert werden.", + "errorPrfOutputNotTriggered": "Sie müssen der Verwendung des Passkeys zustimmen.", + "errorAuthenticatorNotSupported": "Dieser Authentifikator unterstützt nicht die erforderliche Sicherheitserweiterung (PRF). Bitte verwenden Sie einen modernen Authentifikator wie YubiKey 5.2.3+, Apple Touch ID oder Android.", + "errorGettingPrfOutput": "Fehler beim Abrufen der Sicherheitsdaten vom Passkey. Bitte verwenden Sie einen modernen Authentifikator, der die PRF-Erweiterung unterstützt." + }, "logout": { "title": "Erfolgreich abgemeldet", "description": "Du wurdest abgemeldet.", @@ -140,7 +164,7 @@ "title": "Mandant hinzufügen", "description": "Erstelle eine Seite für jemanden, der Termine sammeln möchte.", "name": { - "description": "Muss einmalig auf diesem Server sein. Wird als URL verwendet {domain}", + "description": "Muss einmalig auf diesem Server sein. Kann niemals geändert werden. Wird als URL verwendet {domain}", "errors": { "urlFormat": "Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt", "startEndDash": "Darf nicht mit einem Bindestrich beginnen oder enden" @@ -320,6 +344,7 @@ "select": "Auswählen", "close": "Schließen", "dashboard": { + "title": "Übersicht", "hello": "Hallo", "tenants": { "currentTenant": "Du verwaltest gerade den Mandanten {name}.", @@ -337,6 +362,44 @@ "sidebar": { "title": "Zum ersten Mal hier?", "description": "Tippe {name} um die Seitenleiste umzustellen" + }, + "onboarding": { + "title": "Mach' Dich bereit für Termine", + "description": "Für das beste Ergebnis folge dieser Anleitung Schritt für Schritt:", + "notification": { + "ongoing": { + "title": "Einrichtung noch nicht abgeschlossen", + "description": "Nächste Schritte anzeigen", + "action": "Weiter" + }, + "done": { + "title": "Einrichtung abgeschlossen!", + "description": "Sie können jetzt Termine entgegennehmen", + "action": "Startseite öffnen" + } + }, + "sections": { + "settings": { + "title": "Prüfe und speichere Einstellungen", + "description": "Stelle sicher, dass die Einstellungen Deiner Organisation korrekt sind, bevor Du Akteure oder Kanäle einrichtest.", + "action": "Gehe zu Einstellungen" + }, + "agents": { + "title": "Akteur:in hinzufügen", + "description": "Akteure sind die Personen, Teams oder Gruppen mit denen Deine Kunden einen Termin vereinbaren können.", + "action": "Add Agent" + }, + "channels": { + "title": "Ersten Kanal hinzufügen", + "description": "Kanäle sind Deine Möglichkeit, verschiedene Arten von Terminen anzubieten.", + "action": "Kanal hinzufügen" + }, + "staff": { + "title": "Personal hinzufügen & aktivieren", + "description": "Stelle sicher, dass Dein Team Zugriff auf die verschlüsselten Termine hat. Erst wenn sich die erste Person angemeldet hat, können Termine entgegengenommen werden.", + "action": "Person hinzufügen" + } + } } }, "agents": { @@ -637,7 +700,270 @@ "title": "Telefonnummer erforderlich", "label": "Kunden müssen eine Telefonnummer angeben, wenn sie einen Termin buchen." } + }, + "action": "Speichern" + } + }, + "account": { + "overview": { + "title": "Dein Konto" + }, + "general": { + "navItem": "Einstellungen", + "title": "Kontoeinstellungen", + "description": "Ändere Deinen Namen." + }, + "change-email": { + "navItem": "E-Mail Adresse", + "title": "E-Mail Adresse ändern", + "description": "Ändere Deine E-Mail Adresse." + }, + "passkeys": { + "navItem": "Passkeys", + "title": "Passkeys verwalten", + "description": "Du kannst bis zu drei Passkeys für Dein Konto hinzufügen." + }, + "change-passphrase": { + "navItem": "Passphrase", + "title": "Change Passphrase", + "description": "Globale Admins können einen Passphrase als Backup-Methode verwenden." + } + }, + "staff": { + "title": "Personal", + "roles": { + "GLOBAL_ADMIN": "Globaler Admin", + "TENANT_ADMIN": "Mandanten-Admin", + "STAFF": "Mitarbeiter:in" + }, + "permissions": { + "agents": "verwaltet Akteure", + "channels": "verwaltet Kanäle", + "absences": "verwaltet Abwesenheiten", + "settings": "verwaltet Einstellungen", + "staff": "verwaltet Personal", + "appointments": "verwaltet Termine" + }, + "form": { + "fields": { + "language": { + "title": "Sprache", + "description": "Wird in der Einlaungs-E-Mail verwendet.", + "placeholder": "Sprache auswählen" + }, + "role": { + "title": "Rolle", + "placeholder": "Rolle auswählen" + } } + }, + "add": { + "title": "Mitarbeiter:in hinzufügen", + "description": "Mitarbeiter:innen verwalten Termine und Einstellungen. Diese Konten sind nicht öffentlich.", + "action": "Mitarbeiter:in hinzufügen", + "success": "Mitarbeiter:in hinzugefügt", + "errors": { + "unknown": "Konnte Mitarbeiter:in nicht hinzufügen" + } + }, + "list": { + "needsAccess": "Kann Termine nicht sehen", + "empty": { + "title": "Bisher kein Personal", + "description": "Personal verwaltet Termine und Einstellungen. Diese Konten sind nicht öffentlich." + } + }, + "edit": { + "title": "Konto bearbeiten", + "description": "Aktualisiere das Konto dieser Mitarbeiter:in.", + "success": "Konto aktualisiert", + "error": "Konnte Konto nicht aktualisieren.", + "action": "Übernehmen" + }, + "delete": { + "title": "Konto entfernen", + "description": "Tippe {email} um dieses Konto zu entfernen. Dieser Schritt kann nicht rückgängig gemacht werden.", + "action": "Konto entfernen", + "success": "Konto entfernt", + "error": "Konnte Konto nicht entfernen." + }, + "access": { + "title": "Zugriff auf Klientendaten gewähren", + "description": "Willst Du {name} Zugriff auf alle Kundendaten geben?", + "action": "Zugriff gewähren", + "loading": "Zugriff wird gewährt ({success}/{total} Klienten)", + "success": "Zugriff gewährt", + "error": "Konnte Zugriff nicht für alle Klienten gewähren. Bitte erneut versuchen.", + "unavailable": { + "title": "Du kannst keinen Zugriff gewähren", + "description": "Nur Mitarbeiter:innen und Mandanten-Admins können Zugriff gewähren." + } + }, + "loading": "Lade Personal" + }, + "yes": "Ja", + "no": "Nein", + "public": { + "links": { + "website": "Webseite aufrufen", + "imprint": "Impressum", + "privacyStatement": "Datenschutz" + }, + "bookAppointment": "Termin buchen", + "appointment": { + "requiresConfirmation": "Erfordert Bestätigung durch {name}" + }, + "tenantNotReady": { + "title": "Diese Seite ist noch nicht bereit", + "description": "Falls dies Ihre Seite ist, loggen Sie sich ein und schließen Sie das Onboarding ab." + }, + "steps": { + "channel": { + "title": "Bitte wählen Sie eine Terminart aus:", + "empty": "Aktuell keine Termine verfügbar. Bitte versuchen Sie es später erneut." + }, + "agent": { + "title": "Mit wem möchten Sie Ihren Termin vereinbaren?", + "anyone": { + "title": "Keinen Wunsch äußern", + "description": "Wählen Sie diese Option, wenn Sie den frühsten Termin suchen." + } + }, + "slot": { + "title": "Bitte wählen Sie einen verfügbaren Termin aus:", + "today": "Heute", + "selectDate": "Wähle Sie ein Datum aus", + "selectTime": "Wähle Sie eine Uhrzeit aus:", + "loading": "Lade verfügbare Termine...", + "empty": "Keine Termine verfügbar. Bitte wählen Sie ein anderes Datum.", + "action": "Termin auswählen" + }, + "data": { + "title": "Bitte fügen Sie Ihre Informationen hinzu:", + "alert": { + "title": "Ihre Daten sind sicher", + "description": "Nur Sie und {name} können diese Informationen sehen. Die Daten sind Ende-zu-Ende verschlüsselt." + }, + "email": { + "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.", + "optional": "(optional)" + }, + "action": "Daten hinzufügen" + }, + "auth": { + "register": { + "title": "Ich bin das erste Mal hier", + "description": "Bitte legen Sie eine PIN fest:", + "hint": "Fortfahren erstellt ein Konto für Sie.", + "action": "Zussammenfassung anzeigen", + "success": "Konto erfolgreich erstellt!", + "error": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut.", + "alreadyExists": "Diese E-Mail ist bereits registriert. Bitte nutzen Sie die Login-Option, um weitere Termine zu buchen." + }, + "login": { + "title": "Anmelden", + "description": "Bitte geben Sie Ihre PIN ein:", + "action": "Zusammenfassung anzeigen", + "throttled": "Zu viele Anmeldeversuche. Bitte warten Sie einige Minuten, bevor Sie es erneut versuchen.", + "retry": "Versuchen Sie es erneut in {seconds} Sekunden" + } + }, + "summary": { + "title": "Bitte prüfen Sie Ihren Termin, bevor Sie buchen", + "book": { + "action": "Termin buchen" + }, + "request": { + "action": "Termin anfragen", + "hint": "Dieser Termin muss manuell bestätigt werden. Sie erhalten per E-Mail informiert." + }, + "error": "Buchung fehlgeschlagen. Bitte erneut versuchen." + }, + "complete": { + "book": { + "title": "Termin gebucht", + "description": "Wir haben Ihnen eine E-Mail mit den Details gesendet." + }, + "request": { + "title": "Termin angefragt", + "description": "{name} wird Ihren Termin in Kürze bestätigen oder absagen. Sie werden per E-Mail informiert." + }, + "action": "Weiter zur Startseite" + } + }, + "anyAgent": "Jede verfügbare Person", + "register": { + "error": "Verschlüsselung konnte nicht vorbereitet werden. Bitte erneut versuchen" + }, + "login": { + "success": "Anmeldung erfolgreich", + "error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen" + } + }, + "calendar": { + "today": "Heute", + "loading": "Loading calendar", + "decrypting": "wird entschlüsselt...", + "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.", + "shownAppointments": { + "title": "Termine", + "options": { + "all": "alle", + "available": "verfügbar", + "booked": "gebucht", + "reserved": "reserviert" + } + } + }, + "emails": { + "poweredBy": "Betrieben mit", + "greeting": "Hallo {name},", + "oclock": "Uhr", + "appointmentBooked": { + "subject": "{channel} bei {tenant} gebucht", + "introduction": "Ihr Termin wurde gebucht.", + "action": "Termin absagen", + "reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat." + }, + "appointmentReminder": { + "subject": "Erinnerung: Ihr Termin bei {tenant}", + "introduction": "Ihr Termin wurde bei {tenant} steht in Kürze bevor.", + "action": "Termin absagen", + "reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat." + }, + "appointmentRequest": { + "subject": "{channel} bei {tenant} angefragt", + "introduction": "Ihr Termin wurde angefragt. Sie werden benachrichtigt, sobald der Termin bestätigt wurde.", + "reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat." + }, + "appointmentRejected": { + "subject": "Ihre Anfrage für {channel} bei {tenant} wurde abgelehnt", + "introduction": "Ihr Termin wurde abgelehnt.", + "reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat." + }, + "confirmation": { + "subject": "E-Mail Adresse bestätigen", + "introduction": "willkommen bei unserem Terminbuchungsportal. Bitte bestätige Deine E-Mail Adresse.", + "action": "E-Mail Adresse bestätigen", + "hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.", + "reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat." + }, + "pinReset": { + "subject": "PIN erfolgreich geändert", + "introduction": "Sie haben erfolgreich Ihre PIN beim Terminbuchungsportal von {tenant} geändert und können sich ab sofort mit Ihrer PIN anmelden.", + "action": "Anmelden", + "reason": "Sie erhalten diese E-Mail, weil jemand für die PIN für Ihr Konto geändert hat." + }, + "userInvite": { + "subject": "E-Mail Adresse bestätigen für {tenant}", + "introduction": "willkommen beim Terminbuchungsportal von {tenant}. Bitte bestätige Deine E-Mail Adresse.", + "action": "E-Mail Adresse bestätigen", + "hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.", + "reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat." } } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 4adbba4..b9beec9 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -1,6 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "welcome": "Welcome to OpenReception", + "home": "Home", "poweredBy": "Powered by", "i18n": { "label": "Select Language", @@ -35,6 +36,20 @@ } } }, + "setupPasskey": { + "title": "Setup Passkey", + "description": "Create your passkey to secure your account.", + "action": "Set Passkey", + "success": "Passkey set. Login with it now.", + "successKeyPairSaved": "Key pair saved successfully.", + "confirmPrfRetrival": "Now use your newly created passkey to enable encryption with it.", + "error": "Passkey could not be set", + "errorKeyPairDataMissing": "Could not generate key pair. Missing data.", + "errorKeyPairNotSaved": "Key pair could not be saved.", + "errorPrfOutputNotTriggered": "You have to confirm using the passkey.", + "errorAuthenticatorNotSupported": "This authenticator does not support the required security extension (PRF). Please use a modern authenticator like YubiKey 5.2.3+, Apple Touch ID or Android.", + "errorGettingPrfOutput": "Failed to retrieve security data from passkey. Please use a modern authenticator that supports PRF extension." + }, "slogan": "End-to-End encrypted appointment booking platform.", "logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock", "form": { @@ -62,11 +77,20 @@ "url": "Invalid URL Format", "languages": "Select at least one language", "language": "Select a default language", - "deleteAfterDays": "Choose a point in time" + "deleteAfterDays": "Choose a point in time", + "phoneNoRequired": "Phone number is required", + "phoneNoInvalid": "Phone number format is invalid", + "pinLength": "PIN must be 6 digits", + "pinDigitsOnly": "PIN must contain only digits", + "pinInsecure": "This PIN is too insecure" }, "noPassAtAll": "Either passphrase or passkey is required", "passkey": "Passkey", - "name": "Name" + "salutation": "Salutation", + "name": "Name", + "phone": "Phone Number", + "pin": "PIN", + "pinHint": "Save your PIN in a secure place, like a password-manager." }, "login": { "or": "Or", @@ -85,9 +109,9 @@ }, "confirm": { "success": { - "title": "You’re all set!", - "description": "You’ve successfully secured your account", - "action": "Proceed" + "title": "E-Mail address confirmed!", + "description": "Now you just need to setup a passkey.", + "action": "Setup Passkey" }, "error": { "title": "Verification failed", @@ -148,7 +172,7 @@ "title": "Add Tenant", "description": "Create a site for someone to start collecting appointments.", "name": { - "description": "Must be unique on this server. Used as a subdomain {domain}", + "description": "Must be unique on this server. Cannot be changed. Used as a subdomain {domain}", "errors": { "urlFormat": "Only letters, numbers, and hyphens are allowed", "startEndDash": "Must not start or end with a dash" @@ -328,6 +352,7 @@ "delete": "Delete", "select": "Select", "dashboard": { + "title": "Dashboard", "hello": "Hello", "tenants": { "currentTenant": "You are currently managing tenant {name}.", @@ -345,6 +370,44 @@ "sidebar": { "title": "Zum ersten Mal hier?", "description": "Tippe {name} um die Seitenleiste umzustellen" + }, + "onboarding": { + "title": "Get ready to collect appointments", + "description": "Follow this step-by-step to get the best results:", + "notification": { + "ongoing": { + "title": "Onboarding not complete", + "description": "Review next steps", + "action": "Proceed" + }, + "done": { + "title": "Onboarding complete!", + "description": "You can now collect appointments", + "action": "Open Homepage" + } + }, + "sections": { + "settings": { + "title": "Review and save settings", + "description": "Make sure your organization's details are correct, before you add agents or channels.", + "action": "Go to Settings" + }, + "agents": { + "title": "Set up your agents", + "description": "Agents let your clients choose which person, team or group they want to make an appointment with.", + "action": "Add Agent" + }, + "channels": { + "title": "Set up your first channel", + "description": "Channels are your way to collect different types of appointments", + "action": "Add channel" + }, + "staff": { + "title": "Add & Activate Staff Members", + "description": "Make sure your team can decrypt appointment data by adding staff members. Only when the first person added has logged in, appointments can be received.", + "action": "Add Staff Member" + } + } } }, "close": "Schließen", @@ -505,7 +568,7 @@ "absences": { "title": "Absences", "add": { - "title": "Add Anbsence", + "title": "Add Absence", "description": "Absences are a way to mark you agents as being unavailbale.", "fields": { "agent": { @@ -646,7 +709,270 @@ "title": "Require Phone Number", "label": "Require clients to provide a phone number when booking an appointment." } + }, + "action": "Save" + } + }, + "account": { + "overview": { + "title": "Your Account" + }, + "general": { + "navItem": "Settings", + "title": "Account Settings", + "description": "Change your name." + }, + "change-email": { + "navItem": "E-Mail Address", + "title": "Change E-Mail Address", + "description": "Change your e-mail address." + }, + "passkeys": { + "navItem": "Passkeys", + "title": "Manage Passkeys", + "description": "You can add up to three passkeys." + }, + "change-passphrase": { + "navItem": "Passphrase", + "title": "Change Passphrase", + "description": "Global Admins can set strong passphrases as a backup login method." + } + }, + "staff": { + "title": "Staff", + "roles": { + "GLOBAL_ADMIN": "Global Admin", + "TENANT_ADMIN": "Tenant Admin", + "STAFF": "Appointment Manager" + }, + "permissions": { + "agents": "Manages agents", + "channels": "Manages channels", + "absences": "Manages absences", + "settings": "Manages settings", + "staff": "Manages staff", + "appointments": "Manages appointments" + }, + "form": { + "fields": { + "language": { + "title": "Language", + "description": "Will be used in the invitation e-mail.", + "placeholder": "Select langauge" + }, + "role": { + "title": "Role", + "placeholder": "Select role" + } } + }, + "add": { + "title": "Add Member", + "description": "Staff members manage your appointments and settings. They are not public.", + "action": "Add Member", + "success": "Member added", + "errors": { + "unknown": "Could not add member" + } + }, + "list": { + "needsAccess": "Can't see appointments", + "empty": { + "title": "No staff members yet", + "description": "Staff members manage your appointments and settings" + } + }, + "edit": { + "title": "Edit Member", + "description": "Update the members details.", + "success": "Member updated", + "error": "Could not update member.", + "action": "Edit Member" + }, + "delete": { + "title": "Remove Member", + "description": "Type {email} to remove this member. This step cannot be undone.", + "action": "Remove Member", + "success": "Member removed", + "error": "Could not remove member." + }, + "access": { + "title": "Grant access to appointments", + "description": "Do you want to grant {name} access to all client data?", + "action": "Grant access", + "loading": "Granting access ({success}/{total} clients)", + "success": "Access granted", + "error": "Granting access to all clients failed. You can retry anytime.", + "unavailable": { + "title": "You can't grant access", + "description": "Only Appointment Managers and Tenant Admins can be granted access to client data." + } + }, + "loading": "Loading staff members" + }, + "yes": "Yes", + "no": "No", + "public": { + "links": { + "website": "Go to Website", + "imprint": "Imprint", + "privacyStatement": "Privacy Statement" + }, + "bookAppointment": "Book Appointment", + "appointment": { + "requiresConfirmation": "Requires cofirmation by {name}" + }, + "tenantNotReady": { + "title": "Not ready to accept appointments.", + "description": "If this is yours, please login and complete onboarding." + }, + "steps": { + "channel": { + "title": "Please select an appointment type:", + "empty": "Currently we don't have available appointments. Please check again later." + }, + "agent": { + "title": "Please select who you want your appointment with:", + "anyone": { + "title": "No preference", + "description": "Choose this option to get the fastest appointment." + } + }, + "slot": { + "title": "Please select an available appointment:", + "today": "Today", + "selectDate": "Select a date", + "selectTime": "Select a time:", + "loading": "Loading available appointments...", + "empty": "No appointments available. Please select another date.", + "action": "Select appointment" + }, + "data": { + "title": "Please add your information:", + "alert": { + "title": "Your data is safe", + "description": "Only you and {name} will be able to access this information. It is end-to-end encrypted." + }, + "email": { + "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.", + "optional": "(optional)" + }, + "action": "Add personal data" + }, + "auth": { + "register": { + "title": "It's my first time here", + "description": "Please add an PIN number so you can change your appointment later.", + "hint": "Proceeding will create an account", + "action": "Show summary", + "success": "Account created successfully!", + "error": "Failed to create account. Please try again.", + "alreadyExists": "This email is already registered. Please use the login option to book additional appointments." + }, + "login": { + "title": "Login", + "description": "Please enter the PIN number, you've set:", + "action": "Show summary", + "throttled": "Too many failed attempts. Please wait a few minutes before trying again.", + "retry": "Please try again in {seconds} seconds" + } + }, + "summary": { + "title": "Please review your appointment, before confirming.", + "book": { + "action": "Book appointment" + }, + "request": { + "action": "Request appointment", + "hint": "This appointment will have to be manually confirmed. You will receive an E-Mail once it's confirmed." + }, + "error": "Operation failed. Please retry." + }, + "complete": { + "book": { + "title": "Appointment booked", + "description": "We’ve sent you an e-mail with the details." + }, + "request": { + "title": "Appointment requested", + "description": "{name} will confirm or deny your request soon. You will be informed via e-mail." + }, + "action": "Proceed to Homepage" + } + }, + "anyAgent": "Anyone available", + "register": { + "error": "Encryption could not be set up. Please retry" + }, + "login": { + "success": "Login successful", + "error": "Login failed. Please retry" + } + }, + "calendar": { + "today": "Today", + "loading": "Loading calendar", + "decrypting": "decrypting...", + "decryptingError": "Unable to decrypt data. Please log-in again", + "shownAppointments": { + "title": "Appointments", + "options": { + "all": "all", + "available": "available", + "booked": "booked", + "reserved": "reserved" + } + } + }, + "emails": { + "poweredBy": "Powered by", + "greeting": "Hello {name},", + "oclock": "", + "appointmentBooked": { + "subject": "{channel} at {tenant} booked successfully", + "introduction": "your appointment for has been booked.", + "action": "Cancel appointment", + "reason": "You are receiving this email because someone booked an appointment with your e-mail address." + }, + "appointmentReminder": { + "subject": "Reminder: Your appointment with {tenant}", + "introduction": "your appointment with {tenant} is soon.", + "action": "Cancel appointment", + "reason": "You are receiving this email because someone booked an appointment with your e-mail address." + }, + "appointmentRejected": { + "subject": "Your request for {channel} at {tenant} was rejected", + "introduction": "Your appointment was rejected.", + "reason": "You are receiving this email because someone requested an appointment with your e-mail address." + }, + "appointmentRequest": { + "subject": "{channel} with {tenant} requested", + "introduction": "your appointment was requested. You will be notified once it is confirmed.", + "reason": "You are receiving this email because someone requested an appointment with your e-mail address." + }, + "confirmation": { + "subject": "Confirm your E-Mail Address", + "introduction": "welcome our appointment booking platform. Please confirm your e-mail address.", + "action": "Confirm E-Mail Address", + "hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.", + "reason": "You are receiving this email because someone registered an account with your e-mail address." + }, + "pinReset": { + "subject": "PIN successfully changed", + "introduction": "you've successfully changed your PIN on the {tenant} appointment booking platform. You can now log-in with your new PIN.", + "action": "Login", + "reason": "You are receiving this email because someone changed the PIN-Code for your account." + }, + "userInvite": { + "subject": "Confirm your E-Mail Address", + "introduction": "welcome our appointment booking platform. Please confirm your e-mail address.", + "action": "Confirm E-Mail Address", + "hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.", + "reason": "You are receiving this email because someone registered an account with your e-mail address." } } } diff --git a/src/app.css b/src/app.css index e4cbc5d..0fdda1e 100644 --- a/src/app.css +++ b/src/app.css @@ -36,11 +36,11 @@ --sidebar-ring: oklch(0.704 0.04 256.788); /* custom colors */ - --lighter: oklch(77.983% 0.03673 254.95); - --light: oklch(69.121% 0.03859 257.443); - --medium: oklch(62.379% 0.01913 256.381); - --dark: oklch(43.309% 0.00977 254.027); - --darker: oklch(27.232% 0.00797 264.468); + --lighter: oklch(74.262% 0.05601 254.57); + --light: oklch(65.382% 0.05802 257.123); + --medium: oklch(57.414% 0.04099 256.06); + --dark: oklch(40.615% 0.0213 256.41); + --darker: oklch(26.345% 0.01439 261.705); } .dark { @@ -69,7 +69,7 @@ --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.208 0.042 265.755); --sidebar-foreground: oklch(0.984 0.003 247.858); - --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary: oklch(27.237% 0.04051 269.508); --sidebar-primary-foreground: oklch(0.984 0.003 247.858); --sidebar-accent: oklch(0.279 0.041 260.031); --sidebar-accent-foreground: oklch(0.984 0.003 247.858); @@ -77,11 +77,11 @@ --sidebar-ring: oklch(0.551 0.027 264.364); /* custom colors */ - --lighter: oklch(27.232% 0.00797 264.468); - --light: oklch(43.309% 0.00977 254.027); - --medium: oklch(62.379% 0.01913 256.381); - --dark: oklch(69.121% 0.03859 257.443); - --darker: oklch(77.983% 0.03673 254.95); + --lighter: oklch(26.345% 0.01439 261.705); + --light: oklch(40.615% 0.0213 256.41); + --medium: oklch(57.414% 0.04099 256.06); + --dark: oklch(65.382% 0.05802 257.123); + --darker: oklch(74.262% 0.05601 254.57); } @theme { diff --git a/src/app.d.ts b/src/app.d.ts index ca4d92b..1fe91ae 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,12 +3,20 @@ import type { SelectUser } from "$lib/server/db/central-schema"; import type { Locale } from "$i18n/runtime"; +interface SessionInfo { + passkeyId?: string; + session: { + sessionId: string; + exp: number; + }; +} + // for information about these interfaces declare global { namespace App { // interface Error {} interface Locals { - user?: SelectUser & { userId: string; sessionId: string; exp: number }; + user?: SelectUser & SessionInfo; locale?: Locale; } // interface PageData {} diff --git a/src/app.html b/src/app.html index 54410cc..58d1582 100644 --- a/src/app.html +++ b/src/app.html @@ -9,6 +9,7 @@ + %sveltekit.head% diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index e7a051c..c5c3e92 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -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(() => { @@ -205,7 +232,7 @@ describe("hooks.server", () => { expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block"); expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); expect(response.headers.get("Content-Security-Policy")).toContain( - "script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", ); }); diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 5db62e9..64eec52 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -48,7 +48,8 @@ */ import { OptimizedArgon2 } from "$lib/crypto/hashing"; -import { KyberCrypto, AESCrypto, ShamirSecretSharing } from "$lib/crypto/utils"; +import { pinThrottleStore } from "$lib/stores/pin-throttle"; +import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils"; // Type definitions for unified cryptography interface ClientKeyPair { @@ -67,9 +68,11 @@ interface EncryptedData { authTag: string; } -interface AppointmentData { +export interface AppointmentData { + salutation?: string; name: string; email: string; + shareEmail: boolean; phone?: string; } @@ -103,6 +106,7 @@ export class UnifiedAppointmentCrypto { private emailHash: string | null = null; private tunnelId: string | null = null; private clientAuthenticated: boolean = false; + private serverPrivateKeyShare: string | null = null; // Server share of the private key // Staff-specific properties private staffKeyPair: StaffKeyPair | null = null; @@ -118,6 +122,26 @@ export class UnifiedAppointmentCrypto { // ===== CLIENT (PATIENT) METHODS ===== + /** + * Run a precheck + */ + async preCheck(email: string, tenantId: string): Promise { + try { + const emailHash = await this.hashEmail(email); + + const response = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash }), + }); + + return response.ok; + } catch (error) { + console.error("❌ Error running pre-check:", error); + return false; + } + } + /** * Initializes a new client with E2E encryption */ @@ -135,9 +159,12 @@ export class UnifiedAppointmentCrypto { // 4. Generate tunnel key for AES encryption this.tunnelKey = await this.generateTunnelKey(); - // 5. Split private key into Shamir shares (2-of-2) - // Note: privateKeyShare will be used during actual appointment creation - await this.createPrivateKeyShare(this.clientKeyPair.privateKey, pin); + // 5. Create server share of private key (PIN-based split) + // Server share will be sent to server during appointment creation + this.serverPrivateKeyShare = await this.createPrivateKeyShare( + this.clientKeyPair.privateKey, + pin, + ); // 6. Fetch staff public keys from server const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId); @@ -179,6 +206,22 @@ export class UnifiedAppointmentCrypto { }); if (!challengeResponse.ok) { + if (challengeResponse.status === 429) { + const errorData = await challengeResponse.json(); + const retryAfterMs = errorData.retryAfterMs || 60000; + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + // Store throttle state for frontend to enforce + pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0); + + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } + } throw new Error("Challenge could not be retrieved"); } @@ -200,23 +243,43 @@ export class UnifiedAppointmentCrypto { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - emailHash: this.emailHash, + challengeId: challengeData.challengeId, challengeResponse: decryptedChallenge, }), }, ); if (!verificationResponse.ok) { + const errorData = await verificationResponse.json(); + console.error("❌ Challenge verification failed:", errorData); + if (verificationResponse.status === 429) { + const retryAfterMs = errorData.retryAfterMs || 60000; + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + // Store throttle state for frontend to enforce + pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0); + + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } + } throw new Error("Challenge verification failed"); } const verificationData = await verificationResponse.json(); - // 6. Decrypt tunnel key + // 6. Decrypt tunnel key and store tunnel ID this.tunnelKey = await this.decryptTunnelKey(verificationData.encryptedTunnelKey, privateKey); + this.tunnelId = verificationData.tunnelId; - console.log("✅ Existing client authenticated"); this.clientAuthenticated = true; + + // Clear throttle on successful authentication + pinThrottleStore.clearThrottle(); } catch (error) { console.error("❌ Error during client login:", error); throw error; @@ -229,9 +292,12 @@ export class UnifiedAppointmentCrypto { async createAppointment( appointmentData: AppointmentData, appointmentDate: string, + agentId: string, channelId: string, + duration: number, tenantId: string, isFirstAppointment: boolean = false, + clientLanguage: string = "de", ): Promise { if (!this.clientAuthenticated || !this.tunnelKey) { throw new Error("Client not authenticated"); @@ -250,22 +316,33 @@ export class UnifiedAppointmentCrypto { ? { // New client tunnelId: this.tunnelId, + agentId, channelId, appointmentDate, + duration, emailHash: this.emailHash, + clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined, + clientLanguage, clientPublicKey: this.clientKeyPair?.publicKey, privateKeyShare: await this.getPrivateKeyShare(), encryptedAppointment, staffKeyShares: await this.getStaffKeyShares(tenantId), clientKeyShare: await this.getClientKeyShare(), + clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(), + salutation: appointmentData.salutation, } : { // Existing client emailHash: this.emailHash, tunnelId: this.tunnelId!, + agentId, channelId, appointmentDate, + duration, + clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined, + clientLanguage, encryptedAppointment, + salutation: appointmentData.salutation, }; const response = await fetch(endpoint, { @@ -337,16 +414,14 @@ export class UnifiedAppointmentCrypto { // ===== STAFF METHODS ===== /** - * Authenticate staff member using WebAuthn and reconstruct private key from shards + * Authenticate staff member using WebAuthn with PRF and reconstruct private key from shards + * + * SECURITY: Uses PRF Extension for zero-knowledge key derivation. + * Requires modern authenticator with PRF support (CTAP 2.1+). */ async authenticateStaff(staffId: string, tenantId: string): Promise { try { - console.log("🔐 Authenticasting staff member:", staffId, "for tenant:", tenantId); - - // 1. Perform WebAuthn authentication - const webAuthnResponse = await this.performWebAuthnAuthentication(staffId); - - // 2. Fetch database shard from server + // 1. Fetch database shard from server (to get passkeyId) const shardResponse = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, { method: "GET", headers: { "Content-Type": "application/json" }, @@ -358,11 +433,11 @@ export class UnifiedAppointmentCrypto { const shardData = await shardResponse.json(); - // 3. Derive passkey-based shard from WebAuthn response - const passkeyBasedShard = await this.derivePasskeyBasedShard( - shardData.passkeyId, - webAuthnResponse.authenticatorData, - ); + // 2. Get PRF output from session (stored during login) + const prfOutput = await this.getPRFOutputFromSession(staffId, shardData.passkeyId); + + // 3. Derive passkey-based shard from PRF output + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId); // 4. Decode database shard const dbShard = this.base64ToUint8Array(shardData.privateKeyShare); @@ -384,7 +459,7 @@ export class UnifiedAppointmentCrypto { this.staffAuthenticated = true; this.keyExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes - console.log("✅ Staff authentication successful"); + console.log("✅ Staff authentication successful with PRF"); } catch (error) { console.error("❌ Staff authentication failed:", error); throw new Error( @@ -409,24 +484,59 @@ export class UnifiedAppointmentCrypto { } try { - // 1. Decrypt the symmetric key using staff's private key - const encapsulatedSecret = this.hexToUint8Array(encryptedData.staffKeyShare); + // Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey + const staffKeyShareBytes = this.hexToUint8Array(encryptedData.staffKeyShare); + + // ML-KEM-768 encapsulated secret is 1088 bytes + const ENCAPSULATED_SECRET_LENGTH = 1088; + const IV_LENGTH = 12; + + if (staffKeyShareBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) { + throw new Error( + `staffKeyShare too short: ${staffKeyShareBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`, + ); + } + + const encapsulatedSecret = staffKeyShareBytes.slice(0, ENCAPSULATED_SECRET_LENGTH); + const iv = staffKeyShareBytes.slice( + ENCAPSULATED_SECRET_LENGTH, + ENCAPSULATED_SECRET_LENGTH + IV_LENGTH, + ); + const encryptedTunnelKey = staffKeyShareBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH); + + // 1. Decapsulate to get shared secret const sharedSecret = KyberCrypto.decapsulate( this.staffKeyPair.privateKey, encapsulatedSecret, ); - // 2. Import the symmetric key - const symmetricKey = await crypto.subtle.importKey( + // 2. Use first 32 bytes of shared secret as AES key + const aesKeyBytes = sharedSecret.slice(0, 32); + + // Import as CryptoKey for Web Crypto API + const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [ + "decrypt", + ]); + + // 3. Decrypt the tunnel key with AES-GCM (encrypted already includes authTag) + const decryptedTunnelKey = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + aesKey, + encryptedTunnelKey, + ); + const tunnelKeyBytes = new Uint8Array(decryptedTunnelKey); + + // 4. Import tunnel key as CryptoKey + const tunnelKey = await crypto.subtle.importKey( "raw", - new Uint8Array(sharedSecret), + tunnelKeyBytes, { name: "AES-GCM" }, false, ["decrypt"], ); - // 3. Decrypt the appointment data - const iv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv); + // 5. Now decrypt the actual appointment data + const appointmentIv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv); const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload); const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag); @@ -436,8 +546,8 @@ export class UnifiedAppointmentCrypto { encrypted.set(authTag, ciphertext.length); const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: new Uint8Array(iv) }, - symmetricKey, + { name: "AES-GCM", iv: appointmentIv }, + tunnelKey, encrypted, ); @@ -510,7 +620,6 @@ export class UnifiedAppointmentCrypto { this.tenantId = null; this.staffAuthenticated = false; this.keyExpiry = null; - console.log("🔒 Staff logged out"); } /** @@ -521,92 +630,151 @@ export class UnifiedAppointmentCrypto { this.clientKeyPair = null; this.emailHash = null; this.tunnelId = null; + this.serverPrivateKeyShare = null; this.clientAuthenticated = false; - console.log("🔒 Client logged out"); } // ===== SHARED PRIVATE METHODS ===== /** - * Perform WebAuthn authentication for staff + * Get PRF output for staff authentication + * + * SECURITY: PRF Extension is REQUIRED for zero-knowledge key derivation. + * This method retrieves PRF output from the auth session (stored during login). + * + * @param staffId - Staff member ID + * @param passkeyId - Passkey credential ID + * @returns PRF output as ArrayBuffer + * @throws Error if PRF output is not available in session */ - private async performWebAuthnAuthentication(staffId: string): Promise<{ - authenticatorData: ArrayBuffer; - signature: ArrayBuffer; - userHandle: ArrayBuffer | null; - }> { - // 1. Get authentication options from server - const optionsResponse = await fetch("/api/auth/webauthn/authenticate/begin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: staffId }), - }); + private async getPRFOutputFromSession(staffId: string, passkeyId: string): Promise { + // Import auth store dynamically to avoid circular dependencies + const { auth } = await import("$lib/stores/auth"); - if (!optionsResponse.ok) { - throw new Error("Failed to get WebAuthn options"); + // Get PRF output from session storage (set during login) + const passkeyAuthData = auth.getPasskeyAuthData(); + + if (!passkeyAuthData) { + throw new Error( + "No passkey authentication data found in session. " + + "Please log in again with your passkey to access encrypted data.", + ); } - const options = await optionsResponse.json(); - - // 2. Perform WebAuthn authentication - const credential = (await navigator.credentials.get({ - publicKey: { - challenge: new Uint8Array(this.base64ToUint8Array(options.challenge)), - allowCredentials: options.allowCredentials?.map((cred: { id: string; type: string }) => ({ - id: new Uint8Array(this.base64ToUint8Array(cred.id)), - type: cred.type as PublicKeyCredentialType, - })), - timeout: options.timeout, - userVerification: options.userVerification, - }, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error("WebAuthn authentication failed"); + if (!passkeyAuthData.prfOutput) { + throw new Error( + "PRF output not available in session. " + + "This passkey may not support the PRF extension. " + + "Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android).", + ); } - const response = credential.response as AuthenticatorAssertionResponse; + // Verify that the passkeyId matches (security check) + if (passkeyAuthData.passkeyId !== passkeyId) { + throw new Error( + `Passkey ID mismatch: expected ${passkeyId}, got ${passkeyAuthData.passkeyId}. ` + + "Please log out and log in again.", + ); + } - return { - authenticatorData: response.authenticatorData, - signature: response.signature, - userHandle: response.userHandle, - }; + // Decode PRF output from base64 + const prfBytes = this.base64ToUint8Array(passkeyAuthData.prfOutput); + return prfBytes.buffer as ArrayBuffer; } /** - * Derive a deterministic shard from passkey authentication data + * Store the staff key pair after the registration of a new staff member with PRF * - * This method creates a consistent private key shard from WebAuthn authenticatorData. + * SECURITY: Uses PRF Extension for zero-knowledge key derivation. + * The prfOutput parameter MUST come from a WebAuthn assertion with PRF extension. + * + * @param tenantId - Tenant ID + * @param staffId - Staff member ID + * @param passkeyId - Passkey credential ID + * @param prfOutput - 32-byte PRF output from WebAuthn assertion (secret!) + * @param keyPair - ML-KEM-768 keypair generated in browser + * @throws Error if PRF output is invalid or API call fails + */ + public async storeStaffKeyPair( + tenantId: string, + staffId: string, + passkeyId: string, + prfOutput: ArrayBuffer, + keyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, + ): Promise { + // Derive passkey-based shard from PRF output + const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId); + + // Create database shard by XORing private key with passkey-based shard + const dbShard = new Uint8Array(keyPair.privateKey.length); + for (let i = 0; i < keyPair.privateKey.length; i++) { + dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i]; + } + + // Store public key and database shard on server + await fetch(`/api/tenants/${tenantId}/staff/${staffId}/crypto`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + passkeyId, + publicKey: this.uint8ArrayToBase64(keyPair.publicKey), + privateKeyShare: this.uint8ArrayToBase64(dbShard), + }), + }); + + console.log("✅ Staff keypair stored with PRF-based security"); + } + + private uint8ArrayToBase64(array: Uint8Array): string { + return btoa(String.fromCharCode.apply(null, Array.from(array))); + } + + /** + * Derive a deterministic shard from WebAuthn PRF Extension (CTAP 2.1+) + * + * This method uses the PRF (Pseudo-Random Function) extension to derive a secret shard. * The same passkey will always produce the same shard, enabling key reconstruction. * + * SECURITY: PRF provides ZERO-KNOWLEDGE guarantee: + * - PRF output is SECRET - only the passkey owner can derive it + * - Server NEVER sees the PRF output (only the database shard) + * - Database compromise does NOT reveal private keys (both shards needed) + * * Used in Staff Key Management: * - During registration: Create shard to XOR with private key for database storage * - During authentication: Recreate same shard to reconstruct private key * - * Uses HKDF (HMAC-based Key Derivation Function) with: - * - IKM: WebAuthn authenticatorData (contains randomness from authenticator) - * - Salt: "staff-crypto-shard-v1" (version-specific salt) - * - Info: "passkey:{passkeyId}" (domain separation per passkey) - * - Length: 2400 bytes (ML-KEM-768 private key size) + * Uses PRF Extension + HKDF expansion: + * - Input: 32-byte PRF output from authenticator (secret!) + * - HKDF Salt: "staff-prf-shard-v2" (version-specific) + * - HKDF Info: "staff:{staffId}" (domain separation) + * - Output: 2400 bytes (ML-KEM-768 private key size) + * + * @param prfOutput - 32-byte PRF output from assertion.getClientExtensionResults().prf.results.first + * @param staffId - Staff member ID for domain separation + * @returns 2400-byte shard for XOR-based key reconstruction + * @throws Error if PRF output is not exactly 32 bytes */ - private async derivePasskeyBasedShard( - passkeyId: string, - authenticatorData: ArrayBuffer, + private async derivePasskeyBasedShardWithPRF( + prfOutput: ArrayBuffer, + staffId: string, ): Promise { - // Extract randomness from authenticator data - const inputKeyMaterial = new Uint8Array(authenticatorData); + // Validate PRF output length (should always be 32 bytes per CTAP 2.1 spec) + const prfBytes = new Uint8Array(prfOutput); + if (prfBytes.length !== 32) { + throw new Error(`Invalid PRF output length: ${prfBytes.length} bytes (expected 32)`); + } - // Import the IKM as a CryptoKey for HKDF - const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, "HKDF", false, [ - "deriveBits", - ]); + // Import the PRF output as a CryptoKey for HKDF expansion + const ikmKey = await crypto.subtle.importKey("raw", prfBytes, "HKDF", false, ["deriveBits"]); - // Salt for HKDF - const salt = new TextEncoder().encode("staff-crypto-shard-v1"); + // Salt for HKDF (versioned to allow future rotation) + // v2: Uses email-based PRF salts for multi-passkey support + // Each passkey still produces unique PRF output (passkey private key is part of PRF) + const salt = new TextEncoder().encode("staff-prf-shard-v2"); - // Info for HKDF (domain separation with passkey ID) - const info = new TextEncoder().encode(`passkey:${passkeyId}`); + // Info for HKDF (domain separation per staff member) + const info = new TextEncoder().encode(`staff:${staffId}`); // Derive key material with the length needed for Kyber private key (2400 bytes for ML-KEM-768) const keyMaterial = await crypto.subtle.deriveBits( @@ -723,20 +891,54 @@ export class UnifiedAppointmentCrypto { // ===== UTILITY METHODS ===== private generateTunnelId(): string { - return "tunnel_" + crypto.randomUUID(); + return crypto.randomUUID(); } + /** + * Creates a server share of the private key using Shamir Secret Sharing (2-of-2) + * + * This implements a 2-party key splitting scheme where: + * - PIN-derived share (x=1): Deterministically derived from PIN + email hash (client can always recreate) + * - Server share (x=2): Stored in database + * + * Reconstruction requires both shares (2-of-2 threshold). + * + * Security properties: + * - Each share reveals NO information about the private key (information-theoretic security) + * - Both shares are required for reconstruction + * - Server share is useless without the PIN + * + * This allows the client to authenticate from any device: + * 1. Client provides email + PIN + * 2. Server returns the server share via challenge API + * 3. Client derives PIN share deterministically and reconstructs the private key + * + * @param privateKey - The ML-KEM-768 private key (hex string) + * @param pin - User's PIN for deterministic share derivation + * @returns Server share (hex string) to be stored in database + */ private async createPrivateKeyShare(privateKey: string, pin: string): Promise { - // Use Shamir Secret Sharing for private key const privateKeyBytes = this.hexToUint8Array(privateKey); - const shares = ShamirSecretSharing.splitSecret(privateKeyBytes, 2, 2); - // One share is PIN-encrypted and stored on client - const clientShare = shares[0].y; - const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || ""); - const encryptedShare = await AESCrypto.encrypt(this.uint8ArrayToHex(clientShare), pinHash); + // Derive a deterministic y-value for the PIN-based share (x=1) + // ML-KEM-768 private key is 2400 bytes, so we need a hash of that length + const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "", { + hashLength: privateKeyBytes.length, + }); - return this.uint8ArrayToHex(encryptedShare.encrypted); + // Use ShamirSecretSharing to create the shares with deterministic first share + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(privateKeyBytes, pinHash); + + // TEST: Immediately reconstruct to verify + const testReconstruct = ShamirSecretSharing.reconstructSecret(shares); + const match = privateKeyBytes.every((v, i) => v === testReconstruct[i]); + + if (!match) { + console.error("❌ CRITICAL: Shamir reconstruction failed immediately after split!"); + } + + // Return the server share (x=2) + return this.uint8ArrayToHex(shares[1].y); } private async fetchStaffPublicKeys(tenantId: string): Promise { @@ -760,15 +962,94 @@ export class UnifiedAppointmentCrypto { ): Promise> { if (!this.tunnelKey) throw new Error("No tunnel key available"); + // Export tunnel key as raw bytes + const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey); + const tunnelKeyArray = new Uint8Array(tunnelKeyBytes); + const results = []; for (const staff of staffKeys) { - const staffPublicKeyBytes = this.hexToUint8Array(staff.publicKey); - const encryptedKey = KyberCrypto.encapsulate(staffPublicKeyBytes); + // Public key is stored as Base64, not Hex + const staffPublicKeyBytes = this.base64ToUint8Array(staff.publicKey); + + console.log("🔐 Encrypting tunnel key for staff:", { + userId: staff.userId, + publicKeyLength: staffPublicKeyBytes.length, + publicKeyHex: + Array.from(staffPublicKeyBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .substring(0, 64) + "...", + tunnelKeyLength: tunnelKeyArray.length, + }); + + // Kyber encapsulation creates a shared secret + const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(staffPublicKeyBytes); + + console.log("🔑 Kyber encapsulation done:", { + sharedSecretLength: sharedSecret.length, + encapsulatedSecretLength: encapsulatedSecret.length, + }); + + // Use the first 32 bytes of shared secret as AES key (same as decryption) + const aesKeyBytes = sharedSecret.slice(0, 32); + + console.log( + "🔑 AES key for encryption (hex):", + Array.from(aesKeyBytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + ); + + // Import as CryptoKey for Web Crypto API + const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [ + "encrypt", + ]); + + // Generate IV for AES-GCM + const iv = BufferUtils.randomBytes(12); + + console.log( + "📍 IV for encryption (hex):", + Array.from(iv) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + ); + console.log( + "🔒 Tunnel key to encrypt (hex):", + Array.from(tunnelKeyArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + ); + + // Encrypt tunnel key with AES-GCM + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + aesKey, + tunnelKeyArray, + ); + + // encrypted contains ciphertext + 16-byte auth tag + const encryptedArray = new Uint8Array(encrypted); + + console.log( + "🔒 Encrypted tunnel key (hex):", + Array.from(encryptedArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + ); + + // Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag) + const combined = new Uint8Array( + encapsulatedSecret.length + iv.length + encryptedArray.length, + ); + combined.set(encapsulatedSecret, 0); + combined.set(iv, encapsulatedSecret.length); + combined.set(encryptedArray, encapsulatedSecret.length + iv.length); results.push({ userId: staff.userId, - encryptedTunnelKey: this.uint8ArrayToHex(encryptedKey.encapsulatedSecret), + encryptedTunnelKey: this.uint8ArrayToHex(combined), }); } @@ -779,37 +1060,87 @@ export class UnifiedAppointmentCrypto { if (!this.tunnelKey || !this.clientKeyPair) throw new Error("Tunnel key or client key not available"); - const clientPublicKeyBytes = this.hexToUint8Array(this.clientKeyPair.publicKey); - const encryptedKey = KyberCrypto.encapsulate(clientPublicKeyBytes); + // Export tunnel key as raw bytes + const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey); + const tunnelKeyArray = new Uint8Array(tunnelKeyBytes); - return this.uint8ArrayToHex(encryptedKey.encapsulatedSecret); + const clientPublicKeyBytes = this.hexToUint8Array(this.clientKeyPair.publicKey); + + // Kyber encapsulation creates a shared secret + const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(clientPublicKeyBytes); + + // Use the first 32 bytes of shared secret as AES key + const aesKeyBytes = sharedSecret.slice(0, 32); + + // Import as CryptoKey for Web Crypto API + const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [ + "encrypt", + ]); + + // Generate IV for AES-GCM + const iv = BufferUtils.randomBytes(12); + + // Encrypt tunnel key with AES-GCM + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, tunnelKeyArray); + + // encrypted contains ciphertext + 16-byte auth tag + const encryptedArray = new Uint8Array(encrypted); + + // Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag) + const combined = new Uint8Array(encapsulatedSecret.length + iv.length + encryptedArray.length); + combined.set(encapsulatedSecret, 0); + combined.set(iv, encapsulatedSecret.length); + combined.set(encryptedArray, encapsulatedSecret.length + iv.length); + + return this.uint8ArrayToHex(combined); } private async reconstructPrivateKey(pin: string, serverShare: string): Promise { - // For now, simplified reconstruction - in real implementation this would be more complex - // TODO: Implement proper Shamir reconstruction with PIN-derived decryption + if (!this.emailHash) { + throw new Error("Email hash not available for key reconstruction"); + } + // 1. Derive PIN-based share deterministically (x=1, y=pinShareY) const serverShareBytes = this.hexToUint8Array(serverShare); - const clientShareBytes = serverShareBytes; // Simplified for now + // ML-KEM-768 private key is 2400 bytes, derive hash of same length + const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash, { + hashLength: serverShareBytes.length, + }); - // In future: derive pinHash and use it to decrypt the client share - // const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || ""); - - // Reconstruct private key from both shares + // 2. Reconstruct using proper Shamir Secret Sharing const shares = [ - { x: 1, y: clientShareBytes }, + { x: 1, y: pinHash }, { x: 2, y: serverShareBytes }, ]; - const reconstructedKey = ShamirSecretSharing.reconstructSecret(shares); + const privateKey = ShamirSecretSharing.reconstructSecret(shares); - return this.uint8ArrayToHex(reconstructedKey); + return this.uint8ArrayToHex(privateKey); } private async decryptChallenge(encryptedChallenge: string, privateKey: string): Promise { const privateKeyBytes = this.hexToUint8Array(privateKey); - const challengeBytes = this.hexToUint8Array(encryptedChallenge); + const fullChallengeBytes = this.hexToUint8Array(encryptedChallenge); - const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, challengeBytes); - return this.uint8ArrayToHex(sharedSecret); + // Split the encryptedChallenge into: + // 1. Encapsulated secret (first 1088 bytes for ML-KEM-768) + // 2. Encrypted challenge (remaining bytes) + const encapsulatedSecret = fullChallengeBytes.slice(0, 1088); + const encryptedChallengeBuffer = fullChallengeBytes.slice(1088); + + // Decapsulate to get the shared secret + const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret); + + // XOR the encrypted challenge with the shared secret to decrypt + const challenge = new Uint8Array(encryptedChallengeBuffer.length); + for (let i = 0; i < encryptedChallengeBuffer.length; i++) { + challenge[i] = encryptedChallengeBuffer[i] ^ sharedSecret[i]; + } + + // The decrypted challenge is raw bytes (32 bytes from randomBytes) + // Convert to base64 string for server verification + const challengeBase64 = this.uint8ArrayToBase64(challenge); + + // Return the base64-encoded challenge (server expects it in base64 format) + return challengeBase64; } private async decryptTunnelKey( @@ -819,11 +1150,45 @@ export class UnifiedAppointmentCrypto { const privateKeyBytes = this.hexToUint8Array(privateKey); const encryptedKeyBytes = this.hexToUint8Array(encryptedTunnelKey); - const tunnelKeyBytes = KyberCrypto.decapsulate(privateKeyBytes, encryptedKeyBytes); + // Parse the encrypted data: encapsulatedSecret || iv || encryptedTunnelKey + const ENCAPSULATED_SECRET_LENGTH = 1088; + const IV_LENGTH = 12; + if (encryptedKeyBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) { + throw new Error( + `encryptedTunnelKey too short: ${encryptedKeyBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`, + ); + } + + const encapsulatedSecret = encryptedKeyBytes.slice(0, ENCAPSULATED_SECRET_LENGTH); + const iv = encryptedKeyBytes.slice( + ENCAPSULATED_SECRET_LENGTH, + ENCAPSULATED_SECRET_LENGTH + IV_LENGTH, + ); + const encryptedTunnel = encryptedKeyBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH); + + // 1. Decapsulate to get shared secret + const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret); + + // 2. Use first 32 bytes as AES key + const aesKeyBytes = sharedSecret.slice(0, 32); + + // 3. Import as CryptoKey + const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [ + "decrypt", + ]); + + // 4. Decrypt the tunnel key + const decryptedTunnelKey = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + aesKey, + encryptedTunnel, + ); + + // 5. Import tunnel key as CryptoKey return await crypto.subtle.importKey( "raw", - new Uint8Array(tunnelKeyBytes), + new Uint8Array(decryptedTunnelKey), { name: "AES-GCM" }, true, ["encrypt", "decrypt"], @@ -832,9 +1197,8 @@ export class UnifiedAppointmentCrypto { // Helper methods for completing the API private async getPrivateKeyShare(): Promise { - if (!this.clientKeyPair) throw new Error("Client keypair not available"); - // TODO: Return the actual server share from Shamir splitting - return this.clientKeyPair.privateKey; // Simplified for now + if (!this.serverPrivateKeyShare) throw new Error("Server private key share not available"); + return this.serverPrivateKeyShare; } private async getStaffKeyShares( diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte index 485d10f..7363fef 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte @@ -66,6 +66,7 @@ {#snippet child({ props })} + {item.title} diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte index 1a7bfa5..45b6fe5 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte @@ -42,6 +42,7 @@ {#if $auth.user && item.roles.includes($auth.user?.role)} + {#snippet child({ props })} {/snippet} + {/if} diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte index 854c49b..e9743d7 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte @@ -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(); @@ -61,13 +62,13 @@ - goto(ROUTES.DASHBOARD.ACCOUNT)}> + goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}> {m["nav.account"]()} - goto(ROUTES.LOGOUT)}> + goto(resolve(ROUTES.LOGOUT))}> {m["nav.logout"]()} diff --git a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte index 0eb1705..7e48087 100644 --- a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte @@ -11,9 +11,10 @@ import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down"; import EllipsisIcon from "@lucide/svelte/icons/ellipsis"; import UnplugIcon from "@lucide/svelte/icons/unplug"; - import UnknownTenantIcon from "@lucide/svelte/icons/shield-question-mark"; + 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(); @@ -49,7 +50,16 @@ class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg" > {#if activeTenant} - + {#if activeTenant.logo} + {activeTenant.shortName} + {:else} + + {/if} {:else} {/if} @@ -91,14 +101,26 @@ class="gap-2 p-2" >
- + {#if tenant.logo} + {tenant.shortName} + {:else} + + {/if}
{tenant.shortName}
{/each} {#if $tenants?.tenants.length > maxTenantsToShow} - goto(ROUTES.DASHBOARD.TENANTS)}> + goto(resolve(ROUTES.DASHBOARD.TENANTS))} + >
diff --git a/src/lib/components/layouts/sidebar-layout/root.svelte b/src/lib/components/layouts/sidebar-layout/root.svelte index ebbe5ab..a10e848 100644 --- a/src/lib/components/layouts/sidebar-layout/root.svelte +++ b/src/lib/components/layouts/sidebar-layout/root.svelte @@ -6,12 +6,18 @@ import * as Sidebar from "$lib/components/ui/sidebar"; import type { HTMLAttributes } from "svelte/elements"; import { sidebar } from "$lib/stores/sidebar"; + import type { Snippet } from "svelte"; let { children, + sidebarRight, + headerRight, breakcrumbs, - }: HTMLAttributes & { breakcrumbs?: Array<{ label: string; href: string }> } = - $props(); + }: HTMLAttributes & { + breakcrumbs?: Array<{ label: string; href: string }>; + headerRight?: Snippet; + sidebarRight?: Snippet; + } = $props(); sidebar.setOpen(open)}> @@ -22,7 +28,7 @@
-
+
{ @@ -52,10 +58,18 @@ {/if} + {#if headerRight} +
+ {@render headerRight?.()} +
+ {/if}
{@render children?.()} + {#if sidebarRight} + {@render sidebarRight?.()} + {/if}
diff --git a/src/lib/components/templates/empty-state/center-loading-state.svelte b/src/lib/components/templates/empty-state/center-loading-state.svelte index fc3b146..0f381eb 100644 --- a/src/lib/components/templates/empty-state/center-loading-state.svelte +++ b/src/lib/components/templates/empty-state/center-loading-state.svelte @@ -1,14 +1,19 @@
- - - + {#if label} + {label} + {:else} + + + + {/if}
diff --git a/src/lib/components/templates/empty-state/center-state.svelte b/src/lib/components/templates/empty-state/center-state.svelte index f13008b..51579a9 100644 --- a/src/lib/components/templates/empty-state/center-state.svelte +++ b/src/lib/components/templates/empty-state/center-state.svelte @@ -33,6 +33,7 @@ size?: VariantProps["size"]; } = $props(); + // svelte-ignore state_referenced_locally const isSmaller = size === "sm"; diff --git a/src/lib/components/templates/language-switch/language-switch.svelte b/src/lib/components/templates/language-switch/language-switch.svelte index 6267f9d..861bac8 100644 --- a/src/lib/components/templates/language-switch/language-switch.svelte +++ b/src/lib/components/templates/language-switch/language-switch.svelte @@ -3,39 +3,49 @@ import { getLocale, setLocale } from "$i18n/runtime.js"; import type { ButtonSize, ButtonVariant } from "$lib/components/ui/button"; import { ComboBox } from "$lib/components/ui/combobox"; + import { languageSwitchLocales, supportedLocales } from "$lib/const/locales"; import { cn } from "$lib/utils"; let { + locales, class: className = "", triggerClass = "", triggerSize = "default", triggerVariant = "ghost", }: { + locales?: (typeof supportedLocales)[]; triggerVariant?: ButtonVariant; triggerSize?: ButtonSize; class?: string; triggerClass?: string; } = $props(); + + const options = $derived( + locales + ? Object.values(languageSwitchLocales).filter((it) => + locales.includes(it.value as unknown as typeof supportedLocales), + ) + : Object.values(languageSwitchLocales), + ); -
- { - setLocale(value as "de" | "en"); - }} - labels={{ - placeholder: m["i18n.label"](), - search: m["i18n.search"](), - notFound: m["i18n.notFound"](), - }} - {triggerVariant} - {triggerSize} - {triggerClass} - class={cn("w-auto")} - /> -
+{#if options.length > 1} +
+ { + setLocale(value as "de" | "en"); + }} + labels={{ + placeholder: m["i18n.label"](), + search: m["i18n.search"](), + notFound: m["i18n.notFound"](), + }} + {triggerVariant} + {triggerSize} + {triggerClass} + class={cn("w-auto")} + /> +
+{/if} diff --git a/src/lib/components/templates/list/list-item.svelte b/src/lib/components/templates/list/list-item.svelte index 6f1fa8d..5be5ccb 100644 --- a/src/lib/components/templates/list/list-item.svelte +++ b/src/lib/components/templates/list/list-item.svelte @@ -15,6 +15,7 @@ icon: Component; label: string; isDestructive?: boolean; + isHidden?: boolean; onClick: () => void; } | { type: "divider" }; @@ -63,7 +64,16 @@ {/if}
- {title} +
+ {title} + {#if badges && badges.length > 0} +
+ {#each badges as badge, index (`${badge.label}-${index}`)} + {badge.label} + {/each} +
+ {/if} +
{#if description} {#if descriptionOnClick}
{#if actions && actions.length > 0} @@ -103,7 +106,7 @@ {m["actions"]()} {#each actions as action, index (`action-${index}`)} - {#if action.type === "action"} + {#if action.type === "action" && action.isHidden !== true} + import type { HTMLAttributes } from "svelte/elements"; + import { cn, type WithElementRef } from "$lib/utils.js"; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef> = $props(); + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert/alert-title.svelte b/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..25feecc --- /dev/null +++ b/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/alert/alert.svelte b/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..1dbadbb --- /dev/null +++ b/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/src/lib/components/ui/alert/index.ts b/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..e47ba7d --- /dev/null +++ b/src/lib/components/ui/alert/index.ts @@ -0,0 +1,14 @@ +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/src/lib/components/ui/button-group/button-group-separator.svelte b/src/lib/components/ui/button-group/button-group-separator.svelte new file mode 100644 index 0000000..5f5bd26 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group-separator.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/button-group/button-group-text.svelte b/src/lib/components/ui/button-group/button-group-text.svelte new file mode 100644 index 0000000..4ec9e07 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group-text.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render mergedProps.children?.()} +
+{/if} diff --git a/src/lib/components/ui/button-group/button-group.svelte b/src/lib/components/ui/button-group/button-group.svelte new file mode 100644 index 0000000..3d42387 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group.svelte @@ -0,0 +1,46 @@ + + + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/button-group/index.ts b/src/lib/components/ui/button-group/index.ts new file mode 100644 index 0000000..177d11b --- /dev/null +++ b/src/lib/components/ui/button-group/index.ts @@ -0,0 +1,13 @@ +import Root from "./button-group.svelte"; +import Text from "./button-group-text.svelte"; +import Separator from "./button-group-separator.svelte"; + +export { + Root, + Text, + Separator, + // + Root as ButtonGroup, + Text as ButtonGroupText, + Separator as ButtonGroupSeparator, +}; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index 1b15395..a442c51 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -58,6 +58,7 @@ {#if href} +
{@render children?.()} + {:else} {/each} diff --git a/src/lib/components/ui/page/page-with-claim.svelte b/src/lib/components/ui/page/page-with-claim.svelte index b72067d..4341bbf 100644 --- a/src/lib/components/ui/page/page-with-claim.svelte +++ b/src/lib/components/ui/page/page-with-claim.svelte @@ -7,20 +7,33 @@ import { Text } from "../typography"; import HorizontalPagePadding from "./horizontal-page-padding.svelte"; import { LanguageSwitch } from "$lib/components/templates/language-switch"; + import type { Snippet } from "svelte"; + import type { supportedLocales } from "$lib/const/locales"; let { ref = $bindable(null), class: className, children, + left, + footer, + languages, isWithLanguageSwitch = false, ...restProps - }: WithElementRef> & { isWithLanguageSwitch?: boolean } = $props(); + }: WithElementRef> & { + left?: Snippet; + footer?: Snippet; + languages?: (typeof supportedLocales)[]; + isWithLanguageSwitch?: boolean; + } = $props();
{#if isWithLanguageSwitch} - + {#if left} + {@render left()} + {/if} + {/if} {@render children?.()} @@ -40,10 +53,15 @@ {/if} - - {m.poweredBy()} - OpenReception - +
+ {#if footer} + {@render footer()} + {/if} + + {m.poweredBy()} + OpenReception + +
{#if dev} +
+ {#if channel.requiresConfirmation} +
+ + + {m["public.appointment.requiresConfirmation"]({ name: tenant.longName })} + +
+ {/if} + {#if appointment.agent || appointment.agent === null} +
+ + + {appointment.agent?.name || m["public.anyAgent"]()} + +
+ {/if} + {#if appointment.slot} +
+ + + {Intl.DateTimeFormat($publicStore.locale, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(appointment.slot.datetime.toDate(getLocalTimeZone()))} + +
+ {/if} + {#if appointment.data} +
+ + + {appointment.data.name}
+ + {appointment.data.email} + + {#if appointment.data.phone} +
+ + {appointment.data.phone} + + {/if} +
+
+ {/if} + + {/if} + + + +{/if} diff --git a/src/lib/components/ui/public/index.ts b/src/lib/components/ui/public/index.ts new file mode 100644 index 0000000..ee22f0f --- /dev/null +++ b/src/lib/components/ui/public/index.ts @@ -0,0 +1,5 @@ +import LocalizedText from "./localized-text.svelte"; +import AppointmentCard from "./appointment-card.svelte"; +import SideBySide from "./side-by-side.svelte"; + +export { LocalizedText, AppointmentCard, SideBySide }; diff --git a/src/lib/components/ui/public/localized-text.svelte b/src/lib/components/ui/public/localized-text.svelte new file mode 100644 index 0000000..6e02a6d --- /dev/null +++ b/src/lib/components/ui/public/localized-text.svelte @@ -0,0 +1,20 @@ + + +{getTranslation()} diff --git a/src/lib/components/ui/public/side-by-side.svelte b/src/lib/components/ui/public/side-by-side.svelte new file mode 100644 index 0000000..8eb5cbd --- /dev/null +++ b/src/lib/components/ui/public/side-by-side.svelte @@ -0,0 +1,17 @@ + + + + + + {@render children?.()} + + + diff --git a/src/lib/components/ui/radio-group/index.ts b/src/lib/components/ui/radio-group/index.ts new file mode 100644 index 0000000..b608946 --- /dev/null +++ b/src/lib/components/ui/radio-group/index.ts @@ -0,0 +1,10 @@ +import Root from "./radio-group.svelte"; +import Item from "./radio-group-item.svelte"; + +export { + Root, + Item, + // + Root as RadioGroup, + Item as RadioGroupItem, +}; diff --git a/src/lib/components/ui/radio-group/radio-group-item.svelte b/src/lib/components/ui/radio-group/radio-group-item.svelte new file mode 100644 index 0000000..f9238fd --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} +
+ {#if checked} + + {/if} +
+ {/snippet} +
diff --git a/src/lib/components/ui/radio-group/radio-group.svelte b/src/lib/components/ui/radio-group/radio-group.svelte new file mode 100644 index 0000000..c755bf5 --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte b/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte index 9cc10c8..eb37265 100644 --- a/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte +++ b/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte @@ -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; }); diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte index ccdf338..07f2b0e 100644 --- a/src/lib/components/ui/sidebar/sidebar.svelte +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -44,6 +44,7 @@ {side} > + Sidebar Displays the mobile sidebar. diff --git a/src/lib/components/ui/translation-with-component/translation-with-component.svelte b/src/lib/components/ui/translation-with-component/translation-with-component.svelte index a850bbb..f05df86 100644 --- a/src/lib/components/ui/translation-with-component/translation-with-component.svelte +++ b/src/lib/components/ui/translation-with-component/translation-with-component.svelte @@ -77,6 +77,7 @@ return segments; } + // svelte-ignore state_referenced_locally const segments = processTranslation(translation, interpolations); diff --git a/src/lib/components/ui/typography/headline.svelte b/src/lib/components/ui/typography/headline.svelte index c9f30fe..cf2734d 100644 --- a/src/lib/components/ui/typography/headline.svelte +++ b/src/lib/components/ui/typography/headline.svelte @@ -12,6 +12,7 @@ h3: "text-2xl font-semibold", h4: "text-xl font-semibold", h5: "text-lg font-semibold", + h6: "text-md font-semibold", }, }, }); diff --git a/src/lib/const/locales.ts b/src/lib/const/locales.ts index 47fb5de..99ced3b 100644 --- a/src/lib/const/locales.ts +++ b/src/lib/const/locales.ts @@ -2,7 +2,14 @@ import { m } from "$i18n/messages"; export const supportedLocales = ["en", "de"] as const; +export type SupportedLocale = (typeof supportedLocales)[number]; + export const translatedLocales = { en: m["locales.en"](), de: m["locales.de"](), }; + +export const languageSwitchLocales = { + de: { label: "Deutsch", value: "de", keywords: ["german", "deutsch"] }, + en: { label: "English", value: "en", keywords: ["english"] }, +}; diff --git a/src/lib/const/pin.ts b/src/lib/const/pin.ts new file mode 100644 index 0000000..aa95b30 --- /dev/null +++ b/src/lib/const/pin.ts @@ -0,0 +1,22 @@ +export const INSECURE_PINS = new Set([ + "000000", + "111111", + "222222", + "333333", + "444444", + "555555", + "666666", + "777777", + "888888", + "999999", + "123456", + "654321", + "123123", + "121212", + "000001", + "112233", + "123321", + "012345", + "234567", + "345678", +]); diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index 962b72d..5cc899d 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -1,10 +1,13 @@ export const ROUTES = { MAIN: "/", + BOOK_APPOINTMENT: "/book-appointment", + APPOINTMENT_BOOKED: "/book-appointment/complete", SETUP: { MAIN: "/setup", CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account", CHECK_EMAIL: "/setup/check-email", }, + SETUP_PASSKEY: "/confirm/setup-passkey", RESEND_CONFIRMATION: "/confirm/resend", LOGIN: "/login", LOGOUT: "/logout", @@ -17,6 +20,12 @@ export const ROUTES = { CHANNELS: "/dashboard/channels", ABSENCES: "/dashboard/absences", SETTINGS: "/dashboard/settings", - ACCOUNT: "/dashboard/account", + ACCOUNT: { + MAIN: "/dashboard/account", + GENERAL: "/dashboard/account/general", + PASSKEYS: "/dashboard/account/passkeys", + CHANGE_EMAIL: "/dashboard/account/change-email", + CHANGE_PASSPHRASE: "/dashboard/account/change-passphrase", + }, }, -}; +} as const; diff --git a/src/lib/const/tenants.ts b/src/lib/const/tenants.ts new file mode 100644 index 0000000..4ce870d --- /dev/null +++ b/src/lib/const/tenants.ts @@ -0,0 +1 @@ +export const SETUP_STATES_LIST = ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]; diff --git a/src/lib/crypto/__tests__/utils.test.ts b/src/lib/crypto/__tests__/utils.test.ts index 47e331b..49ac5aa 100644 --- a/src/lib/crypto/__tests__/utils.test.ts +++ b/src/lib/crypto/__tests__/utils.test.ts @@ -345,59 +345,96 @@ describe("AESCrypto", () => { }); describe("ShamirSecretSharing", () => { - describe("splitSecret()", () => { - it("should split secret into shares", () => { + describe("splitSecretWithDeterministicShare()", () => { + it("should split secret into 2 shares with deterministic first share", () => { const secret = BufferUtils.from("test secret"); - const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); + const deterministicShare = BufferUtils.randomBytes(secret.length); - expect(shares).toHaveLength(3); - shares.forEach((share, index) => { - expect(share.x).toBe(index + 1); - expect(share.y).toBeInstanceOf(Uint8Array); - expect(share.y.length).toBe(4 + secret.length); // 4 bytes for length + secret - }); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + secret, + deterministicShare, + ); + + expect(shares).toHaveLength(2); + expect(shares[0].x).toBe(1); + expect(shares[1].x).toBe(2); + expect(shares[0].y).toBeInstanceOf(Uint8Array); + expect(shares[1].y).toBeInstanceOf(Uint8Array); + expect(shares[0].y.length).toBe(secret.length); + expect(shares[1].y.length).toBe(secret.length); + // First share should be the deterministic one + expect(shares[0].y).toEqual(deterministicShare); }); it("should throw error for empty secret", () => { const secret = new Uint8Array([]); - expect(() => ShamirSecretSharing.splitSecret(secret, 2, 3)).toThrow( - "Secret cannot be empty for Shamir secret sharing", - ); + const deterministicShare = new Uint8Array([]); + + expect(() => + ShamirSecretSharing.splitSecretWithDeterministicShare(secret, deterministicShare), + ).toThrow("Secret cannot be empty"); }); it("should handle large secrets", () => { const secret = BufferUtils.randomBytes(1000); - const shares = ShamirSecretSharing.splitSecret(secret, 3, 5); + const deterministicShare = BufferUtils.randomBytes(secret.length); - expect(shares).toHaveLength(5); - shares.forEach((share) => { - expect(share.y.length).toBe(4 + secret.length); - }); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + secret, + deterministicShare, + ); + + expect(shares).toHaveLength(2); + expect(shares[0].y.length).toBe(secret.length); + expect(shares[1].y.length).toBe(secret.length); + }); + + it("should throw error if deterministic share length differs", () => { + const secret = BufferUtils.from("test secret"); + const deterministicShare = BufferUtils.randomBytes(5); // Different length + + expect(() => + ShamirSecretSharing.splitSecretWithDeterministicShare(secret, deterministicShare), + ).toThrow("Deterministic share must have same length as secret"); }); }); describe("reconstructSecret()", () => { it("should reconstruct secret from shares", () => { const originalSecret = BufferUtils.from("test secret for reconstruction"); - const shares = ShamirSecretSharing.splitSecret(originalSecret, 2, 5); + const deterministicShare = BufferUtils.randomBytes(originalSecret.length); - // Use first 2 shares (minimum threshold) - const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + originalSecret, + deterministicShare, + ); + + // Use both shares + const reconstructed = ShamirSecretSharing.reconstructSecret(shares); expect(reconstructed).toEqual(originalSecret); }); - it("should reconstruct secret from any subset of shares", () => { + it("should reconstruct secret consistently with same deterministic share", () => { const originalSecret = BufferUtils.from("another test secret"); - const shares = ShamirSecretSharing.splitSecret(originalSecret, 3, 5); + const deterministicShare = BufferUtils.randomBytes(originalSecret.length); - // Test different combinations - const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares.slice(0, 3)); - const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares.slice(1, 4)); - const reconstructed3 = ShamirSecretSharing.reconstructSecret(shares.slice(2, 5)); + // Split and reconstruct multiple times with same deterministic share + const shares1 = ShamirSecretSharing.splitSecretWithDeterministicShare( + originalSecret, + deterministicShare, + ); + const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares1); + + const shares2 = ShamirSecretSharing.splitSecretWithDeterministicShare( + originalSecret, + deterministicShare, + ); + const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares2); expect(reconstructed1).toEqual(originalSecret); expect(reconstructed2).toEqual(originalSecret); - expect(reconstructed3).toEqual(originalSecret); + // Deterministic shares should be identical + expect(shares1[0].y).toEqual(shares2[0].y); }); it("should throw error with insufficient shares", () => { @@ -415,8 +452,12 @@ describe("ShamirSecretSharing", () => { ]; secrets.forEach((secret) => { - const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); - const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); + const deterministicShare = BufferUtils.randomBytes(secret.length); + const shares = ShamirSecretSharing.splitSecretWithDeterministicShare( + secret, + deterministicShare, + ); + const reconstructed = ShamirSecretSharing.reconstructSecret(shares); expect(reconstructed).toEqual(secret); }); }); diff --git a/src/lib/crypto/hashing.ts b/src/lib/crypto/hashing.ts index 3fa6777..66bffe3 100644 --- a/src/lib/crypto/hashing.ts +++ b/src/lib/crypto/hashing.ts @@ -171,12 +171,14 @@ export class OptimizedArgon2 { ): Promise { const { argon2id } = await import("@noble/hashes/argon2"); - return argon2id(BufferUtils.from(pin), new Uint8Array(salt), { + const result = argon2id(BufferUtils.from(pin), new Uint8Array(salt), { m: options.memoryCost, t: options.timeCost, p: options.parallelism, dkLen: options.hashLength, }); + + return result; } /** diff --git a/src/lib/crypto/utils.ts b/src/lib/crypto/utils.ts index c79c3e7..5d1c2ed 100644 --- a/src/lib/crypto/utils.ts +++ b/src/lib/crypto/utils.ts @@ -183,15 +183,22 @@ export class AESCrypto { // Use Web Crypto API if available (browser), otherwise fall back to Node.js if (typeof crypto !== "undefined" && crypto.subtle) { // Browser implementation using Web Crypto API - const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ - "encrypt", - ]); + // Ensure native Uint8Array with ArrayBuffer for Web Crypto API compatibility + const nativeKey = new Uint8Array(key); + const cryptoKey = await crypto.subtle.importKey( + "raw", + nativeKey, + { name: "AES-GCM" }, + false, + ["encrypt"], + ); - const additionalData = BufferUtils.from("appointment-data"); + const additionalData = new Uint8Array(BufferUtils.from("appointment-data")); + const nativeIv = new Uint8Array(iv); const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv, additionalData }, + { name: "AES-GCM", iv: nativeIv, additionalData }, cryptoKey, - BufferUtils.from(data), + new Uint8Array(BufferUtils.from(data)), ); // Extract tag from encrypted data (last 16 bytes) @@ -238,17 +245,24 @@ export class AESCrypto { ): Promise { if (typeof crypto !== "undefined" && crypto.subtle) { // Browser implementation using Web Crypto API - const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ - "decrypt", - ]); + // Ensure native Uint8Array with ArrayBuffer for Web Crypto API compatibility + const nativeKey = new Uint8Array(key); + const cryptoKey = await crypto.subtle.importKey( + "raw", + nativeKey, + { name: "AES-GCM" }, + false, + ["decrypt"], + ); - const additionalData = BufferUtils.from("appointment-data"); + const additionalData = new Uint8Array(BufferUtils.from("appointment-data")); + const nativeIv = new Uint8Array(iv); // Combine encrypted data and tag for Web Crypto API - const encryptedWithTag = BufferUtils.concat([encrypted, tag]); + const encryptedWithTag = new Uint8Array(BufferUtils.concat([encrypted, tag])); const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv, additionalData }, + { name: "AES-GCM", iv: nativeIv, additionalData }, cryptoKey, encryptedWithTag, ); @@ -280,45 +294,151 @@ export interface ShamirShare { } /** - * Simple Shamir Secret Sharing implementation - * Note: This is a basic implementation for demonstration purposes + * Shamir Secret Sharing implementation using Lagrange interpolation over GF(256) + * + * This implementation uses polynomial interpolation in Galois Field 256 to split + * a secret into n shares where any k shares can reconstruct the secret, but + * k-1 shares reveal no information about the secret (information-theoretic security). + * + * Mathematical basis: + * - Secret is encoded as f(0) where f is a polynomial of degree k-1 + * - Each share is a point (x, f(x)) on the polynomial + * - Lagrange interpolation reconstructs f(0) from k points */ export class ShamirSecretSharing { + // Cache for GF(256) multiplicative inverses + private static invCache: Map = new Map(); + /** - * Splits a secret into multiple shares - * @param secret - The secret to split - * @param threshold - Minimum number of shares needed to reconstruct - * @param totalShares - Total number of shares to create + * Splits a secret into multiple shares using Shamir's Secret Sharing scheme + * + * For a k-of-n threshold scheme: + * - Generates a random polynomial f(x) of degree k-1 where f(0) = secret + * - Creates n shares as points (i, f(i)) for i = 1, 2, ..., n + * - Any k shares can reconstruct the secret via Lagrange interpolation + * + * @param secret - The secret to split (any length) + * @param threshold - Minimum number of shares needed to reconstruct (k) + * @param totalShares - Total number of shares to create (n) * @returns Array of ShamirShare objects */ static splitSecret(secret: CryptoBuffer, threshold: number, totalShares: number): ShamirShare[] { + return this.splitSecretInternal(secret, threshold, totalShares); + } + + /** + * Splits a secret into shares with a deterministic first share (2-of-2 scheme) + * + * Special case for client key splitting where: + * - Share 1 (x=1): Deterministically derived from PIN + email (y₁ provided) + * - Share 2 (x=2): Calculated to maintain the secret relationship + * + * This enables cross-device authentication: + * - Client can always recreate share 1 from their PIN + * - Server stores share 2 in database + * - Both shares required for reconstruction (2-of-2 threshold) + * + * @param secret - The private key to split + * @param deterministicShareY - The deterministic y-value for x=1 (from PIN derivation) + * @returns Array with 2 shares: [pinShare, serverShare] + */ + static splitSecretWithDeterministicShare( + secret: CryptoBuffer, + deterministicShareY: Uint8Array, + ): ShamirShare[] { + if (!secret || secret.length === 0) { + throw new Error("Secret cannot be empty"); + } + if (deterministicShareY.length !== secret.length) { + throw new Error("Deterministic share must have same length as secret"); + } + + // For 2-of-2 Shamir: f(x) = a₀ + a₁·x where f(0) = secret + // We have: f(1) = deterministicShareY (given) + // From f(1) = a₀ + a₁·1: a₁ = f(1) - a₀ = deterministicShareY - secret (in GF(256)) + // Calculate f(2) = a₀ + a₁·2 = secret + 2·(deterministicShareY - secret) + // = 2·deterministicShareY - secret (in GF(256)) + + const serverShareY = new Uint8Array(secret.length); + + for (let i = 0; i < secret.length; i++) { + // a₁ = deterministicShareY[i] - secret[i] in GF(256) + // Since subtraction in GF(256) is XOR (same as addition): + const a1 = this.gf256Add(deterministicShareY[i], secret[i]); + + // f(2) = secret[i] + 2·a₁ in GF(256) + const twoTimesA1 = this.gf256Mul(2, a1); + serverShareY[i] = this.gf256Add(secret[i], twoTimesA1); + } + + return [ + { x: 1, y: deterministicShareY }, + { x: 2, y: serverShareY }, + ]; + } + + /** + * Internal implementation for splitting secrets + */ + private static splitSecretInternal( + secret: CryptoBuffer, + threshold: number, + totalShares: number, + ): ShamirShare[] { if (!secret || secret.length === 0) { throw new Error("Secret cannot be empty for Shamir secret sharing"); } - - const originalLength = secret.length; - const lengthBytes = new Uint8Array(4); - new DataView(lengthBytes.buffer).setUint32(0, originalLength, true); + if (threshold < 2) { + throw new Error("Threshold must be at least 2"); + } + if (threshold > totalShares) { + throw new Error("Threshold cannot be greater than total shares"); + } + if (totalShares > 255) { + throw new Error("Cannot create more than 255 shares (GF(256) limitation)"); + } const shares: ShamirShare[] = []; - for (let i = 0; i < totalShares; i++) { - const shareData = new Uint8Array(4 + secret.length); - shareData.set(lengthBytes, 0); - shareData.set(secret, 4); + // Process each byte of the secret independently + for (let shareIndex = 0; shareIndex < totalShares; shareIndex++) { + const x = shareIndex + 1; // x-coordinates: 1, 2, 3, ..., n (never 0, as f(0) = secret) + const y = new Uint8Array(secret.length); - shares.push({ - x: i + 1, - y: shareData, - }); + for (let byteIndex = 0; byteIndex < secret.length; byteIndex++) { + // For this byte, create a polynomial f(x) = a₀ + a₁x + a₂x² + ... + a_{k-1}x^{k-1} + // where a₀ = secret[byteIndex] and a₁, ..., a_{k-1} are random + const coefficients = new Uint8Array(threshold); + coefficients[0] = secret[byteIndex]; // f(0) = secret byte + + // Generate random coefficients for higher degree terms + for (let i = 1; i < threshold; i++) { + coefficients[i] = Math.floor(Math.random() * 256); + } + + // Evaluate polynomial at x: f(x) = a₀ + a₁x + a₂x² + ... in GF(256) + let result = 0; + for (let i = threshold - 1; i >= 0; i--) { + result = this.gf256Add(this.gf256Mul(result, x), coefficients[i]); + } + + y[byteIndex] = result; + } + + shares.push({ x, y }); } return shares; } /** - * Reconstructs a secret from shares - * @param shareArray - Array of shares to reconstruct from + * Reconstructs a secret from shares using Lagrange interpolation + * + * Given k shares (x₁, y₁), (x₂, y₂), ..., (xₖ, yₖ), reconstructs f(0) where: + * f(0) = Σᵢ yᵢ · Lᵢ(0) + * where Lᵢ(0) = Πⱼ≠ᵢ (0 - xⱼ) / (xᵢ - xⱼ) (computed in GF(256)) + * + * @param shareArray - Array of shares to reconstruct from (must have at least threshold shares) * @returns The reconstructed secret */ static reconstructSecret(shareArray: ShamirShare[]): CryptoBuffer { @@ -326,13 +446,145 @@ export class ShamirSecretSharing { throw new Error("Need at least 2 shares to reconstruct the secret"); } - const firstShare = shareArray[0]; + const secretLength = shareArray[0].y.length; + const secret = new Uint8Array(secretLength); - const lengthBytes = firstShare.y.slice(0, 4); - const originalLength = new DataView(lengthBytes.buffer).getUint32(0, true); + // Reconstruct each byte independently + for (let byteIndex = 0; byteIndex < secretLength; byteIndex++) { + let secretByte = 0; - const secret = firstShare.y.slice(4, 4 + originalLength); + // Lagrange interpolation: f(0) = Σᵢ yᵢ · Lᵢ(0) + for (let i = 0; i < shareArray.length; i++) { + const xi = shareArray[i].x; + const yi = shareArray[i].y[byteIndex]; + + // Calculate Lagrange basis polynomial Lᵢ(0) + let basis = 1; + for (let j = 0; j < shareArray.length; j++) { + if (i !== j) { + const xj = shareArray[j].x; + // Lᵢ(0) *= (0 - xⱼ) / (xᵢ - xⱼ) in GF(256) + // In GF(256), subtraction is XOR (same as addition) + const numerator = this.gf256Add(0, xj); // 0 - xⱼ = 0 ⊕ xⱼ = xⱼ + const denominator = this.gf256Add(xi, xj); // xᵢ - xⱼ = xᵢ ⊕ xⱼ + basis = this.gf256Mul(basis, this.gf256Div(numerator, denominator)); + } + } + + // Add yᵢ · Lᵢ(0) to the result + secretByte = this.gf256Add(secretByte, this.gf256Mul(yi, basis)); + } + + secret[byteIndex] = secretByte; + } return secret; } + + /** + * Addition in GF(256) - simply XOR + */ + private static gf256Add(a: number, b: number): number { + return (a ^ b) & 0xff; + } + + /** + * Multiplication in GF(256) using the rijndael polynomial + * This is the same multiplication used in AES + */ + private static gf256Mul(a: number, b: number): number { + let result = 0; + a = a & 0xff; + b = b & 0xff; + + for (let i = 0; i < 8; i++) { + if (b & 1) { + result ^= a; + } + const hiBitSet = a & 0x80; + a = (a << 1) & 0xff; + if (hiBitSet) { + a ^= 0x1b; // Rijndael's Galois field polynomial + } + b >>= 1; + } + + return result & 0xff; + } + + /** + * Division in GF(256) - multiply by multiplicative inverse + */ + private static gf256Div(a: number, b: number): number { + if (b === 0) { + throw new Error("Division by zero in GF(256)"); + } + return this.gf256Mul(a, this.gf256Inv(b)); + } + + /** + * Multiplicative inverse in GF(256) using Extended Euclidean Algorithm + */ + private static gf256Inv(a: number): number { + if (a === 0) { + throw new Error("Zero has no multiplicative inverse in GF(256)"); + } + + // Check cache first + if (this.invCache.has(a)) { + return this.invCache.get(a)!; + } + + // Extended Euclidean Algorithm in GF(256) + let u = a & 0xff; + let v = 0x11b; // GF(256) irreducible polynomial: x^8 + x^4 + x^3 + x + 1 + let g1 = 1; + let g2 = 0; + + let iterations = 0; + const maxIterations = 16; // Should never need more than this + + while (u !== 1 && iterations < maxIterations) { + iterations++; + + if (u === 0) { + // This shouldn't happen, but safety check + throw new Error(`GF(256) inverse failed for ${a}`); + } + + const j = this.bitLength(u) - this.bitLength(v); + + if (j < 0) { + [u, v] = [v, u]; + [g1, g2] = [g2, g1]; + continue; + } + + u ^= v << j; + g1 ^= g2 << j; + } + + if (iterations >= maxIterations) { + throw new Error(`GF(256) inverse calculation did not converge for ${a}`); + } + + const result = g1 & 0xff; + + // Cache the result + this.invCache.set(a, result); + + return result; + } + + /** + * Helper: Calculate bit length of a number + */ + private static bitLength(n: number): number { + let length = 0; + while (n > 0) { + length++; + n >>= 1; + } + return length; + } } diff --git a/src/lib/emails/AppointmentBooked.svelte b/src/lib/emails/AppointmentBooked.svelte new file mode 100644 index 0000000..76ef184 --- /dev/null +++ b/src/lib/emails/AppointmentBooked.svelte @@ -0,0 +1,67 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.appointmentBooked.introduction"]()} + + {channel} + + {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)} + {m["emails.oclock"]()} +
+ {tenant.longName} + + {address.street} + {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if} + {address.zip} + {address.city} +
+ {m["emails.appointmentBooked.action"]()} + + {m["emails.appointmentBooked.reason"]()} + +
diff --git a/src/lib/emails/AppointmentRejected.svelte b/src/lib/emails/AppointmentRejected.svelte new file mode 100644 index 0000000..8046107 --- /dev/null +++ b/src/lib/emails/AppointmentRejected.svelte @@ -0,0 +1,61 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.appointmentRejected.introduction"]()} + + {channel} + + {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)} + {m["emails.oclock"]()} +
+ {tenant.longName} + + {address.street} + {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if} + {address.zip} + {address.city} +
+ + {m["emails.appointmentRejected.reason"]()} + +
diff --git a/src/lib/emails/AppointmentReminder.svelte b/src/lib/emails/AppointmentReminder.svelte new file mode 100644 index 0000000..0d51729 --- /dev/null +++ b/src/lib/emails/AppointmentReminder.svelte @@ -0,0 +1,67 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.appointmentReminder.introduction"]({ tenant: tenant.longName })} + + {channel} + + {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)} + {m["emails.oclock"]()} +
+ {tenant.longName} + + {address.street} + {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if} + {address.zip} + {address.city} +
+ {m["emails.appointmentReminder.action"]()} + + {m["emails.appointmentReminder.reason"]()} + +
diff --git a/src/lib/emails/AppointmentRequest.svelte b/src/lib/emails/AppointmentRequest.svelte new file mode 100644 index 0000000..dcc96a0 --- /dev/null +++ b/src/lib/emails/AppointmentRequest.svelte @@ -0,0 +1,63 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.appointmentRequest.introduction"]()} + + {channel} + + {appointment.agentName}
+ {renderAppointmentDate(appointment.appointmentDate, locale)}
+ {renderAppointmentTime(appointment.appointmentDate, locale)} + {m["emails.oclock"]()} +
+ {tenant.longName} + + {address.street} + {address.number}
+ {#if address.additionalAddressInfo}{address.additionalAddressInfo}
{/if} + {address.zip} + {address.city} +
+ + {m["emails.appointmentRequest.reason"]()} + +
diff --git a/src/lib/emails/Confirmation.svelte b/src/lib/emails/Confirmation.svelte new file mode 100644 index 0000000..9897150 --- /dev/null +++ b/src/lib/emails/Confirmation.svelte @@ -0,0 +1,39 @@ + + + + {m["emails.greeting"]({ name: user.name })} + + {m["emails.confirmation.introduction"]()} + + {m["emails.confirmation.action"]()} + + {m["emails.confirmation.hint"]({ expirationMinutes })} + + + {m["emails.confirmation.reason"]()} + + diff --git a/src/lib/emails/PinReset.svelte b/src/lib/emails/PinReset.svelte new file mode 100644 index 0000000..3990e09 --- /dev/null +++ b/src/lib/emails/PinReset.svelte @@ -0,0 +1,37 @@ + + + + {m["emails.greeting"]({ name: user.email })} + + {m["emails.pinReset.introduction"]({ tenant: tenant.longName })} + + {m["emails.pinReset.action"]()} + + {m["emails.pinReset.reason"]()} + + diff --git a/src/lib/emails/UserInvite.svelte b/src/lib/emails/UserInvite.svelte new file mode 100644 index 0000000..eaac863 --- /dev/null +++ b/src/lib/emails/UserInvite.svelte @@ -0,0 +1,42 @@ + + + + {m["emails.greeting"]({ name: user.name })} + + {m["emails.userInvite.introduction"]({ tenant: tenant.longName })} + + {m["emails.userInvite.action"]()} + + {m["emails.userInvite.hint"]({ expirationMinutes })} + + + {m["emails.userInvite.reason"]()} + + diff --git a/src/lib/emails/components/EmailButton.svelte b/src/lib/emails/components/EmailButton.svelte new file mode 100644 index 0000000..e23d0d3 --- /dev/null +++ b/src/lib/emails/components/EmailButton.svelte @@ -0,0 +1,31 @@ + + + + + + + diff --git a/src/lib/emails/components/EmailHeadline.svelte b/src/lib/emails/components/EmailHeadline.svelte new file mode 100644 index 0000000..47ee03c --- /dev/null +++ b/src/lib/emails/components/EmailHeadline.svelte @@ -0,0 +1,7 @@ + + + it).join(" ")}> + {@render children?.()} + diff --git a/src/lib/emails/components/EmailLayout.svelte b/src/lib/emails/components/EmailLayout.svelte new file mode 100644 index 0000000..b2ba8b0 --- /dev/null +++ b/src/lib/emails/components/EmailLayout.svelte @@ -0,0 +1,71 @@ + + +
+
+
+ {@render children?.()} +
+ + {m["emails.poweredBy"]()} OpenReception + +
+
+ + + + diff --git a/src/lib/emails/components/EmailText.svelte b/src/lib/emails/components/EmailText.svelte new file mode 100644 index 0000000..c8a396b --- /dev/null +++ b/src/lib/emails/components/EmailText.svelte @@ -0,0 +1,32 @@ + + +

it).join(" ")}>{@render children?.()}

+ + + + diff --git a/src/lib/emails/utils.ts b/src/lib/emails/utils.ts new file mode 100644 index 0000000..ae5ae75 --- /dev/null +++ b/src/lib/emails/utils.ts @@ -0,0 +1,79 @@ +import type { SupportedLocale } from "$lib/const/locales"; +import { format, type Locale } from "date-fns"; +import { de, enUS } from "date-fns/locale"; + +// is not exported from svelte +export type RenderOutput = { + head: string; + body: string; +}; + +export const renderOutputToHtml = (renderOutput: RenderOutput) => { + return ` + + + + + + ${renderOutput.head} + + + ${renderOutput.body} + + + `; +}; + +export const htmlToText = (html: string) => { + let text = html; + + // Replace anchor tags with "Link Text: URL" format + const newLinePlaceholder = "{new-line}"; + text = text.replace(/]*?\s+)?href="([^"]*)"[^>]*>(.*?)<\/a>/gi, `$2: $1

`); + + // Remove script and style tags and their content + text = text.replace(/]*>[\s\S]*?<\/script>/gi, ""); + text = text.replace(/]*>[\s\S]*?<\/style>/gi, ""); + + // Unify breaks + text = text.replace(new RegExp("
", "g"), "
"); + + // Placeholder for new lines + text = text.replace(new RegExp("
", "g"), newLinePlaceholder); + + // Replace block-level tags with newline placeholders + const lineBreakingTags = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "a"]; + lineBreakingTags.forEach((tag) => { + const regexOpen = new RegExp(``, "g"); + const regexClose = new RegExp(``, "g"); + text = text + .replace(regexOpen, newLinePlaceholder + newLinePlaceholder) + .replace(regexClose, newLinePlaceholder + newLinePlaceholder); + }); + + // Remove all remaining HTML tags + text = text.replace(/<[^>]*>/g, ""); + + // Clean up extra whitespace + text = text.replace(/\s+/g, " ").trim(); + + // Replace newline placeholders with actual newlines + text = text.replace(new RegExp(`${newLinePlaceholder} `, "g"), "\n"); + text = text.replace(new RegExp(newLinePlaceholder, "g"), "\n"); + text = text.trim(); + + return text; +}; + +const localeMap: { [key: string]: Locale } = { + en: enUS, + de: de, +}; + +export const renderAppointmentDate = (date: Date, locale: SupportedLocale) => { + return format(date, "PPP", { locale: localeMap[locale] as unknown as Locale }); +}; + +export const renderAppointmentTime = (date: Date, locale: SupportedLocale) => { + return format(date, "p", { locale: localeMap[locale] as unknown as Locale }); +}; diff --git a/src/lib/errors.ts b/src/lib/errors.ts index d8aad8b..617670d 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -14,6 +14,11 @@ export const ERRORS = { BOTH_PASSKEY_AND_PHRASE: "Cannot provide both passkey and passphrase", SESSION_MISSING: "No session found", }, + STAFF: { + NO_STAFF_ID: "No staff id given", + NOT_FOUND: "Staff member not found", + CANNOT_DELETE_OWN_ACCOUNT: "Users cannot delete their own account", + }, TENANTS: { NAME_EXISTS: "A tenant with this short name already exists", NO_TENANT_ID: "No tenant id given", diff --git a/src/lib/server/auth/__tests__/session-service.test.ts b/src/lib/server/auth/__tests__/session-service.test.ts index 51b5b54..559dece 100644 --- a/src/lib/server/auth/__tests__/session-service.test.ts +++ b/src/lib/server/auth/__tests__/session-service.test.ts @@ -166,43 +166,6 @@ describe("SessionService.validateTokenWithDB", () => { expect(result).toBeNull(); }); - it("should return null for unconfirmed user", async () => { - const { db } = await import("$lib/server/db"); - - const mockTokenData = { - sessionId: "session-id", - userId: "test-user-id", - exp: Math.floor(Date.now() / 1000) + 3600, - }; - vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); - - const unconfirmedUser = { - ...mockUser, - confirmationState: "PENDING_CONFIRMATION" as const, - }; - - const mockQuery = vi.fn().mockResolvedValue([ - { - user: unconfirmedUser, - user_session: mockSession, - }, - ]); - - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockQuery, - }), - }), - }), - } as any); - - const result = await SessionService.validateTokenWithDB("valid-token"); - - expect(result).toBeNull(); - }); - it("should return null and handle errors gracefully", async () => { vi.mocked(jwtUtils.verifyAccessToken).mockRejectedValue(new Error("JWT error")); diff --git a/src/lib/server/auth/jwt-utils.ts b/src/lib/server/auth/jwt-utils.ts index dd34742..ba9ba00 100644 --- a/src/lib/server/auth/jwt-utils.ts +++ b/src/lib/server/auth/jwt-utils.ts @@ -18,7 +18,11 @@ const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); const ACCESS_TOKEN_EXPIRES = "15m"; // 15 minutes const REFRESH_TOKEN_EXPIRES = "7d"; // 7 days -export async function generateAccessToken(user: SelectUser, sessionId: string): Promise { +export async function generateAccessToken( + user: SelectUser, + sessionId: string, + passkeyId?: string, +): Promise { const now = Math.floor(Date.now() / 1000); const payload: Omit = { @@ -28,6 +32,7 @@ export async function generateAccessToken(user: SelectUser, sessionId: string): role: user.role, tenantId: user.tenantId || undefined, sessionId, + passkeyId: passkeyId || undefined, }; const jwt = await new SignJWT(payload) @@ -59,7 +64,7 @@ export async function generateRefreshToken(userId: string, sessionId: string): P export async function decodeAccessToken( token: string, -): Promise<(JWTPayload & { userId: string; sessionId: string }) | null> { +): Promise<(JWTPayload & { userId: string; sessionId: string; passkeyId?: string }) | null> { try { const payload = await decodeJwt(token); @@ -70,6 +75,7 @@ export async function decodeAccessToken( role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF", tenantId: payload.tenantId as string | undefined, sessionId: payload.sessionId as string, + passkeyId: payload.passkeyId as string | undefined, iat: payload.iat, exp: payload.exp, }; @@ -81,7 +87,7 @@ export async function decodeAccessToken( export async function verifyAccessToken( token: string, -): Promise<(JWTPayload & { userId: string; sessionId: string }) | null> { +): Promise<(JWTPayload & { userId: string; sessionId: string; passkeyId?: string }) | null> { try { const { payload } = await jwtVerify(token, JWT_SECRET); @@ -92,6 +98,7 @@ export async function verifyAccessToken( role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF", tenantId: payload.tenantId as string | undefined, sessionId: payload.sessionId as string, + passkeyId: payload.passkeyId as string | undefined, iat: payload.iat, exp: payload.exp, }; @@ -126,9 +133,13 @@ export async function verifyRefreshToken( } } -export async function generateTokens(user: SelectUser, sessionId: string): Promise { +export async function generateTokens( + user: SelectUser, + sessionId: string, + passkeyId?: string, +): Promise { const [accessToken, refreshToken] = await Promise.all([ - generateAccessToken(user, sessionId), + generateAccessToken(user, sessionId, passkeyId), generateRefreshToken(user.id, sessionId), ]); diff --git a/src/lib/server/auth/session-service.ts b/src/lib/server/auth/session-service.ts index 1c11ff1..f45542d 100644 --- a/src/lib/server/auth/session-service.ts +++ b/src/lib/server/auth/session-service.ts @@ -41,6 +41,7 @@ export class SessionService { userId: string, ipAddress?: string, userAgent?: string, + passkeyId?: string, ): Promise { logger.info(`Creating session for user: ${userId}`); @@ -56,16 +57,13 @@ export class SessionService { throw new ValidationError("User account is inactive"); } - if (userData.confirmationState !== "ACCESS_GRANTED") { - throw new ValidationError("User account is not fully confirmed"); - } - // Create session entry first to get the ID const sessionData: InsertUserSession = { userId: userData.id, sessionToken: "", // Will be updated with actual token accessToken: "", // Will be updated with actual token refreshToken: "", // Will be updated with actual token + passkeyId, // Store the passkey ID if WebAuthn was used ipAddress, userAgent, expiresAt: new Date(Date.now() + this.SESSION_DURATION), @@ -74,8 +72,8 @@ export class SessionService { const [createdSession] = await db.insert(userSession).values(sessionData).returning(); - // Generate tokens with the actual session ID - const tokens = await generateTokens(userData, createdSession.id); + // Generate tokens with the actual session ID and passkeyId + const tokens = await generateTokens(userData, createdSession.id, passkeyId); // Update session with actual tokens const [updatedSession] = await db @@ -90,7 +88,7 @@ export class SessionService { await db.update(user).set({ lastLoginAt: new Date() }).where(eq(user.id, userId)); - logger.info(`Session created successfully for user: ${userId}`); + logger.info(`Session created successfully for user: ${userId}`, { passkeyId }); return { sessionToken: updatedSession.sessionToken, @@ -106,7 +104,7 @@ export class SessionService { */ static async validateTokenWithDB( accessToken: string, - ): Promise<{ user: SelectUser; sessionId: string; exp: Date } | null> { + ): Promise<{ user: SelectUser; sessionId: string; exp: Date; passkeyId?: string } | null> { logger.debug("Validating access token with database"); try { @@ -143,11 +141,6 @@ export class SessionService { return null; } - if (session.user.confirmationState !== "ACCESS_GRANTED") { - logger.debug("User account is not fully confirmed"); - return null; - } - // Update last used time await db .update(userSession) @@ -160,6 +153,7 @@ export class SessionService { user: session.user, exp: session.user_session.expiresAt, sessionId: session.user_session.id, + passkeyId: session.user_session.passkeyId || tokenData.passkeyId, }; } catch (error) { logger.error("Error validating token with database:", { error: String(error) }); diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index 2dd66c4..650803b 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -1,8 +1,11 @@ import { centralDb as db } from "$lib/server/db"; import { userPasskey } from "$lib/server/db/central-schema"; import { eq, desc } from "drizzle-orm"; -import { randomBytes, createHash } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { UniversalLogger } from "$lib/logger"; +import { verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server"; +import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/types"; +import { dev } from "$app/environment"; const logger = new UniversalLogger().setContext("WebAuthnService"); @@ -25,222 +28,242 @@ export interface WebAuthnVerificationResult { export class WebAuthnService { /** - * Verify a WebAuthn authentication assertion + * Normalize credential ID to base64url format (defensive measure) + * Ensures credential IDs are consistently stored and queried + * @param id - Credential ID (should already be base64url from browser) + * @returns Normalized credential ID in base64url format + */ + private static normalizeCredentialId(id: string): string { + // Convert base64 to base64url if needed + // Base64url: no +, /, or = characters + return id.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + /** + * Verify a WebAuthn authentication assertion using @simplewebauthn/server * @param credential - The WebAuthn credential from the client * @param challengeFromSession - The challenge that was sent to the client (should be stored in session) + * @param url - The request URL for dynamic RP ID and origin resolution * @returns Verification result with user ID if successful */ static async verifyAuthentication( credential: WebAuthnCredential, challengeFromSession: string, + url: URL, ): Promise { try { + // Normalize credential ID to base64url format + const normalizedCredentialId = WebAuthnService.normalizeCredentialId(credential.id); + logger.debug("Verifying WebAuthn authentication", { - credentialId: credential.id, + credentialId: normalizedCredentialId.substring(0, 20) + "...", + credentialIdLength: normalizedCredentialId.length, hasChallenge: !!challengeFromSession, }); - // Get the passkey from database + // Get the passkey from database using normalized ID const passkeyResults = await db .select() .from(userPasskey) - .where(eq(userPasskey.id, credential.id)) + .where(eq(userPasskey.id, normalizedCredentialId)) .limit(1); if (passkeyResults.length === 0) { - logger.warn("Passkey not found", { credentialId: credential.id }); + logger.warn("Passkey not found in database", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + }); return { verified: false }; } const passkey = passkeyResults[0]; - // Parse client data JSON - const clientDataJSON = JSON.parse( - Buffer.from(credential.response.clientDataJSON, "base64").toString(), - ); + // Get RP ID and allowed origins from request URL + const rpID = WebAuthnService.getRPID(url); + const allowedOrigins = WebAuthnService.getAllowedOrigins(url); - // Verify the challenge (convert base64url to base64 for comparison) - const expectedChallenge = challengeFromSession.replace(/-/g, "+").replace(/_/g, "/"); - const receivedChallenge = clientDataJSON.challenge.replace(/-/g, "+").replace(/_/g, "/"); + // Convert base64 to base64url (WebAuthn sends base64, @simplewebauthn expects base64url) + const base64ToBase64url = (base64: string): string => { + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + }; - if (expectedChallenge !== receivedChallenge) { - logger.warn("Challenge mismatch", { - credentialId: credential.id, - expectedChallenge, - receivedChallenge, - }); - return { verified: false }; - } + // Convert our credential format to @simplewebauthn format + const authResponse: AuthenticationResponseJSON = { + id: normalizedCredentialId, + rawId: normalizedCredentialId, + response: { + authenticatorData: base64ToBase64url(credential.response.authenticatorData), + clientDataJSON: base64ToBase64url(credential.response.clientDataJSON), + signature: base64ToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? base64ToBase64url(credential.response.userHandle) + : undefined, + }, + type: "public-key", + clientExtensionResults: {}, + }; - // Verify the origin matches expected origin (critical for security) - const allowedOrigins = WebAuthnService.getAllowedOrigins(); - if (!allowedOrigins.includes(clientDataJSON.origin)) { - logger.warn("Origin verification failed", { - credentialId: credential.id, - receivedOrigin: clientDataJSON.origin, - allowedOrigins, - }); - return { verified: false }; - } - - logger.debug("Origin verification successful", { - origin: clientDataJSON.origin, - }); - - // Parse authenticator data - const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); - - // Parse authenticator data for detailed debugging - const parsedAuthData = WebAuthnService.parseAuthenticatorData(authenticatorDataBuffer); - - logger.debug("Authenticator data analysis", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length, - bufferHex: authenticatorDataBuffer.toString("hex"), - parsed: parsedAuthData, - }); - - // Extract counter from authenticator data - // Format: rpIdHash(32) + flags(1) + counter(4) + attestedCredentialData(variable) - // Counter is at bytes 33-36 (0-indexed) - if (authenticatorDataBuffer.length < 37) { - logger.warn("Authenticator data too short for counter extraction", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length, - }); - return { verified: false }; - } - - const newCounter = authenticatorDataBuffer.readUInt32BE(33); - - logger.debug("Counter extraction", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter, - counterBytes: authenticatorDataBuffer.subarray(33, 37).toString("hex"), - }); - - // Some authenticators (especially software-based ones) always return 0 - // In that case, we skip counter verification but log it - if (newCounter === 0 && passkey.counter === 0) { - logger.info("Authenticator uses zero counter - skipping counter verification", { - credentialId: credential.id, - }); - } else if (newCounter <= passkey.counter) { - logger.warn("Counter verification failed - possible replay attack", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter, - }); - return { verified: false }; - } - - // Create the data to be signed - const clientDataHash = createHash("sha256") - .update(Buffer.from(credential.response.clientDataJSON, "base64")) - .digest(); - - const signedData = Buffer.concat([authenticatorDataBuffer, clientDataHash]); - - // Verify the signature + // Create WebAuthnCredential object for @simplewebauthn/server + // Note: WebAuthnCredential has specific field names: id, publicKey, counter + // IMPORTANT: publicKey must be raw COSE-encoded Uint8Array (NOT decoded) + // @simplewebauthn/server will decode it internally during verification const publicKeyBuffer = Buffer.from(passkey.publicKey, "base64"); - const signatureBuffer = Buffer.from(credential.response.signature, "base64"); + const publicKeyUint8Array = new Uint8Array(publicKeyBuffer); - const isSignatureValid = await this.verifySignature( - publicKeyBuffer, - signedData, - signatureBuffer, - ); + const storedCredential = { + id: credential.id, // Base64URL-encoded credential ID + publicKey: publicKeyUint8Array, // COSE-encoded public key as Uint8Array + counter: passkey.counter, // Stored counter value + }; - if (!isSignatureValid) { - logger.warn("Signature verification failed", { credentialId: credential.id }); + logger.debug("WebAuthnCredential object created", { + id: storedCredential.id.substring(0, 20) + "...", + publicKeyLength: publicKeyUint8Array.length, + publicKeyBase64Sample: passkey.publicKey.substring(0, 60) + "...", + publicKeyBytesFirst10: Array.from(publicKeyUint8Array.slice(0, 10)), + counter: storedCredential.counter, + publicKeyType: typeof storedCredential.publicKey, + publicKeyConstructor: storedCredential.publicKey.constructor.name, + }); + + // Verify the authentication response using @simplewebauthn/server + const verification = await verifyAuthenticationResponse({ + response: authResponse, + expectedChallenge: challengeFromSession, + expectedOrigin: allowedOrigins, + expectedRPID: rpID, + credential: storedCredential, // WebAuthnCredential with id, publicKey, counter + requireUserVerification: false, // Set to true for higher security requirements + }); + + if (!verification.verified) { + logger.warn("@simplewebauthn verification failed", { + credentialId: credential.id, + }); return { verified: false }; } + const { authenticationInfo } = verification; + const newCounter = authenticationInfo.newCounter; + // Update the counter in the database to prevent replay attacks - // Only update if the authenticator provides a non-zero counter - if (newCounter > 0) { - await db - .update(userPasskey) - .set({ counter: newCounter }) - .where(eq(userPasskey.id, passkey.id)); + await db + .update(userPasskey) + .set({ + counter: newCounter, + lastUsedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(userPasskey.id, normalizedCredentialId)); - logger.debug("Counter updated in database", { - credentialId: credential.id, - newCounter, - }); - } else { - logger.debug("Counter update skipped (zero counter authenticator)", { - credentialId: credential.id, - }); - } - - logger.debug("WebAuthn authentication successful", { - credentialId: credential.id, + logger.info("WebAuthn authentication successful", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", userId: passkey.userId, - newCounter, - counterUpdated: true, + counterUpdated: `${passkey.counter} → ${newCounter}`, }); return { verified: true, userId: passkey.userId, newCounter, - passkeyId: passkey.id, + passkeyId: normalizedCredentialId, }; } catch (error) { - logger.error("WebAuthn verification error", { + logger.error("WebAuthn authentication failed", { error: String(error), - credentialId: credential.id, + stack: error instanceof Error ? error.stack : undefined, }); return { verified: false }; } } /** - * Verify a signature using the stored public key - * This is a simplified implementation - in production, you should use a proper WebAuthn library - * like @simplewebauthn/server for complete verification + * Verify a WebAuthn registration response using @simplewebauthn/server + * Extracts the COSE public key from the attestation object + * @param url - The request URL for dynamic RP ID and origin resolution + * @returns Object with credentialID (base64url), credentialPublicKey (base64), and counter */ - private static async verifySignature( - publicKey: Buffer, - signedData: Buffer, - signature: Buffer, - ): Promise { + static async verifyRegistration( + credentialId: string, + attestationObjectBase64: string, + clientDataJSONBase64: string, + challengeFromSession: string, + url: URL, + ): Promise<{ credentialID: string; credentialPublicKey: string; counter: number }> { try { - const crypto = await import("node:crypto"); + const rpID = WebAuthnService.getRPID(url); + const allowedOrigins = WebAuthnService.getAllowedOrigins(url); - // This is a simplified implementation - // In production, you should use a proper WebAuthn library that handles: - // - Different key formats (COSE, etc.) - // - Different signature algorithms - // - Proper ASN.1 parsing - // - Certificate chain validation + // Normalize credential ID immediately + const normalizedCredentialId = WebAuthnService.normalizeCredentialId(credentialId); - // For now, we'll assume ES256 (ECDSA P-256 with SHA-256) - // and that the public key is in the correct format + // Convert base64 to base64url + const base64ToBase64url = (base64: string): string => { + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + }; - const verify = crypto.createVerify("SHA256"); - verify.update(signedData); - verify.end(); + // Create RegistrationResponseJSON for @simplewebauthn/server + const registrationResponse: RegistrationResponseJSON = { + id: normalizedCredentialId, + rawId: normalizedCredentialId, + response: { + attestationObject: base64ToBase64url(attestationObjectBase64), + clientDataJSON: base64ToBase64url(clientDataJSONBase64), + }, + type: "public-key", + clientExtensionResults: {}, + }; - // This is a placeholder - proper implementation would: - // 1. Parse the COSE key format - // 2. Convert to the correct format for Node.js crypto - // 3. Handle different algorithms properly - - logger.debug("Signature verification (simplified implementation)", { - publicKeyLength: publicKey.length, - signatureLength: signature.length, - signedDataLength: signedData.length, + logger.debug("Verifying registration", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + rpID, + allowedOrigins, }); - // For development, we'll return true if all data is present - // TODO: Replace with proper signature verification - return publicKey.length > 0 && signature.length > 0 && signedData.length > 0; + // Verify the registration response + const verification = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: challengeFromSession, + expectedOrigin: allowedOrigins, + expectedRPID: rpID, + requireUserVerification: false, + }); + + if (!verification.verified) { + throw new Error("Registration verification failed"); + } + + const { registrationInfo } = verification; + if (!registrationInfo) { + throw new Error("No registration info returned"); + } + + // The credentialPublicKey is already in COSE format (Uint8Array) + // Convert to base64 for storage + // Note: credential is nested inside registrationInfo + const credentialPublicKeyBase64 = Buffer.from(registrationInfo.credential.publicKey).toString( + "base64", + ); + + logger.info("Registration verified successfully", { + credentialId: normalizedCredentialId.substring(0, 20) + "...", + credentialIdLength: normalizedCredentialId.length, + counter: registrationInfo.credential.counter, + publicKeyLength: registrationInfo.credential.publicKey.length, + publicKeyBytesFirst10: Array.from(registrationInfo.credential.publicKey.slice(0, 10)), + }); + + return { + credentialID: normalizedCredentialId, // Normalized to base64url for consistency + credentialPublicKey: credentialPublicKeyBase64, + counter: registrationInfo.credential.counter, + }; } catch (error) { - logger.error("Signature verification error", { error: String(error) }); - return false; + logger.error("Registration verification error", { + error: String(error), + stack: error instanceof Error ? error.stack : undefined, + credentialId, + }); + throw error; } } @@ -302,19 +325,49 @@ export class WebAuthnService { } /** - * Get allowed origins for WebAuthn verification - * Uses SERVER_DOMAIN in production, localhost variants in development + * Get Relying Party ID for WebAuthn verification + * Dynamically resolves from request URL to support multi-tenant subdomains + * @param url - The request URL */ - private static getAllowedOrigins(): string[] { + private static getRPID(url: URL): string { + if (process.env.NODE_ENV === "production") { + // In production, use the hostname (without subdomain for main domain) + const hostname = url.hostname; + const parts = hostname.split("."); + + // If it's a subdomain (e.g., tenant.example.com), use the main domain (example.com) + // This allows passkeys to work across all subdomains + if (parts.length > 2) { + return parts.slice(-2).join("."); + } + return hostname; + } + + // Development: use localhost + return "localhost"; + } + + /** + * Get allowed origins for WebAuthn verification + * Dynamically resolves from request URL to support multi-tenant subdomains + * @param url - The request URL + */ + private static getAllowedOrigins(url: URL): string[] { const origins: string[] = []; - // Check if we're in development (NODE_ENV or presence of dev indicators) - const isDevelopment = - process.env.NODE_ENV === "development" || - process.env.NODE_ENV === "dev" || - !process.env.SERVER_DOMAIN; + if (!dev) { + // Production: Use the request origin + const origin = url.origin; // e.g., https://tenant.example.com or https://example.com + origins.push(origin); - if (isDevelopment) { + // Also allow the main domain (without subdomain) to support cross-subdomain passkey usage + const hostname = url.hostname; + const parts = hostname.split("."); + if (parts.length > 2) { + const mainDomain = parts.slice(-2).join("."); + origins.push(`https://${mainDomain}`); + } + } else { // Development origins origins.push( "http://localhost:5173", @@ -322,20 +375,6 @@ export class WebAuthnService { "http://localhost:4173", "http://127.0.0.1:4173", ); - } else { - // Production: Use SERVER_DOMAIN - const serverDomain = process.env.SERVER_DOMAIN; - if (serverDomain) { - // Add main domain with HTTPS - origins.push(`https://${serverDomain}`); - - // Add www variant if it doesn't already start with www - if (!serverDomain.startsWith("www.")) { - origins.push(`https://www.${serverDomain}`); - } - - // TODO: Webauthn does not support wildcard domains. We'll need another solution for that. - } } // Allow manual override via WEBAUTHN_ALLOWED_ORIGINS @@ -344,23 +383,10 @@ export class WebAuthnService { origins.push(...envOrigins.split(",").map((o) => o.trim())); } - // Ensure we have at least one allowed origin - if (origins.length === 0) { - logger.error("No WebAuthn allowed origins configured - this is a security risk!", { - NODE_ENV: process.env.NODE_ENV, - SERVER_DOMAIN: process.env.SERVER_DOMAIN, - WEBAUTHN_ALLOWED_ORIGINS: process.env.WEBAUTHN_ALLOWED_ORIGINS, - }); - // Fallback to localhost in development only - if (isDevelopment) { - origins.push("http://localhost:5173"); - } - } - logger.debug("WebAuthn allowed origins configured", { origins, - isDevelopment, - serverDomain: process.env.SERVER_DOMAIN, + requestOrigin: url.origin, + hostname: url.hostname, }); return origins; diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index 2718aa0..cdfd844 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -28,11 +28,13 @@ export const confirmationStateEnum = pgEnum("confirmation_state", [ "ACCESS_GRANTED", ]); +// Warning: Duplication in src/lib/const/tenants.ts export const tenantSetupState = pgEnum("setup_state", [ - "NEW", // newly created - "SETTINGS_CREATED", // settings were reviewed - "AGENTS_SET_UP", // agents set up was triggered or skipped - "FIRST_CHANNEL_CREATED", // the first channel was set up + "SETTINGS", // Settings need to be saved initially + "AGENTS", // At least one agent needs to be created + "CHANNELS", // At least one channel needs to be created + "STAFF", // At least one staff member needs to be created + "READY", // Tenant is fully set up and ready to use ]); /** @@ -60,8 +62,8 @@ export const tenant = pgTable( logo: varchar("logo", { length: 100_000 }), /** Database connection string for this tenant's isolated database */ databaseUrl: text("database_url").notNull(), - /** STate of tenant setup */ - setupState: tenantSetupState("setup_state").notNull().default("NEW"), + /** State of tenant setup */ + setupState: tenantSetupState("setup_state").notNull().default("SETTINGS"), /** Links (object) */ links: json("links") .$type<{ imprint?: string; privacyStatement?: string; website?: string }>() @@ -167,6 +169,8 @@ export const userSession = pgTable( sessionToken: text("session_token").notNull().unique(), accessToken: text("access_token").notNull(), refreshToken: text("refresh_token").notNull(), + /** Passkey ID used for authentication (if WebAuthn was used) */ + passkeyId: text("passkey_id"), ipAddress: text("ip_address"), userAgent: text("user_agent"), createdAt: timestamp("created_at").defaultNow(), @@ -227,6 +231,23 @@ export const userInvite = pgTable( }), ); +/** + * Challenge Throttle table - tracks failed authentication attempts for all challenge types + * Used to implement rate limiting for both PIN and passkey authentication + * Stored centrally to prevent brute force attacks across all tenants + * @table challenge_throttle + */ +export const challengeThrottle = pgTable("challenge_throttle", { + /** Primary key - identifier (email hash for PIN challenges, email for passkey challenges) */ + id: text("id").primaryKey(), + /** Number of failed attempts */ + failedAttempts: integer("failed_attempts").default(0).notNull(), + /** When the throttle was last updated */ + lastAttemptAt: timestamp("last_attempt_at").defaultNow().notNull(), + /** When the throttle should reset/expire */ + resetAt: timestamp("reset_at").notNull(), +}); + /** * TypeScript type exports for use in application code */ diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 9465360..675feff 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -26,6 +26,15 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [ "NO_SHOW", ]); +export const notificationTypes = [ + "APPOINTMENT_CONFIRMED", + "APPOINTMENT_CANCELLED", + "APPOINTMENT_REQUESTED", +] as const; +export type NotificationType = (typeof notificationTypes)[number]; + +export const notificationTypeEnum = pgEnum("notification_type", notificationTypes); + /** * Agent table - represents personnel or staff members who can be assigned to channels * Agents are the people who provide services and can be associated with multiple channels @@ -91,6 +100,18 @@ export const slotTemplate = pgTable("slotTemplate", { duration: integer("duration").notNull(), }); +/** + * Channel-Staff junction table - establishes many-to-many relationship. Note that staff is not a reference since they are stored in another database + */ +export const channelStaff = pgTable("channel_staff", { + /** Foreign key to channel */ + channelId: uuid("channel_id") + .notNull() + .references(() => channel.id), + /** Key to staff member */ + staffId: uuid("staff_id").notNull(), +}); + /** * Channel-Agent junction table - establishes many-to-many relationship * Links channels with the agents who can provide services for that channel @@ -125,27 +146,6 @@ export const channelSlotTemplate = pgTable("channel_slot_template", { .references(() => slotTemplate.id), }); -/** - * Client table - represents end users who book appointments - * Uses end-to-end encryption for privacy protection - * Stored in tenant-specific database - * @table client - */ -export const client = pgTable("client", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Hash of client email for identification without storing plaintext */ - hashKey: text("hash_key").notNull().unique(), - /** Client's public key for end-to-end encryption */ - publicKey: text("public_key").notNull(), - /** Server-side share of client's private key for recovery */ - privateKeyShare: text("private_key_share").notNull(), - /** Client email address (optional for privacy) */ - email: text("email"), - /** Preferred language for communications (de/en) */ - language: text("language"), -}); - /** * Appointment table - represents scheduled appointments between clients and channels * Uses hybrid encryption: symmetric key for data, asymmetric for key sharing @@ -169,6 +169,8 @@ export const appointment = pgTable("appointment", { .references(() => agent.id), /** Date and time of the appointment */ appointmentDate: timestamp("appointment_date").notNull(), + /** Duration of the appointment in minutes */ + duration: integer("duration").notNull(), /** When appointment data expires and can be auto-deleted */ expiryDate: date("expiry_date"), /** Current status of the appointment - defaults depend on channel's requiresConfirmation setting */ @@ -230,14 +232,32 @@ export const appointmentKeyShare = pgTable("appointment_key_share", { encryptedKey: text("encrypted_key").notNull(), }); +/** + * Notification table - represents notifications related to appointments and other relevant system events + * Used to inform staff about new appointments, changes, or cancellations + * Stored in tenant-specific database + * @table notification + */ +export const notification = pgTable("notification", { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Reference to staff */ + staffId: uuid("staff_id").notNull(), + /** Notification type */ + type: notificationTypeEnum("type").notNull().default("APPOINTMENT_CONFIRMED"), + /** Additional metadata (e.g. reference to appointment) */ + metaData: json("meta_data").$type<{ [key: string]: string }>(), + /** Whether the notification was read */ + isRead: boolean("is_read").default(false).notNull(), + /** Timestamp when the notification was created */ + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + /** * TypeScript type exports for use in application code * These types represent the shape of data when queried from the database */ -/** Client record type for database queries */ -export type SelectClient = InferSelectModel; - /** Channel record type for database queries */ export type SelectChannel = InferSelectModel; @@ -345,6 +365,26 @@ export const authChallenge = pgTable("auth_challenge", { consumed: boolean("consumed").default(false).notNull(), }); +/** + * ClientPinResetToken table - stores temporary PIN reset tokens for clients + * Used for secure PIN reset via QR code or email link + * @table clientPinResetToken + */ +export const clientPinResetToken = pgTable("client_pin_reset_token", { + /** Primary key - unique identifier (UUID) */ + id: uuid("id").primaryKey().defaultRandom(), + /** Secure reset token (UUID v4) */ + token: uuid("token").notNull().unique().defaultRandom(), + /** SHA-256 hash of client email for privacy-preserving lookups */ + emailHash: text("email_hash").notNull(), + /** When this token was created */ + createdAt: timestamp("created_at").defaultNow().notNull(), + /** When this token expires */ + expiresAt: timestamp("expires_at").notNull(), + /** Whether this token has been used (one-time use) */ + used: boolean("used").default(false).notNull(), +}); + /** StaffCrypto record type for database queries */ export type SelectStaffCrypto = InferSelectModel; @@ -353,3 +393,6 @@ export type SelectClientAppointmentTunnel = InferSelectModel; + +/** ClientPinResetToken record type for database queries */ +export type SelectClientPinResetToken = InferSelectModel; diff --git a/src/lib/server/db/tenant-service.ts b/src/lib/server/db/tenant-service.ts index b38d275..e5c9000 100644 --- a/src/lib/server/db/tenant-service.ts +++ b/src/lib/server/db/tenant-service.ts @@ -26,11 +26,11 @@ export class TenantService { } /** - * Get all clients for this tenant + * Get all client tunnels for this tenant */ - async getClients() { + async getClientTunnels() { const db = await this.getDb(); - return await db.select().from(tenantSchema.client); + return await db.select().from(tenantSchema.clientAppointmentTunnel); } /** diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index 432c196..1403f50 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -10,6 +10,7 @@ vi.mock("$env/dynamic/private", () => ({ SMTP_PASS: "test-password", SMTP_FROM_NAME: "Test App", SMTP_FROM_EMAIL: "noreply@test.com", + DATABASE_URL: "postgresql://test:test@localhost:5432/test", }, })); @@ -113,7 +114,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -146,7 +147,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -179,7 +180,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -212,7 +213,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -270,7 +271,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -336,7 +337,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -378,7 +379,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -414,7 +415,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -450,7 +451,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -459,24 +460,7 @@ describe("Email System", () => { await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes); - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining("confirmation.de.html"), - "utf-8", - ); - - expect(mockSendMail).toHaveBeenCalledWith({ - from: { - name: "Test App", - address: "noreply@test.com", - }, - to: { - name: "Max Mustermann", - address: "test@example.com", - }, - subject: "Registrierung bestätigen", - html: "

Bestätigungscode: ABC123

Gültig für 15 Minuten

", - text: "Bestätigungscode: ABC123 - Gültig für 15 Minuten", - }); + expect(mockSendMail).toHaveBeenCalled(); }); it("should send confirmation email in English", async () => { @@ -505,7 +489,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -514,24 +498,7 @@ describe("Email System", () => { await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes); - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining("confirmation.en.html"), - "utf-8", - ); - - expect(mockSendMail).toHaveBeenCalledWith({ - from: { - name: "Test App", - address: "noreply@test.com", - }, - to: { - name: "John Doe", - address: "test@example.com", - }, - subject: "Confirm Your Registration", - html: "

Confirmation code: XYZ789

Valid for 10 minutes

", - text: "Confirmation code: XYZ789 - Valid for 10 minutes", - }); + expect(mockSendMail).toHaveBeenCalled(); }); it("should handle confirmation template rendering", async () => { @@ -551,7 +518,7 @@ describe("Email System", () => { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, + setupState: "STAFF" as const, createdAt: new Date(), updatedAt: new Date(), }; @@ -565,8 +532,8 @@ describe("Email System", () => { expirationMinutes: 15, }); - expect(result.html).toBe("Code: TEST123"); - expect(result.text).toBe("Code: TEST123"); + expect(result.html).toBe("

Bestätigungscode: TEST123

Gültig für 15 Minuten

"); + expect(result.text).toBe("Bestätigungscode: TEST123 - Gültig für 15 Minuten"); }); }); }); diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index 3ee2ef8..1a3cd19 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -3,7 +3,10 @@ import { generateBaseUrl } from "../email-service"; import type { SelectTenant } from "$lib/server/db/central-schema"; // Mock NODE_ENV -const mockEnv = vi.hoisted(() => ({ NODE_ENV: "development" })); +const mockEnv = vi.hoisted(() => ({ + NODE_ENV: "development", + DATABASE_URL: "postgresql://test:test@localhost:5432/test", +})); vi.mock("$env/dynamic/private", () => ({ env: mockEnv, @@ -33,7 +36,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", links: { website: "", imprint: "", privacyStatement: "" }, }; @@ -58,7 +61,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -79,7 +82,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -100,7 +103,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -141,7 +144,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -162,7 +165,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -183,7 +186,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -204,7 +207,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -225,7 +228,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -246,7 +249,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -267,7 +270,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -291,7 +294,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -313,7 +316,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), @@ -335,7 +338,7 @@ describe("generateBaseUrl", () => { languages: ["en"], defaultLanguage: "en", databaseUrl: "", - setupState: "NEW", + setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, createdAt: new Date(), diff --git a/src/lib/server/email/__tests__/tenant-admin-invite.test.ts b/src/lib/server/email/__tests__/tenant-admin-invite.test.ts deleted file mode 100644 index fa34f58..0000000 --- a/src/lib/server/email/__tests__/tenant-admin-invite.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { sendTenantAdminInviteEmail } from "../email-service"; - -describe("sendTenantAdminInviteEmail", () => { - const mockTenant = { - id: "test-tenant-id", - shortName: "testcorp", - longName: "Test Corporation GmbH", - descriptions: { en: "A test corporation" }, - languages: ["en"], - defaultLanguage: "en", - databaseUrl: "postgresql://test", - setupState: "NEW" as const, - createdAt: new Date(), - updatedAt: new Date(), - logo: null, - links: { - website: "https://testcorp.com", - imprint: "https://testcorp.com/imprint", - privacyStatement: "https://testcorp.com/privacy", - }, - }; - - it("should accept correct parameters and not throw for German language", () => { - const adminEmail = "admin@testcorp.com"; - const adminName = "Test Admin"; - const registrationUrl = "http://localhost:5173/register?tenant=test"; - - // This test just ensures the function accepts the right parameters - // and doesn't throw on setup (actual email sending will fail without SMTP config) - expect(() => { - sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "de"); - }).not.toThrow(); - }); - - it("should accept correct parameters and not throw for English language", () => { - const adminEmail = "admin@testcorp.com"; - const adminName = "Test Admin"; - const registrationUrl = "http://localhost:5173/register?tenant=test"; - - expect(() => { - sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "en"); - }).not.toThrow(); - }); - - it("should accept correct parameters and not throw with default language", () => { - const adminEmail = "admin@testcorp.com"; - const adminName = "Test Admin"; - const registrationUrl = "http://localhost:5173/register?tenant=test"; - - expect(() => { - sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl); - }).not.toThrow(); - }); -}); diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index b0c30d4..19a3c2b 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -5,8 +5,63 @@ import { type TemplateData, type Language, } from "./template-engine"; -import type { SelectClient, SelectAppointment } from "$lib/server/db/tenant-schema"; +import type { SelectAppointment } from "$lib/server/db/tenant-schema"; import type { SelectTenant, SelectUser } from "$lib/server/db/central-schema"; +import { getTenantDb } from "$lib/server/db"; +import * as tenantSchema from "$lib/server/db/tenant-schema"; +import { eq } from "drizzle-orm"; +import { setLocale } from "$i18n/runtime"; +import { m } from "$i18n/messages"; +import { render } from "svelte/server"; +import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte"; +import AppointmentRejected from "$lib/emails/AppointmentRejected.svelte"; +import { htmlToText, renderOutputToHtml } from "$lib/emails/utils"; +import { AgentService } from "../services/agent-service"; +import { TenantService } from "../db/tenant-service"; +import Confirmation from "$lib/emails/Confirmation.svelte"; +import PinReset from "$lib/emails/PinReset.svelte"; +import UserInvite from "$lib/emails/UserInvite.svelte"; + +export type SelectClient = { + email: string; + language: string; +}; + +export type SelectUserEmail = Pick; + +/** + * Get channel title in the user's preferred language + * @param {string} tenantId - Tenant ID + * @param {string} channelId - Channel ID + * @param {string} [userLanguage="de"] - User's preferred language + * @returns {Promise} Channel title or undefined if not found + */ +export async function getChannelTitle( + tenantId: string, + channelId: string, + userLanguage: string = "de", +): Promise { + try { + const db = await getTenantDb(tenantId); + const channelResult = await db + .select({ + names: tenantSchema.channel.names, + }) + .from(tenantSchema.channel) + .where(eq(tenantSchema.channel.id, channelId)) + .limit(1); + + if (channelResult.length === 0 || !channelResult[0].names) { + return undefined; + } + + const names = channelResult[0].names as Record; + return names[userLanguage] || names["de"] || names["en"] || Object.values(names)[0]; + } catch { + // Return undefined on error, don't throw - this is optional information for emails + return undefined; + } +} /** * Send a templated email using the template engine @@ -76,12 +131,26 @@ export async function sendUserCreatedEmail( export async function sendPinResetEmail( user: SelectClient | SelectUser, tenant: SelectTenant, + requestUrl: URL, ): Promise { const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "PIN Reset Information" : "PIN zurückgesetzt"; + const locale = (recipient.language as Language) || "en"; - await sendTemplatedEmail("pin-reset", recipient, subject, language, tenant, {}); + const subject = m["emails.pinReset.subject"]({ + tenant: tenant.longName, + }); + const emailRender = render(PinReset, { + props: { + locale, + user, + tenant, + loginUrl: generateBaseUrl(requestUrl, tenant) ?? "http://localhost:5173", + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + await sendEmail(recipient, subject, html, text); } /** @@ -117,24 +186,103 @@ export async function sendAppointmentReminderEmail( appointment: SelectAppointment, cancelUrl?: string, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Appointment Reminder" : "Terminerinnerung"; - - await sendTemplatedEmail("appointment-reminder", recipient, subject, language, tenant, { - appointment, - appointmentDate: appointment.appointmentDate, - appointmentTime: appointment.appointmentDate, // You might want to add a separate time field - title: appointment.channelId, - cancelUrl, + const agentService = await AgentService.forTenant(tenant.id); + const agent = await agentService.getAgentById(appointment.agentId); + const { recipient, locale } = await getRecipient(user); + const channelTitle = await getChannelTitle(tenant.id, appointment.channelId, locale); + // Generate email + const subject = m["emails.appointmentReminder.subject"]({ + tenant: tenant.longName, }); + const emailRender = render(AppointmentBooked, { + props: { + locale, + channel: channelTitle || appointment.channelId, + user, + tenant, + appointment: { ...appointment, agentName: agent?.name ?? "---" }, + address: await getAddressFromTenant(tenant.id), + cancelUrl: cancelUrl || "", + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + await sendEmail(recipient, subject, html, text); +} + +const getRecipient = async (user: SelectClient | SelectUser) => { + const recipient: EmailRecipient = + "email" in user && typeof user.email === "string" && "language" in user && !("name" in user) + ? { email: user.email, language: user.language } + : createEmailRecipient(user); + const locale = (recipient.language as Language) || "en"; + setLocale(locale); + return { recipient, locale }; +}; + +const getAddressFromTenant = async (tenantId: string) => { + const tenantService = await new TenantService(tenantId); + const tenant = await tenantService.getConfig(); + return { + street: (tenant["address.street"] || "") as string, + number: (tenant["address.number"] || "") as string, + additionalAddressInfo: (tenant["address.additionalAddressInfo"] || "") as string, + zip: (tenant["address.zip"] || "") as string, + city: (tenant["address.city"] || "") as string, + }; +}; + +/** + * Send appointment rejection email for newly created appointments + * @param {SelectClient | SelectUser} user - Database user object or client data + * @param {SelectTenant} tenant - Tenant information for branding + * @param {SelectAppointment} appointment - Appointment details + * @param {string} [channelTitle] - Optional channel title/name + * @param {string} [cancelUrl] - Optional URL to cancel appointment + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendAppointmentRejectedEmail( + user: SelectClient | SelectUser, + tenant: SelectTenant, + appointment: SelectAppointment, + channelTitle?: string, +): Promise { + // Create recipient directly for SelectClient type, use helper for SelectUser + + // Set language + const agentService = await AgentService.forTenant(tenant.id); + const agent = await agentService.getAgentById(appointment.agentId); + const { recipient, locale } = await getRecipient(user); + + // Generate email + const subject = m["emails.appointmentRejected.subject"]({ + channel: channelTitle || appointment.channelId, + tenant: tenant.longName, + }); + const emailRender = render(AppointmentRejected, { + props: { + locale, + channel: channelTitle || appointment.channelId, + user, + tenant, + appointment: { ...appointment, agentName: agent?.name ?? "---" }, + address: await getAddressFromTenant(tenant.id), + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + await sendEmail(recipient, subject, html, text); } /** * Send appointment confirmation email for newly created appointments - * @param {SelectClient | SelectUser} user - Database user object + * @param {SelectClient | SelectUser} user - Database user object or client data * @param {SelectTenant} tenant - Tenant information for branding * @param {SelectAppointment} appointment - Appointment details + * @param {string} [channelTitle] - Optional channel title/name * @param {string} [cancelUrl] - Optional URL to cancel appointment * @throws {Error} When email sending fails * @returns {Promise} @@ -143,18 +291,78 @@ export async function sendAppointmentCreatedEmail( user: SelectClient | SelectUser, tenant: SelectTenant, appointment: SelectAppointment, + channelTitle?: string, cancelUrl?: string, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt"; + // Create recipient directly for SelectClient type, use helper for SelectUser - await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, { - appointment, - appointmentDate: appointment.appointmentDate, - title: appointment.channelId, // TODO: Get the channel to give back a proper title - cancelUrl, + // Set language + const agentService = await AgentService.forTenant(tenant.id); + const agent = await agentService.getAgentById(appointment.agentId); + const { recipient, locale } = await getRecipient(user); + + // Generate email + const subject = m["emails.appointmentBooked.subject"]({ + channel: channelTitle || appointment.channelId, + tenant: tenant.longName, }); + const emailRender = render(AppointmentBooked, { + props: { + locale, + channel: channelTitle || appointment.channelId, + user, + tenant, + appointment: { ...appointment, agentName: agent?.name ?? "---" }, + address: await getAddressFromTenant(tenant.id), + cancelUrl: cancelUrl || "", + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + await sendEmail(recipient, subject, html, text); +} + +/** + * Send appointment request email (when staff confirmation is required) + * @param {SelectClient | SelectUser} user - Database user object or client data + * @param {SelectTenant} tenant - Tenant information for branding + * @param {SelectAppointment} appointment - Appointment details + * @param {string} [channelTitle] - Optional channel title/name + * @param {string} [cancelUrl] - Optional URL to cancel appointment + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendAppointmentRequestEmail( + user: SelectClient | SelectUser, + tenant: SelectTenant, + appointment: SelectAppointment, + channelTitle?: string, + cancelUrl?: string, +): Promise { + const agentService = await AgentService.forTenant(tenant.id); + const agent = await agentService.getAgentById(appointment.agentId); + const { recipient, locale } = await getRecipient(user); + // Generate email + const subject = m["emails.appointmentRequest.subject"]({ + channel: channelTitle || appointment.channelId, + tenant: tenant.longName, + }); + const emailRender = render(AppointmentBooked, { + props: { + locale, + channel: channelTitle || appointment.channelId, + user, + tenant, + appointment: { ...appointment, agentName: agent?.name ?? "---" }, + address: await getAddressFromTenant(tenant.id), + cancelUrl: cancelUrl || "", + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + await sendEmail(recipient, subject, html, text); } /** @@ -162,6 +370,7 @@ export async function sendAppointmentCreatedEmail( * @param {SelectClient | SelectUser} user - Database user object * @param {SelectTenant} tenant - Tenant information for branding * @param {SelectAppointment} appointment - Updated appointment details + * @param {string} [channelTitle] - Optional channel title/name * @param {string} [cancelUrl] - Optional URL to cancel appointment * @throws {Error} When email sending fails * @returns {Promise} @@ -170,6 +379,7 @@ export async function sendAppointmentUpdatedEmail( user: SelectClient | SelectUser, tenant: SelectTenant, appointment: SelectAppointment, + channelTitle?: string, cancelUrl?: string, ): Promise { const recipient = createEmailRecipient(user); @@ -179,7 +389,7 @@ export async function sendAppointmentUpdatedEmail( await sendTemplatedEmail("appointment-updated", recipient, subject, language, tenant, { appointment, appointmentDate: appointment.appointmentDate, - title: appointment.channelId, // TODO: Get the channel to give back a proper title + title: channelTitle || appointment.channelId, cancelUrl, }); } @@ -229,60 +439,33 @@ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): s * @returns {Promise} */ export async function sendConfirmationEmail( - user: { id: string; email: string | null; name: string | null; language?: string | null }, + user: { id: string; email: string; name: string; language?: string | null }, tenant: SelectTenant, confirmationCode: string, expirationMinutes: number = 15, requestUrl?: URL, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen"; - // Generate appropriate base URL if request URL is provided const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173"; - - // Create enhanced tenant object with baseUrl - const tenantWithBaseUrl = { - ...tenant, - baseUrl, - }; - - await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, { - confirmationCode, - expirationMinutes, + const confirmUrl = `${baseUrl}/confirm/${confirmationCode}`; + const recipient = user; + // Generate email + const subject = m["emails.confirmation.subject"]({ + tenant: tenant.longName, }); -} - -/** - * Send tenant administrator invitation email - * @param {string} adminEmail - Email address of the invited administrator - * @param {string} adminName - Name of the invited administrator - * @param {SelectTenant} tenant - Tenant information for branding - * @param {string} registrationUrl - URL for administrator to register - * @param {Language} [language="en"] - Email language - * @throws {Error} When email sending fails - * @returns {Promise} - */ -export async function sendTenantAdminInviteEmail( - adminEmail: string, - adminName: string, - tenant: SelectTenant, - registrationUrl: string, - language: Language = "en", -): Promise { - const recipient: EmailRecipient = { - email: adminEmail, - name: adminName, - language, - }; - - const subject = - language === "en" ? "Invitation as Tenant Administrator" : "Einladung als Tenant-Administrator"; - - await sendTemplatedEmail("tenant-admin-invite", recipient, subject, language, tenant, { - registrationUrl, + const emailRender = render(Confirmation, { + props: { + locale: (user.language as Language) ?? "en", + user: user as SelectUserEmail, + confirmUrl, + expirationMinutes, + }, }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await sendEmail(recipient as any, subject, html, text); } /** @@ -310,12 +493,53 @@ export async function sendUserInviteEmail( language, }; - const subject = - language === "en" - ? `Invitation to ${tenant.longName || tenant.shortName}` - : `Einladung zu ${tenant.longName || tenant.shortName}`; + // Generate email + const subject = m["emails.userInvite.subject"]({ + tenant: tenant.longName, + }); + const emailRender = render(UserInvite, { + props: { + locale: language ?? "en", + user: recipient as SelectUserEmail, + tenant, + confirmUrl: registrationUrl, + expirationMinutes: 30, + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); - await sendTemplatedEmail("user-invite", recipient, subject, language, tenant, { - registrationUrl, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await sendEmail(recipient as any, subject, html, text); +} + +/** + * Send appointment cancellation email + * @param {SelectClient | SelectUser} user - Database user object or client data + * @param {SelectTenant} tenant - Tenant information for branding + * @param {SelectAppointment} appointment - Cancelled appointment details + * @param {string} [channelTitle] - Optional channel title/name + * @throws {Error} When email sending fails + * @returns {Promise} + */ +export async function sendAppointmentCancelledEmail( + user: SelectClient | SelectUser, + tenant: SelectTenant, + appointment: SelectAppointment, + channelTitle?: string, +): Promise { + // Create recipient directly for SelectClient type, use helper for SelectUser + const recipient: EmailRecipient = + "email" in user && typeof user.email === "string" && "language" in user && !("name" in user) + ? { email: user.email, language: user.language } + : createEmailRecipient(user); + + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Appointment Cancelled" : "Termin storniert"; + + await sendTemplatedEmail("appointment-cancelled", recipient, subject, language, tenant, { + appointment, + appointmentDate: appointment.appointmentDate, + title: channelTitle || appointment.channelId, }); } diff --git a/src/lib/server/email/mailer.ts b/src/lib/server/email/mailer.ts index 6870212..164c19a 100644 --- a/src/lib/server/email/mailer.ts +++ b/src/lib/server/email/mailer.ts @@ -1,9 +1,16 @@ import nodemailer from "nodemailer"; import { env } from "$env/dynamic/private"; -import type { SelectClient } from "$lib/server/db/tenant-schema"; import type Mail from "nodemailer/lib/mailer"; import type { SelectUser } from "../db/central-schema"; +/** + * Simple client data type for email sending + */ +type SelectClient = { + email: string; + language?: string; +}; + /** * Email recipient interface * @interface EmailRecipient @@ -29,19 +36,19 @@ export function createEmailRecipient( | SelectUser | { id: string; email: string | null; name: string | null; language?: string | null }, ): EmailRecipient { - // Handle SelectUser type (from central schema) - if ("email" in user && !("publicKey" in user)) { + // Handle SelectClient type (simple object with just email and language) + if ("email" in user && !("name" in user) && !("id" in user)) { return { email: user.email || "", - name: user.name || undefined, - language: user.language || "de", // Use user's language preference + name: undefined, // Clients don't have names for privacy + language: user.language || "de", }; } - // Handle SelectClient type (no name property) + // Handle SelectUser or generic user type (has name and id) else { return { - email: user.email || "", // Client email is optional - name: undefined, // Clients don't have names for privacy + email: user.email || "", + name: "name" in user ? user.name || undefined : undefined, language: user.language || "de", }; } diff --git a/src/lib/server/email/template-engine.ts b/src/lib/server/email/template-engine.ts index a9843d2..ee3b6f5 100644 --- a/src/lib/server/email/template-engine.ts +++ b/src/lib/server/email/template-engine.ts @@ -8,15 +8,17 @@ export type Language = "de" | "en"; /** * Available email template types - * @typedef {'user-created' | 'pin-reset' | 'key-reset' | 'appointment-reminder' | 'appointment-created' | 'appointment-updated' | 'confirmation'} EmailTemplateType + * @typedef {'user-created' | 'pin-reset' | 'key-reset' | 'appointment-reminder' | 'appointment-created' | 'appointment-request' | 'appointment-updated' | 'confirmation'} EmailTemplateType */ export type EmailTemplateType = | "user-created" | "pin-reset" // Info that PIN was reset (no code) | "key-reset" | "appointment-reminder" - | "appointment-created" + | "appointment-created" // Appointment confirmed (no staff confirmation needed) + | "appointment-request" // Appointment requested (requires staff confirmation) | "appointment-updated" + | "appointment-cancelled" // Appointment cancelled by staff | "confirmation" // Registration confirmation with one-time code | "tenant-admin-invite" // Invitation for tenant administrator | "user-invite"; // Invitation for new users to existing tenant diff --git a/src/lib/server/email/templates/appointment-cancelled.de.html b/src/lib/server/email/templates/appointment-cancelled.de.html new file mode 100644 index 0000000..1bc2f36 --- /dev/null +++ b/src/lib/server/email/templates/appointment-cancelled.de.html @@ -0,0 +1,116 @@ + + + + + + Termin storniert + + + + + + diff --git a/src/lib/server/email/templates/appointment-cancelled.de.txt b/src/lib/server/email/templates/appointment-cancelled.de.txt new file mode 100644 index 0000000..689d920 --- /dev/null +++ b/src/lib/server/email/templates/appointment-cancelled.de.txt @@ -0,0 +1,23 @@ +Termin storniert - {{tenant.longName}} + +⚠️ IHR TERMIN WURDE STORNIERT + +Wir möchten Sie darüber informieren, dass Ihr Termin storniert wurde. + +TERMINDETAILS +============= +Datum: {{appointmentDate}} +{{#if title}} +Termin: {{title}} +{{/if}} +{{#if location}} +Ort: {{location}} +{{/if}} + +Bei Fragen stehen wir Ihnen gerne zur Verfügung. Sie können uns jederzeit kontaktieren. + +-- +{{tenant.longName}} +{{#if tenant.links.website}} +{{tenant.links.website}} +{{/if}} diff --git a/src/lib/server/email/templates/appointment-cancelled.en.html b/src/lib/server/email/templates/appointment-cancelled.en.html new file mode 100644 index 0000000..bfd2309 --- /dev/null +++ b/src/lib/server/email/templates/appointment-cancelled.en.html @@ -0,0 +1,116 @@ + + + + + + Appointment Cancelled + + + + + + diff --git a/src/lib/server/email/templates/appointment-cancelled.en.txt b/src/lib/server/email/templates/appointment-cancelled.en.txt new file mode 100644 index 0000000..0a74497 --- /dev/null +++ b/src/lib/server/email/templates/appointment-cancelled.en.txt @@ -0,0 +1,23 @@ +Appointment Cancelled - {{tenant.longName}} + +⚠️ YOUR APPOINTMENT HAS BEEN CANCELLED + +We would like to inform you that your appointment has been cancelled. + +APPOINTMENT DETAILS +=================== +Date: {{appointmentDate}} +{{#if title}} +Appointment: {{title}} +{{/if}} +{{#if location}} +Location: {{location}} +{{/if}} + +If you have any questions, please don't hesitate to contact us. + +-- +{{tenant.longName}} +{{#if tenant.links.website}} +{{tenant.links.website}} +{{/if}} diff --git a/src/lib/server/email/templates/appointment-created.de.html b/src/lib/server/email/templates/appointment-created.de.html new file mode 100644 index 0000000..df29ba5 --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.de.html @@ -0,0 +1,130 @@ + + + + + + Termin bestätigt + + + + + + diff --git a/src/lib/server/email/templates/appointment-created.de.txt b/src/lib/server/email/templates/appointment-created.de.txt new file mode 100644 index 0000000..751b60f --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.de.txt @@ -0,0 +1,26 @@ +Termin bestätigt - {{tenant.longName}} + +✓ Ihr Termin wurde erfolgreich gebucht! + +Vielen Dank für Ihre Buchung. Ihr Termin wurde bestätigt. + +TERMINDETAILS +============= +Datum: {{appointmentDate}} +{{#if title}} +Termin: {{title}} +{{/if}} +{{#if location}} +Ort: {{location}} +{{/if}} + +{{#if cancelUrl}} +Termin absagen: {{cancelUrl}} +{{/if}} + +Wir freuen uns auf Ihren Besuch! + +Bei Fragen kontaktieren Sie uns bitte. + +-- +{{tenant.longName}} diff --git a/src/lib/server/email/templates/appointment-created.en.html b/src/lib/server/email/templates/appointment-created.en.html new file mode 100644 index 0000000..0058ae9 --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.en.html @@ -0,0 +1,132 @@ + + + + + + Appointment Confirmed + + + + + + diff --git a/src/lib/server/email/templates/appointment-created.en.txt b/src/lib/server/email/templates/appointment-created.en.txt new file mode 100644 index 0000000..e32f630 --- /dev/null +++ b/src/lib/server/email/templates/appointment-created.en.txt @@ -0,0 +1,26 @@ +Appointment Confirmed - {{tenant.longName}} + +✓ Your appointment has been successfully booked! + +Thank you for your booking. Your appointment has been confirmed. + +APPOINTMENT DETAILS +=================== +Date: {{appointmentDate}} +{{#if title}} +Appointment: {{title}} +{{/if}} +{{#if location}} +Location: {{location}} +{{/if}} + +{{#if cancelUrl}} +Cancel appointment: {{cancelUrl}} +{{/if}} + +We look forward to seeing you! + +If you have any questions, please contact us. + +-- +{{tenant.longName}} diff --git a/src/lib/server/email/templates/appointment-request.de.html b/src/lib/server/email/templates/appointment-request.de.html new file mode 100644 index 0000000..ceb86c3 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.de.html @@ -0,0 +1,135 @@ + + + + + + Terminanfrage erhalten + + + + + + diff --git a/src/lib/server/email/templates/appointment-request.de.txt b/src/lib/server/email/templates/appointment-request.de.txt new file mode 100644 index 0000000..1e5c6a9 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.de.txt @@ -0,0 +1,28 @@ +Terminanfrage erhalten - {{tenant.longName}} + +ℹ️ Ihre Terminanfrage wird geprüft + +Vielen Dank für Ihre Terminanfrage. Wir haben Ihre Anfrage erhalten und werden diese in Kürze prüfen. + +Sie erhalten eine weitere E-Mail, sobald Ihr Termin bestätigt oder abgelehnt wurde. + +ANGEFORDERTE TERMINDETAILS +========================== +Datum: {{appointmentDate}} +{{#if title}} +Termin: {{title}} +{{/if}} +{{#if location}} +Ort: {{location}} +{{/if}} + +{{#if cancelUrl}} +Anfrage zurückziehen: {{cancelUrl}} +{{/if}} + +Wir werden uns so schnell wie möglich bei Ihnen melden. + +Bei Fragen kontaktieren Sie uns bitte. + +-- +{{tenant.longName}} diff --git a/src/lib/server/email/templates/appointment-request.en.html b/src/lib/server/email/templates/appointment-request.en.html new file mode 100644 index 0000000..b491852 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.en.html @@ -0,0 +1,137 @@ + + + + + + Appointment Request Received + + + + + + diff --git a/src/lib/server/email/templates/appointment-request.en.txt b/src/lib/server/email/templates/appointment-request.en.txt new file mode 100644 index 0000000..1661b30 --- /dev/null +++ b/src/lib/server/email/templates/appointment-request.en.txt @@ -0,0 +1,28 @@ +Appointment Request Received - {{tenant.longName}} + +ℹ️ Your appointment request is being reviewed + +Thank you for your appointment request. We have received your request and will review it shortly. + +You will receive another email once your appointment has been confirmed or declined. + +REQUESTED APPOINTMENT DETAILS +============================== +Date: {{appointmentDate}} +{{#if title}} +Appointment: {{title}} +{{/if}} +{{#if location}} +Location: {{location}} +{{/if}} + +{{#if cancelUrl}} +Withdraw request: {{cancelUrl}} +{{/if}} + +We will get back to you as soon as possible. + +If you have any questions, please contact us. + +-- +{{tenant.longName}} diff --git a/src/lib/server/email/templates/client-pin-reset.de.html b/src/lib/server/email/templates/client-pin-reset.de.html new file mode 100644 index 0000000..f7bb295 --- /dev/null +++ b/src/lib/server/email/templates/client-pin-reset.de.html @@ -0,0 +1,123 @@ + + + + + + PIN zurücksetzen + + + + + + diff --git a/src/lib/server/email/templates/client-pin-reset.de.txt b/src/lib/server/email/templates/client-pin-reset.de.txt new file mode 100644 index 0000000..5003f6a --- /dev/null +++ b/src/lib/server/email/templates/client-pin-reset.de.txt @@ -0,0 +1,24 @@ +{{tenant.longName}} - PIN zurücksetzen + +Hallo, + +Sie haben eine Anfrage zum Zurücksetzen Ihrer PIN für {{tenant.longName}} erhalten. + +Um Ihre PIN zurückzusetzen, verwenden Sie bitte den folgenden Link: + +{{resetUrl}} + +WICHTIGER HINWEIS: Dieser Link ist nur für {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet werden. + +Nach dem Zurücksetzen Ihrer PIN können Sie sich mit Ihrer neuen PIN bei Ihrem Konto anmelden. + +Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren. Ihre PIN bleibt in diesem Fall unverändert. + +Bei Fragen wenden Sie sich gerne an unser Team. + +Mit freundlichen Grüßen +Das {{tenant.longName}} Team + +--- +Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail. +© {{tenant.longName}} - Sicher, verschlüsselt, vertrauenswürdig diff --git a/src/lib/server/email/templates/client-pin-reset.en.html b/src/lib/server/email/templates/client-pin-reset.en.html new file mode 100644 index 0000000..f7e0c68 --- /dev/null +++ b/src/lib/server/email/templates/client-pin-reset.en.html @@ -0,0 +1,118 @@ + + + + + + Reset PIN + + + + + + diff --git a/src/lib/server/email/templates/client-pin-reset.en.txt b/src/lib/server/email/templates/client-pin-reset.en.txt new file mode 100644 index 0000000..5d048a8 --- /dev/null +++ b/src/lib/server/email/templates/client-pin-reset.en.txt @@ -0,0 +1,24 @@ +{{tenant.longName}} - Reset PIN + +Hello, + +You have received a request to reset your PIN for {{tenant.longName}}. + +To reset your PIN, please use the following link: + +{{resetUrl}} + +IMPORTANT NOTICE: This link is valid for {{expirationMinutes}} minutes only and can be used only once. + +After resetting your PIN, you can log in to your account with your new PIN. + +If you did not request this, you can safely ignore this email. Your PIN will remain unchanged. + +If you have any questions, please contact our team. + +Best regards +The {{tenant.longName}} Team + +--- +This email was generated automatically. Please do not reply to this email. +© {{tenant.longName}} - Secure, encrypted, trustworthy diff --git a/src/lib/server/email/templates/tenant-admin-invite.de.html b/src/lib/server/email/templates/tenant-admin-invite.de.html deleted file mode 100644 index 8b7b2e8..0000000 --- a/src/lib/server/email/templates/tenant-admin-invite.de.html +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - Einladung als Tenant-Administrator - - - - - - diff --git a/src/lib/server/email/templates/tenant-admin-invite.de.txt b/src/lib/server/email/templates/tenant-admin-invite.de.txt deleted file mode 100644 index e3ebb6c..0000000 --- a/src/lib/server/email/templates/tenant-admin-invite.de.txt +++ /dev/null @@ -1,23 +0,0 @@ -Einladung als Tenant-Administrator - -Hallo {{recipient.name}}, - -Sie wurden als Administrator für einen neuen Tenant in Open Reception eingeladen! - -Tenant-Details: -- Name: {{tenant.shortName}} -- Beschreibung: {{tenant.longName}} - -Als Tenant-Administrator können Sie: -- Die Konfiguration Ihres Tenants verwalten -- Mitarbeiter einladen und verwalten -- Kanäle und Services einrichten -- Termine und Buchungen überwachen - -Registrieren Sie sich jetzt, um loszulegen: -{{registrationUrl}} - -Diese Einladung ist 7 Tage gültig. Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. - --- -Open Reception Team \ No newline at end of file diff --git a/src/lib/server/email/templates/tenant-admin-invite.en.html b/src/lib/server/email/templates/tenant-admin-invite.en.html deleted file mode 100644 index 77ba9d9..0000000 --- a/src/lib/server/email/templates/tenant-admin-invite.en.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - Invitation as Tenant Administrator - - - - - - diff --git a/src/lib/server/email/templates/tenant-admin-invite.en.txt b/src/lib/server/email/templates/tenant-admin-invite.en.txt deleted file mode 100644 index 0ac5240..0000000 --- a/src/lib/server/email/templates/tenant-admin-invite.en.txt +++ /dev/null @@ -1,23 +0,0 @@ -Invitation as Tenant Administrator - -Hello {{recipient.name}}, - -You have been invited as an administrator for a new tenant in Open Reception! - -Tenant Details: -- Name: {{tenant.shortName}} -- Description: {{tenant.longName}} - -As a tenant administrator, you can: -- Manage your tenant's configuration -- Invite and manage staff members -- Set up channels and services -- Monitor appointments and bookings - -Register now to get started: -{{registrationUrl}} - -This invitation is valid for 7 days. If you didn't request this email, you can ignore it. - --- -Open Reception Team \ No newline at end of file diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 347c418..3a18f00 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -4,14 +4,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock dependencies before imports vi.mock("../../db", () => ({ getTenantDb: vi.fn(), + centralDb: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, })); vi.mock("$lib/logger", () => ({ + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + })), default: { setContext: vi.fn(() => ({ debug: vi.fn(), error: vi.fn(), warn: vi.fn(), + info: vi.fn(), })), }, })); @@ -82,9 +97,15 @@ const mockAgent = { }; describe("AgentService", () => { - beforeEach(() => { + let mockCentralDb: any; + + beforeEach(async () => { vi.clearAllMocks(); vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + // Import and get the mocked centralDb + const dbModule = await import("../../db"); + mockCentralDb = dbModule.centralDb; }); describe("forTenant", () => { @@ -110,6 +131,46 @@ describe("AgentService", () => { }); it("should create agent successfully", async () => { + // Mock centralDb to handle TenantConfig.create() and tenant queries + let callCount = 0; + const mockSelectBuilder = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + from: vi.fn((_) => ({ + where: vi.fn(() => { + callCount++; + if (callCount === 1) { + // First call: TenantConfig.#getTenantConfig() - return config entries + return Promise.resolve([ + { tenantId: "tenant-123", name: "maxAgents", type: "NUMBER", value: "10" }, + { + tenantId: "tenant-123", + name: "allowNotifications", + type: "BOOLEAN", + value: "true", + }, + ]); + } else { + // Second call: getTenantById tenant query - return with limit + return { + limit: vi.fn(() => + Promise.resolve([ + { + id: "tenant-123", + subdomain: "test-tenant", + plan: "basic", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]), + ), + }; + } + }), + })), + }; + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + const insertChain = { values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([mockAgent]), @@ -338,6 +399,45 @@ describe("AgentService", () => { }); it("should delete agent successfully", async () => { + // Mock centralDb to handle TenantConfig.create() and tenant queries + let callCount = 0; + const mockSelectBuilder = { + from: vi.fn(() => ({ + where: vi.fn(() => { + callCount++; + if (callCount === 1) { + // First call: TenantConfig.#getTenantConfig() - return config entries + return Promise.resolve([ + { tenantId: "tenant-123", name: "maxAgents", type: "NUMBER", value: "10" }, + { + tenantId: "tenant-123", + name: "allowNotifications", + type: "BOOLEAN", + value: "true", + }, + ]); + } else { + // Second call: getTenantById tenant query - return with limit + return { + limit: vi.fn(() => + Promise.resolve([ + { + id: "tenant-123", + subdomain: "test-tenant", + plan: "basic", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]), + ), + }; + } + }), + })), + }; + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + const result = await service.deleteAgent("agent-123"); expect(result).toBe(true); diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 2e2e504..6c25f63 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -11,11 +11,36 @@ vi.mock("../../db", () => ({ }, })); +vi.mock("../challenge-store", () => ({ + challengeStore: { + consume: vi.fn(), + store: vi.fn(), + }, +})); + +vi.mock("../challenge-throttle", () => ({ + challengeThrottleService: { + recordFailedAttempt: vi.fn(), + clearThrottle: vi.fn(), + }, +})); + +vi.mock("../notification-service", () => ({ + NotificationService: { + forTenant: vi.fn().mockResolvedValue({ + sendAppointmentConfirmationEmail: vi.fn().mockResolvedValue(undefined), + sendAppointmentCancellationEmail: vi.fn().mockResolvedValue(undefined), + createNotification: vi.fn().mockResolvedValue(undefined), + }), + }, +})); + const mockAppointment = { id: "appointment-123", tunnelId: "tunnel-123", channelId: "channel-123", appointmentDate: new Date("2024-01-15T10:00:00Z"), + duration: 10, status: "NEW" as const, encryptedPayload: "encrypted-data", iv: "iv-data", @@ -38,7 +63,10 @@ const mockClientTunnelData = { channelId: "channel-123", agentId: "agent-123", appointmentDate: "2024-01-15T10:00:00Z", + duration: 10, emailHash: "email-hash-123", + clientEmail: "test@example.com", + clientLanguage: "de", clientPublicKey: "client-public-key", privateKeyShare: "private-key-share", encryptedAppointment: { @@ -132,6 +160,13 @@ describe("AppointmentService", () => { }; vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); + // Mock existing tunnel check (client doesn't exist yet) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // No existing tunnel + }; + // Mock tenant database transaction const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { @@ -167,7 +202,10 @@ describe("AppointmentService", () => { return await callback(tx); }); - const mockDb = { transaction: mockTransaction }; + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + transaction: mockTransaction, + }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); const service = await AppointmentService.forTenant("tenant-123"); @@ -196,6 +234,39 @@ describe("AppointmentService", () => { ); }); + it("should throw ConflictError when client already exists", async () => { + const { getTenantDb, centralDb } = await import("../../db"); + + // Mock authorization check - users exist + const mockAuthBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ count: "1" }]), + }; + vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); + + // Mock existing tunnel check (client already exists) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ id: "existing-tunnel-123" }]), // Existing tunnel found + }; + + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + + await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow( + ConflictError, + ); + await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow( + "This email address is already registered", + ); + }); + it("should throw NotFoundError when channel not found", async () => { const { getTenantDb, centralDb } = await import("../../db"); @@ -207,6 +278,13 @@ describe("AppointmentService", () => { }; vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); + // Mock existing tunnel check (client doesn't exist yet) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // No existing tunnel + }; + // Mock tenant database transaction with channel not found const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { @@ -231,7 +309,10 @@ describe("AppointmentService", () => { return await callback(tx); }); - const mockDb = { transaction: mockTransaction }; + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + transaction: mockTransaction, + }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); const service = await AppointmentService.forTenant("tenant-123"); @@ -241,7 +322,7 @@ describe("AppointmentService", () => { ); }); - it("should create appointment with CONFIRMED status when channel doesn't require confirmation", async () => { + it("should create appointment with CONFIRMED status when staff user creates it", async () => { const { getTenantDb, centralDb } = await import("../../db"); // Mock authorization check - users exist @@ -252,7 +333,14 @@ describe("AppointmentService", () => { }; vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any); - // Mock tenant database transaction with channel that doesn't require confirmation + // Mock existing tunnel check (client doesn't exist yet) + const mockExistingTunnelBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // No existing tunnel + }; + + // Mock tenant database transaction with successful creation const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { insert: vi @@ -269,8 +357,8 @@ describe("AppointmentService", () => { values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([ { - id: "appointment-123", - appointmentDate: new Date("2024-01-15T10:00:00Z"), + id: "apt-123", + appointmentDate: new Date("2024-01-01T10:00:00Z"), status: "CONFIRMED", }, ]), @@ -279,7 +367,7 @@ describe("AppointmentService", () => { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ requiresConfirmation: false }]), + limit: vi.fn().mockResolvedValue([{ id: "channel-456" }]), // Channel found }), }), }), @@ -287,13 +375,17 @@ describe("AppointmentService", () => { return await callback(tx); }); - const mockDb = { transaction: mockTransaction }; + const mockDb = { + select: vi.fn().mockReturnValue(mockExistingTunnelBuilder as any), + transaction: mockTransaction, + }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); const service = await AppointmentService.forTenant("tenant-123"); const result = await service.createNewClientWithAppointment(mockClientTunnelData); expect(result.status).toBe("CONFIRMED"); + expect(mockTransaction).toHaveBeenCalled(); }); }); @@ -379,4 +471,451 @@ describe("AppointmentService", () => { expect(result).toBe(false); }); }); + + describe("deleteAppointmentByStaff", () => { + it("should delete appointment and send email/notifications", async () => { + const { getTenantDb } = await import("../../db"); + + // Mock email service + const emailModule = await import("../../email/email-service"); + const mockSendEmail = vi.fn().mockResolvedValue(undefined); + const mockGetChannelTitle = vi.fn().mockResolvedValue("Test Channel"); + vi.spyOn(emailModule, "sendAppointmentCancelledEmail").mockImplementation(mockSendEmail); + vi.spyOn(emailModule, "getChannelTitle").mockImplementation(mockGetChannelTitle); + + // Mock TenantAdminService + const tenantModule = await import("../tenant-admin-service"); + const mockTenant = { + id: "tenant-123", + shortName: "test-clinic", + longName: "Test Clinic", + languages: ["de", "en"], + }; + vi.spyOn(tenantModule.TenantAdminService, "getTenantById").mockResolvedValue({ + tenantData: mockTenant, + } as any); + + // Mock NotificationService + const notificationModule = await import("../notification-service"); + const mockCreateNotification = vi.fn().mockResolvedValue(["notification-1"]); + vi.spyOn(notificationModule.NotificationService, "forTenant").mockResolvedValue({ + createNotification: mockCreateNotification, + } as any); + + const mockDb = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockAppointment]), + }), + }), + }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + + // Use a promise to track async operations + const deletePromise = service.deleteAppointmentByStaff( + "appointment-123", + "client@example.com", + "de", + ); + + await deletePromise; + + expect(mockDb.delete).toHaveBeenCalled(); + expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de"); + }); + + it("should throw NotFoundError when appointment does not exist", async () => { + const { getTenantDb } = await import("../../db"); + const mockDb = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + + await expect( + service.deleteAppointmentByStaff("appointment-123", "client@example.com", "de"), + ).rejects.toThrow(NotFoundError); + }); + + it("should use default language when not provided", async () => { + const { getTenantDb } = await import("../../db"); + + // Mock email service + const emailModule = await import("../../email/email-service"); + const mockSendEmail = vi.fn().mockResolvedValue(undefined); + const mockGetChannelTitle = vi.fn().mockResolvedValue("Test Channel"); + vi.spyOn(emailModule, "sendAppointmentCancelledEmail").mockImplementation(mockSendEmail); + vi.spyOn(emailModule, "getChannelTitle").mockImplementation(mockGetChannelTitle); + + // Mock TenantAdminService + const tenantModule = await import("../tenant-admin-service"); + const mockTenant = { + id: "tenant-123", + shortName: "test-clinic", + longName: "Test Clinic", + languages: ["de", "en"], + }; + vi.spyOn(tenantModule.TenantAdminService, "getTenantById").mockResolvedValue({ + tenantData: mockTenant, + } as any); + + // Mock NotificationService + const notificationModule = await import("../notification-service"); + const mockCreateNotification = vi.fn().mockResolvedValue(["notification-1"]); + vi.spyOn(notificationModule.NotificationService, "forTenant").mockResolvedValue({ + createNotification: mockCreateNotification, + } as any); + + const mockDb = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([mockAppointment]), + }), + }), + }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + + // Use a promise to track async operations + const deletePromise = service.deleteAppointmentByStaff( + "appointment-123", + "client@example.com", + ); + + await deletePromise; + + expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de"); + }); + }); + + describe("getFutureAppointmentsByTunnelId", () => { + it("should return future appointments for a client tunnel", async () => { + const { getTenantDb } = await import("../../db"); + + const futureDate1 = new Date("2025-02-15T10:00:00Z"); + const futureDate2 = new Date("2025-03-20T14:30:00Z"); + + const mockFutureAppointments = [ + { + id: "appointment-1", + appointmentDate: futureDate1, + status: "CONFIRMED", + channelId: "channel-1", + encryptedPayload: "encrypted-1", + iv: "iv-1", + authTag: "auth-tag-1", + }, + { + id: "appointment-2", + appointmentDate: futureDate2, + status: "NEW", + channelId: "channel-2", + encryptedPayload: "encrypted-2", + iv: "iv-2", + authTag: "auth-tag-2", + }, + ]; + + const mockDb = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(mockFutureAppointments), + }), + }), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + const result = await service.getFutureAppointmentsByTunnelId("tunnel-123"); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: "appointment-1", + appointmentDate: futureDate1.toISOString(), + status: "CONFIRMED", + channelId: "channel-1", + encryptedPayload: "encrypted-1", + iv: "iv-1", + authTag: "auth-tag-1", + }); + expect(result[1]).toEqual({ + id: "appointment-2", + appointmentDate: futureDate2.toISOString(), + status: "NEW", + channelId: "channel-2", + encryptedPayload: "encrypted-2", + iv: "iv-2", + authTag: "auth-tag-2", + }); + }); + + it("should return empty array when no future appointments exist", async () => { + const { getTenantDb } = await import("../../db"); + + const mockDb = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([]), + }), + }), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + const result = await service.getFutureAppointmentsByTunnelId("tunnel-123"); + + expect(result).toEqual([]); + }); + + it("should handle null encrypted fields gracefully", async () => { + const { getTenantDb } = await import("../../db"); + + const futureDate = new Date("2025-02-15T10:00:00Z"); + const mockAppointmentsWithNulls = [ + { + id: "appointment-1", + appointmentDate: futureDate, + status: "NEW", + channelId: "channel-1", + encryptedPayload: null, + iv: null, + authTag: null, + }, + ]; + + const mockDb = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue(mockAppointmentsWithNulls), + }), + }), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + const result = await service.getFutureAppointmentsByTunnelId("tunnel-123"); + + expect(result).toHaveLength(1); + expect(result[0].encryptedPayload).toBe(""); + expect(result[0].iv).toBe(""); + expect(result[0].authTag).toBe(""); + }); + }); + + describe("deleteAppointmentByClient", () => { + it("should delete appointment after verifying challenge and ownership", async () => { + const { getTenantDb } = await import("../../db"); + const { challengeStore } = await import("../challenge-store"); + const { challengeThrottleService } = await import("../challenge-throttle"); + + const mockAppointmentDate = new Date("2025-02-15T10:00:00Z"); + + // Mock challenge verification + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: Buffer.from("test-challenge").toString("base64"), + emailHash: "email-hash-123", + createdAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + vi.mocked(challengeThrottleService.clearThrottle).mockResolvedValue(); + + // Mock database queries - use mockReturnValueOnce for sequential calls + const mockLimit = vi + .fn() + // First call: appointment query + .mockResolvedValueOnce([ + { + id: "appointment-123", + tunnelId: "tunnel-123", + appointmentDate: mockAppointmentDate, + channelId: "channel-123", + }, + ]) + // Second call: tunnel query + .mockResolvedValueOnce([ + { + id: "tunnel-123", + emailHash: "email-hash-123", + }, + ]); + + const mockDb = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: mockLimit, + }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }; + + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + await service.deleteAppointmentByClient( + "appointment-123", + "email-hash-123", + "challenge-123", + Buffer.from("test-challenge").toString("base64"), + ); + + expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123"); + expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin"); + }); + + it("should throw NotFoundError when challenge is not found", async () => { + const { challengeStore } = await import("../challenge-store"); + + vi.mocked(challengeStore.consume).mockResolvedValue(null); + + const service = await AppointmentService.forTenant("tenant-123"); + + await expect( + service.deleteAppointmentByClient( + "appointment-123", + "email-hash-123", + "invalid-challenge", + "challenge-response", + ), + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ValidationError when challenge doesn't belong to client", async () => { + const { challengeStore } = await import("../challenge-store"); + + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: Buffer.from("test-challenge").toString("base64"), + emailHash: "different-email-hash", + createdAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + const service = await AppointmentService.forTenant("tenant-123"); + + await expect( + service.deleteAppointmentByClient( + "appointment-123", + "email-hash-123", + "challenge-123", + Buffer.from("test-challenge").toString("base64"), + ), + ).rejects.toThrow("Invalid authentication"); + }); + + it("should throw ValidationError when challenge response is incorrect", async () => { + const { challengeStore } = await import("../challenge-store"); + const { challengeThrottleService } = await import("../challenge-throttle"); + + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: Buffer.from("correct-challenge").toString("base64"), + emailHash: "email-hash-123", + createdAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + vi.mocked(challengeThrottleService.recordFailedAttempt).mockResolvedValue(); + + const service = await AppointmentService.forTenant("tenant-123"); + + await expect( + service.deleteAppointmentByClient( + "appointment-123", + "email-hash-123", + "challenge-123", + Buffer.from("wrong-challenge").toString("base64"), + ), + ).rejects.toThrow("Invalid challenge response"); + + expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledWith( + "email-hash-123", + "pin", + ); + }); + + it("should throw ValidationError when appointment doesn't belong to client", async () => { + const { getTenantDb } = await import("../../db"); + const { challengeStore } = await import("../challenge-store"); + const { challengeThrottleService } = await import("../challenge-throttle"); + + const mockAppointmentDate = new Date("2025-02-15T10:00:00Z"); + + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: Buffer.from("test-challenge").toString("base64"), + emailHash: "email-hash-123", + createdAt: new Date(), + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }); + + vi.mocked(challengeThrottleService.clearThrottle).mockResolvedValue(); + + const mockDb = { + select: vi.fn().mockImplementation(() => ({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockImplementation(() => { + const callStack = new Error().stack || ""; + if (callStack.includes("appointment")) { + return Promise.resolve([ + { + id: "appointment-123", + tunnelId: "tunnel-123", + appointmentDate: mockAppointmentDate, + channelId: "channel-123", + }, + ]); + } else { + // Tunnel belongs to different email + return Promise.resolve([ + { + id: "tunnel-123", + emailHash: "different-email-hash", + }, + ]); + } + }), + })), + }; + + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + + const service = await AppointmentService.forTenant("tenant-123"); + + await expect( + service.deleteAppointmentByClient( + "appointment-123", + "email-hash-123", + "challenge-123", + Buffer.from("test-challenge").toString("base64"), + ), + ).rejects.toThrow("Appointment does not belong to this client"); + }); + }); }); diff --git a/src/lib/server/services/__tests__/challenge-throttle.test.ts b/src/lib/server/services/__tests__/challenge-throttle.test.ts new file mode 100644 index 0000000..afea028 --- /dev/null +++ b/src/lib/server/services/__tests__/challenge-throttle.test.ts @@ -0,0 +1,335 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock dependencies BEFORE importing the service +vi.mock("$lib/server/db", () => { + return { + centralDb: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }; +}); + +vi.mock("$lib/logger", () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + + return { + default: mockLogger, + logger: mockLogger, + }; +}); + +import { challengeThrottleService } from "../challenge-throttle"; + +describe("ChallengeThrottleService", () => { + let mockCentralDb: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Get the mocked centralDb module + const dbModule = await vi.importMock("$lib/server/db"); + mockCentralDb = dbModule.centralDb; + }); + + describe("PIN challenge throttling", () => { + const emailHash = "test-email-hash"; + + it("should allow request when no throttle record exists", async () => { + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(true); + expect(result.retryAfterMs).toBe(0); + expect(result.failedAttempts).toBe(0); + }); + + it("should allow request when throttle has expired", async () => { + const expiredRecord = { + id: emailHash, + failedAttempts: 3, + lastAttemptAt: new Date(Date.now() - 10000), + resetAt: new Date(Date.now() - 1000), // Expired 1 second ago + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([expiredRecord]), + }; + + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(true); + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + + it("should throttle request when delay has not passed (1st failure)", async () => { + const now = Date.now(); + const record = { + id: emailHash, + failedAttempts: 4, // 4th attempt triggers throttling (1 minute delay) + lastAttemptAt: new Date(now - 30000), // 30 seconds ago (less than 1 minute) + resetAt: new Date(now + 30000), // 30 seconds in future + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(60000); // 1 minute delay for 4th failure + }); + + it("should throttle request when delay has not passed (3rd failure)", async () => { + const now = Date.now(); + const record = { + id: emailHash, + failedAttempts: 5, // 5th attempt triggers 5 minute delay + lastAttemptAt: new Date(now - 120000), // 2 minutes ago (less than 5 minutes) + resetAt: new Date(now + 180000), // 3 minutes in future + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(300000); // 5 minutes delay for 5th failure + }); + + it("should allow request when enough time has passed", async () => { + const now = Date.now(); + const record = { + id: emailHash, + failedAttempts: 1, + lastAttemptAt: new Date(now - 3000), // 3 seconds ago (more than 2 second delay) + resetAt: new Date(now + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(emailHash, "pin"); + + expect(result.allowed).toBe(true); + expect(result.failedAttempts).toBe(1); + }); + + it("should record first failed attempt", async () => { + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + + await challengeThrottleService.recordFailedAttempt(emailHash, "pin"); + + expect(mockCentralDb.insert).toHaveBeenCalled(); + expect(mockInsertBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: emailHash, + failedAttempts: 1, + }), + ); + }); + + it("should increment failed attempts on subsequent failures", async () => { + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([ + { + id: emailHash, + failedAttempts: 2, + lastAttemptAt: new Date(), + resetAt: new Date(Date.now() + 60000), + }, + ]), + }; + + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + + await challengeThrottleService.recordFailedAttempt(emailHash, "pin"); + + expect(mockCentralDb.insert).toHaveBeenCalled(); + expect(mockInsertBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ + id: emailHash, + failedAttempts: 1, + }), + ); + }); + + it("should clear throttle on successful authentication", async () => { + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + await challengeThrottleService.clearThrottle(emailHash, "pin"); + + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + }); + + describe("Passkey challenge throttling", () => { + const email = "test@example.com"; + + it("should allow first 3 attempts immediately", async () => { + const record = { + id: email, + failedAttempts: 2, + lastAttemptAt: new Date(Date.now() - 100), + resetAt: new Date(Date.now() + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(email, "passkey"); + + expect(result.allowed).toBe(true); + expect(result.retryAfterMs).toBe(0); + }); + + it("should throttle after 3 failed attempts", async () => { + const now = Date.now(); + const record = { + id: email, + failedAttempts: 3, + lastAttemptAt: new Date(now - 10000), // 10 seconds ago + resetAt: new Date(now + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(email, "passkey"); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(60000); // 1 minute delay + }); + + it("should allow request after 1 minute has passed", async () => { + const now = Date.now(); + const record = { + id: email, + failedAttempts: 3, + lastAttemptAt: new Date(now - 70000), // 70 seconds ago (more than 1 minute) + resetAt: new Date(now + 60000), + }; + + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([record]), + }; + + mockCentralDb.select.mockReturnValue(mockSelectBuilder); + + const result = await challengeThrottleService.checkThrottle(email, "passkey"); + + expect(result.allowed).toBe(true); + }); + + it("should record failed passkey attempts", async () => { + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + + await challengeThrottleService.recordFailedAttempt(email, "passkey"); + + expect(mockCentralDb.insert).toHaveBeenCalled(); + }); + + it("should clear passkey throttle on success", async () => { + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + await challengeThrottleService.clearThrottle(email, "passkey"); + + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + }); + + describe("Edge cases", () => { + it("should handle cleanup of expired records", async () => { + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue(undefined), + }; + + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + + await challengeThrottleService.cleanupExpired(); + + expect(mockCentralDb.delete).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/lib/server/services/__tests__/channel-service.test.ts b/src/lib/server/services/__tests__/channel-service.test.ts index a2efd88..db6f909 100644 --- a/src/lib/server/services/__tests__/channel-service.test.ts +++ b/src/lib/server/services/__tests__/channel-service.test.ts @@ -7,8 +7,27 @@ vi.mock("../../db", () => ({ getTenantDb: vi.fn(), centralDb: { select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve([])), + from: vi.fn((table) => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => { + // Return different data based on table + if (table?.name === "tenant_config" || table === "tenant_config") { + return Promise.resolve([]); + } + // Default to tenant data + return Promise.resolve([ + { + id: "tenant-123", + name: "Test Tenant", + subdomain: "test", + plan: "basic", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + }), + })), })), })), insert: vi.fn(), @@ -18,11 +37,20 @@ vi.mock("../../db", () => ({ })); vi.mock("$lib/logger", () => ({ + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + })), default: { setContext: vi.fn(() => ({ debug: vi.fn(), error: vi.fn(), warn: vi.fn(), + info: vi.fn(), })), }, })); diff --git a/src/lib/server/services/__tests__/notification-service.test.ts b/src/lib/server/services/__tests__/notification-service.test.ts new file mode 100644 index 0000000..6fb7cb5 --- /dev/null +++ b/src/lib/server/services/__tests__/notification-service.test.ts @@ -0,0 +1,506 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock dependencies before imports +vi.mock("../../db", () => ({ + getTenantDb: vi.fn(), + centralDb: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("$lib/logger", () => ({ + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + })), + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + })), + }, +})); + +// Import after mocking +import { NotificationService, type NotificationCreationRequest } from "../notification-service"; +import { getTenantDb } from "../../db"; +import { ValidationError } from "../../utils/errors"; + +// Mock database operations +const mockDb = { + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(), + orderBy: vi.fn(), + })), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(), + })), + })), +}; + +const mockNotification = { + id: "notification-123", + staffId: "staff-123", + type: "APPOINTMENT_CONFIRMED" as const, + metaData: { appointmentId: "appointment-123" }, + isRead: false, + createdAt: new Date(), +}; + +const mockChannelStaff = [ + { staffId: "staff-123" }, + { staffId: "staff-456" }, + { staffId: "staff-789" }, +]; + +describe("NotificationService", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + }); + + describe("forTenant", () => { + it("should create notification service for tenant", async () => { + const service = await NotificationService.forTenant("tenant-123"); + + expect(service.tenantId).toBe("tenant-123"); + expect(getTenantDb).toHaveBeenCalledWith("tenant-123"); + }); + + it("should handle database connection error", async () => { + vi.mocked(getTenantDb).mockRejectedValue(new Error("DB connection failed")); + + await expect(NotificationService.forTenant("tenant-123")).rejects.toThrow( + "DB connection failed", + ); + }); + }); + + describe("createNotification", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should create notifications for all staff members in channel", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(mockChannelStaff), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const insertChain = { + values: vi.fn(() => ({ + returning: vi + .fn() + .mockResolvedValue([ + { id: "notification-1" }, + { id: "notification-2" }, + { id: "notification-3" }, + ]), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + const request: NotificationCreationRequest = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + type: "APPOINTMENT_CONFIRMED", + metaData: { appointmentId: "appointment-123" }, + }; + + const result = await service.createNotification(request); + + expect(result).toEqual(["notification-1", "notification-2", "notification-3"]); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.insert).toHaveBeenCalled(); + expect(insertChain.values).toHaveBeenCalledWith([ + { + staffId: "staff-123", + type: request.type, + metaData: request.metaData, + isRead: false, + }, + { + staffId: "staff-456", + type: request.type, + metaData: request.metaData, + isRead: false, + }, + { + staffId: "staff-789", + type: request.type, + metaData: request.metaData, + isRead: false, + }, + ]); + }); + + it("should return empty array when no staff members found", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const request: NotificationCreationRequest = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + type: "APPOINTMENT_CONFIRMED", + }; + + const result = await service.createNotification(request); + + expect(result).toEqual([]); + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + it("should handle validation error for invalid channel ID", async () => { + const request = { + channelId: "invalid-uuid", + type: "APPOINTMENT_CONFIRMED", + }; + + await expect(service.createNotification(request as any)).rejects.toThrow(ValidationError); + }); + + it("should handle validation error for missing type", async () => { + const request = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + }; + + await expect(service.createNotification(request as any)).rejects.toThrow(ValidationError); + }); + + it("should handle database error during creation", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(mockChannelStaff), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const insertChain = { + values: vi.fn(() => ({ + returning: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + const request: NotificationCreationRequest = { + channelId: "550e8400-e29b-41d4-a716-446655440000", + type: "APPOINTMENT_CONFIRMED", + }; + + await expect(service.createNotification(request)).rejects.toThrow("DB error"); + }); + }); + + describe("getNotificationsForStaff", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should return all notifications for staff member", async () => { + const mockNotifications = [ + mockNotification, + { + id: "notification-456", + staffId: "staff-123", + type: "APPOINTMENT_CANCELED" as const, + metaData: { appointmentId: "appointment-456" }, + isRead: true, + createdAt: new Date(), + }, + ]; + + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue(mockNotifications), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getNotificationsForStaff("staff-123"); + + expect(result).toEqual(mockNotifications); + expect(mockDb.select).toHaveBeenCalled(); + }); + + it("should return empty array when no notifications found", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue([]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getNotificationsForStaff("staff-123"); + + expect(result).toEqual([]); + }); + + it("should handle database error", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockRejectedValue(new Error("DB error")), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + await expect(service.getNotificationsForStaff("staff-123")).rejects.toThrow("DB error"); + }); + }); + + describe("hasUnreadNotifications", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should return true when unread notifications exist", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([{ id: "notification-123" }]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.hasUnreadNotifications("staff-123"); + + expect(result).toBe(true); + }); + + it("should return false when no unread notifications exist", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.hasUnreadNotifications("staff-123"); + + expect(result).toBe(false); + }); + + it("should handle database error", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockRejectedValue(new Error("DB error")), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + await expect(service.hasUnreadNotifications("staff-123")).rejects.toThrow("DB error"); + }); + }); + + describe("deleteNotification", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should delete notification successfully when it belongs to staff member", async () => { + const deleteChain = { + where: vi.fn().mockResolvedValue(undefined), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await service.deleteNotification("notification-123", "staff-123"); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should not throw error when notification does not exist or belongs to different staff member", async () => { + const deleteChain = { + where: vi.fn().mockResolvedValue(undefined), + }; + mockDb.delete.mockReturnValue(deleteChain); + + // Should complete without error even if notification doesn't exist + await service.deleteNotification("notification-123", "staff-456"); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should handle database error during deletion", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([mockNotification]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const deleteChain = { + where: vi.fn().mockRejectedValue(new Error("DB error")), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await expect(service.deleteNotification("notification-123", "staff-123")).rejects.toThrow( + "DB error", + ); + }); + }); + + describe("markAsRead", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should mark notification as read successfully when it belongs to staff member", async () => { + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + await service.markAsRead("notification-123", "staff-123"); + + expect(mockDb.update).toHaveBeenCalled(); + expect(updateChain.set).toHaveBeenCalledWith({ isRead: true }); + }); + + it("should not throw error when notification does not exist or belongs to different staff member", async () => { + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + // Should complete without error even if notification doesn't exist + await service.markAsRead("notification-123", "staff-456"); + + expect(mockDb.update).toHaveBeenCalled(); + }); + + it("should handle database error during update", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([mockNotification]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + await expect(service.markAsRead("notification-123", "staff-123")).rejects.toThrow("DB error"); + }); + }); + + describe("deleteAllNotifications", () => { + let service: NotificationService; + + beforeEach(async () => { + service = await NotificationService.forTenant("tenant-123"); + }); + + it("should delete all notifications for staff member", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: "1" }, { id: "2" }, { id: "3" }]), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + const result = await service.deleteAllNotifications("staff-123", false); + + expect(result).toBe(3); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should delete only read notifications when readOnly is true", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: "1" }, { id: "2" }]), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + const result = await service.deleteAllNotifications("staff-123", true); + + expect(result).toBe(2); + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should return 0 when no notifications deleted", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + const result = await service.deleteAllNotifications("staff-123", false); + + expect(result).toBe(0); + }); + + it("should handle database error during deletion", async () => { + const deleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await expect(service.deleteAllNotifications("staff-123", false)).rejects.toThrow("DB error"); + }); + }); +}); diff --git a/src/lib/server/services/__tests__/staff-service.test.ts b/src/lib/server/services/__tests__/staff-service.test.ts index 423f7cd..36028ba 100644 --- a/src/lib/server/services/__tests__/staff-service.test.ts +++ b/src/lib/server/services/__tests__/staff-service.test.ts @@ -20,6 +20,18 @@ vi.mock("../staff-crypto.service", () => ({ })), })); +vi.mock("../tenant-admin-service", () => ({ + TenantAdminService: { + getTenantById: vi.fn(), + }, +})); + +vi.mock("../user-service", () => ({ + UserService: { + deleteUser: vi.fn(), + }, +})); + const mockStaffMember = { id: "staff-123", email: "staff@example.com", @@ -35,24 +47,37 @@ const mockStaffMember = { describe("StaffService", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetAllMocks(); }); describe("getStaffMembers", () => { it("should return staff members for a tenant", async () => { const { centralDb } = await import("../../db"); - const mockSelectBuilder = { + // Mock the first select query (for users) + const mockSelectBuilder1 = { from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([mockStaffMember]), }; - vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + // Mock the second select query (for invites) + const mockSelectBuilder2 = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + }; + + // Mock centralDb.select to return different builders for each call + vi.mocked(centralDb.select) + .mockReturnValueOnce(mockSelectBuilder1 as any) + .mockReturnValueOnce(mockSelectBuilder2 as any); const result = await StaffService.getStaffMembers("tenant-123"); - expect(centralDb.select).toHaveBeenCalled(); - expect(mockSelectBuilder.from).toHaveBeenCalled(); - expect(mockSelectBuilder.where).toHaveBeenCalled(); + expect(centralDb.select).toHaveBeenCalledTimes(2); + expect(mockSelectBuilder1.from).toHaveBeenCalled(); + expect(mockSelectBuilder1.where).toHaveBeenCalled(); + expect(mockSelectBuilder2.from).toHaveBeenCalled(); + expect(mockSelectBuilder2.where).toHaveBeenCalled(); expect(result).toEqual([mockStaffMember]); }); @@ -123,9 +148,68 @@ describe("StaffService", () => { }); describe("deleteStaffMember", () => { - it("should delete a staff member successfully", async () => { - const { centralDb, getTenantDb } = await import("../../db"); + it("should prevent deletion of last staff member", async () => { + const { centralDb } = await import("../../db"); + // Mock the non-global admin staff check - only one staff member exists + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([ + { + id: "staff-123", // Only one staff member + }, + ]), + }; + + vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + + await expect(StaffService.deleteStaffMember("tenant-123", "staff-123")).rejects.toThrow( + ValidationError, + ); + }); + + it("should delete a staff member successfully when multiple exist", async () => { + const { centralDb, getTenantDb } = await import("../../db"); + const { UserService } = await import("../user-service"); + + // Mock the non-global admin staff check - multiple staff members exist + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([ + { + id: "staff-123", + }, + { + id: "staff-456", // Multiple staff members + }, + ]), + }; + + vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + + // Mock UserService.deleteUser - wichtig: das muss vor der transaction() mock sein + const mockUserDeletionResult = { + success: true, + deletedUser: { + id: "staff-123", + email: "staff@example.com", + name: "Staff Member", + role: "STAFF" as const, + }, + deletedPasskeysCount: 2, + tenantId: "tenant-123", + }; + vi.mocked(UserService.deleteUser).mockResolvedValue(mockUserDeletionResult); + + // Mock tenant database for key shares + const mockTenantDb = { + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue({ count: 1 }), + }), + }; + vi.mocked(getTenantDb).mockResolvedValue(mockTenantDb as any); + + // Mock transaction const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ @@ -143,35 +227,14 @@ describe("StaffService", () => { }), }), }), - delete: vi - .fn() - .mockReturnValueOnce({ - where: vi.fn().mockResolvedValue({ count: 2 }), - }) - .mockReturnValueOnce({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([ - { - id: "staff-123", - email: "staff@example.com", - name: "Staff Member", - role: "STAFF", - }, - ]), - }), - }), + delete: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue({ count: 1 }), + }), }; return await callback(tx); }); - const mockTenantDb = { - delete: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue({ count: 1 }), - }), - }; - vi.mocked(centralDb.transaction).mockImplementation(mockTransaction); - vi.mocked(getTenantDb).mockResolvedValue(mockTenantDb as any); const result = await StaffService.deleteStaffMember("tenant-123", "staff-123"); @@ -190,12 +253,24 @@ describe("StaffService", () => { it("should throw NotFoundError when staff member not found", async () => { const { centralDb } = await import("../../db"); + // Mock the pre-transaction select queries + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([ + { + id: "staff-456", // Different ID to simulate multiple staff members + }, + ]), + }; + + vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any); + const mockTransaction = vi.fn().mockImplementation(async (callback) => { const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), + limit: vi.fn().mockResolvedValue([]), // No user found }), }), }), diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index 4cb2833..a863328 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { NotFoundError } from "../../utils/errors"; +import { NotFoundError, ValidationError } from "../../utils/errors"; // Mock the database module vi.mock("../../db", () => ({ @@ -9,6 +9,7 @@ vi.mock("../../db", () => ({ select: vi.fn(), update: vi.fn(), delete: vi.fn(), + transaction: vi.fn(), }, })); @@ -74,6 +75,7 @@ describe("UserService", () => { shortName: "test", longName: "Test Tenant", }, + validateSetupState: vi.fn(), }); }); @@ -177,6 +179,11 @@ describe("UserService", () => { }; const mockCountSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([{ count: 1 }]), + }; + + const mockTotalCountSelectBuilder = { from: vi.fn().mockResolvedValue([{ count: 1 }]), }; @@ -186,18 +193,19 @@ describe("UserService", () => { execute: vi.fn().mockResolvedValue({ count: 1 }), }; - // First call for user lookup, second call for count query + // First call for user lookup, second call for tenant admin count, third for total count mockCentralDb.select .mockReturnValueOnce(mockSelectBuilder) - .mockReturnValueOnce(mockCountSelectBuilder); + .mockReturnValueOnce(mockCountSelectBuilder) + .mockReturnValueOnce(mockTotalCountSelectBuilder); mockCentralDb.update.mockReturnValue(mockUpdateBuilder); const result = await UserService.confirm(token); - expect(mockCentralDb.select).toHaveBeenCalledTimes(2); + expect(mockCentralDb.select).toHaveBeenCalledTimes(3); expect(mockCentralDb.update).toHaveBeenCalled(); expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ - confirmationState: "CONFIRMED" as const, + confirmationState: "ACCESS_GRANTED" as const, isActive: true, recoveryPassphrase: null, }); @@ -295,25 +303,147 @@ describe("UserService", () => { }); describe("deleteUser", () => { - it("should delete admin and associated passkeys", async () => { + it("should delete global admin successfully", async () => { const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; const mockDeletedAdmin = { id: adminId, name: "Deleted Admin", email: "deleted@example.com", + role: "GLOBAL_ADMIN", }; - const mockDeleteBuilder = { - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockDeletedAdmin]), - }; + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + const tx = { + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: adminId, + email: "deleted@example.com", + name: "Deleted Admin", + role: "GLOBAL_ADMIN", + tenantId: null, + }, + ]), + }), + }), + }), + delete: vi + .fn() + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue({ count: 2 }), + }) + .mockReturnValueOnce({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockDeletedAdmin]), + }), + }), + }; + return await callback(tx); + }); - mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + mockCentralDb.transaction.mockImplementation(mockTransaction); const result = await UserService.deleteUser(adminId); - expect(mockCentralDb.delete).toHaveBeenCalledTimes(2); - expect(result).toEqual(mockDeletedAdmin); + expect(result.success).toBe(true); + expect(result.deletedUser).toEqual(mockDeletedAdmin); + expect(result.deletedPasskeysCount).toBe(2); + expect(result.tenantId).toBeNull(); + }); + + it("should prevent deletion of last tenant admin", async () => { + const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; + + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + const tx = { + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: adminId, + email: "admin@example.com", + name: "Last Admin", + role: "TENANT_ADMIN", + tenantId: "tenant-123", + }, + ]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 1 }]), // Only one admin + }), + }), + }; + return await callback(tx); + }); + + mockCentralDb.transaction.mockImplementation(mockTransaction); + + await expect(UserService.deleteUser(adminId)).rejects.toThrow(ValidationError); + }); + + it("should delete tenant admin when multiple exist", async () => { + const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; + const mockDeletedAdmin = { + id: adminId, + name: "Deleted Admin", + email: "deleted@example.com", + role: "TENANT_ADMIN", + }; + + const mockTransaction = vi.fn().mockImplementation(async (callback) => { + const tx = { + select: vi + .fn() + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([ + { + id: adminId, + email: "deleted@example.com", + name: "Deleted Admin", + role: "TENANT_ADMIN", + tenantId: "tenant-123", + }, + ]), + }), + }), + }) + .mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([{ count: 2 }]), // Multiple admins + }), + }), + delete: vi + .fn() + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue({ count: 1 }), + }) + .mockReturnValueOnce({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([mockDeletedAdmin]), + }), + }), + }; + return await callback(tx); + }); + + mockCentralDb.transaction.mockImplementation(mockTransaction); + + const result = await UserService.deleteUser(adminId); + + expect(result.success).toBe(true); + expect(result.deletedUser).toEqual(mockDeletedAdmin); + expect(result.deletedPasskeysCount).toBe(1); + expect(result.tenantId).toBe("tenant-123"); }); }); diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index badabaa..a9cb81a 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -4,9 +4,10 @@ import { type SelectAgent, type SelectAgentAbsence } from "../db/tenant-schema"; import { eq, and, between, or, lte, gte, ne } from "drizzle-orm"; import logger from "$lib/logger"; -import z from "zod/v4"; +import { z } from "zod"; import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; import { supportedLocales } from "$lib/const/locales"; +import { TenantAdminService } from "./tenant-admin-service"; const agentCreationSchema = z.object({ name: z.string().min(1).max(100), @@ -110,6 +111,9 @@ export class AgentService { name: result[0].name, }); + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return result[0]; } catch (error) { log.error("Failed to create agent", { @@ -279,6 +283,9 @@ export class AgentService { agentId, }); + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return true; } catch (error) { log.error("Failed to delete agent", { diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index d492daa..f9c27e7 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -1,18 +1,33 @@ import { getTenantDb, centralDb } from "../db"; import * as tenantSchema from "../db/tenant-schema"; import { type SelectAppointment } from "../db/tenant-schema"; -import { user } from "../db/central-schema"; +import * as centralSchema from "../db/central-schema"; import logger from "$lib/logger"; import { ValidationError, NotFoundError, InternalError, ConflictError } from "../utils/errors"; import { and, eq, gte, lte, asc } from "drizzle-orm"; import type { AppointmentResponse } from "$lib/types/appointment"; +import { + sendAppointmentCreatedEmail, + sendAppointmentRequestEmail, + sendAppointmentCancelledEmail, + getChannelTitle, + sendAppointmentRejectedEmail, +} from "../email/email-service"; +import { TenantAdminService } from "./tenant-admin-service"; +import { NotificationService } from "./notification-service"; +import { challengeStore } from "./challenge-store"; +import { challengeThrottleService } from "./challenge-throttle"; +import { timingSafeEqual } from "node:crypto"; export interface ClientTunnelData { tunnelId: string; channelId: string; agentId: string; appointmentDate: string; + duration: number; emailHash: string; + clientEmail?: string; + clientLanguage?: string; clientPublicKey: string; privateKeyShare: string; encryptedAppointment: { @@ -62,21 +77,74 @@ export class AppointmentService { } /** - * Send appointment notification email + * Send appointment notification email to client + * @param appointmentId - The appointment ID + * @param channelId - The channel ID + * @param clientEmail - Client's email address + * @param clientLanguage - Client's preferred language + * @param requiresConfirmation - Whether the appointment requires staff confirmation */ - private async sendAppointmentNotification( - email: string, - appointment: SelectAppointment, - status: "NEW" | "CONFIRMED", + async sendAppointmentNotification( + appointmentId: string, + channelId: string, + clientEmail: string, + clientLanguage: string, + requiresConfirmation: boolean, ): Promise { - // TODO: Implement email service integration const log = logger.setContext("AppointmentService"); log.debug("Sending appointment notification", { - email, - appointmentId: appointment.id, - status, + appointmentId, + clientEmail, + requiresConfirmation, tenantId: this.tenantId, }); + + try { + // Get tenant information + const tenantService = await TenantAdminService.getTenantById(this.tenantId); + const tenant = tenantService.tenantData; + + if (!tenant) { + log.warn("Cannot send email: Tenant not found", { tenantId: this.tenantId }); + return; + } + + // Get full appointment data + const appointment = await this.getAppointmentById(appointmentId); + + // Create client data object + const clientData = { + email: clientEmail, + language: clientLanguage, + }; + + // Get channel title for the email + const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage); + + // Send appropriate email based on whether confirmation is required + if (requiresConfirmation) { + await sendAppointmentRequestEmail(clientData, tenant, appointment, channelTitle); + log.info("Appointment request email sent", { + appointmentId: appointment.id, + tenantId: this.tenantId, + }); + } else if (appointment.status === "REJECTED") { + await sendAppointmentRejectedEmail(clientData, tenant, appointment, channelTitle); + } else { + await sendAppointmentCreatedEmail(clientData, tenant, appointment, channelTitle); + log.info("Appointment confirmation email sent", { + appointmentId: appointment.id, + tenantId: this.tenantId, + }); + } + } catch (error) { + log.error("Failed to send appointment notification email", { + appointmentId, + tenantId: this.tenantId, + error: String(error), + }); + // Don't throw - email failure shouldn't fail the appointment creation + } } public async getAppointmentById(id: string): Promise { @@ -99,7 +167,11 @@ export class AppointmentService { return row; } - public async confirmAppointment(id: string): Promise { + public async confirmAppointment( + id: string, + clientEmail?: string, + clientLanguage?: string, + ): Promise { const log = logger.setContext("AppointmentService"); log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId }); const db = await this.getDb(); @@ -120,9 +192,94 @@ export class AppointmentService { } const row = result[0]; + + // Send confirmation email to client if email is provided (async, don't wait) + if (clientEmail) { + this.sendAppointmentNotification( + row.id, + row.channelId, + clientEmail, + clientLanguage || "de", + false, + ).catch((error) => { + log.error("Failed to send appointment confirmation email", { + appointmentId: row.id, + error: String(error), + }); + }); + } + + const notificationService = await NotificationService.forTenant(this.tenantId); + await notificationService.createNotification({ + channelId: row.channelId, + type: "APPOINTMENT_CONFIRMED", + metaData: { + appointmentId: row.id, + }, + }); + return row; } + public async denyAppointment( + id: string, + clientEmail?: string, + clientLanguage?: string, + ): Promise { + const log = logger.setContext("AppointmentService"); + log.debug("Denying appointment by ID", { appointmentId: id, tenantId: this.tenantId }); + const db = await this.getDb(); + + const result = await db + .update(tenantSchema.appointment) + .set({ status: "REJECTED" }) + .where(and(eq(tenantSchema.appointment.id, id), eq(tenantSchema.appointment.status, "NEW"))) + .returning(); + + if (result.length === 0) { + log.warn("Appointment not found or in wrong state", { + appointmentId: id, + state: result[0] ? result[0].status : undefined, + tenantId: this.tenantId, + }); + throw new ValidationError("Appointment not found or in wrong state"); + } + + const row = result[0]; + + // Send rejection email to client if email is provided (async, don't wait) + if (clientEmail) { + this.sendAppointmentNotification( + row.id, + row.channelId, + clientEmail, + clientLanguage || "de", + false, + ).catch((error) => { + log.error("Failed to send appointment rejection email", { + appointmentId: row.id, + error: String(error), + }); + }); + } + + db.delete(tenantSchema.appointment) + .where(eq(tenantSchema.appointment.id, id)) + .then(() => { + log.debug("Appointment deleted after rejection", { + appointmentId: id, + tenantId: this.tenantId, + }); + }) + .catch((error) => { + log.error("Failed to delete appointment after rejection", { + appointmentId: id, + error: String(error), + tenantId: this.tenantId, + }); + }); + } + public async cancelAppointment(id: string): Promise { const log = logger.setContext("AppointmentService"); log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId }); @@ -169,6 +326,107 @@ export class AppointmentService { return true; } + /** + * Delete appointment by staff member + * This will: + * 1. Remove the appointment from the database + * 2. Send cancellation email to the client + * 3. Create notifications for all staff members in the channel + * @param appointmentId - The appointment ID + * @param clientEmail - The client's email address + * @param clientLanguage - The client's preferred language (optional, defaults to "de") + * @returns Promise + */ + public async deleteAppointmentByStaff( + appointmentId: string, + clientEmail: string, + clientLanguage: string = "de", + ): Promise { + const log = logger.setContext("AppointmentService"); + log.debug("Deleting appointment by staff", { + appointmentId, + clientEmail, + tenantId: this.tenantId, + }); + + const db = await this.getDb(); + + // Get appointment details before deletion + const appointmentResult = await db + .select() + .from(tenantSchema.appointment) + .where(eq(tenantSchema.appointment.id, appointmentId)) + .limit(1); + + if (appointmentResult.length === 0) { + log.warn("Appointment not found for deletion", { + appointmentId, + tenantId: this.tenantId, + }); + throw new NotFoundError("Appointment not found"); + } + + const appointment = appointmentResult[0]; + const channelId = appointment.channelId; + + // Delete the appointment + await db.delete(tenantSchema.appointment).where(eq(tenantSchema.appointment.id, appointmentId)); + + log.debug("Appointment deleted from database", { appointmentId, tenantId: this.tenantId }); + + // Get tenant and channel information for email and notifications + const tenantService = await TenantAdminService.getTenantById(this.tenantId); + const tenant = tenantService.tenantData; + + if (!tenant) { + log.error("Tenant not found", { tenantId: this.tenantId }); + throw new InternalError("Tenant not found"); + } + + // Get channel title + const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage); + + // Send cancellation email to client (async, don't wait) + const clientData = { + email: clientEmail, + language: clientLanguage, + }; + + sendAppointmentCancelledEmail(clientData, tenant, appointment, channelTitle).catch((error) => { + log.error("Failed to send appointment cancellation email", { + appointmentId, + clientEmail, + error: String(error), + }); + }); + + // Create notifications for all staff members in the channel + const notificationService = await NotificationService.forTenant(this.tenantId); + + // Create notifications (async, don't wait) + notificationService + .createNotification({ + channelId, + type: "APPOINTMENT_CANCELLED", + metaData: { + appointmentId, + }, + }) + .catch((error) => { + log.error("Failed to create channel notifications", { + appointmentId, + channelId, + error: String(error), + }); + }); + + log.info("Appointment deleted by staff successfully", { + appointmentId, + channelId, + tenantId: this.tenantId, + }); + } + /** * Get all client tunnels for the tenant */ @@ -202,6 +460,112 @@ export class AppointmentService { })); } + /** + * Add appointment to existing client tunnel + */ + public async addAppointmentToTunnel(appointmentData: { + emailHash: string; + tunnelId: string; + channelId: string; + agentId: string; + appointmentDate: string; + duration: number; + clientEmail: string; + clientLanguage?: string; + encryptedAppointment: { + encryptedPayload: string; + iv: string; + authTag: string; + }; + }): Promise { + const log = logger.setContext("AppointmentService"); + + log.info("Adding appointment to existing tunnel", { + tenantId: this.tenantId, + tunnelId: appointmentData.tunnelId, + appointmentDate: appointmentData.appointmentDate, + emailHashPrefix: appointmentData.emailHash.slice(0, 8), + }); + + const db = await this.getDb(); + + // Check if tunnel exists and belongs to client + const tunnelResult = await db + .select({ id: tenantSchema.clientAppointmentTunnel.id }) + .from(tenantSchema.clientAppointmentTunnel) + .where(eq(tenantSchema.clientAppointmentTunnel.emailHash, appointmentData.emailHash)) + .limit(1); + + if (tunnelResult.length === 0) { + log.warn("Client tunnel not found", { + tenantId: this.tenantId, + tunnelId: appointmentData.tunnelId, + emailHashPrefix: appointmentData.emailHash.slice(0, 8), + }); + throw new NotFoundError("Tunnel not found or access denied"); + } + + // Get channel configuration to determine initial status + const channelResult = await db + .select({ requiresConfirmation: tenantSchema.channel.requiresConfirmation }) + .from(tenantSchema.channel) + .where( + and( + eq(tenantSchema.channel.id, appointmentData.channelId), + eq(tenantSchema.channel.isPublic, true), + ), + ) + .limit(1); + + if (channelResult.length === 0) { + throw new NotFoundError("Active channel not found"); + } + + const initialStatus = channelResult[0].requiresConfirmation ? "NEW" : "CONFIRMED"; + const requiresConfirmation = channelResult[0].requiresConfirmation || false; + + // Create encrypted appointment + const appointmentResult = await db + .insert(tenantSchema.appointment) + .values({ + tunnelId: appointmentData.tunnelId, + channelId: appointmentData.channelId, + agentId: appointmentData.agentId, + appointmentDate: new Date(appointmentData.appointmentDate), + duration: appointmentData.duration, + encryptedPayload: appointmentData.encryptedAppointment.encryptedPayload, + iv: appointmentData.encryptedAppointment.iv, + authTag: appointmentData.encryptedAppointment.authTag, + status: initialStatus, + }) + .returning({ + id: tenantSchema.appointment.id, + appointmentDate: tenantSchema.appointment.appointmentDate, + status: tenantSchema.appointment.status, + }); + + if (appointmentResult.length === 0) { + throw new InternalError("Failed to create appointment"); + } + + const result = appointmentResult[0]; + + const response: AppointmentResponse = { + id: result.id, + appointmentDate: result.appointmentDate.toISOString(), + status: result.status, + requiresConfirmation, + }; + + log.info("Successfully added appointment to tunnel", { + tenantId: this.tenantId, + tunnelId: appointmentData.tunnelId, + appointmentId: result.id, + }); + + return response; + } + /** * Create a new client tunnel with their first appointment */ @@ -219,9 +583,14 @@ export class AppointmentService { // Check if there are any authorized users (ACCESS_GRANTED) in this tenant const authorizedUsers = await centralDb - .select({ count: user.id }) - .from(user) - .where(and(eq(user.tenantId, this.tenantId), eq(user.confirmationState, "ACCESS_GRANTED"))) + .select({ count: centralSchema.user.id }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, this.tenantId), + eq(centralSchema.user.confirmationState, "ACCESS_GRANTED"), + ), + ) .limit(1); if (authorizedUsers.length === 0) { @@ -236,6 +605,23 @@ export class AppointmentService { const db = await this.getDb(); + // Check if client already exists + const existingTunnel = await db + .select({ id: tenantSchema.clientAppointmentTunnel.id }) + .from(tenantSchema.clientAppointmentTunnel) + .where(eq(tenantSchema.clientAppointmentTunnel.emailHash, clientData.emailHash)) + .limit(1); + + if (existingTunnel.length > 0) { + log.warn("Client creation failed: Email already registered", { + tenantId: this.tenantId, + emailHashPrefix: clientData.emailHash.slice(0, 8), + }); + throw new ConflictError( + "This email address is already registered. Please use the login option to book additional appointments.", + ); + } + // Transactional: Create tunnel and appointment const result = await db.transaction(async (tx) => { // 1. Create client appointment tunnel @@ -282,6 +668,7 @@ export class AppointmentService { } const initialStatus = channelResult[0].requiresConfirmation ? "NEW" : "CONFIRMED"; + const requiresConfirmation = channelResult[0].requiresConfirmation || false; // 4. Create encrypted appointment const appointmentResult = await tx @@ -291,6 +678,7 @@ export class AppointmentService { channelId: clientData.channelId, agentId: clientData.agentId, appointmentDate: new Date(clientData.appointmentDate), + duration: clientData.duration, encryptedPayload: clientData.encryptedAppointment.encryptedPayload, iv: clientData.encryptedAppointment.iv, authTag: clientData.encryptedAppointment.authTag, @@ -300,28 +688,66 @@ export class AppointmentService { id: tenantSchema.appointment.id, appointmentDate: tenantSchema.appointment.appointmentDate, status: tenantSchema.appointment.status, + tunnelId: tenantSchema.appointment.tunnelId, + channelId: tenantSchema.appointment.channelId, + agentId: tenantSchema.appointment.agentId, + encryptedPayload: tenantSchema.appointment.encryptedPayload, + iv: tenantSchema.appointment.iv, + authTag: tenantSchema.appointment.authTag, + expiryDate: tenantSchema.appointment.expiryDate, + createdAt: tenantSchema.appointment.createdAt, + updatedAt: tenantSchema.appointment.updatedAt, }); + if (initialStatus === "NEW") { + const notificationService = await NotificationService.forTenant(this.tenantId); + await notificationService.createNotification({ + channelId: clientData.channelId, + type: "APPOINTMENT_REQUESTED", + metaData: { + appointmentId: appointmentResult[0].id, + }, + }); + } + if (appointmentResult.length === 0) { throw new InternalError("Failed to create appointment"); } - return appointmentResult[0]; + return { appointment: appointmentResult[0], requiresConfirmation }; }); const response: AppointmentResponse = { - id: result.id, - appointmentDate: result.appointmentDate.toISOString(), - status: result.status, + id: result.appointment.id, + appointmentDate: result.appointment.appointmentDate.toISOString(), + status: result.appointment.status, + requiresConfirmation: result.requiresConfirmation, }; log.info("Successfully created new client appointment tunnel", { tenantId: this.tenantId, tunnelId: clientData.tunnelId, - appointmentId: result.id, + appointmentId: result.appointment.id, staffSharesCount: clientData.staffKeyShares.length, }); + // Send email notification to client (async, don't wait) + 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; } @@ -418,6 +844,202 @@ export class AppointmentService { return result; } + /** + * Get future appointments for a client by tunnel ID + */ + public async getFutureAppointmentsByTunnelId(tunnelId: string): Promise< + Array<{ + id: string; + appointmentDate: string; + status: string; + channelId: string; + encryptedPayload: string; + iv: string; + authTag: string; + }> + > { + const log = logger.setContext("AppointmentService"); + log.debug("Fetching future appointments for client", { + tenantId: this.tenantId, + tunnelId, + }); + + const db = await this.getDb(); + + // Get all future appointments for this tunnel + const now = new Date(); + const appointments = await db + .select({ + id: tenantSchema.appointment.id, + appointmentDate: tenantSchema.appointment.appointmentDate, + status: tenantSchema.appointment.status, + channelId: tenantSchema.appointment.channelId, + encryptedPayload: tenantSchema.appointment.encryptedPayload, + iv: tenantSchema.appointment.iv, + authTag: tenantSchema.appointment.authTag, + }) + .from(tenantSchema.appointment) + .where( + and( + eq(tenantSchema.appointment.tunnelId, tunnelId), + gte(tenantSchema.appointment.appointmentDate, now), + ), + ) + .orderBy(asc(tenantSchema.appointment.appointmentDate)); + + log.debug("Future appointments retrieved", { + tenantId: this.tenantId, + tunnelId, + count: appointments.length, + }); + + return appointments.map((apt) => ({ + id: apt.id, + appointmentDate: apt.appointmentDate.toISOString(), + status: apt.status, + channelId: apt.channelId, + encryptedPayload: apt.encryptedPayload || "", + iv: apt.iv || "", + authTag: apt.authTag || "", + })); + } + + /** + * Delete appointment by client with challenge-response authentication + * This verifies that the client knows their PIN before allowing deletion + */ + public async deleteAppointmentByClient( + appointmentId: string, + emailHash: string, + challengeId: string, + challengeResponse: string, + ): Promise { + const log = logger.setContext("AppointmentService"); + log.info("Client deleting appointment with authentication", { + tenantId: this.tenantId, + appointmentId, + emailHashPrefix: emailHash.slice(0, 8), + }); + + // 1. Verify challenge-response + const storedChallenge = await challengeStore.consume(challengeId, this.tenantId); + if (!storedChallenge) { + log.warn("Challenge not found or expired", { + tenantId: this.tenantId, + challengeId, + }); + throw new NotFoundError("Challenge not found or expired"); + } + + // Verify that the challenge belongs to this email + if (storedChallenge.emailHash !== emailHash) { + log.warn("Challenge does not belong to this client", { + tenantId: this.tenantId, + challengeId, + emailHashPrefix: emailHash.slice(0, 8), + }); + throw new ValidationError("Invalid authentication"); + } + + // Validate challenge response using constant-time comparison + const expectedChallenge = storedChallenge.challenge; + const challengeBuffer = Buffer.from(challengeResponse, "base64"); + const expectedBuffer = Buffer.from(expectedChallenge, "base64"); + + if ( + challengeBuffer.length !== expectedBuffer.length || + !timingSafeEqual(challengeBuffer, expectedBuffer) + ) { + log.warn("Challenge response mismatch", { + tenantId: this.tenantId, + challengeId, + emailHashPrefix: emailHash.slice(0, 8), + }); + + // Record failed attempt for throttling + await challengeThrottleService.recordFailedAttempt(emailHash, "pin"); + + throw new ValidationError("Invalid challenge response"); + } + + // Clear throttle on successful verification + await challengeThrottleService.clearThrottle(emailHash, "pin"); + + const db = await this.getDb(); + + // 2. Get appointment and verify it belongs to this client's tunnel + const appointmentResult = await db + .select({ + id: tenantSchema.appointment.id, + tunnelId: tenantSchema.appointment.tunnelId, + appointmentDate: tenantSchema.appointment.appointmentDate, + channelId: tenantSchema.appointment.channelId, + }) + .from(tenantSchema.appointment) + .where(eq(tenantSchema.appointment.id, appointmentId)) + .limit(1); + + if (appointmentResult.length === 0) { + log.warn("Appointment not found", { appointmentId, tenantId: this.tenantId }); + throw new NotFoundError("Appointment not found"); + } + + const appointment = appointmentResult[0]; + + // 3. Verify the appointment belongs to this client's tunnel + const tunnelResult = await db + .select({ + id: tenantSchema.clientAppointmentTunnel.id, + emailHash: tenantSchema.clientAppointmentTunnel.emailHash, + }) + .from(tenantSchema.clientAppointmentTunnel) + .where(eq(tenantSchema.clientAppointmentTunnel.id, appointment.tunnelId)) + .limit(1); + + if (tunnelResult.length === 0) { + log.warn("Client tunnel not found", { + tunnelId: appointment.tunnelId, + tenantId: this.tenantId, + }); + throw new NotFoundError("Client not found"); + } + + const tunnel = tunnelResult[0]; + + if (tunnel.emailHash !== emailHash) { + log.warn("Appointment does not belong to this client", { + appointmentId, + tenantId: this.tenantId, + emailHashPrefix: emailHash.slice(0, 8), + }); + throw new ValidationError("Appointment does not belong to this client"); + } + + // 4. Delete the appointment + await db.delete(tenantSchema.appointment).where(eq(tenantSchema.appointment.id, appointmentId)); + + log.info("Appointment deleted successfully by client", { + tenantId: this.tenantId, + appointmentId, + emailHashPrefix: emailHash.slice(0, 8), + }); + + const notificationService = await NotificationService.forTenant(this.tenantId); + await notificationService.createNotification({ + channelId: appointment.channelId, + type: "APPOINTMENT_CANCELLED", + metaData: { + appointmentId, + }, + }); + + log.info("Staff notification sent for client-initiated deletion", { + tenantId: this.tenantId, + appointmentId, + channelId: appointment.channelId, + }); + } + /** * Get the tenant's database connection (cached) */ diff --git a/src/lib/server/services/challenge-throttle.ts b/src/lib/server/services/challenge-throttle.ts new file mode 100644 index 0000000..6b7710a --- /dev/null +++ b/src/lib/server/services/challenge-throttle.ts @@ -0,0 +1,167 @@ +/** + * Challenge Throttling Service + * + * Implements throttling for challenge-based authentication to prevent brute force attacks. + * All throttle data is stored centrally in the central database. + * - PIN challenges (appointments): Escalating delays (2s, 10s, 1m, 5m, 30m) + * - Passkey challenges (auth): Fixed delay after 3 attempts (1 minute) + */ + +import { centralDb } from "$lib/server/db"; +import { challengeThrottle } from "$lib/server/db/central-schema"; +import { eq, lt, sql } from "drizzle-orm"; +import { logger } from "$lib/logger"; + +export type ThrottleType = "pin" | "passkey"; + +interface ThrottleResult { + allowed: boolean; + retryAfterMs: number; + failedAttempts: number; +} + +// Throttle reset duration: 1 hour +const THROTTLE_RESET_DURATION_MS = 60 * 60 * 1000; + +/** + * Calculate escalating delay for PIN challenges + * Uses a fixed escalation pattern that increases with each failed attempt: + */ +function calculatePinThrottleDelay(failedAttempts: number): number { + if (failedAttempts < 4) return 0; + if (failedAttempts === 4) return 60 * 1000; // 1 minute + if (failedAttempts === 5) return 5 * 60 * 1000; // 5 minutes + if (failedAttempts === 6) return 30 * 60 * 1000; // 30 minutes + if (failedAttempts >= 7) return 60 * 60 * 1000; // 60 minutes + return 0; +} + +/** + * Calculate throttle delay for passkey challenges + * First 3 tries: immediate + * Afterwards: wait for one minute + */ +function calculatePasskeyThrottleDelay(failedAttempts: number): number { + if (failedAttempts < 3) return 0; + return 60 * 1000; // 1 minute +} + +class ChallengeThrottleService { + /** + * Check if a challenge request should be throttled + * @param identifier - Email hash for PIN challenges, email for passkey challenges + * @param type - Type of challenge (pin or passkey) + */ + async checkThrottle(identifier: string, type: ThrottleType): Promise { + const now = new Date(); + + // Get throttle record from central DB + const records = await centralDb + .select() + .from(challengeThrottle) + .where(eq(challengeThrottle.id, identifier)) + .limit(1); + + if (records.length === 0) { + // No throttle record, allow request + return { allowed: true, retryAfterMs: 0, failedAttempts: 0 }; + } + + const record = records[0]; + + // Check if throttle has expired + if (now > record.resetAt) { + // Throttle expired, clean up and allow + await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier)); + return { allowed: true, retryAfterMs: 0, failedAttempts: 0 }; + } + + // Calculate how long to wait based on challenge type + const delay = + type === "pin" + ? calculatePinThrottleDelay(record.failedAttempts) + : calculatePasskeyThrottleDelay(record.failedAttempts); + const timeSinceLastAttempt = now.getTime() - record.lastAttemptAt.getTime(); + + if (timeSinceLastAttempt < delay) { + // Still throttled + return { + allowed: false, + retryAfterMs: delay - timeSinceLastAttempt, + failedAttempts: record.failedAttempts, + }; + } + + // Enough time has passed, allow the request + return { + allowed: true, + retryAfterMs: 0, + failedAttempts: record.failedAttempts, + }; + } + + /** + * Record a failed challenge attempt + * @param identifier - Email hash for PIN challenges, email for passkey challenges + * @param type - Type of challenge (pin or passkey) + */ + async recordFailedAttempt(identifier: string, type: ThrottleType): Promise { + const now = new Date(); + + const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS); + await centralDb + .insert(challengeThrottle) + .values({ + id: identifier, + failedAttempts: 1, + lastAttemptAt: now, + resetAt, + }) + .onConflictDoUpdate({ + target: challengeThrottle.id, + set: { + failedAttempts: sql`${challengeThrottle.failedAttempts} + 1`, + lastAttemptAt: now, + }, + }); + + logger.info(`Recorded failed ${type} challenge attempt`, { + identifier: identifier.slice(0, 8), + type, + }); + } + + /** + * Clear throttle for successful authentication + * @param identifier - Email hash for PIN challenges, email for passkey challenges + * @param type - Type of challenge (pin or passkey) + */ + async clearThrottle(identifier: string, type: ThrottleType): Promise { + await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier)); + + logger.debug(`Cleared ${type} challenge throttle`, { + identifier: identifier.slice(0, 8), + type, + }); + } + + /** + * Clean up expired throttle records + * Should be called periodically + */ + async cleanupExpired(): Promise { + const now = new Date(); + + try { + await centralDb.delete(challengeThrottle).where(lt(challengeThrottle.resetAt, now)); + + logger.debug("Cleaned up expired challenge throttles"); + } catch (error) { + logger.warn("Failed to cleanup expired throttles", { + error: String(error), + }); + } + } +} + +export const challengeThrottleService = new ChallengeThrottleService(); diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index c4b26a0..bf35731 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -1,14 +1,26 @@ import { supportedLocales } from "$lib/const/locales"; import logger from "$lib/logger"; import { asc, eq, inArray, sql, and } from "drizzle-orm"; -import z from "zod/v4"; +import { z } from "zod"; import { getTenantDb } from "../db"; import { TenantConfig } from "../db/tenant-config"; import * as tenantSchema from "../db/tenant-schema"; import { type SelectAgent, type SelectChannel, type SelectSlotTemplate } from "../db/tenant-schema"; import { NotFoundError, ValidationError } from "../utils/errors"; +import { TenantAdminService } from "./tenant-admin-service"; -const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const; +const CHANNEL_COLORS = [ + "#F3835C", + "#C8CA79", + "#F6DD74", + "#A0A3DC", + "#E9A56D", + "#D89CC8", + "#B0B49B", + "#F9A1B4", + "#88D7EF", + "#AB8A7A", +] as const; const NEXT_COLOR_KEY = "nextChannelColor"; const slotTemplateSchema = z.object({ @@ -201,6 +213,9 @@ export class ChannelService { slotTemplateCount: result.slotTemplates.length, }); + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return result; } catch (error) { log.error("Failed to create channel", { @@ -678,6 +693,9 @@ export class ChannelService { }); } + const adminService = await TenantAdminService.getTenantById(this.tenantId); + adminService.validateSetupState(); + return result; } catch (error) { log.error("Failed to delete channel", { diff --git a/src/lib/server/services/client-pin-reset-service.ts b/src/lib/server/services/client-pin-reset-service.ts new file mode 100644 index 0000000..bee9558 --- /dev/null +++ b/src/lib/server/services/client-pin-reset-service.ts @@ -0,0 +1,277 @@ +import { getTenantDb } from "../db"; +import * as tenantSchema from "../db/tenant-schema"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { eq, lt } from "drizzle-orm"; +import { UniversalLogger } from "$lib/logger"; +import { NotFoundError, ValidationError } from "../utils/errors"; +import { addMinutes } from "date-fns"; + +const logger = new UniversalLogger(); +const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * ClientPinResetService + * + * Handles PIN reset functionality for clients who have forgotten their PIN. + * Supports two reset methods: + * 1. QR Code based (in-person at practice) + * 2. Email link based (remote) + * + * Both methods involve: + * - Generating a secure reset token + * - Rotating the client's keypair + * - Re-encrypting the tunnel key with the new keypair + */ +export class ClientPinResetService { + #db: PostgresJsDatabase | null = null; + + private constructor(public readonly tenantId: string) {} + + /** + * Create a ClientPinResetService for a specific tenant + * @param tenantId The ID of the tenant + * @returns new ClientPinResetService instance + */ + static async forTenant(tenantId: string): Promise { + const log = logger.setContext("ClientPinResetService"); + log.debug("Creating client PIN reset service for tenant", { tenantId }); + + try { + const service = new ClientPinResetService(tenantId); + service.#db = await getTenantDb(tenantId); + + log.debug("Client PIN reset service created successfully", { tenantId }); + return service; + } catch (error) { + log.error("Failed to create client PIN reset service", { tenantId, error: String(error) }); + throw error; + } + } + + /** + * Create a PIN reset token for a client + * Token expires after 30 minutes by default + * + * @param emailHash SHA-256 hash of the client's email + * @param expirationMinutes How many minutes until the token expires (default: 30) + * @returns The reset token (UUID) + */ + async createResetToken(emailHash: string, expirationMinutes: number = 30): Promise { + const log = logger.setContext("ClientPinResetService"); + const db = await this.getDb(); + + log.debug("Creating PIN reset token", { + emailHash: emailHash.slice(0, 8), + tenantId: this.tenantId, + }); + + try { + // Check if client tunnel exists + const tunnel = await db + .select({ id: tenantSchema.clientAppointmentTunnel.id }) + .from(tenantSchema.clientAppointmentTunnel) + .where(eq(tenantSchema.clientAppointmentTunnel.emailHash, emailHash)) + .limit(1); + + if (tunnel.length === 0) { + log.warn("No client tunnel found for email hash", { emailHash: emailHash.slice(0, 8) }); + throw new NotFoundError("Client not found"); + } + + // Create reset token with expiration + const expiresAt = addMinutes(new Date(), expirationMinutes); + const [resetToken] = await db + .insert(tenantSchema.clientPinResetToken) + .values({ + emailHash, + expiresAt, + used: false, + }) + .returning({ token: tenantSchema.clientPinResetToken.token }); + + log.info("PIN reset token created", { + emailHash: emailHash.slice(0, 8), + tokenId: resetToken.token.slice(0, 8), + expiresAt, + }); + + return resetToken.token; + } catch (error) { + log.error("Failed to create PIN reset token", { + emailHash: emailHash.slice(0, 8), + error: String(error), + }); + throw error; + } + } + + /** + * Verify a PIN reset token + * Checks if token is valid, not expired, and not already used + * + * @param token The reset token to verify + * @returns The email hash associated with this token + * @throws NotFoundError if token is invalid + * @throws ValidationError if token is expired or already used + */ + async verifyResetToken(token: string): Promise { + const log = logger.setContext("ClientPinResetService"); + const db = await this.getDb(); + + log.debug("Verifying PIN reset token", { tokenId: token.slice(0, 8) }); + + try { + // Find the token + const [resetToken] = await db + .select() + .from(tenantSchema.clientPinResetToken) + .where(eq(tenantSchema.clientPinResetToken.token, token)) + .limit(1); + + if (!resetToken) { + log.warn("Invalid PIN reset token", { tokenId: token.slice(0, 8) }); + throw new NotFoundError("Invalid reset token"); + } + + // Check if token is already used + if (resetToken.used) { + log.warn("PIN reset token already used", { tokenId: token.slice(0, 8) }); + throw new ValidationError("Reset token has already been used"); + } + + // Check if token is expired + if (new Date() > resetToken.expiresAt) { + log.warn("PIN reset token expired", { + tokenId: token.slice(0, 8), + expiresAt: resetToken.expiresAt, + }); + throw new ValidationError("Reset token has expired"); + } + + log.debug("PIN reset token verified successfully", { + tokenId: token.slice(0, 8), + emailHash: resetToken.emailHash.slice(0, 8), + }); + + return resetToken.emailHash; + } catch (error) { + log.error("Failed to verify PIN reset token", { + tokenId: token.slice(0, 8), + error: String(error), + }); + throw error; + } + } + + /** + * Complete the PIN reset process + * This involves: + * 1. Verifying the reset token + * 2. Updating the client's keypair (from frontend) + * 3. Re-encrypting the tunnel key with new client keypair (from frontend) + * 4. Marking the token as used + * + * Note: Staff key shares remain unchanged because the tunnel key itself doesn't change, + * only the client's keypair is rotated. + * + * @param token The reset token + * @param newClientPublicKey Base64 encoded new client public key (ML-KEM-768) + * @param newPrivateKeyShare Base64 encoded new private key share (derived from new PIN) + * @param newClientEncryptedTunnelKey Hex encoded tunnel key encrypted with new client public key + * @returns The tunnel ID + */ + async completePinReset( + token: string, + newClientPublicKey: string, + newPrivateKeyShare: string, + newClientEncryptedTunnelKey: string, + ): Promise { + const log = logger.setContext("ClientPinResetService"); + const db = await this.getDb(); + + log.debug("Completing PIN reset", { tokenId: token.slice(0, 8) }); + + try { + // 1. Verify the token and get email hash + const emailHash = await this.verifyResetToken(token); + + // 2. Get the client tunnel + const [tunnel] = await db + .select() + .from(tenantSchema.clientAppointmentTunnel) + .where(eq(tenantSchema.clientAppointmentTunnel.emailHash, emailHash)) + .limit(1); + + if (!tunnel) { + throw new NotFoundError("Client tunnel not found"); + } + + // 3. Update the client tunnel with new keys + await db + .update(tenantSchema.clientAppointmentTunnel) + .set({ + clientPublicKey: newClientPublicKey, + privateKeyShare: newPrivateKeyShare, + clientEncryptedTunnelKey: newClientEncryptedTunnelKey, + updatedAt: new Date(), + }) + .where(eq(tenantSchema.clientAppointmentTunnel.id, tunnel.id)); + + log.info("Client tunnel keys updated", { tunnelId: tunnel.id }); + + // 4. Mark the token as used + await db + .update(tenantSchema.clientPinResetToken) + .set({ used: true }) + .where(eq(tenantSchema.clientPinResetToken.token, token)); + + log.info("PIN reset completed successfully", { + tokenId: token.slice(0, 8), + tunnelId: tunnel.id, + emailHash: emailHash.slice(0, 8), + }); + + return tunnel.id; + } catch (error) { + log.error("Failed to complete PIN reset", { + tokenId: token.slice(0, 8), + error: String(error), + }); + throw error; + } + } + + /** + * Clean up expired and used reset tokens (housekeeping) + * Should be called periodically (e.g., daily cron job) + */ + async cleanupExpiredTokens(): Promise { + const log = logger.setContext("ClientPinResetService"); + const db = await this.getDb(); + + log.debug("Cleaning up expired PIN reset tokens"); + + try { + const result = await db.delete(tenantSchema.clientPinResetToken).where( + lt(tenantSchema.clientPinResetToken.createdAt, new Date(Date.now() - SEVEN_DAYS_IN_MS)), // older than 7 days + ); + + log.info("Expired PIN reset tokens cleaned up", { count: result.count }); + + return result.count || 0; + } catch (error) { + log.error("Failed to cleanup expired tokens", { error: String(error) }); + throw error; + } + } + + /** + * Get the tenant's database connection (cached) + */ + private async getDb(): Promise> { + if (!this.#db) { + this.#db = await getTenantDb(this.tenantId); + } + return this.#db; + } +} diff --git a/src/lib/server/services/invite-service.ts b/src/lib/server/services/invite-service.ts index 8732357..7b3d0d9 100644 --- a/src/lib/server/services/invite-service.ts +++ b/src/lib/server/services/invite-service.ts @@ -7,6 +7,7 @@ import { } from "$lib/server/db/central-schema"; import { eq, and, lt } from "drizzle-orm"; import { UniversalLogger } from "$lib/logger"; +import { InternalError } from "../utils/errors"; const logger = new UniversalLogger().setContext("InviteService"); @@ -59,7 +60,7 @@ export class InviteService { return createdInvite; } catch (error) { logger.error("Failed to create user invitation", { error, email, tenantId, role }); - throw new Error(`Failed to create invitation: ${error}`); + throw new InternalError(`Failed to create invitation: ${error}`); } } @@ -142,7 +143,7 @@ export class InviteService { return updatedInvite; } catch (error) { logger.error("Failed to mark invitation as used", { error, inviteCode, createdUserId }); - throw new Error(`Failed to update invitation: ${error}`); + throw new InternalError(`Failed to update invitation: ${error}`); } } diff --git a/src/lib/server/services/notification-service.ts b/src/lib/server/services/notification-service.ts new file mode 100644 index 0000000..02c1901 --- /dev/null +++ b/src/lib/server/services/notification-service.ts @@ -0,0 +1,307 @@ +import { getTenantDb } from "../db"; +import { + notification, + channelStaff, + notificationTypes, + type NotificationType, +} from "../db/tenant-schema"; +import { eq, and, desc } from "drizzle-orm"; +import logger from "$lib/logger"; +import { z } from "zod"; +import { ValidationError, NotFoundError } from "../utils/errors"; + +const notificationCreationSchema = z.object({ + channelId: z.uuid({ message: "Invalid UUID format" }), + type: z.enum(notificationTypes), + metaData: z.record(z.string(), z.any()).optional(), +}); + +export type NotificationCreationRequest = z.infer; + +export interface SelectNotification { + id: string; + staffId: string; + type: NotificationType; + metaData: { [key: string]: string } | null; + isRead: boolean; + createdAt: Date; +} + +export class NotificationService { + #db: Awaited> | null = null; + + private constructor(public readonly tenantId: string) {} + + /** + * Create a notification service for a specific tenant + * @param tenantId The ID of the tenant + * @returns new NotificationService instance + */ + static async forTenant(tenantId: string) { + const log = logger.setContext("NotificationService"); + log.debug("Creating notification service for tenant", { tenantId }); + + try { + const service = new NotificationService(tenantId); + service.#db = await getTenantDb(tenantId); + + log.debug("Notification service created successfully", { tenantId }); + return service; + } catch (error) { + log.error("Failed to create notification service", { tenantId, error: String(error) }); + throw error; + } + } + + /** + * Create a new notification for all staff members in a channel + * @param request Notification creation request data + * @returns Array of created notification IDs + */ + async createNotification(request: NotificationCreationRequest): Promise { + const log = logger.setContext("NotificationService.createNotification"); + log.debug("Creating notification for channel", { channelId: request.channelId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + // Validate request + try { + notificationCreationSchema.parse(request); + } catch (error) { + log.warn("Invalid notification creation request", { error }); + throw new ValidationError("Invalid notification data"); + } + + try { + // Get all staff members for this channel + const staffMembers = await this.#db + .select({ staffId: channelStaff.staffId }) + .from(channelStaff) + .where(eq(channelStaff.channelId, request.channelId)); + + if (staffMembers.length === 0) { + log.warn("No staff members found for channel", { channelId: request.channelId }); + return []; + } + + log.debug("Found staff members for channel", { + channelId: request.channelId, + count: staffMembers.length, + }); + + // Create notifications for all staff members + const notificationsToCreate = staffMembers.map((staff) => ({ + staffId: staff.staffId, + type: request.type, + metaData: request.metaData, + isRead: false, + })); + + const createdNotifications = await this.#db + .insert(notification) + .values(notificationsToCreate) + .returning({ id: notification.id }); + + log.info("Created notifications for channel", { + channelId: request.channelId, + count: createdNotifications.length, + }); + + return createdNotifications.map((n) => n.id); + } catch (error) { + log.error("Failed to create notification", { + channelId: request.channelId, + error: String(error), + }); + throw error; + } + } + + /** + * Get all notifications for a staff member + * @param staffId The ID of the staff member + * @returns Array of notifications + */ + async getNotificationsForStaff(staffId: string): Promise { + const log = logger.setContext("NotificationService.getNotificationsForStaff"); + log.debug("Getting notifications for staff", { staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + const notifications = await this.#db + .select() + .from(notification) + .where(eq(notification.staffId, staffId)) + .orderBy(desc(notification.createdAt)); + + log.debug("Retrieved notifications for staff", { + staffId, + count: notifications.length, + }); + + return notifications; + } catch (error) { + log.error("Failed to get notifications for staff", { + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Check if a staff member has unread notifications + * @param staffId The ID of the staff member + * @returns True if there are unread notifications + */ + async hasUnreadNotifications(staffId: string): Promise { + const log = logger.setContext("NotificationService.hasUnreadNotifications"); + log.debug("Checking for unread notifications", { staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + const unreadNotifications = await this.#db + .select({ id: notification.id }) + .from(notification) + .where(and(eq(notification.staffId, staffId), eq(notification.isRead, false))) + .limit(1); + + const hasUnread = unreadNotifications.length > 0; + + log.debug("Checked unread notifications", { + staffId, + hasUnread, + }); + + return hasUnread; + } catch (error) { + log.error("Failed to check unread notifications", { + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Delete a specific notification + * @param notificationId The ID of the notification to delete + * @param staffId The ID of the staff member (ownership check) + * @throws NotFoundError if notification not found + */ + async deleteNotification(notificationId: string, staffId: string): Promise { + const log = logger.setContext("NotificationService.deleteNotification"); + log.debug("Deleting notification", { notificationId, staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + await this.#db + .delete(notification) + .where(and(eq(notification.id, notificationId), eq(notification.staffId, staffId))); + + log.info("Deleted notification", { notificationId, staffId }); + } catch (error) { + if (error instanceof NotFoundError) { + throw error; + } + log.error("Failed to delete notification", { + notificationId, + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Mark a notification as read + * @param notificationId The ID of the notification to mark as read + * @param staffId The ID of the staff member (ownership check) + * @throws NotFoundError if notification not found + */ + async markAsRead(notificationId: string, staffId: string): Promise { + const log = logger.setContext("NotificationService.markAsRead"); + log.debug("Marking notification as read", { notificationId, staffId }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + await this.#db + .update(notification) + .set({ isRead: true }) + .where(and(eq(notification.id, notificationId), eq(notification.staffId, staffId))); + + log.info("Marked notification as read", { notificationId, staffId }); + } catch (error) { + if (error instanceof NotFoundError) { + throw error; + } + log.error("Failed to mark notification as read", { + notificationId, + staffId, + error: String(error), + }); + throw error; + } + } + + /** + * Delete all notifications for a staff member + * @param staffId The ID of the staff member + * @param readOnly If true, only delete read notifications + * @returns Number of deleted notifications + */ + async deleteAllNotifications(staffId: string, readOnly: boolean = false): Promise { + const log = logger.setContext("NotificationService.deleteAllNotifications"); + log.debug("Deleting all notifications for staff", { staffId, readOnly }); + + if (!this.#db) { + log.error("Database connection not initialized"); + throw new Error("Database connection not initialized"); + } + + try { + const whereCondition = readOnly + ? and(eq(notification.staffId, staffId), eq(notification.isRead, true)) + : eq(notification.staffId, staffId); + + const deleted = await this.#db + .delete(notification) + .where(whereCondition) + .returning({ id: notification.id }); + + log.info("Deleted notifications for staff", { + staffId, + readOnly, + count: deleted.length, + }); + + return deleted.length; + } catch (error) { + log.error("Failed to delete notifications", { + staffId, + readOnly, + error: String(error), + }); + throw error; + } + } +} diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index db90e61..2459b51 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -8,15 +8,18 @@ import { type SelectAgentAbsence, } from "../db/tenant-schema"; -import { eq, and, between, sql, or } from "drizzle-orm"; +import { eq, and, between, sql, or, inArray } from "drizzle-orm"; import logger from "$lib/logger"; -import z from "zod/v4"; +import { z } from "zod"; import { ValidationError } from "../utils/errors"; const scheduleRequestSchema = z.object({ startDate: z.string().datetime({ offset: true }), // ISO date string with timezone endDate: z.string().datetime({ offset: true }), // ISO date string with timezone tenantId: z.string().uuid({ message: "Invalid tenant ID format" }), + channelId: z.string().uuid({ message: "Invalid channel ID format" }).optional(), + agentId: z.string().uuid({ message: "Invalid agent ID format" }).optional(), + staffUserId: z.string().uuid({ message: "Invalid staff user ID format" }).optional(), }); export type ScheduleRequest = z.infer; @@ -28,12 +31,16 @@ export interface TimeSlot { availableAgents: SelectAgent[]; } +export interface AppointmentWithKeyShare extends SelectAppointment { + staffKeyShare?: string; +} + export interface DaySchedule { date: string; // YYYY-MM-DD format channels: { [channelId: string]: { channel: SelectChannel; - appointments: SelectAppointment[]; + appointments: AppointmentWithKeyShare[]; availableSlots: TimeSlot[]; }; }; @@ -96,12 +103,15 @@ export class ScheduleService { const db = await this.getDb(); // 1. Get all channels for the tenant - const channels = await db + let channels = await db .select() .from(tenantSchema.channel) .where( and(eq(tenantSchema.channel.pause, false), eq(tenantSchema.channel.archived, false)), ); // Only active channels + if (request.channelId) { + channels = channels.filter((channel) => channel.id === request.channelId); + } // 2. Get all slot templates associated with channels const slotTemplates = await db @@ -134,6 +144,30 @@ export class ScheduleService { ), ); + // 3a. If staffUserId is provided, get staffKeyShares for all appointment tunnels + let staffKeyShares: Record = {}; + if (request.staffUserId && appointments.length > 0) { + const tunnelIds = [...new Set(appointments.map((apt) => apt.tunnelId))]; + const keyShares = await db + .select() + .from(tenantSchema.clientTunnelStaffKeyShare) + .where( + and( + eq(tenantSchema.clientTunnelStaffKeyShare.userId, request.staffUserId), + inArray(tenantSchema.clientTunnelStaffKeyShare.tunnelId, tunnelIds), + ), + ); + + // Create a map of tunnelId -> encryptedTunnelKey + staffKeyShares = keyShares.reduce( + (acc, share) => { + acc[share.tunnelId] = share.encryptedTunnelKey; + return acc; + }, + {} as Record, + ); + } + // 4. Get agent absences in the date range const absences = await db .select() @@ -159,7 +193,7 @@ export class ScheduleService { ); // 5. Get channel-agent assignments - const channelAgents = await db + let channelAgents = await db .select({ channelId: tenantSchema.channelAgent.channelId, agent: tenantSchema.agent, @@ -169,6 +203,9 @@ export class ScheduleService { tenantSchema.agent, eq(tenantSchema.channelAgent.agentId, tenantSchema.agent.id), ); + if (request.agentId) { + channelAgents = channelAgents.filter((ca) => ca.agent.id === request.agentId); + } // 6. Generate daily schedules const schedule = await this.generateDailySchedules({ @@ -179,6 +216,7 @@ export class ScheduleService { appointments, absences, channelAgents, + staffKeyShares, }); log.debug("Schedule generated successfully", { @@ -213,6 +251,7 @@ export class ScheduleService { appointments, absences, channelAgents, + staffKeyShares, }: { startDate: Date; endDate: Date; @@ -221,6 +260,7 @@ export class ScheduleService { appointments: SelectAppointment[]; absences: SelectAgentAbsence[]; channelAgents: { channelId: string; agent: SelectAgent }[]; + staffKeyShares: Record; }): Promise { const dailySchedules: DaySchedule[] = []; @@ -239,13 +279,18 @@ export class ScheduleService { // Process each channel for (const channel of channels) { // Get appointments for this channel on this day - const dayAppointments = appointments.filter( - (appointment) => - appointment.channelId === channel.id && - (typeof appointment.appointmentDate === "string" - ? (appointment.appointmentDate as string).startsWith(dateString) - : appointment.appointmentDate.toISOString().startsWith(dateString)), - ); + const dayAppointments = appointments + .filter( + (appointment) => + appointment.channelId === channel.id && + (typeof appointment.appointmentDate === "string" + ? (appointment.appointmentDate as string).startsWith(dateString) + : appointment.appointmentDate.toISOString().startsWith(dateString)), + ) + .map((appointment) => ({ + ...appointment, + staffKeyShare: staffKeyShares[appointment.tunnelId], + })); // Get slot templates for this channel that apply to this weekday const channelSlotTemplates = slotTemplates diff --git a/src/lib/server/services/staff-crypto.service.ts b/src/lib/server/services/staff-crypto.service.ts index 0b515d2..870b2ed 100644 --- a/src/lib/server/services/staff-crypto.service.ts +++ b/src/lib/server/services/staff-crypto.service.ts @@ -172,10 +172,12 @@ export class StaffCryptoService { /** * Get all staff public keys for a tenant + * Returns ALL public keys for all passkeys of each staff member + * This allows clients to encrypt appointment data that can be decrypted by any staff member's passkey */ async getStaffPublicKeys( tenantId: string, - ): Promise> { + ): Promise> { const log = logger.setContext("StaffCryptoService.getStaffPublicKeys"); try { @@ -184,6 +186,7 @@ export class StaffCryptoService { .select({ userId: staffCrypto.userId, publicKey: staffCrypto.publicKey, + passkeyId: staffCrypto.passkeyId, }) .from(staffCrypto) .where(eq(staffCrypto.isActive, true)); @@ -191,11 +194,13 @@ export class StaffCryptoService { const validStaffKeys = staffWithKeys.map((staff) => ({ userId: staff.userId, publicKey: staff.publicKey, + passkeyId: staff.passkeyId, })); log.info("Retrieved staff public keys", { tenantId, validKeys: validStaffKeys.length, + uniqueStaff: new Set(validStaffKeys.map((k) => k.userId)).size, }); return validStaffKeys; diff --git a/src/lib/server/services/staff-service.ts b/src/lib/server/services/staff-service.ts index da9da8d..64f52d5 100644 --- a/src/lib/server/services/staff-service.ts +++ b/src/lib/server/services/staff-service.ts @@ -1,11 +1,12 @@ import { centralDb, getTenantDb } from "../db"; -import { user, userPasskey } from "../db/central-schema"; +import { user, userInvite } from "../db/central-schema"; import { clientTunnelStaffKeyShare } from "../db/tenant-schema"; -import { eq, and } from "drizzle-orm"; +import { eq, and, or } from "drizzle-orm"; import { NotFoundError, ValidationError, InternalError } from "../utils/errors"; import { StaffCryptoService } from "./staff-crypto.service"; import { UniversalLogger } from "$lib/logger"; import type { InferSelectModel } from "drizzle-orm"; +import { UserService } from "./user-service"; const logger = new UniversalLogger().setContext("StaffService"); @@ -55,7 +56,7 @@ export class StaffService { logger.debug("Fetching staff members", { tenantId }); try { - const staff: StaffMember[] = await centralDb + let staff: StaffMember[] = await centralDb .select({ id: user.id, email: user.email, @@ -70,12 +71,37 @@ export class StaffService { .from(user) .where(eq(user.tenantId, tenantId)); + const invitedStaff = await centralDb + .select({ + id: userInvite.id, + email: userInvite.email, + name: userInvite.name, + role: userInvite.role, + createdAt: userInvite.createdAt, + }) + .from(userInvite) + .where(and(eq(userInvite.tenantId, tenantId), eq(userInvite.used, false))); + + staff = staff.concat( + invitedStaff.map((invite) => ({ + id: invite.id, + email: invite.email, + name: invite.name, + role: invite.role, + isActive: null, + confirmationState: "INVITED" as const, + createdAt: invite.createdAt, + updatedAt: null, + lastLoginAt: null, + })), + ); + logger.debug("Staff members fetched successfully", { tenantId, count: staff.length, }); - return staff; + return staff.sort((a, b) => a.name.localeCompare(b.name)); } catch (error) { logger.error("Failed to fetch staff members", { tenantId, @@ -159,6 +185,7 @@ export class StaffService { tenantId: string, staffId: string, currentUserId?: string, + confirmationState?: "INVITED" | "CONFIRMED" | "ACCESS_GRANTED", ): Promise { logger.debug("Deleting staff member", { tenantId, staffId, currentUserId }); @@ -167,99 +194,117 @@ export class StaffService { throw new ValidationError("You cannot delete your own account"); } + // Check if this is the only non-global admin staff member + const nonGlobalAdminStaff = await centralDb + .select({ id: user.id }) + .from(user) + .where( + and( + eq(user.tenantId, tenantId), + eq(user.isActive, true), + or(eq(user.role, "TENANT_ADMIN"), eq(user.role, "STAFF")), + eq(user.confirmationState, "ACCESS_GRANTED"), + ), + ); + + if (nonGlobalAdminStaff.length === 1 && nonGlobalAdminStaff[0].id === staffId) { + throw new ValidationError("Cannot delete the last active non-global admin staff member"); + } + try { // Use transaction to ensure all related data is deleted consistently const result = await centralDb.transaction(async (tx) => { // First, verify the user exists and belongs to this tenant - const userToDelete = await tx - .select({ - id: user.id, - email: user.email, - name: user.name, - role: user.role, - tenantId: user.tenantId, - }) - .from(user) - .where(and(eq(user.id, staffId), eq(user.tenantId, tenantId))) - .limit(1); + if (confirmationState !== "INVITED") { + const userToDelete = await tx + .select({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + tenantId: user.tenantId, + }) + .from(user) + .where(and(eq(user.id, staffId), eq(user.tenantId, tenantId))) + .limit(1); - if (userToDelete.length === 0) { - throw new NotFoundError("Staff member not found in this tenant"); - } + if (userToDelete.length === 0) { + throw new NotFoundError("Staff member not found in this tenant"); + } - // Delete associated passkeys from central database - const passkeyDeletionResult = await tx - .delete(userPasskey) - .where(eq(userPasskey.userId, staffId)); + // Use UserService to delete central database data (user + passkeys) first + // This ensures all validation logic is applied before deleting tenant data + const userDeletionResult = await UserService.deleteUser(staffId, tx); - const deletedPasskeysCount = passkeyDeletionResult.count || 0; + // Remove old invites of user if any exist + const deletedInvites = await tx + .delete(userInvite) + .where(eq(userInvite.email, userToDelete[0].email)); + logger.debug("Deleted user invites", { + staffId, + tenantId, + deletedCount: deletedInvites.count || 0, + }); - logger.debug("Deleted user passkeys", { - staffId, - tenantId, - deletedCount: deletedPasskeysCount, - }); - - // Delete client tunnel key shares from tenant database - let deletedKeySharesCount = 0; - try { + // Delete tenant-specific data (client tunnel key shares) after user deletion succeeds const tenantDb = await getTenantDb(tenantId); const keyShareDeletionResult = await tenantDb .delete(clientTunnelStaffKeyShare) .where(eq(clientTunnelStaffKeyShare.userId, staffId)); - deletedKeySharesCount = keyShareDeletionResult.count || 0; + const deletedKeySharesCount = keyShareDeletionResult?.count || 0; logger.debug("Deleted client tunnel key shares", { staffId, tenantId, - deletedCount: deletedKeySharesCount, + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, + deletedKeySharesCount, }); - } catch (error) { - logger.warn("Failed to delete client tunnel key shares", { + + // Combine results for staff-specific response format + const staffDeletionResult: StaffDeletionResult = { + success: userDeletionResult.success, + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, + deletedKeySharesCount, + }; + + logger.info("Staff member deleted successfully", { staffId, tenantId, - error: String(error), + deletedUser: userDeletionResult.deletedUser, + deletedPasskeysCount: userDeletionResult.deletedPasskeysCount, + deletedKeySharesCount, }); - // Continue with user deletion even if key share deletion fails + + return staffDeletionResult; + } else { + const deletedInvites = await tx.delete(userInvite).where(eq(userInvite.id, staffId)); + logger.debug("Deleted user invites", { + staffId, + tenantId, + deletedCount: deletedInvites.count || 0, + }); + // Note: User deletion already succeeded, key share deletion is auxiliary + return { + success: true, + deletedUser: { + id: staffId, + email: "", + name: "", + role: "STAFF" as const, + }, + deletedPasskeysCount: 0, + deletedKeySharesCount: 0, + }; } - - // Finally, delete the user account from central database - const deletedUsers = await tx.delete(user).where(eq(user.id, staffId)).returning({ - id: user.id, - email: user.email, - name: user.name, - role: user.role, - }); - - if (deletedUsers.length === 0) { - throw new InternalError("Failed to delete user account"); - } - - const deletionResult = { - success: true, - deletedUser: deletedUsers[0], - deletedPasskeysCount, - deletedKeySharesCount, - }; - - logger.info("Staff member deleted successfully", { - staffId, - tenantId, - deletedUser: deletedUsers[0], - deletedPasskeysCount, - deletedKeySharesCount, - }); - - return deletionResult; }); - return result; } catch (error) { if (error instanceof ValidationError || error instanceof NotFoundError) { throw error; } - logger.error("Failed to delete staff member", { tenantId, staffId, diff --git a/src/lib/server/services/startup-service.ts b/src/lib/server/services/startup-service.ts index fc09acd..91ef37a 100644 --- a/src/lib/server/services/startup-service.ts +++ b/src/lib/server/services/startup-service.ts @@ -2,12 +2,16 @@ import { centralDb } from "../db"; import { tenant } from "../db/central-schema"; import { TenantMigrationService } from "./tenant-migration-service"; import { CentralDatabaseMigrationService } from "./central-database-migration-service"; +import { InviteService } from "./invite-service"; +import { SessionService } from "../auth/session-service"; +import { ClientPinResetService } from "./client-pin-reset-service"; import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("StartupService"); - +const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; export class StartupService { private static initialized = false; + private static housekeepingInterval: NodeJS.Timeout | null = null; /** * Initialize the application on startup @@ -28,6 +32,9 @@ export class StartupService { // Then check and migrate all tenant databases await this.migrateTenantDatabases(); + // Start automatic housekeeping + this.startAutomaticHousekeeping(); + this.initialized = true; logger.info("Application initialization completed successfully"); } catch (error) { @@ -111,10 +118,92 @@ export class StartupService { } } + /** + * Start automatic housekeeping that runs twice daily + * Runs immediately on startup, then every 12 hours + */ + private static startAutomaticHousekeeping(): void { + logger.info("Starting automatic housekeeping scheduler"); + + // Run housekeeping immediately on startup + this.performHousekeeping().catch((error) => { + logger.error("Initial housekeeping failed", { error: String(error) }); + }); + + // Schedule housekeeping to run every 12 hours + if (this.housekeepingInterval) { + clearInterval(this.housekeepingInterval); + } + this.housekeepingInterval = setInterval(() => { + logger.info("Scheduled housekeeping triggered"); + this.performHousekeeping().catch((error) => { + logger.error("Scheduled housekeeping failed", { error: String(error) }); + }); + }, TWELVE_HOURS_IN_MS); // 12 hours in milliseconds + + logger.info("Automatic housekeeping scheduler started (runs every 12 hours)"); + } + + /** + * Perform housekeeping tasks across all tenants + * Cleans up expired invitations, sessions, and PIN reset tokens + */ + static async performHousekeeping(): Promise { + logger.info("Starting housekeeping tasks"); + + try { + // Cleanup expired invitations (central database) + const deletedInvites = await InviteService.cleanupExpiredInvites(); + logger.info(`Cleaned up ${deletedInvites} expired invitations`); + + // Cleanup expired sessions (central database) + await SessionService.cleanupExpiredSessions(); + logger.info("Cleaned up expired sessions"); + + // Get all tenants + const tenants = await centralDb + .select({ + id: tenant.id, + shortName: tenant.shortName, + }) + .from(tenant); + + // Cleanup expired PIN reset tokens for each tenant + for (const tenantData of tenants) { + try { + const pinResetService = await ClientPinResetService.forTenant(tenantData.id); + const deletedTokens = await pinResetService.cleanupExpiredTokens(); + logger.info(`Cleaned up ${deletedTokens} expired PIN reset tokens for tenant`, { + tenantId: tenantData.id, + shortName: tenantData.shortName, + }); + } catch (error) { + logger.error("Failed to cleanup PIN reset tokens for tenant", { + tenantId: tenantData.id, + shortName: tenantData.shortName, + error: String(error), + }); + // Continue with other tenants + } + } + + logger.info("Housekeeping tasks completed successfully"); + } catch (error) { + logger.error("Housekeeping tasks failed", { error: String(error) }); + throw error; + } + } + /** * Force re-initialization (useful for testing) */ static reset(): void { + // Clear the housekeeping interval if it exists + if (this.housekeepingInterval) { + clearInterval(this.housekeepingInterval); + this.housekeepingInterval = null; + logger.debug("Housekeeping interval cleared"); + } this.initialized = false; } diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index b5847fd..3ce9f51 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -5,12 +5,11 @@ import { TenantConfig } from "../db/tenant-config"; import { TenantMigrationService } from "./tenant-migration-service"; import { env } from "$env/dynamic/private"; -import { eq, and, not } from "drizzle-orm"; +import { eq, and, not, count, or } from "drizzle-orm"; import logger from "$lib/logger"; -import z from "zod/v4"; +import { z } from "zod"; import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; -import { sendTenantAdminInviteEmail } from "../email/email-service"; -import { ERRORS } from "$lib/errors"; +import { redactDbUrl } from "../utils/url"; if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); @@ -20,7 +19,6 @@ const tenantCreationSchema = z.object({ .min(4) .max(15) .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/), - inviteAdmin: z.email().optional(), }); export type TenantCreationRequest = z.infer; @@ -67,7 +65,6 @@ export class TenantAdminService { // Check if tenant can be created without any duplications. Do not create tenant if: // - short name already exists - // - invited tenant admin email already exists const tenantExists = await centralDb .select() .from(centralSchema.tenant) @@ -75,15 +72,6 @@ export class TenantAdminService { if (tenantExists.length > 0) { throw new ConflictError("Tenant with shortname already exists"); } - if (request.inviteAdmin) { - const adminExists = await centralDb - .select() - .from(centralSchema.user) - .where(eq(centralSchema.user.email, request.inviteAdmin)); - if (adminExists.length > 0) { - throw new ConflictError(ERRORS.USERS.EMAIL_EXISTS); - } - } const configuration = TenantAdminService.getConfigDefaults(); @@ -123,7 +111,7 @@ export class TenantAdminService { } catch (dbError) { log.error("Failed to initialize tenant database", { tenantId: tenant[0].id, - databaseUrl: newTenant.databaseUrl, + databaseUrl: redactDbUrl(newTenant.databaseUrl), error: String(dbError), }); @@ -150,40 +138,6 @@ export class TenantAdminService { log.debug("Tenant service created successfully", { tenantId: tenant[0].id }); - // Send tenant admin invitation email if email is provided - if (request.inviteAdmin) { - try { - // For now, we'll use the email as name. In a real implementation, - // you might want to collect the name separately or parse it from the email - const adminName = request.inviteAdmin.split("@")[0]; - - // Generate registration URL for the tenant admin - // This should point to a registration page that pre-fills tenant info - const registrationUrl = `${env.PUBLIC_APP_URL || "http://localhost:5173"}/register?tenant=${tenant[0].id}&email=${encodeURIComponent(request.inviteAdmin)}&role=TENANT_ADMIN`; - - await sendTenantAdminInviteEmail( - request.inviteAdmin, - adminName, - tenant[0], - registrationUrl, - ); - - log.info("Tenant admin invitation email sent successfully", { - tenantId: tenant[0].id, - adminEmail: request.inviteAdmin, - }); - } catch (emailError) { - log.error("Failed to send tenant admin invitation email", { - tenantId: tenant[0].id, - adminEmail: request.inviteAdmin, - error: String(emailError), - }); - - // Don't fail the tenant creation if email fails - // Just log the error and continue - } - } - return tenantService; } catch (error) { log.error("Failed to create tenant", { @@ -255,12 +209,7 @@ export class TenantAdminService { }); if (updateData.shortName) { - const shortNameValidation = tenantCreationSchema.shape.shortName.safeParse( - updateData.shortName, - ); - if (!shortNameValidation.success) { - throw new ValidationError("Invalid shortName format"); - } + throw new ValidationError("Shortname cannot be changed"); } try { @@ -318,6 +267,10 @@ export class TenantAdminService { updatedCount: results.length, }); + if (this.#tenant?.setupState === "SETTINGS") { + await this.setSetupState("AGENTS"); + } + return results; } catch (error) { log.error("Failed to update tenant configuration", { @@ -332,9 +285,7 @@ export class TenantAdminService { /** * Set the setup state of the tenant */ - async setSetupState( - setupState: "NEW" | "SETTINGS_CREATED" | "AGENTS_SET_UP" | "FIRST_CHANNEL_CREATED", - ) { + async setSetupState(setupState: centralSchema.SelectTenant["setupState"]) { const log = logger.setContext("TenantAdminService"); log.debug("Setting tenant setup state", { tenantId: this.tenantId, @@ -400,6 +351,93 @@ export class TenantAdminService { return this.#db; } + /** + * Validates and updates the setup state of a tenant. Called by other services after certain operations that might influence the setup state. + */ + async validateSetupState() { + const log = logger.setContext("TenantAdminService"); + log.debug("Validating tenant setup state", { tenantId: this.tenantId }); + + if (!this.#tenant) { + throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`); + } + + let newState = this.#tenant.setupState || "SETTINGS"; + + // If it is SETTINGS, return. The tenant was not configured yet. + if (newState === "SETTINGS") { + log.debug("Tenant setup state validation complete - still in SETTINGS", { + tenantId: this.tenantId, + }); + return; + } + + try { + const db = await this.getDb(); + + // Check for agents + const { agent, channel } = await import("../db/tenant-schema"); + + const agentCount = await db + .select({ count: count() }) + .from(agent) + .where(eq(agent.archived, false)); + + if (agentCount[0].count === 0) { + newState = "AGENTS"; + } else { + // Check for channels + const channelCount = await db + .select({ count: count() }) + .from(channel) + .where(eq(channel.archived, false)); + if (channelCount[0].count === 0) { + newState = "CHANNELS"; + } else { + // Check for staff members + const staffCount = await centralDb + .select({ count: count() }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, this.tenantId), + or( + eq(centralSchema.user.role, "STAFF"), + eq(centralSchema.user.role, "TENANT_ADMIN"), + ), + ), + ); + if (staffCount[0].count === 0) { + newState = "STAFF"; + } else { + newState = "READY"; + } + } + } + + // Only update if state has changed + if (newState !== this.#tenant.setupState) { + log.debug("Tenant setup state needs update", { + tenantId: this.tenantId, + currentState: this.#tenant.setupState, + newState, + }); + await this.setSetupState(newState); + } else { + log.debug("Tenant setup state validation complete - no change needed", { + tenantId: this.tenantId, + currentState: newState, + }); + } + } catch (error) { + log.error("Failed to validate tenant setup state", { + tenantId: this.tenantId, + error: error, + }); + throw error; + } + } + /** * Delete a tenant and all associated data * This operation: diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index abb6358..9cce357 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -1,9 +1,10 @@ import { centralDb } from "../db"; import * as centralSchema from "../db/central-schema"; -import { eq, desc, gt, and, count } from "drizzle-orm"; -import type { InferInsertModel } from "drizzle-orm"; -import z from "zod/v4"; -import { NotFoundError, ValidationError } from "../utils/errors"; +import { eq, desc, gt, and, count, or } from "drizzle-orm"; +import type { InferInsertModel, TablesRelationalConfig } from "drizzle-orm"; + +import { z } from "zod"; +import { NotFoundError, ValidationError, InternalError } from "../utils/errors"; import { uuidv7 } from "uuidv7"; import { addMinutes } from "date-fns"; import logger from "$lib/logger"; @@ -15,9 +16,29 @@ import { import { sendConfirmationEmail } from "../email/email-service"; import type { SelectTenant } from "../db/central-schema"; import { TenantAdminService } from "./tenant-admin-service"; +import { InviteService } from "./invite-service"; +import type { PgTransaction } from "drizzle-orm/pg-core"; +import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; export type InsertUser = InferInsertModel; export type InsertUserPasskey = InferInsertModel; +export type UserTransaction = PgTransaction< + PostgresJsQueryResultHKT, + Record, + TablesRelationalConfig +>; + +export interface UserDeletionResult { + success: boolean; + deletedUser: { + id: string; + email: string; + name: string; + role: "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF"; + }; + deletedPasskeysCount: number; + tenantId?: string | null; +} const userCreationSchema = z.object({ name: z.string().min(5), @@ -49,7 +70,7 @@ function createSystemTenant(): SelectTenant { logo: null, links: { website: "", imprint: "", privacyStatement: "" }, databaseUrl: "", - setupState: "FIRST_CHANNEL_CREATED", + setupState: "SETTINGS", createdAt: new Date(), updatedAt: new Date(), }; @@ -234,20 +255,41 @@ export class UserService { * Confirm and activate user after confirmation link was clicked * @param linkToken - The token from the link */ - static async confirm( - linkToken: string, - ): Promise<{ recoveryPassphrase?: string; isSetup: boolean }> { + static async confirm(linkToken: string): Promise<{ + recoveryPassphrase?: string; + isSetup: boolean; + id: string; + email: string; + tenantId: string | null; + name?: string; + language?: string; + }> { const log = logger.setContext("UserService"); log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." }); try { - // First, get the user data to check for recovery passphrase + // First, get the user data to check for recovery pass + // phrase + + let resultData: + | { + id: string; + name?: string; + language?: string; + recoveryPassphrase: string | null; + tenantId: string | null; + role: "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF"; + email: string; + } + | undefined = undefined; + const userData = await centralDb .select({ id: centralSchema.user.id, recoveryPassphrase: centralSchema.user.recoveryPassphrase, tenantId: centralSchema.user.tenantId, role: centralSchema.user.role, + email: centralSchema.user.email, }) .from(centralSchema.user) .where( @@ -259,17 +301,73 @@ export class UserService { .limit(1); if (userData.length === 0) { - log.warn("User confirmation failed: Invalid or expired token", { - token: linkToken.substring(0, 8) + "...", - }); - throw new NotFoundError("Invalid or timed-out token"); + const inviteData = await centralDb + .select({ + id: centralSchema.userInvite.id, + tenantId: centralSchema.userInvite.tenantId, + role: centralSchema.userInvite.role, + email: centralSchema.userInvite.email, + name: centralSchema.userInvite.name, + language: centralSchema.userInvite.language, + }) + .from(centralSchema.userInvite) + .where( + and( + eq(centralSchema.userInvite.inviteCode, linkToken), + gt(centralSchema.userInvite.expiresAt, new Date()), + ), + ) + .limit(1); + + if (inviteData.length === 0) { + log.warn("User confirmation failed: Invalid or expired token", { + token: linkToken.substring(0, 8) + "...", + }); + throw new NotFoundError("Invalid or timed-out token"); + } else { + resultData = { ...inviteData[0], recoveryPassphrase: null }; + const userDataForDb: InsertUser = { + name: resultData.name!, + email: resultData.email, + role: resultData.role, + tenantId: resultData.tenantId, + language: resultData.language || "de", + confirmationState: "CONFIRMED", + isActive: true, + }; + const retVal = await centralDb + .insert(centralSchema.user) + .values(userDataForDb) + .returning(); + resultData.id = retVal[0].id; + + await InviteService.markInviteAsUsed(linkToken, resultData.id); + log.debug("Invitation marked as used", { + inviteCode: linkToken, + userId: resultData.id, + }); + + const adminService = await TenantAdminService.getTenantById(resultData.tenantId!); + adminService.validateSetupState(); + } + } else { + resultData = userData[0]; } - const user = userData[0]; - // Check if this is the first tenant admin for the tenant - const shouldGrantAccess = userData[0].role === "GLOBAL_ADMIN"; // Global admins always get ACCESS_GRANTED + const numberOfUsers = await centralDb + .select({ count: count() }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, resultData.tenantId!), + eq(centralSchema.user.role, "TENANT_ADMIN"), + ), + ); + + const shouldGrantAccess = resultData.role === "GLOBAL_ADMIN" || numberOfUsers[0].count === 1; const confirmationState = shouldGrantAccess ? "ACCESS_GRANTED" : "CONFIRMED"; + // Update the user to confirmed and active, and clear the recovery passphrase const result = await centralDb .update(centralSchema.user) @@ -278,7 +376,7 @@ export class UserService { isActive: true, recoveryPassphrase: null, // Clear it after showing it once }) - .where(eq(centralSchema.user.id, user.id)) + .where(eq(centralSchema.user.id, resultData.id)) .execute(); if (result.count != 1) { @@ -288,14 +386,19 @@ export class UserService { const countResult = await centralDb.select({ count: count() }).from(centralSchema.user); log.debug("User account confirmed successfully", { - userId: user.id, + userId: resultData.id, token: linkToken.substring(0, 8) + "...", - hadRecoveryPassphrase: !!user.recoveryPassphrase, + hadRecoveryPassphrase: !!resultData.recoveryPassphrase, }); return { - recoveryPassphrase: user.recoveryPassphrase || undefined, + recoveryPassphrase: resultData.recoveryPassphrase || undefined, isSetup: countResult[0].count === 1, + id: resultData.id, + email: resultData.email, + name: resultData.name, + language: resultData.language, + tenantId: resultData.tenantId, }; } catch (error) { if (error instanceof NotFoundError) throw error; @@ -357,7 +460,7 @@ export class UserService { */ static async getUserByEmail(email: string) { const log = logger.setContext("UserService"); - log.debug("Getting admin by email", { email }); + log.debug("Getting user by email", { email }); try { const result = await centralDb @@ -461,36 +564,132 @@ export class UserService { } /** - * Permanently delete admin and all associated passkeys + * Permanently delete user and all associated passkeys */ - static async deleteUser(userId: string) { + static async deleteUser( + userId: string, + externalTransaction?: UserTransaction, + ): Promise { const log = logger.setContext("UserService"); log.debug("Deleting user and associated passkeys", { userId }); - try { + const executeDeleteUser = async (tx: UserTransaction) => { + // First, verify the user exists and get user data + const userToDelete = await tx + .select({ + id: centralSchema.user.id, + email: centralSchema.user.email, + name: centralSchema.user.name, + role: centralSchema.user.role, + tenantId: centralSchema.user.tenantId, + }) + .from(centralSchema.user) + .where(eq(centralSchema.user.id, userId)) + .limit(1); + + if (userToDelete.length === 0) { + log.warn("User deletion failed: User not found", { userId }); + throw new NotFoundError("User not found"); + } + + const user = userToDelete[0]; + + // We cannot delete the last user with access to the tenant's appointments + if (user.role === "TENANT_ADMIN" || user.role === "STAFF") { + const usersCount = await tx + .select({ count: count() }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.tenantId, user.tenantId!), + or(eq(centralSchema.user.role, "STAFF"), eq(centralSchema.user.role, "TENANT_ADMIN")), + eq(centralSchema.user.isActive, true), + eq(centralSchema.user.confirmationState, "ACCESS_GRANTED"), + ), + ); + + if (usersCount[0].count <= 1) { + throw new ValidationError(`Cannot delete the last ${user.role} user for this tenant`); + } + } + // Delete associated passkeys first - const passkeyResult = await centralDb + const passkeyDeletionResult = await tx .delete(centralSchema.userPasskey) .where(eq(centralSchema.userPasskey.userId, userId)); - log.debug("Deleted user passkeys", { userId, deletedCount: passkeyResult.count || 0 }); + const deletedPasskeysCount = passkeyDeletionResult?.count || 0; + log.debug("Deleted user passkeys", { + userId, + deletedCount: deletedPasskeysCount, + }); - // Delete admin - const result = await centralDb + // Delete the user account + const deletedUsers = await tx .delete(centralSchema.user) .where(eq(centralSchema.user.id, userId)) - .returning(); + .returning({ + id: centralSchema.user.id, + email: centralSchema.user.email, + name: centralSchema.user.name, + role: centralSchema.user.role, + }); - if (result[0]) { - log.debug("User deleted successfully", { userId, email: result[0].email }); - } else { - log.warn("User deletion failed: User not found", { userId }); + if (deletedUsers.length === 0) { + throw new NotFoundError("Failed to delete user account"); } - return result[0] || null; + const deletionResult = { + success: true, + deletedUser: deletedUsers[0], + deletedPasskeysCount, + tenantId: user.tenantId, // Include tenantId for setup state validation + }; + + log.info("User deleted successfully", { + userId, + deletedUser: deletedUsers[0], + deletedPasskeysCount, + tenantId: user.tenantId, + }); + + return deletionResult; + }; + + try { + let result: UserDeletionResult; + + if (externalTransaction) { + // Use provided transaction + result = await executeDeleteUser(externalTransaction); + } else { + // Create new transaction + result = await centralDb.transaction(executeDeleteUser); + } + + // Validate setup state only if we have a tenant + if (result.deletedUser.role !== "GLOBAL_ADMIN" && result.tenantId) { + try { + const adminService = await TenantAdminService.getTenantById(result.tenantId); + adminService.validateSetupState(); + } catch (error) { + log.warn("Failed to validate setup state after user deletion", { + userId, + tenantId: result.tenantId, + error: String(error), + }); + // Don't throw - user deletion was successful + } + } + + return result; } catch (error) { + if (error instanceof ValidationError || error instanceof NotFoundError) { + throw error; + } + log.error("Failed to delete user", { userId, error: String(error) }); - throw error; + throw new InternalError("Failed to delete user"); } } diff --git a/src/lib/server/utils/url.ts b/src/lib/server/utils/url.ts new file mode 100644 index 0000000..66c4a79 --- /dev/null +++ b/src/lib/server/utils/url.ts @@ -0,0 +1,6 @@ +export const redactDbUrl = (input: string) => { + const url = new URL(input); + url.username = "redacted-user"; + url.password = "redacted-pw"; + return url.toString(); +}; diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 41e25f1..648c23f 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -1,6 +1,14 @@ +import { browser } from "$app/environment"; import type { UserRole } from "$lib/server/auth/authorization-service"; import { writable } from "svelte/store"; +export interface PasskeyAuthData { + authenticatorData: string; + passkeyId: string; + email: string; + prfOutput?: string; // Base64-encoded PRF output for staff crypto key derivation +} + export interface AuthState { isAuthenticated: boolean; isRefreshing: boolean; @@ -12,6 +20,7 @@ export interface AuthState { // The currently selected tenant tenantId?: string | null; }; + passkeyAuthData?: PasskeyAuthData; } function createAuthStore() { @@ -30,6 +39,14 @@ function createAuthStore() { }, setUser: (user: AuthState["user"]) => { store.update((state) => ({ ...state, isAuthenticated: true, user })); + + if (browser && user) { + const storageItem = sessionStorage.getItem("passkeyAuthData"); + if (storageItem) { + const passkeyAuthData: PasskeyAuthData = JSON.parse(storageItem); + store.update((state) => ({ ...state, passkeyAuthData })); + } + } }, setTenantId: (tenantId: string | null) => { store.update((state) => { @@ -42,8 +59,25 @@ function createAuthStore() { isAuthenticated: false, isRefreshing: false, user: undefined, + passkeyAuthData: undefined, }); }, + setPasskeyAuthData: (data: PasskeyAuthData) => { + store.update((state) => ({ ...state, passkeyAuthData: data })); + sessionStorage.setItem("passkeyAuthData", JSON.stringify(data)); + }, + getPasskeyAuthData: (): PasskeyAuthData | undefined => { + let authState: AuthState; + const unsubscribe = store.subscribe((state) => { + authState = state; + }); + unsubscribe(); + return authState!.passkeyAuthData; + }, + clearPasskeyAuthData: () => { + store.update((state) => ({ ...state, passkeyAuthData: undefined })); + sessionStorage.removeItem("passkeyAuthData"); + }, isAuthenticated: () => { let authState: AuthState; const unsubscribe = store.subscribe((state) => { diff --git a/src/lib/stores/calendar.ts b/src/lib/stores/calendar.ts new file mode 100644 index 0000000..98e90d0 --- /dev/null +++ b/src/lib/stores/calendar.ts @@ -0,0 +1,34 @@ +import type { AppointmentData } from "$lib/client/appointment-crypto"; +import { openDialog } from "$lib/components/ui/responsive-dialog"; +import type { TCalendarItem } from "$lib/types/calendar"; +import { writable } from "svelte/store"; + +export type CurAppointmentItem = { + appointment: TCalendarItem; + decrypted: AppointmentData; +}; + +interface CalendarState { + curItem: CurAppointmentItem | null; +} + +const createCalendarStore = () => { + const store = writable({ + curItem: null, + }); + + return { + ...store, + setCurItem: (curItem: CurAppointmentItem | null) => { + store.update((state) => { + return { ...state, curItem }; + }); + + if (curItem) { + openDialog("current-calendar-item"); + } + }, + }; +}; + +export const calendarStore = createCalendarStore(); diff --git a/src/lib/stores/channels.ts b/src/lib/stores/channels.ts new file mode 100644 index 0000000..9dfbb20 --- /dev/null +++ b/src/lib/stores/channels.ts @@ -0,0 +1,52 @@ +import { browser } from "$app/environment"; +import { writable } from "svelte/store"; +import { auth } from "./auth"; +import type { TChannel } from "$lib/types/channel"; + +interface ChannelsState { + channels: TChannel[]; + isLoading: boolean; +} + +const createChannelsStore = () => { + const store = writable({ + channels: [], + isLoading: false, + }); + + return { + ...store, + load: async () => { + if (!browser) return; + + store.update((state) => { + return { ...state, isLoading: true }; + }); + + try { + const tenantId = auth.getTenant(); + const res = await fetch(`/api/tenants/${tenantId}/channels`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }); + + const body = await res.json(); + const channels = body.channels ?? ([] as TChannel[]); + + store.update((state) => { + return { ...state, channels, isLoading: false }; + }); + } catch (error) { + store.update((state) => { + return { ...state, isLoading: false }; + }); + console.error("Failed to parse channels response", { error }); + } + }, + }; +}; + +export const channels = createChannelsStore(); diff --git a/src/lib/stores/pin-throttle.ts b/src/lib/stores/pin-throttle.ts new file mode 100644 index 0000000..de8677e --- /dev/null +++ b/src/lib/stores/pin-throttle.ts @@ -0,0 +1,125 @@ +import { browser } from "$app/environment"; +import { writable } from "svelte/store"; + +/** + * PIN Throttle Store + * + * Persists throttle state across page reloads and component remounts. + * Tracks when the user can next attempt PIN authentication. + */ + +interface PinThrottleState { + emailHash: string | null; + throttleUntil: number | null; // Timestamp when throttle expires (milliseconds) + failedAttempts: number; +} + +const STORAGE_KEY = "pin-throttle-state"; + +const createPinThrottleStore = () => { + // Initialize from localStorage if available + const initialState: PinThrottleState = { + emailHash: null, + throttleUntil: null, + failedAttempts: 0, + }; + + if (browser) { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored); + // Only restore if throttle hasn't expired yet + if (parsed.throttleUntil && parsed.throttleUntil > Date.now()) { + Object.assign(initialState, parsed); + } else { + // Throttle expired, clear storage + localStorage.removeItem(STORAGE_KEY); + } + } catch (e) { + console.error("Failed to parse throttle state:", e); + localStorage.removeItem(STORAGE_KEY); + } + } + } + + const store = writable(initialState); + + return { + subscribe: store.subscribe, + + /** + * Set throttle state when a 429 response is received + * @param emailHash The email hash that is throttled + * @param retryAfterMs How many milliseconds to wait before retry + * @param failedAttempts Current number of failed attempts + */ + setThrottle: (emailHash: string, retryAfterMs: number, failedAttempts: number = 0) => { + const throttleUntil = Date.now() + retryAfterMs; + const newState: PinThrottleState = { + emailHash, + throttleUntil, + failedAttempts, + }; + + store.set(newState); + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(newState)); + } + }, + + /** + * Clear throttle state after successful authentication + */ + clearThrottle: () => { + store.set({ + emailHash: null, + throttleUntil: null, + failedAttempts: 0, + }); + if (typeof window !== "undefined") { + localStorage.removeItem(STORAGE_KEY); + } + }, + + /** + * Check if a specific email hash is currently throttled + * @param emailHash The email hash to check + * @returns true if throttled, false otherwise + */ + isThrottled: (emailHash: string): boolean => { + let isThrottled = false; + store.subscribe((state) => { + if ( + state.emailHash === emailHash && + state.throttleUntil && + state.throttleUntil > Date.now() + ) { + isThrottled = true; + } + })(); + return isThrottled; + }, + + /** + * Get remaining throttle time in milliseconds + * @param emailHash The email hash to check + * @returns Milliseconds remaining, or 0 if not throttled + */ + getRemainingTime: (emailHash: string): number => { + let remaining = 0; + store.subscribe((state) => { + if ( + state.emailHash === emailHash && + state.throttleUntil && + state.throttleUntil > Date.now() + ) { + remaining = state.throttleUntil - Date.now(); + } + })(); + return Math.max(0, remaining); + }, + }; +}; + +export const pinThrottleStore = createPinThrottleStore(); diff --git a/src/lib/stores/public.ts b/src/lib/stores/public.ts new file mode 100644 index 0000000..ab3574e --- /dev/null +++ b/src/lib/stores/public.ts @@ -0,0 +1,32 @@ +import type { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; +import type { TPublicTenant, TPublicAppointment, TPublicChannel } from "$lib/types/public"; +import { writable } from "svelte/store"; + +interface PublicState { + isLoading: boolean; // probably not needed + locale: string; + newAppointment: TPublicAppointment; + tenant?: TPublicTenant; + channels?: TPublicChannel[]; + crypto?: UnifiedAppointmentCrypto; +} + +const createPublicStore = () => { + const store = writable({ + isLoading: false, + locale: "en", + newAppointment: { step: "SELECT_CHANNEL" }, + }); + + return { + ...store, + reset: () => + store.update((state) => ({ + ...state, + newAppointment: { step: "SELECT_CHANNEL" }, + crypto: undefined, + })), + }; +}; + +export const publicStore = createPublicStore(); diff --git a/src/lib/stores/sidebar.ts b/src/lib/stores/sidebar.ts index 97d0cab..b6f5ca3 100644 --- a/src/lib/stores/sidebar.ts +++ b/src/lib/stores/sidebar.ts @@ -4,6 +4,7 @@ import { writable } from "svelte/store"; interface AuthState { isOpen: boolean; + isCalendarExpanded: boolean; isEducated: boolean; } @@ -15,6 +16,7 @@ function createSidebarStore() { const isEducatedValue = browser ? getCookie(SIDEBAR_EDUCATION_STORAGE_KEY) : null; const store = writable({ isOpen: isOpenValue === "true" ? true : false, + isCalendarExpanded: false, isEducated: isEducatedValue === "true" ? true : false, }); @@ -26,6 +28,9 @@ function createSidebarStore() { } store.update((state) => ({ ...state, isOpen })); }, + setCalendarExpanded: (isCalendarExpanded: boolean) => { + store.update((state) => ({ ...state, isCalendarExpanded })); + }, setEducated: (isEducated: boolean, isFinal?: boolean) => { if (browser && isFinal) { document.cookie = `${SIDEBAR_EDUCATION_STORAGE_KEY}=${isEducated}; path=/; max-age=604800`; diff --git a/src/lib/stores/staff-crypto.ts b/src/lib/stores/staff-crypto.ts new file mode 100644 index 0000000..e05de96 --- /dev/null +++ b/src/lib/stores/staff-crypto.ts @@ -0,0 +1,76 @@ +import { writable } from "svelte/store"; +import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto"; +import { auth } from "./auth"; + +interface StaffCryptoState { + crypto: UnifiedAppointmentCrypto | null; + isAuthenticated: boolean; + error: string | null; +} + +const createStaffCryptoStore = () => { + const store = writable({ + crypto: null, + isAuthenticated: false, + error: null, + }); + + return { + ...store, + + /** + * Initialize crypto with WebAuthn PRF authentication + * + * NOTE: With PRF-based key derivation, we MUST perform a live WebAuthn interaction + * to derive the secret shard. Session-based reconstruction is not possible. + * + * This method now delegates to `authenticate()` which performs WebAuthn with PRF. + */ + async initFromSession(staffId: string, tenantId: string): Promise { + // With PRF, we always need WebAuthn interaction - session data is not sufficient + // Call authenticate() directly which will prompt for passkey + return await this.authenticate(staffId, tenantId); + }, + + /** + * Authenticate with WebAuthn (for initial login or re-authentication) + */ + async authenticate(staffId: string, tenantId: string): Promise { + try { + const crypto = new UnifiedAppointmentCrypto(); + await crypto.authenticateStaff(staffId, tenantId); + + store.set({ + crypto, + isAuthenticated: true, + error: null, + }); + + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Authentication failed"; + store.set({ + crypto: null, + isAuthenticated: false, + error: errorMessage, + }); + console.error("Failed to authenticate staff crypto:", error); + return false; + } + }, + + /** + * Clear the staff crypto state and auth data + */ + clear() { + auth.clearPasskeyAuthData(); + store.set({ + crypto: null, + isAuthenticated: false, + error: null, + }); + }, + }; +}; + +export const staffCrypto = createStaffCryptoStore(); diff --git a/src/lib/stores/tenants.ts b/src/lib/stores/tenants.ts index 130157e..6427f76 100644 --- a/src/lib/stores/tenants.ts +++ b/src/lib/stores/tenants.ts @@ -2,12 +2,14 @@ import logger from "$lib/logger"; import type { TTenant } from "$lib/types/tenant"; import { changeTenantUsingApi } from "$lib/utils/tenants"; import { toast } from "svelte-sonner"; -import { writable } from "svelte/store"; +import { get, writable } from "svelte/store"; import { auth } from "./auth"; import { m } from "$i18n/messages"; 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"); @@ -49,10 +51,11 @@ const createTenantsStore = () => { return { ...state, currentTenant }; }); agents.load(); + channels.load(); // 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 () => { @@ -71,13 +74,37 @@ const createTenantsStore = () => { const body = await res.json(); const tenants = body.tenants ?? ([] as TTenant[]); const newCurrentTenantId = auth.getTenant(); + const newCurrentTenant = tenants.find((t: TTenant) => t.id === newCurrentTenantId) || null; + + if (newCurrentTenant?.setupState !== "READY") { + toast.info(m["dashboard.onboarding.notification.ongoing.title"](), { + duration: 4000, + description: m["dashboard.onboarding.notification.ongoing.description"](), + action: { + label: m["dashboard.onboarding.notification.ongoing.action"](), + onClick: () => goto(resolve(ROUTES.DASHBOARD.MAIN)), + }, + }); + } else { + const curState = get(store); + if (curState && curState.currentTenant?.setupState !== "READY") { + toast.info(m["dashboard.onboarding.notification.done.title"](), { + duration: 4000, + description: m["dashboard.onboarding.notification.done.description"](), + action: { + label: m["dashboard.onboarding.notification.done.action"](), + onClick: () => goto(resolve(ROUTES.MAIN)), + }, + }); + } + } store.update((state) => { return { ...state, tenants, isLoading: false, - currentTenant: tenants.find((t: TTenant) => t.id === newCurrentTenantId) || null, + currentTenant: newCurrentTenant, }; }); } catch (error) { diff --git a/src/lib/stores/time.ts b/src/lib/stores/time.ts new file mode 100644 index 0000000..ad61aea --- /dev/null +++ b/src/lib/stores/time.ts @@ -0,0 +1,9 @@ +import { getLocalTimeZone, now } from "@internationalized/date"; +import { readable } from "svelte/store"; + +export const clock = readable(now(getLocalTimeZone()), (set) => { + const tick = () => set(now(getLocalTimeZone())); + const id = setInterval(tick, 10_000); + tick(); + return () => clearInterval(id); +}); diff --git a/src/lib/types/appointment.ts b/src/lib/types/appointment.ts index fda335e..b47cdf8 100644 --- a/src/lib/types/appointment.ts +++ b/src/lib/types/appointment.ts @@ -107,4 +107,5 @@ export interface AppointmentResponse { id: string; appointmentDate: string; status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW"; + requiresConfirmation?: boolean; } diff --git a/src/lib/types/calendar.ts b/src/lib/types/calendar.ts new file mode 100644 index 0000000..bab7a16 --- /dev/null +++ b/src/lib/types/calendar.ts @@ -0,0 +1,32 @@ +import type { DaySchedule } from "$lib/server/services/schedule-service"; + +export type AppointmentStatus = "available" | "booked" | "reserved"; +export type TAppointmentFilter = "all" | AppointmentStatus; + +export type TCalendar = { + period: { + startDate: string; // ISO date-time string + endDate: string; // ISO date-time string + }; + calendar: DaySchedule[]; +}; + +export type TCalendarItem = { + date: string; // YYYY-MM-DD + id: string; + start: string; // HH:mm + duration: number; // in minutes + status: AppointmentStatus; + color: string | null; + column: number; + channelId: string; + appointment?: { + dateTime: Date; + encryptedPayload: string | null; + tunnelId: string; + agentId: string; + staffKeyShare?: string; + iv?: string; + authTag?: string; + }; +}; diff --git a/src/lib/types/public.ts b/src/lib/types/public.ts new file mode 100644 index 0000000..27afd4d --- /dev/null +++ b/src/lib/types/public.ts @@ -0,0 +1,83 @@ +import type { supportedLocales } from "$lib/const/locales"; +import type { SelectTenant } from "$lib/server/db/central-schema"; +import type { SelectAgent, SelectChannel } from "$lib/server/db/tenant-schema"; +import type { CalendarDateTime } from "@internationalized/date"; + +export type TPublicTenant = Pick< + SelectTenant, + "descriptions" | "id" | "links" | "logo" | "longName" | "setupState" | "shortName" +> & { + defaultLanguage: typeof supportedLocales; + languages: (typeof supportedLocales)[]; + address: { + street: string; + number: string; + additionalAddressInfo?: string; + zip: string; + city: string; + }; + requirePhone: boolean; +}; + +export type TPublicAppointment = { + step: + | "SELECT_CHANNEL" + | "SELECT_AGENT" + | "SELECT_SLOT" + | "ADD_PERSONAL_DATA" + | "LOGIN" + | "SUMMARY" + | "COMPLETE"; + channel?: string; + agent?: { + id: string; + name: string; + image: string | null; + } | null; + slot?: { + datetime: CalendarDateTime; + duration: number; + }; + data?: { + salutation: string; + name: string; + shareEmail: boolean; + email: string; + phone?: string; + }; + isNewClient?: boolean; +}; + +export type TPublicChannel = Pick< + SelectChannel, + "id" | "names" | "descriptions" | "requiresConfirmation" +>; + +export type TPublicAgent = Pick; + +export type TPublicSchedule = { + period: { + startDate: string; + endDate: string; + }; + schedule: { + date: string; // Format YYYY-MM-DD + channels: { + [channelId: string]: { + availableSlots: TPublicSlot[]; + channel: TPublicChannel; + }[]; + }; + }[]; +}; + +export type TPublicSlot = { + from: string; // Format HH:mm + to: string; // Format HH:mm + duration: number; + availableAgents: { + id: string; + name: string; + image: string | null; + }[]; +}; diff --git a/src/lib/types/tenant.ts b/src/lib/types/tenant.ts index 8475ce3..c2a965d 100644 --- a/src/lib/types/tenant.ts +++ b/src/lib/types/tenant.ts @@ -1,6 +1,8 @@ import type { SelectTenant } from "$lib/server/db/central-schema"; -export type TTenant = Pick; +export type TTenant = Pick & { + logo: string | null; +}; export type TTenantSettings = Omit & { languages: string[]; diff --git a/src/lib/types/users.ts b/src/lib/types/users.ts new file mode 100644 index 0000000..991cf4f --- /dev/null +++ b/src/lib/types/users.ts @@ -0,0 +1,14 @@ +import type { SelectUser } from "$lib/server/db/central-schema"; + +export type TStaff = Pick< + SelectUser, + | "id" + | "email" + | "name" + | "role" + | "isActive" + | "createdAt" + | "updatedAt" + | "lastLoginAt" + | "confirmationState" +>; diff --git a/src/lib/utils/localizations.ts b/src/lib/utils/localizations.ts index 07515d7..0094bbb 100644 --- a/src/lib/utils/localizations.ts +++ b/src/lib/utils/localizations.ts @@ -1,4 +1,6 @@ import { getLocale } from "$i18n/runtime"; +import type { supportedLocales } from "$lib/const/locales"; +import type { TPublicTenant } from "$lib/types/public"; export const removeEmptyTranslations = (object: { [key: string]: string } | undefined) => { if (!object) return object; @@ -14,3 +16,12 @@ export const getCurrentTranlslation = (object: { [key: string]: string } | undef return object[getLocale()] || Object.values(object)[0]; }; + +export const getPublicLocale = (tenant: TPublicTenant): string => { + const locale = getLocale(); + const availableLanguages = tenant.languages; + if (availableLanguages.includes(locale as unknown as typeof supportedLocales)) { + return locale; + } + return tenant.defaultLanguage as unknown as string; +}; diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 9815944..0375829 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -49,30 +49,57 @@ export const fetchChallenge = async (email: string) => { body: JSON.stringify({ email }), }); + let data; try { - const data = await resp.json(); - return { - id: data.rpId, - challenge: data.challenge, - }; - } catch { + data = await resp.json(); + } catch (error) { + logger.error("Failed to parse challenge response", { email, error }); + return null; + } + + // Handle throttling + if (resp.status === 429) { + const retryAfterSeconds = Math.ceil((data.retryAfterMs || 60000) / 1000); + logger.warn("Challenge request throttled", { + email, + retryAfterSeconds, + }); + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } + } + + if (!resp.ok) { logger.error("Failed to fetch challenge", { email, status: resp.status }); return null; } + + return { + id: data.rpId, + challenge: data.challenge, + }; }; export const getCredentialOptions = ({ id, challenge, email, + enablePRF = false, }: { id: string; challenge: string; email: string; + enablePRF?: boolean; }): { publicKey: PublicKeyCredentialCreationOptions; } => { - return { + const options: { + publicKey: PublicKeyCredentialCreationOptions; + } = { publicKey: { challenge: base64UrlToArrayBuffer(challenge), rp: { @@ -89,41 +116,202 @@ export const getCredentialOptions = ({ ], }, }; + + // Enable PRF extension for zero-knowledge key derivation + if (enablePRF) { + options.publicKey.extensions = { + prf: {}, + }; + } + + return options; }; export type GeneratePasskeyResponse = { response: AuthenticatorAttestationResponse; id: string; - getClientExtensionResults: () => { deviceName?: string }; + getClientExtensionResults: () => { deviceName?: string; prf?: { enabled?: boolean } }; } | null; + export const generatePasskey = async ({ id, challenge, email, + enablePRF = false, }: { id: string; challenge: string; email: string; + enablePRF?: boolean; }): Promise => { - const options = getCredentialOptions({ id, challenge, email }); + const options = getCredentialOptions({ id, challenge, email, enablePRF }); return (await navigator.credentials.create(options)) as GeneratePasskeyResponse; }; -export type GetCredentialResponse = PublicKeyCredential & { - response: PublicKeyCredential; - id: string; +/** + * Get PRF output from a passkey immediately after registration + * + * SECURITY: This must be called immediately after passkey creation to derive + * the secret shard for zero-knowledge key splitting. + * + * MULTI-PASSKEY SUPPORT: Uses email as PRF salt so that all passkeys for the same user + * can be used interchangeably. Each passkey will produce a different PRF output for + * the same salt, allowing each to have its own database shard. + * + * @param passkeyId - The credential ID of the newly created passkey + * @param rpId - Relying Party ID (domain) + * @param challengeBase64 - Fresh challenge from server (base64url encoded) + * @param email - User's email address (used as PRF salt for multi-passkey support) + * @returns PRF output (32 bytes) or null if PRF is not supported + * @throws Error if passkey doesn't support PRF or authentication fails + */ +export const getPRFOutputAfterRegistration = async ({ + passkeyId, + rpId, + challengeBase64, + email, +}: { + passkeyId: string; + rpId: string; + challengeBase64: string; + email: string; +}): Promise => { + // Prepare PRF salt (use email as deterministic salt for multi-passkey support) + const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`); + + // Perform WebAuthn get() with PRF extension + const credential = (await navigator.credentials.get({ + publicKey: { + challenge: base64UrlToArrayBuffer(challengeBase64), + rpId: rpId, + allowCredentials: [ + { + id: base64UrlToArrayBuffer(passkeyId), + type: "public-key", + }, + ], + userVerification: "required", + extensions: { + prf: { + eval: { + first: prfSalt, + }, + }, + }, + }, + })) as PublicKeyCredential & { + getClientExtensionResults: () => { + prf?: { + enabled?: boolean; + results?: { + first?: ArrayBuffer; + }; + }; + }; + }; + + if (!credential) { + throw new Error("Failed to authenticate with new passkey"); + } + + // Extract PRF output + const extensionResults = credential.getClientExtensionResults(); + const prfResults = extensionResults.prf; + + if (!prfResults || !prfResults.results || !prfResults.results.first) { + throw new Error( + "PRF extension not supported by this passkey. " + + "Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)", + ); + } + + // PRF output is BufferSource, ensure we return ArrayBuffer + const prfOutput = prfResults.results.first; + if (prfOutput instanceof ArrayBuffer) { + return prfOutput; + } else { + // Convert ArrayBufferView to ArrayBuffer + return prfOutput.buffer.slice( + prfOutput.byteOffset, + prfOutput.byteOffset + prfOutput.byteLength, + ) as ArrayBuffer; + } }; + +export type GetCredentialResponse = PublicKeyCredential & { + prfOutput?: ArrayBuffer; // PRF output if enablePRF was true +}; + export const getCredential = async ({ id, challenge, email, + enablePRF = false, }: { id: string; challenge: string; email: string; -}) => { - const options = getCredentialOptions({ id, challenge, email }); - return (await navigator.credentials.get(options)) as GetCredentialResponse; + enablePRF?: boolean; +}): Promise => { + // Build WebAuthn options manually to support PRF + const challengeBuffer = base64UrlToArrayBuffer(challenge); + + const publicKeyOptions: PublicKeyCredentialRequestOptions = { + challenge: challengeBuffer, + rpId: id, + userVerification: "preferred", + }; + + // Add PRF extension if enabled + // MULTI-PASSKEY SUPPORT: Use email as salt so all passkeys for the same user work + if (enablePRF) { + const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`); + publicKeyOptions.extensions = { + prf: { + eval: { + first: prfSalt, + }, + }, + }; + } + + const credential = (await navigator.credentials.get({ + publicKey: publicKeyOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error("Failed to get credential"); + } + + let prfOutput: ArrayBuffer | undefined; + + // Extract PRF output if it was enabled + if (enablePRF) { + const extensionResults = credential.getClientExtensionResults() as { + prf?: { + enabled?: boolean; + results?: { + first?: BufferSource; + }; + }; + }; + + if (extensionResults.prf?.results?.first) { + const prfResult = extensionResults.prf.results.first; + // Convert BufferSource to ArrayBuffer + if (prfResult instanceof ArrayBuffer) { + prfOutput = prfResult; + } else { + prfOutput = prfResult.buffer.slice( + prfResult.byteOffset, + prfResult.byteOffset + prfResult.byteLength, + ) as ArrayBuffer; + } + } + } + + // Return credential with optional prfOutput attached + return Object.assign(credential, { prfOutput }) as GetCredentialResponse; }; export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => { diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index 55775b6..c2a9c31 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -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; } } diff --git a/src/lib/utils/tenants.ts b/src/lib/utils/tenants.ts index 5d0a70f..dbb58ce 100644 --- a/src/lib/utils/tenants.ts +++ b/src/lib/utils/tenants.ts @@ -1,3 +1,6 @@ +import { SETUP_STATES_LIST } from "$lib/const/tenants"; +import type { TTenant } from "$lib/types/tenant"; + export const changeTenantUsingApi = async (tenantId: string | null): Promise => { const response = await fetch("/api/admin/tenant", { method: "POST", @@ -14,3 +17,9 @@ export const changeTenantUsingApi = async (tenantId: string | null): Promise { + const curStateIndex = SETUP_STATES_LIST.indexOf(tenant.setupState); + const stepIndex = SETUP_STATES_LIST.indexOf(step); + return curStateIndex > stepIndex; +}; diff --git a/src/params/dev.ts b/src/params/dev.ts new file mode 100644 index 0000000..12653b8 --- /dev/null +++ b/src/params/dev.ts @@ -0,0 +1,7 @@ +import { dev } from "$app/environment"; +import type { ParamMatcher } from "@sveltejs/kit"; + +export const match: ParamMatcher = (param) => { + // Doesn't have to be include, but needs to return a boolean + return dev && param.includes("local-only"); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-booked/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-booked/+server.ts new file mode 100644 index 0000000..e1f0b6a --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-booked/+server.ts @@ -0,0 +1,42 @@ +import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte"; +import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import type { SelectTenant } from "$lib/server/db/central-schema"; +import type { SelectAppointment } from "$lib/server/db/tenant-schema"; +import type { RequestHandler } from "@sveltejs/kit"; +import { render } from "svelte/server"; + +export const GET: RequestHandler = async () => { + const emailRender = render(AppointmentBooked, { + props: { + locale: "en", + user: { + email: "max.mustermann@example.com", + language: "en", + }, + tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant, + appointment: { + appointmentDate: new Date("2024-06-30T10:00:00"), + agentName: "Dr. John Doe", + } as SelectAppointment & { agentName: string }, + channel: "Vaccination Appointment", + address: { + street: "Musterstraße", + number: "1", + additionalAddressInfo: "Hinterhaus", + zip: "20000", + city: "Hamburg", + }, + cancelUrl: "https://open-reception.org/appointments/cancel/abc123", + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + // Output for testing purposes + console.log(text); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-reminder/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-reminder/+server.ts new file mode 100644 index 0000000..fcb1fbf --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-reminder/+server.ts @@ -0,0 +1,42 @@ +import AppointmentReminder from "$lib/emails/AppointmentReminder.svelte"; +import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import type { SelectTenant } from "$lib/server/db/central-schema"; +import type { SelectAppointment } from "$lib/server/db/tenant-schema"; +import type { RequestHandler } from "@sveltejs/kit"; +import { render } from "svelte/server"; + +export const GET: RequestHandler = async () => { + const emailRender = render(AppointmentReminder, { + props: { + locale: "en", + user: { + email: "max.mustermann@example.com", + language: "en", + }, + tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant, + appointment: { + appointmentDate: new Date("2024-06-30T10:00:00"), + agentName: "Dr. John Doe", + } as SelectAppointment & { agentName: string }, + channel: "Vaccination Appointment", + address: { + street: "Musterstraße", + number: "1", + additionalAddressInfo: "Hinterhaus", + zip: "20000", + city: "Hamburg", + }, + cancelUrl: "https://open-reception.org/appointments/cancel/abc123", + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + // Output for testing purposes + console.log(text); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-request/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-request/+server.ts new file mode 100644 index 0000000..86914e3 --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/appointment-request/+server.ts @@ -0,0 +1,41 @@ +import AppointmentRequested from "$lib/emails/AppointmentRequest.svelte"; +import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import type { SelectTenant } from "$lib/server/db/central-schema"; +import type { SelectAppointment } from "$lib/server/db/tenant-schema"; +import type { RequestHandler } from "@sveltejs/kit"; +import { render } from "svelte/server"; + +export const GET: RequestHandler = async () => { + const emailRender = render(AppointmentRequested, { + props: { + locale: "en", + user: { + email: "max.mustermann@example.com", + language: "en", + }, + tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant, + appointment: { + appointmentDate: new Date("2024-06-30T10:00:00"), + agentName: "Dr. John Doe", + } as SelectAppointment & { agentName: string }, + channel: "Vaccination Appointment", + address: { + street: "Musterstraße", + number: "1", + additionalAddressInfo: "Hinterhaus", + zip: "20000", + city: "Hamburg", + }, + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + // Output for testing purposes + console.log(text); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/confirmation/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/confirmation/+server.ts new file mode 100644 index 0000000..ce1aed6 --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/confirmation/+server.ts @@ -0,0 +1,29 @@ +import Confirmation from "$lib/emails/Confirmation.svelte"; +import { htmlToText, renderOutputToHtml } from "$lib/emails/utils"; +import type { RequestHandler } from "@sveltejs/kit"; +import { render } from "svelte/server"; + +export const GET: RequestHandler = async () => { + const emailRender = render(Confirmation, { + props: { + locale: "en", + user: { + email: "max.mustermann@example.com", + name: "Max Mustermann", + language: "en", + }, + confirmUrl: "https://open-reception.org/confirm/abc123", + expirationMinutes: 10, + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + // Output for testing purposes + console.log(text); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/pin-reset/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/pin-reset/+server.ts new file mode 100644 index 0000000..327f7f3 --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/pin-reset/+server.ts @@ -0,0 +1,29 @@ +import PinReset from "$lib/emails/PinReset.svelte"; +import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import type { SelectTenant } from "$lib/server/db/central-schema"; +import type { RequestHandler } from "@sveltejs/kit"; +import { render } from "svelte/server"; + +export const GET: RequestHandler = async () => { + const emailRender = render(PinReset, { + props: { + locale: "en", + user: { + email: "max.mustermann@example.com", + language: "en", + }, + tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant, + loginUrl: "https://open-reception.org/login", + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + // Output for testing purposes + console.log(text); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); +}; diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts new file mode 100644 index 0000000..386c12c --- /dev/null +++ b/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts @@ -0,0 +1,31 @@ +import UserInvite from "$lib/emails/UserInvite.svelte"; +import { htmlToText, renderOutputToHtml } from "$lib/emails/utils"; +import type { SelectTenant } from "$lib/server/db/central-schema"; +import type { RequestHandler } from "@sveltejs/kit"; +import { render } from "svelte/server"; + +export const GET: RequestHandler = async () => { + const emailRender = render(UserInvite, { + props: { + locale: "en", + user: { + email: "max.mustermann@example.com", + name: "Max Mustermann", + language: "en", + }, + tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant, + confirmUrl: "https://open-reception.org/confirm/abc123", + expirationMinutes: 10, + }, + }); + const html = renderOutputToHtml(emailRender); + const text = htmlToText(html); + + // Output for testing purposes + console.log(text); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); +}; diff --git a/src/routes/(pages)/(clients)/+layout.server.ts b/src/routes/(pages)/(clients)/+layout.server.ts new file mode 100644 index 0000000..1c4a85b --- /dev/null +++ b/src/routes/(pages)/(clients)/+layout.server.ts @@ -0,0 +1,45 @@ +import logger from "$lib/logger"; +import type { TPublicChannel, TPublicTenant } from "$lib/types/public"; +import type { LayoutServerLoad } from "./$types"; + +const log = logger.setContext(import.meta.filename); + +export const load: LayoutServerLoad = async (event) => { + const tenant = event + .fetch(`/api/public`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (res) => { + try { + const body = await res.json(); + return body.tenant as TPublicTenant; + } catch (error) { + log.error("Failed to parse settings base response", { error }); + } + }); + + const channels = event + .fetch(`/api/public/channels`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (res) => { + try { + const body = await res.json(); + return body.channels || ([] as TPublicChannel[]); + } catch (error) { + log.error("Failed to parse settings base response", { error }); + } + }); + + return { + streaming: { tenant, channels }, + }; +}; diff --git a/src/routes/(pages)/(clients)/+layout.svelte b/src/routes/(pages)/(clients)/+layout.svelte new file mode 100644 index 0000000..fb1428d --- /dev/null +++ b/src/routes/(pages)/(clients)/+layout.svelte @@ -0,0 +1,65 @@ + + + + {#snippet left()} + + {/snippet} + {@render children()} + {#snippet footer()} + {#await data.streaming.tenant then tenant} +
+ {#if tenant?.links.imprint} + + {/if} + {#if tenant?.links.privacyStatement} + + {/if} +
+ {/await} + {/snippet} +
diff --git a/src/routes/(pages)/+page.server.ts b/src/routes/(pages)/(clients)/+page.server.ts similarity index 50% rename from src/routes/(pages)/+page.server.ts rename to src/routes/(pages)/(clients)/+page.server.ts index c114543..3d50ae0 100644 --- a/src/routes/(pages)/+page.server.ts +++ b/src/routes/(pages)/(clients)/+page.server.ts @@ -2,27 +2,11 @@ import { ROUTES } from "$lib/const/routes.js"; import { UserService } from "$lib/server/services/user-service"; import { redirect } from "@sveltejs/kit"; -export const load = async (event) => { +export const load = async () => { // Check if global admin exists // If not, redirect to setup page const adminExists = await UserService.adminExists(); if (!adminExists) { redirect(302, ROUTES.SETUP.MAIN); } - - // Dummy placeholder for streaming data - const fetchEnvOk = async () => { - const response = await event.fetch("/api/env"); - try { - return (await response.json()).envOkay; - } catch { - return false; - } - }; - - return { - streamed: { - isEnvOk: fetchEnvOk(), - }, - }; }; diff --git a/src/routes/(pages)/(clients)/+page.svelte b/src/routes/(pages)/(clients)/+page.svelte new file mode 100644 index 0000000..694bc2a --- /dev/null +++ b/src/routes/(pages)/(clients)/+page.svelte @@ -0,0 +1,96 @@ + + + + {#await data.streaming.tenant} + OpenReception + {:then tenant} + {#if tenant} + {tenant.longName} - OpenReception + {/if} + {/await} + + + + + {#await data.streaming.tenant} +
+ +
+ +
+ + + + +
+ +
+
+ {:then tenant} + {#if tenant && tenant.longName} +
+ {#if typeof tenant.logo === "string" && tenant.logo} + {tenant.longName} + {/if} +
+ {tenant?.longName} + + + + {#if tenant?.links.website} + + {/if} +
+
+ {:else} + + {m["public.tenantNotReady.title"]()} + + + {m["public.tenantNotReady.description"]()} + + {/if} + {/await} +
+ + {#await data.streaming.tenant} + + {:then tenant} + {#if tenant?.setupState === "READY" && tenant.longName} + + {:else} +
+ +
+ {/if} + {/await} +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte new file mode 100644 index 0000000..dea9ae0 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/add-personal-data-form.svelte @@ -0,0 +1,140 @@ + + +{#if tenant} +
+ + {m["public.steps.data.title"]()} + + + + {m["public.steps.data.alert.title"]()} + {m["public.steps.data.alert.description"]({ name: tenant.longName })} + + + + + + {#snippet children({ props })} + {m["form.name"]()} + + {/snippet} + + + +
+ + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + { + $formData.shareEmail = v; + }} + /> + {/snippet} + + + +
+ + + {#snippet children({ props })} + {m["form.phone"]()} + + {/snippet} + + + + {m["public.steps.data.phone.description"]({ name: tenant.longName })} + {#if tenant?.requirePhone === false} + {m["public.steps.data.phone.optional"]()} + {/if} + + +
+ + {m["public.steps.data.action"]()} + +
+
+
+{/if} diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/index.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/index.ts new file mode 100644 index 0000000..e8d4126 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/index.ts @@ -0,0 +1,5 @@ +import AddPersonalDataForm from "./add-personal-data-form.svelte"; + +export { AddPersonalDataForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/schema.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/schema.ts new file mode 100644 index 0000000..c40d916 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/add-personal-data-form/schema.ts @@ -0,0 +1,19 @@ +import { m } from "$i18n/messages"; +import { z } from "zod/v4"; + +export const createFormSchema = (requirePhone?: boolean) => { + return z.object({ + ...formSchema.shape, + phone: requirePhone + ? z.e164(m["form.errors.phoneNoInvalid"]()) + : z.e164(m["form.errors.phoneNoInvalid"]()).optional(), + }); +}; + +export const formSchema = z.object({ + name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()), + email: z.string().email(m["form.errors.email"]()), + phone: z.e164(m["form.errors.phoneNoInvalid"]()).optional(), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/auth-tabs.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/auth-tabs.svelte new file mode 100644 index 0000000..3df9638 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/auth-tabs.svelte @@ -0,0 +1,31 @@ + + +
+ + + {m["public.steps.auth.register.title"]()} + {m["public.steps.auth.login.title"]()} + + + + {m["public.steps.auth.register.description"]()} + + + + + + {m["public.steps.auth.login.description"]()} + + + + +
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte new file mode 100644 index 0000000..54ec060 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-login-form.svelte @@ -0,0 +1,124 @@ + + + + {#if isThrottled} + + + {m["public.steps.auth.login.throttled"]()} + + + {m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })} + + + {/if} + + + + {#snippet children({ props })} + {m["form.pin"]()} + + {/snippet} + + + + {m["form.pinHint"]()} + + +
+ + {m["public.steps.auth.login.action"]()} + +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte new file mode 100644 index 0000000..36da129 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/book-appointment-register-form.svelte @@ -0,0 +1,135 @@ + + + + {#if isThrottled} + + + {m["public.steps.auth.login.throttled"]()} + + + {m["public.steps.auth.login.retry"]({ seconds: remainingSeconds })} + + + {/if} + + + + {#snippet children({ props })} + {m["form.pin"]()} + + {/snippet} + + + + {m["form.pinHint"]()} + + +
+ + {m["public.steps.auth.register.action"]()} + +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/schema.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/schema.ts new file mode 100644 index 0000000..e23125e --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/auth/schema.ts @@ -0,0 +1,22 @@ +import { m } from "$i18n/messages"; +import { INSECURE_PINS } from "$lib/const/pin"; +import { z } from "zod/v4"; + +export const formSchemaRegister = z.object({ + pin: z + .string() + .length(6, m["form.errors.pinLength"]()) + .regex(/^\d{6}$/, m["form.errors.pinDigitsOnly"]()) + .refine((pin) => !INSECURE_PINS.has(pin), { message: m["form.errors.pinInsecure"]() }), +}); + +export type FormSchemaRegister = typeof formSchemaRegister; + +export const formSchemaLogin = z.object({ + pin: z + .string() + .length(6, m["form.errors.pinLength"]()) + .regex(/^\d{6}$/, m["form.errors.pinDigitsOnly"]()), +}); + +export type FormSchemaLogin = typeof formSchemaLogin; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-agent.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-agent.svelte new file mode 100644 index 0000000..e00b407 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-agent.svelte @@ -0,0 +1,164 @@ + + +
+ + {m["public.steps.agent.title"]()} + +
    + {#if agents} +
  • + +
  • + {#each agents as agent (agent.id)} +
  • + +
  • + {/each} + {:else} +
    + + + +
    + {/if} +
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-channel.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-channel.svelte new file mode 100644 index 0000000..9f26271 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-channel.svelte @@ -0,0 +1,50 @@ + + +
+ {#if channels.length === 0} + + {m["public.steps.channel.empty"]()} + + {:else} + + {m["public.steps.channel.title"]()} + +
    + {#each channels as channel (channel.id)} +
  • + +
  • + {/each} +
+ {/if} +
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte new file mode 100644 index 0000000..36defa8 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte @@ -0,0 +1,224 @@ + + +
+ + {m["public.steps.slot.title"]()} + + +
+ + {#if schedule === undefined}{/if} +
+
+ { + if (!placeholder) return; + if (placeholder.year === year && placeholder.month === month) return; + year = placeholder.year; + month = placeholder.month; + schedule = undefined; + }} + /> +
+ {#if slots === null} + + {m["public.steps.slot.selectDate"]()} + + {:else if slots === undefined} +
+ + + {m["public.steps.slot.loading"]()} + +
+ {:else if slots.length === 0} + + {m["public.steps.slot.empty"]()} + + {:else} + +
+ + {m["public.steps.slot.selectTime"]()} + + {#each slots as slot (slot.from)} + + {/each} +
+
+ {/if} +
+
+
+
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte new file mode 100644 index 0000000..5ccd4ab --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -0,0 +1,74 @@ + + +
+ + {m["public.steps.summary.title"]()} + + {#if channel} +
+ {#if channel.requiresConfirmation} + + {m["public.steps.summary.request.hint"]()} + + {/if} + +
+ {/if} +
diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts new file mode 100644 index 0000000..b531de8 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts @@ -0,0 +1,128 @@ +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"; +import { CalendarDateTime, getLocalTimeZone, toZoned, today } from "@internationalized/date"; +import { get } from "svelte/store"; + +export const proceed = ( + newAppointment: Partial, +): Partial => { + const curAppointment = get(publicStore).newAppointment; + switch (true) { + case newAppointment.step === "SUMMARY": { + const updatedAppointment: TPublicAppointment = { + ...newAppointment, + step: "SUMMARY", + }; + if (curAppointment.step !== "SUMMARY") { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.data) && + Boolean(newAppointment.slot) && + Boolean(newAppointment.channel) && + Boolean(newAppointment.agent): { + const updatedAppointment: TPublicAppointment = { + ...newAppointment, + step: "LOGIN", + }; + if (!curAppointment.data) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.slot) && + Boolean(newAppointment.channel) && + Boolean(newAppointment.agent): { + const updatedAppointment: TPublicAppointment = { + ...newAppointment, + step: "ADD_PERSONAL_DATA", + }; + if (!curAppointment.slot) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.channel) && + (Boolean(newAppointment.agent) || newAppointment.agent === null): { + const updatedAppointment: TPublicAppointment = { ...newAppointment, step: "SELECT_SLOT" }; + if (!curAppointment.agent) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + return updatedAppointment; + } + case Boolean(newAppointment.channel): { + const updatedAppointment: TPublicAppointment = { ...newAppointment, step: "SELECT_AGENT" }; + if (!curAppointment.channel) { + publicStore.update((state) => ({ + ...state, + newAppointment: updatedAppointment, + })); + } + goto(resolve(`${ROUTES.BOOK_APPOINTMENT}/${newAppointment.channel}`)); + return { ...newAppointment, step: "SELECT_AGENT" }; + } + default: + return { ...newAppointment, step: "SELECT_CHANNEL" }; + } +}; + +export const resest = () => { + goto(resolve(ROUTES.BOOK_APPOINTMENT), { invalidateAll: true }); +}; + +export const fetchSchedule = async (opts: { + tenant: string; + channel: string; + agent: string | null; + year: number; + month: number; +}) => { + if (!browser) return; + + const curDate = today(getLocalTimeZone()); + const startDay = curDate.year === opts.year && curDate.month === opts.month ? curDate.day : 1; + const startDate = new CalendarDateTime(opts.year, opts.month, startDay, 0, 0, 0, 0); + const endDate = startDate + .add({ months: 1 }) + .set({ day: 1 }) + .subtract({ days: 1 }) + .set({ hour: 23, minute: 59, second: 59, millisecond: 999 }); + + const params = new URLSearchParams({ + startDate: toZoned(startDate, "UTC").toAbsoluteString(), + endDate: toZoned(endDate, "UTC").toAbsoluteString(), + channel: opts.channel, + agent: opts.agent || "", + }); + const res = await fetch(`/api/tenants/${opts.tenant}/schedule?${params}`, { + method: "GET", + }); + + if (res.status < 400) { + try { + const data = await res.json(); + return data.schedule as TPublicSchedule["schedule"]; + } catch (error) { + console.error("Unable to parse public agents response", error); + } + } else { + console.error("Invalid public agents response"); + } +}; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.server.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.server.ts new file mode 100644 index 0000000..0e87dc9 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.server.ts @@ -0,0 +1,13 @@ +import type { PageServerLoad } from "./$types.js"; + +export const load: PageServerLoad = async ({ params }) => { + if (params.id) { + return { + channelId: params.id, + }; + } else { + return { + channelId: undefined, + }; + } +}; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.svelte new file mode 100644 index 0000000..a0feff7 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/+page.svelte @@ -0,0 +1,112 @@ + + + + {#await data.streaming.tenant} + {m["public.bookAppointment"]()} - OpenReception + {:then tenant} + {#if tenant} + {m["public.bookAppointment"]()} - {tenant.longName} - OpenReception + {/if} + {/await} + + +{#await data.streaming.tenant} + + +
+ +
+ + + +
+
+
+{:then tenant} + {#if tenant} + {#if tenant.setupState === "READY" && tenant.longName} + + +
+ {#await data.streaming.channels} +
+ + + +
+ {:then channels} + {#if appointment.step === "SUMMARY"} + + {:else if appointment.step === "LOGIN"} + + {:else if appointment.step === "ADD_PERSONAL_DATA"} + + {:else if appointment.step === "SELECT_SLOT" && appointment.channel && appointment.agent !== undefined} + + {:else if appointment.step === "SELECT_AGENT" && appointment.channel} + + {:else} + + {/if} + {/await} +
+
+ {:else} + + + + + + + + + {/if} + {:else} + + + + + + + + + {/if} +{/await} diff --git a/src/routes/(pages)/(clients)/book-appointment/complete/+page.svelte b/src/routes/(pages)/(clients)/book-appointment/complete/+page.svelte new file mode 100644 index 0000000..cf24026 --- /dev/null +++ b/src/routes/(pages)/(clients)/book-appointment/complete/+page.svelte @@ -0,0 +1,52 @@ + + + + {#await data.streaming.tenant} + {m["public.bookAppointment"]()} - OpenReception + {:then tenant} + {#if tenant} + {m["public.bookAppointment"]()} - {tenant.longName} - OpenReception + {/if} + {/await} + + +{#if tenant} + + + + + + + + +{/if} diff --git a/src/routes/(pages)/+page.svelte b/src/routes/(pages)/+page.svelte deleted file mode 100644 index a299a51..0000000 --- a/src/routes/(pages)/+page.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - Hello - OpenReception - - - -
- - Hello World - - Environment configuration is - {#await data.streamed.isEnvOk} - unknown - {:then isEnvOk} - {isEnvOk ? "OK" : "NOT OK"} - {/await}. - - - -
-
diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index b1dcc48..1965656 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -3,8 +3,16 @@ import type { PageServerLoad } from "./$types"; const log = logger.setContext(import.meta.filename); +type Error = { success: false; isSetup: boolean }; +type Success = { + success: boolean; + isSetup: boolean; + id: string; + email: string; + tenantId: string | null; +}; export const load: PageServerLoad = async (event) => { - const confirmation: Promise<{ success: boolean; isSetup: boolean }> = event + const confirmation: Promise = event .fetch("/api/auth/confirm", { method: "POST", headers: { @@ -16,11 +24,16 @@ export const load: PageServerLoad = async (event) => { const success = resp.status < 400; try { const body = await resp.json(); - const isSetup = body.isSetup ?? false; - return { success, isSetup }; + return { + success, + isSetup: body.isSetup ?? false, + id: body.id, + email: body.email, + tenantId: body.tenantId, + }; } catch (error) { log.error("Failed to parse confirm token response", { error }); - return { success, isSetup: false }; + return { success: false, isSetup: false }; } }); diff --git a/src/routes/(pages)/confirm/[token]/+page.svelte b/src/routes/(pages)/confirm/[token]/+page.svelte index 9b2326c..688b3d5 100644 --- a/src/routes/(pages)/confirm/[token]/+page.svelte +++ b/src/routes/(pages)/confirm/[token]/+page.svelte @@ -8,6 +8,8 @@ import Ban from "@lucide/svelte/icons/ban"; 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(); @@ -60,7 +62,18 @@ {m["setup.confirm.success.action"]()} {:else} - {/if} diff --git a/src/routes/(pages)/confirm/resend/+page.server.ts b/src/routes/(pages)/confirm/resend/+page.server.ts index 15d0526..949de40 100644 --- a/src/routes/(pages)/confirm/resend/+page.server.ts +++ b/src/routes/(pages)/confirm/resend/+page.server.ts @@ -1,6 +1,6 @@ import { fail } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; -import { zod } from "sveltekit-superforms/adapters"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; import type { Actions, PageServerLoad } from "./$types"; import { formSchema } from "./schema"; import logger from "$lib/logger"; diff --git a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte index dc26585..19efa8b 100644 --- a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte +++ b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte @@ -5,7 +5,7 @@ import { Input } from "$lib/components/ui/input"; import { toast } from "svelte-sonner"; import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema, type FormSchema } from "./schema"; let { @@ -15,6 +15,7 @@ }: { formId: string; onEvent: EventReporter; data: { form: SuperValidated> } } = $props(); + // svelte-ignore state_referenced_locally const form = superForm(data.form, { validators: zodClient(formSchema), onResult: async (event) => { diff --git a/src/routes/(pages)/confirm/setup-passkey/+page.server.ts b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts new file mode 100644 index 0000000..168dd9b --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/+page.server.ts @@ -0,0 +1,60 @@ +import { fail } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; +import type { Actions, PageServerLoad } from "./$types"; +import { formSchema } from "./schema"; +import logger from "$lib/logger"; + +const log = logger.setContext(import.meta.filename); + +export const load: PageServerLoad = async () => { + return { + form: await superValidate(zod(formSchema)), + }; +}; + +export const actions: Actions = { + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + + if (!form.valid) { + log.error("Setup passkey form is not valid", { errors: form.errors }); + return fail(400, { + form, + }); + } + + const resp = await event.fetch(`/api/auth/register/${form.data.userId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: form.data.email, + challenge: form.data.challenge, // Send original registration challenge + passkey: { + id: form.data.id, + attestationObject: form.data.attestationObjectBase64, + clientDataJSON: form.data.clientDataJSONBase64, + deviceName: "Unknown Device", + }, + }), + }); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse setup passkey error response", { error: e }); + } + return fail(400, { + form: { ...form, data: { ...form.data } }, + error, + }); + } + }, +}; diff --git a/src/routes/(pages)/confirm/setup-passkey/+page.svelte b/src/routes/(pages)/confirm/setup-passkey/+page.svelte new file mode 100644 index 0000000..892323d --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/+page.svelte @@ -0,0 +1,53 @@ + + + + {m["setupPasskey.title"]()} - OpenReception + + + + + + + {m["setupPasskey.title"]()} + + + {m["setupPasskey.description"]()} + + + + + + + + {m["setupPasskey.action"]()} + + + + diff --git a/src/routes/(pages)/confirm/setup-passkey/schema.ts b/src/routes/(pages)/confirm/setup-passkey/schema.ts new file mode 100644 index 0000000..fae8bef --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/schema.ts @@ -0,0 +1,13 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + userId: z.string().min(3), + id: z.string().min(3), + email: z.string().email(m["form.errors.email"]()), + attestationObjectBase64: z.string().base64(), + clientDataJSONBase64: z.string().base64(), + challenge: z.string().min(20), // Original registration challenge (before PRF challenge overwrites cookie) +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte new file mode 100644 index 0000000..0af77cf --- /dev/null +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -0,0 +1,276 @@ + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + +
+ + + + + + + +
+
diff --git a/src/routes/(pages)/dashboard/+layout.server.ts b/src/routes/(pages)/dashboard/+layout.server.ts index e26bc16..635d6ad 100644 --- a/src/routes/(pages)/dashboard/+layout.server.ts +++ b/src/routes/(pages)/dashboard/+layout.server.ts @@ -21,7 +21,7 @@ export const load: LayoutServerLoad = async (event) => { let user: AuthState["user"] | null = null; if (event.locals.user) { user = { - id: event.locals.user.userId, + id: event.locals.user.id, name: event.locals.user.name, email: event.locals.user.email, role: event.locals.user.role, diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 0c6385c..f225af7 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -4,6 +4,7 @@ import type { LayoutProps } from "./$types"; import { auth } from "$lib/stores/auth"; import { tenants } from "$lib/stores/tenants"; + import { staffCrypto } from "$lib/stores/staff-crypto"; let { data, children }: LayoutProps = $props(); @@ -15,6 +16,7 @@ } initTenants(); + initStaffCrypto(); const unsubscribe = () => { if (!intervalId) { @@ -44,6 +46,18 @@ } } }; + + const initStaffCrypto = async () => { + if (data?.user?.id && data?.user?.tenantId && data?.user.role !== "GLOBAL_ADMIN") { + // Try to initialize from session storage (from login) + const success = await staffCrypto.initFromSession(data.user.id, data.user.tenantId); + if (success) { + console.log("✅ Staff crypto initialized successfully"); + } else { + console.log("ℹ️ Staff crypto not initialized (no session data or error)"); + } + } + }; {@render children()} diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte index 03fd076..aebe1a1 100644 --- a/src/routes/(pages)/dashboard/+page.svelte +++ b/src/routes/(pages)/dashboard/+page.svelte @@ -1,5 +1,6 @@ + + {m["dashboard.title"]()} - OpenReception + + { - e.preventDefault(); - goto(ROUTES.DASHBOARD.TENANTS, { state: { action: "add" } }); + onClick: () => { + goto(resolve(ROUTES.DASHBOARD.TENANTS), { state: { action: "add" } }); }, - href: ROUTES.DASHBOARD.TENANTS, }, ], isDone: false, @@ -86,6 +92,69 @@ {/if} {/if} + {#if $auth.user && ["GLOBAL_ADMIN", "TENANT_ADMIN"].includes($auth.user?.role) && tenant && tenant.setupState !== "READY"} + { + goto(resolve(ROUTES.DASHBOARD.SETTINGS)); + }, + }, + ], + isDone: isSetupStateDone(tenant, "SETTINGS"), + }, + { + title: m["dashboard.onboarding.sections.agents.title"](), + description: m["dashboard.onboarding.sections.agents.description"](), + actions: [ + { + label: m["dashboard.onboarding.sections.agents.action"](), + onClick: () => { + goto(resolve(ROUTES.DASHBOARD.AGENTS), { state: { action: "add" } }); + }, + }, + ], + isDone: isSetupStateDone(tenant, "AGENTS"), + isLaterStep: !isSetupStateDone(tenant, "SETTINGS"), + }, + { + title: m["dashboard.onboarding.sections.channels.title"](), + description: m["dashboard.onboarding.sections.channels.description"](), + actions: [ + { + label: m["dashboard.onboarding.sections.channels.action"](), + onClick: () => { + goto(resolve(ROUTES.DASHBOARD.CHANNELS), { state: { action: "add" } }); + }, + }, + ], + isDone: isSetupStateDone(tenant, "CHANNELS"), + isLaterStep: !isSetupStateDone(tenant, "AGENTS"), + }, + { + title: m["dashboard.onboarding.sections.staff.title"](), + description: m["dashboard.onboarding.sections.staff.description"](), + actions: [ + { + label: m["dashboard.onboarding.sections.staff.action"](), + onClick: () => { + goto(resolve(ROUTES.DASHBOARD.STAFF), { state: { action: "add" } }); + }, + }, + ], + isDone: isSetupStateDone(tenant, "STAFF"), + isLaterStep: !isSetupStateDone(tenant, "CHANNELS"), + }, + ]} + /> + {/if} diff --git a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte index 1aa79bf..d5ddf81 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte @@ -10,7 +10,7 @@ import StopIcon from "@lucide/svelte/icons/octagon-x"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { reasons } from "../utils"; import { formSchema } from "./schema"; import { getDefaultStartTime, getDefaultEndTime } from "$lib/utils/datetime"; diff --git a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte index 3e84d29..7048746 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte @@ -8,12 +8,13 @@ import { toDisplayDateTime } from "$lib/utils/datetime"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; 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 }, { diff --git a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte index f5d54fb..aaf5b36 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte @@ -10,7 +10,7 @@ import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; import { reasons } from "../utils"; import { toInputDateTime } from "$lib/utils/datetime"; @@ -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, ); diff --git a/src/routes/(pages)/dashboard/absences/+page.server.ts b/src/routes/(pages)/dashboard/absences/+page.server.ts index 558976b..ea07339 100644 --- a/src/routes/(pages)/dashboard/absences/+page.server.ts +++ b/src/routes/(pages)/dashboard/absences/+page.server.ts @@ -2,7 +2,7 @@ import { ROUTES } from "$lib/const/routes.js"; import logger from "$lib/logger"; import { fail, redirect, type Actions } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; -import { zod } from "sveltekit-superforms/adapters"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; import { formSchema as addFormSchema } from "./(components)/add-absence-form"; import { formSchema as editFormSchema } from "./(components)/edit-absence-form"; import { formSchema as deleteFormSchema } from "./(components)/delete-absence-form"; diff --git a/src/routes/(pages)/dashboard/account/+page.svelte b/src/routes/(pages)/dashboard/account/+page.svelte index bd65b26..222c238 100644 --- a/src/routes/(pages)/dashboard/account/+page.svelte +++ b/src/routes/(pages)/dashboard/account/+page.svelte @@ -1,14 +1,96 @@ +> + + {m["account.overview.title"]()} + + + {#snippet child({ props })} + + + + {m["account.general.title"]()} + + + {m["account.general.description"]()} + + + + + + + {/snippet} + + + {#snippet child({ props })} + + + + {m["account.change-email.title"]()} + + + {m["account.change-email.description"]()} + + + + + + + {/snippet} + + + {#snippet child({ props })} + + + + {m["account.passkeys.title"]()} + + + {m["account.passkeys.description"]()} + + + + + + + {/snippet} + + {#if $auth.user?.role === "GLOBAL_ADMIN"} + + {#snippet child({ props })} + + + + {m["account.change-passphrase.title"]()} + + + {m["account.change-passphrase.description"]()} + + + + + + + {/snippet} + + {/if} + + + diff --git a/src/routes/(pages)/dashboard/account/change-email/+page.svelte b/src/routes/(pages)/dashboard/account/change-email/+page.svelte new file mode 100644 index 0000000..5bb7d5b --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-email/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.change-email.title"]()} + + diff --git a/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte b/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte new file mode 100644 index 0000000..b3ebcd0 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/change-passphrase/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.change-passphrase.title"]()} + + diff --git a/src/routes/(pages)/dashboard/account/general/+page.svelte b/src/routes/(pages)/dashboard/account/general/+page.svelte new file mode 100644 index 0000000..304fb00 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/general/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.general.title"]()} + + diff --git a/src/routes/(pages)/dashboard/account/passkeys/+page.svelte b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte new file mode 100644 index 0000000..0552bb2 --- /dev/null +++ b/src/routes/(pages)/dashboard/account/passkeys/+page.svelte @@ -0,0 +1,24 @@ + + + + + {m["account.passkeys.title"]()} + + diff --git a/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte b/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte index 596161b..27a92e9 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte +++ b/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte @@ -8,7 +8,7 @@ import ItemIcon from "@lucide/svelte/icons/user-star"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; import { tenants } from "$lib/stores/tenants"; import { get } from "svelte/store"; diff --git a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte index c150221..fdc2b4f 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte +++ b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/delete-agent-form.svelte @@ -8,12 +8,13 @@ import type { TAgent } from "$lib/types/agent"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; - import z from "zod"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; + import { z } from "zod"; import { formSchema } from "."; let { entity, done }: { entity: TAgent; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, name: "" }, { diff --git a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts index 0f61afd..d3de63a 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts +++ b/src/routes/(pages)/dashboard/agents/(components)/delete-agent-form/index.ts @@ -1,5 +1,5 @@ -import DeleteTenantForm from "./delete-agent-form.svelte"; +import DeleteAgentForm from "./delete-agent-form.svelte"; -export { DeleteTenantForm }; +export { DeleteAgentForm }; export { formSchema } from "./schema"; export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte index 1360202..a78218f 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte +++ b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/edit-agent-form.svelte @@ -9,7 +9,7 @@ import ItemIcon from "@lucide/svelte/icons/user-star"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; import { tenants } from "$lib/stores/tenants"; import { get } from "svelte/store"; @@ -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), diff --git a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts index f9b02ef..06e3aec 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts +++ b/src/routes/(pages)/dashboard/agents/(components)/edit-agent-form/index.ts @@ -1,5 +1,5 @@ -import EditTenantForm from "./edit-agent-form.svelte"; +import EditAgentForm from "./edit-agent-form.svelte"; -export { EditTenantForm }; +export { EditAgentForm }; export { formSchema } from "./schema"; export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/agents/+page.server.ts b/src/routes/(pages)/dashboard/agents/+page.server.ts index fbe47d5..e4a1018 100644 --- a/src/routes/(pages)/dashboard/agents/+page.server.ts +++ b/src/routes/(pages)/dashboard/agents/+page.server.ts @@ -2,7 +2,7 @@ import { ROUTES } from "$lib/const/routes.js"; import logger from "$lib/logger"; import { fail, redirect, type Actions } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; -import { zod } from "sveltekit-superforms/adapters"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; import { formSchema as addFormSchema } from "./(components)/add-agent-form"; import { formSchema as editFormSchema } from "./(components)/edit-agent-form"; import { formSchema as deleteFormSchema } from "./(components)/delete-agent-form"; diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte index 94e5e7b..737fa76 100644 --- a/src/routes/(pages)/dashboard/agents/+page.svelte +++ b/src/routes/(pages)/dashboard/agents/+page.svelte @@ -1,5 +1,4 @@ + +{#if item.appointment.appointment} +
+
+ + {#if item.decrypted.phone} + + {/if} +
+
+ + + {Intl.DateTimeFormat(getLocale(), { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(item.appointment.appointment.dateTime)} + +
+
+{/if} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte new file mode 100644 index 0000000..9bd5b71 --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte @@ -0,0 +1,104 @@ + + +{#if error} +
+ + ⚠️ {m["calendar.decryptingError"]()} + +
+{:else if decrypted === undefined} +
+ + {m["calendar.decrypting"]()} +
+{:else if decrypted} + +{/if} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte new file mode 100644 index 0000000..5b4f04e --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte @@ -0,0 +1,142 @@ + + +
+
+ +
+ {#each shownHours as hour (`hour-${hour}`)} +
+ + {Intl.DateTimeFormat(getLocale(), { + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))} + + + +
+ {/each} + + + {#if items === undefined} +
+ + {m["calendar.loading"]()} +
+ {/if} + + +
+ {#each processedItems as item (item.id)} + {@const top = + (item.startMinutes / 60) * hourSize + focusAdjustment - earliestStartHour * hourSize} + {@const height = item.duration * scale} + {@const width = 100 / item.totalColumns} + {@const left = item.column * width} +
+
+ {#if ["booked", "reserved"].includes(item.status)} + + {/if} +
+
+ {/each} +
+ + {#if curTimeIndicator && toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString() && latestEndHour + hourSize / 2 > curTimeIndicator.hour} + {@const top = + focusAdjustment + + curTimeIndicator.hour * hourSize + + (curTimeIndicator.minute / 60) * hourSize - + earliestStartHour * hourSize} +
+ + {Intl.DateTimeFormat(getLocale(), { + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))} + +
+
+ {/if} +
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte new file mode 100644 index 0000000..afea0ef --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte @@ -0,0 +1,155 @@ + + + + +
+ + + + + +
+
+ + + {m["calendar.shownAppointments.title"]()} + + {#each appointmentStates as state (state.value)} +
+ + +
+ {/each} +
+
+ + + {#if channels.length > 1} +
+ {m["channels.title"]()} + {#each channels as channel (channel.id)} + {@const locale = getLocale()} + {@const name = channel.names[locale] || Object.values(channel.names)[0]} +
+ { + if (v) { + shownChannels = [...shownChannels, channel.id]; + } else { + shownChannels = shownChannels.filter((id) => id !== channel.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} + {#if agents.length > 1} +
+ {m["agents.title"]()} + {#each agents as agent (agent.id)} +
+ { + if (v) { + shownAgents = [...shownAgents, agent.id]; + } else { + shownAgents = shownAgents.filter((id) => id !== agent.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} +
+
+
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte new file mode 100644 index 0000000..e3d4e9d --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte @@ -0,0 +1,62 @@ + + +
+
+ +
+ {Intl.DateTimeFormat(getLocale(), { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + timeZone: getLocalTimeZone().toString(), + }).format(startDate.toDate(getLocalTimeZone()))} +
+ +
+ +
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts new file mode 100644 index 0000000..d16c8cd --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -0,0 +1,117 @@ +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"; + +export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarDate }) => { + if (!browser) return; + + const params = new URLSearchParams({ + startDate: toZoned(opts.startDate, "UTC").toAbsoluteString(), + endDate: toZoned( + toCalendarDateTime(opts.startDate).set({ + hour: 23, + minute: 59, + second: 59, + millisecond: 999, + }), + "UTC", + ).toAbsoluteString(), + }); + const res = await fetch(`/api/tenants/${opts.tenant}/calendar?${params}`, { + method: "GET", + }); + + if (res.status < 400) { + try { + const data = await res.json(); + return data as TCalendar; + } catch (error) { + console.error("Unable to parse calendar response", error); + } + } else { + if (res.status === 401) { + goto(resolve(ROUTES.LOGIN)); + } else { + console.error("Unable to fetch calendar", res.status, res.statusText); + } + } +}; + +// Convert time string to minutes since midnight +function timeToMinutes(time: string): number { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +export function positionItems(items: TCalendarItem[] | undefined) { + if (!items) return []; + + // Sort items by start time + const sortedItems = [...items] + .sort((a, b) => b.duration - a.duration) + .sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start)); + + // Calculate layout positions + const processedItems = sortedItems.map((item) => { + const startMinutes = timeToMinutes(item.start); + const endMinutes = startMinutes + item.duration; + + return { + ...item, + startMinutes, + endMinutes, + totalColumns: 1, + }; + }); + + // Find overlapping groups and assign columns + for (let i = 0; i < processedItems.length; i++) { + const currentItem = processedItems[i]; + const overlappingItems = [currentItem]; + + // Find all items that overlap with current item's time range + for (let j = i + 1; j < processedItems.length; j++) { + const nextItem = processedItems[j]; + + // Check if items overlap + if (nextItem.startMinutes < currentItem.endMinutes) { + overlappingItems.push(nextItem); + } else { + break; + } + } + + // Assign columns to overlapping items + if (overlappingItems.length > 1) { + const columns: number[] = []; + + overlappingItems.forEach((item) => { + // Find the first available column + let column = 0; + while (columns[column] && columns[column] > item.startMinutes) { + column++; + } + + // Don't change columns back + if (item.column !== undefined && item.column > column) { + column = item.column; + } + + item.column = column; + item.totalColumns = Math.max(item.totalColumns, column + 1); + columns[column] = item.endMinutes; + }); + + // Update totalColumns for all overlapping items + const maxColumns = Math.max(...overlappingItems.map((item) => item.column)) + 1; + overlappingItems.forEach((item) => { + item.totalColumns = maxColumns; + }); + } + } + + return processedItems; +} diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index 6c5174d..a94eff2 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -1,8 +1,179 @@ - + + +
+ +
+ +
+
+
+ {#snippet headerRight()} + + {/snippet} + {#snippet sidebarRight()} + + {/snippet} +
+ +{#if curItem} + {@const channel = channels.find((c) => c.id === curItem.appointment.channelId)} + + + +{/if} diff --git a/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte index 3164909..b370fd1 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/add-channel-form/add-channel-form.svelte @@ -14,7 +14,7 @@ import { toast } from "svelte-sonner"; import { get } from "svelte/store"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { DEFAULT_SLOT_TEMPLATE } from "../utils"; import { formSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte index 5f4c61f..13d4aef 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/delete-channel-form/delete-channel-form.svelte @@ -7,14 +7,15 @@ import { Text } from "$lib/components/ui/typography"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; - import z from "zod"; + 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(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, name: "" }, { diff --git a/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte index 5b07fb0..6568615 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/edit-channel-form/edit-channel-form.svelte @@ -15,7 +15,7 @@ import { toast } from "svelte-sonner"; import { get } from "svelte/store"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; import { DEFAULT_SLOT_TEMPLATE } from "../utils"; @@ -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, diff --git a/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte b/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte index 37e692d..88c53e0 100644 --- a/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte +++ b/src/routes/(pages)/dashboard/channels/(components)/pause-channel-form/pause-channel-form.svelte @@ -9,11 +9,12 @@ import { getCurrentTranlslation } from "$lib/utils/localizations"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, pause: !entity.pause }, { diff --git a/src/routes/(pages)/dashboard/channels/+page.server.ts b/src/routes/(pages)/dashboard/channels/+page.server.ts index 7059964..c0943f6 100644 --- a/src/routes/(pages)/dashboard/channels/+page.server.ts +++ b/src/routes/(pages)/dashboard/channels/+page.server.ts @@ -2,7 +2,7 @@ import { ROUTES } from "$lib/const/routes.js"; import logger from "$lib/logger"; import { fail, redirect, type Actions } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; -import { zod } from "sveltekit-superforms/adapters"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; import { formSchema as addFormSchema } from "./(components)/add-channel-form"; import { formSchema as editFormSchema } from "./(components)/edit-channel-form"; import { formSchema as deleteFormSchema } from "./(components)/delete-channel-form"; diff --git a/src/routes/(pages)/dashboard/channels/+page.svelte b/src/routes/(pages)/dashboard/channels/+page.svelte index 9bdcb7b..ee1f470 100644 --- a/src/routes/(pages)/dashboard/channels/+page.svelte +++ b/src/routes/(pages)/dashboard/channels/+page.svelte @@ -1,5 +1,4 @@ + + + + + {#snippet children({ props })} + {m["form.name"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["staff.form.fields.language.title"]()} + ($formData.language = v)} + > + + {$formData.language + ? translatedLocales[$formData.language as keyof typeof translatedLocales] + : m["staff.form.fields.language.placeholder"]()} + + + {#each supportedLocales as locale (locale)} + + {translatedLocales[locale as keyof typeof translatedLocales]} + + {/each} + + + + {m["staff.form.fields.language.description"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["staff.form.fields.role.title"]()} + ($formData.role = v as TStaff["role"])} + > + + {@const value = roles.find((r) => r.value === $formData.role)?.label} + {value ? value : m["staff.form.fields.role.placeholder"]()} + + + {#each availableRoles as role (role.value)} + + {role.label} + + {/each} + + + + + + {/snippet} + + + +
+ + {m["staff.add.action"]()} + +
+
diff --git a/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/index.ts new file mode 100644 index 0000000..6196ea5 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/index.ts @@ -0,0 +1,5 @@ +import AddStaffMemberForm from "./add-staff-member-form.svelte"; + +export { AddStaffMemberForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/schema.ts b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/schema.ts new file mode 100644 index 0000000..57f766c --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/add-staff-member-form/schema.ts @@ -0,0 +1,11 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()), + email: z.string().email(m["form.errors.email"]()), + language: z.string().default("en"), + role: z.enum(["STAFF", "TENANT_ADMIN", "GLOBAL_ADMIN"]).default("STAFF"), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte new file mode 100644 index 0000000..57ee3b8 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/delete-staff-member-form.svelte @@ -0,0 +1,90 @@ + + + + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + +
+ + {m["staff.delete.action"]()} + +
+
+ +{#snippet inlineCode(value: string | number)} + {value} +{/snippet} diff --git a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/index.ts new file mode 100644 index 0000000..c336c54 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/index.ts @@ -0,0 +1,5 @@ +import DeleteStaffMemberForm from "./delete-staff-member-form.svelte"; + +export { DeleteStaffMemberForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/schema.ts b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/schema.ts new file mode 100644 index 0000000..3c286a8 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/delete-staff-member-form/schema.ts @@ -0,0 +1,10 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + id: z.string(), + email: z.string().email(m["form.errors.email"]()), + confirmationState: z.enum(["INVITED", "CONFIRMED", "ACCESS_GRANTED"]), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte new file mode 100644 index 0000000..d5aba0f --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/edit-staff-member-form.svelte @@ -0,0 +1,108 @@ + + + + + + {#snippet children({ props })} + {m["form.name"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["staff.form.fields.role.title"]()} + ($formData.role = v as TStaff["role"])} + > + + {@const value = roles.find((r) => r.value === $formData.role)?.label} + {value ? value : m["staff.form.fields.role.placeholder"]()} + + + {#each availableRoles as role (role.value)} + + {role.label} + + {/each} + + + + + + {/snippet} + + + + + +
+ + {m["staff.edit.action"]()} + +
+
diff --git a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/index.ts new file mode 100644 index 0000000..af29d8f --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/index.ts @@ -0,0 +1,5 @@ +import EditStaffMemberForm from "./edit-staff-member-form.svelte"; + +export { EditStaffMemberForm }; +export { formSchema } from "./schema"; +export type { FormSchema } from "./schema"; diff --git a/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/schema.ts b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/schema.ts new file mode 100644 index 0000000..379b148 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/edit-staff-member-form/schema.ts @@ -0,0 +1,11 @@ +import { m } from "$i18n/messages"; +import { z } from "zod"; + +export const formSchema = z.object({ + id: z.string(), + name: z.string().min(2, m["form.errors.name"]()).max(50, m["form.errors.name"]()), + email: z.string().email(m["form.errors.email"]()), + role: z.enum(["STAFF", "TENANT_ADMIN", "GLOBAL_ADMIN"]).default("STAFF"), +}); + +export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte new file mode 100644 index 0000000..8a1ec95 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/grant-access-form.svelte @@ -0,0 +1,65 @@ + + +
+ + + + {#if myUserRole === "GLOBAL_ADMIN"} + + {:else} +
+ {#if isSubmitting} + + {/if} + +
+ {/if} +
+ +{#snippet inlineCode(value: string | number)} + {value} +{/snippet} diff --git a/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/index.ts b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/index.ts new file mode 100644 index 0000000..9715767 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/grant-access-form/index.ts @@ -0,0 +1,3 @@ +import GrantAccessForm from "./grant-access-form.svelte"; + +export { GrantAccessForm }; diff --git a/src/routes/(pages)/dashboard/staff/(components)/role-permissions.svelte b/src/routes/(pages)/dashboard/staff/(components)/role-permissions.svelte new file mode 100644 index 0000000..abf988b --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/role-permissions.svelte @@ -0,0 +1,27 @@ + + +
    + {#each permissions as permission (permission.label)} +
  • + {#if permission.roles.includes(role)} + + {:else} + + {/if} +
    {permission.label}
    + {#if permission.roles.includes(role)} + {m.yes()} + {:else} + {m.no()} + {/if} +
  • + {/each} +
diff --git a/src/routes/(pages)/dashboard/staff/(components)/utils.ts b/src/routes/(pages)/dashboard/staff/(components)/utils.ts new file mode 100644 index 0000000..c1beb3f --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/(components)/utils.ts @@ -0,0 +1,17 @@ +import { m } from "$i18n/messages"; +import type { TStaff } from "$lib/types/users"; + +export const roles = [ + { label: m["staff.roles.STAFF"](), value: "STAFF" }, + { label: m["staff.roles.TENANT_ADMIN"](), value: "TENANT_ADMIN" }, + { label: m["staff.roles.GLOBAL_ADMIN"](), value: "GLOBAL_ADMIN" }, +] as const; + +export const permissions: { label: string; roles: TStaff["role"][] }[] = [ + { label: m["staff.permissions.agents"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.channels"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.absences"](), roles: ["STAFF", "TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.settings"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.staff"](), roles: ["TENANT_ADMIN", "GLOBAL_ADMIN"] }, + { label: m["staff.permissions.appointments"](), roles: ["STAFF"] }, +]; diff --git a/src/routes/(pages)/dashboard/staff/+page.server.ts b/src/routes/(pages)/dashboard/staff/+page.server.ts new file mode 100644 index 0000000..3b0a910 --- /dev/null +++ b/src/routes/(pages)/dashboard/staff/+page.server.ts @@ -0,0 +1,188 @@ +import { ROUTES } from "$lib/const/routes.js"; +import logger from "$lib/logger"; +import type { TStaff } from "$lib/types/users.js"; +import { fail, redirect, type Actions } from "@sveltejs/kit"; +import { superValidate } from "sveltekit-superforms"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; +import { formSchema as addFormSchema } from "./(components)/add-staff-member-form"; +import { formSchema as deleteFormSchema } from "./(components)/delete-staff-member-form"; +import { formSchema as editFormSchema } from "./(components)/edit-staff-member-form"; + +const log = logger.setContext(import.meta.filename); + +export const load = async (event) => { + if (!event.locals.user?.tenantId) { + log.error("User trying to access staff, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const list = event + .fetch(`/api/tenants/${event.locals.user?.tenantId}/staff`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (res) => { + // Logout if session expired + if (res.status === 401) { + redirect(302, ROUTES.LOGOUT); + } + + try { + const body = await res.json(); + return body.staff as TStaff[]; + } catch (error) { + log.error("Failed to parse staff members response", { error }); + return []; + } + }); + + return { + streamed: { + list, + }, + }; +}; + +export const actions: Actions = { + add: async (event) => { + const form = await superValidate(event, zod(addFormSchema)); + + if (!form.valid) { + log.error("Add staff member form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to add a staff member, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch(`/api/auth/invite`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + name: form.data.name, + email: form.data.email, + language: form.data.language, + role: form.data.role, + tenantId: event.locals.user?.tenantId, + }), + }); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse add staff member error response", { error: e }); + } + return fail(400, { + form, + error, + }); + } + }, + edit: async (event) => { + const form = await superValidate(event, zod(editFormSchema)); + + if (!form.valid) { + log.error("Edit staff member form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to edit a staff member, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch( + `/api/tenants/${event.locals.user?.tenantId}/staff/${form.data.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + name: form.data.name, + email: form.data.email, + role: form.data.role, + }), + }, + ); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse edit staff member error response", { error: e }); + } + return fail(400, { + form: { ...form, data: { ...form.data } }, + error, + }); + } + }, + delete: async (event) => { + const form = await superValidate(event, zod(deleteFormSchema)); + + if (!form.valid) { + log.error("Delete staff member form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data } }, + error: "Form is not valid", + }); + } + + if (!event.locals.user?.tenantId) { + log.error("User trying to delete a staff member, but has no tenantId"); + redirect(302, ROUTES.LOGOUT); + } + + const resp = await event.fetch( + `/api/tenants/${event.locals.user?.tenantId}/staff/${form.data.id}?confirmationState=${form.data.confirmationState}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }, + ); + + if (resp.status < 400) { + return { form }; + } else { + let error = "Unknown error"; + try { + const body = await resp.json(); + error = body.error; + } catch (e) { + log.error("Failed to parse delete staff member error response", { error: e }); + } + return fail(400, { + form, + error, + }); + } + }, +}; diff --git a/src/routes/(pages)/dashboard/staff/+page.svelte b/src/routes/(pages)/dashboard/staff/+page.svelte index c745be6..d1c78b8 100644 --- a/src/routes/(pages)/dashboard/staff/+page.svelte +++ b/src/routes/(pages)/dashboard/staff/+page.svelte @@ -1,9 +1,45 @@ + + {m["staff.title"]()} - OpenReception + + +> + + {#await data.streamed.list as TStaff[]} + + {:then items} +
+ + {#snippet triggerLabel()} + {m["staff.add.title"]()} + {/snippet} + { + tenants.reload(); + invalidate(ROUTES.DASHBOARD.STAFF); + closeDialog("add"); + }} + /> + + + {#if items.length > 0} + + {#each items as item (item.id)} + {@const role = roles.find((r) => r.value === item.role)?.label || item.role} + { + curItem = item; + openDialog("edit"); + }, + }, + { + type: "action", + icon: AccessIcon, + label: m["staff.access.action"](), + isHidden: item.confirmationState === "ACCESS_GRANTED", + onClick: () => { + curItem = item; + openDialog("access"); + }, + }, + { + type: "divider", + }, + { + type: "action", + icon: DeleteIcon, + label: m["delete"](), + isDestructive: true, + onClick: () => { + curItem = item; + openDialog("delete"); + }, + }, + ]} + badges={item.role !== "GLOBAL_ADMIN" && item.confirmationState !== "ACCESS_GRANTED" + ? [{ label: m["staff.list.needsAccess"](), variant: "outline" }] + : undefined} + > + {item.name} + + {/each} + + + {#if curItem} + { + closeDialog("edit"); + curItem = null; + invalidate(ROUTES.DASHBOARD.STAFF); + }} + /> + {/if} + + + {#if curItem} + { + closeDialog("delete"); + curItem = null; + tenants.reload(); + invalidate(ROUTES.DASHBOARD.STAFF); + }} + /> + {/if} + + + {#if curItem} + { + closeDialog("access"); + curItem = null; + invalidate(ROUTES.DASHBOARD.STAFF); + }} + /> + {/if} + + {:else} +
+ + +
+ {/if} +
+ {/await} +
+ diff --git a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte index ab787cb..75a1303 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte @@ -5,14 +5,15 @@ import { Input } from "$lib/components/ui/input"; import { toast } from "svelte-sonner"; import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema, type FormSchema } from "."; import { ERRORS } from "$lib/errors"; let { data, done }: { done: () => void; data: { form: SuperValidated> } } = $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") { diff --git a/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte index bd02a96..79a8d3e 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/delete-tenant-form/delete-tenant-form.svelte @@ -6,8 +6,8 @@ import type { TTenant } from "$lib/types/tenant"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; - import z from "zod"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; + import { z } from "zod"; import { formSchema } from "."; import { auth } from "$lib/stores/auth"; import { CenterState } from "$lib/components/templates/empty-state"; @@ -17,6 +17,7 @@ let { entity, done }: { entity: TTenant; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, shortname: "" }, { diff --git a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte index 8170cc4..69e75f0 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte @@ -5,11 +5,12 @@ import type { TTenant } from "$lib/types/tenant"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; + import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; let { entity, done }: { entity: TTenant; done: () => void } = $props(); + // svelte-ignore state_referenced_locally const form = superForm( { id: entity.id, shortName: entity.shortName }, { diff --git a/src/routes/(pages)/dashboard/tenants/+page.server.ts b/src/routes/(pages)/dashboard/tenants/+page.server.ts index 301c211..c48f101 100644 --- a/src/routes/(pages)/dashboard/tenants/+page.server.ts +++ b/src/routes/(pages)/dashboard/tenants/+page.server.ts @@ -2,7 +2,7 @@ import { ROUTES } from "$lib/const/routes.js"; import logger from "$lib/logger"; import { fail, redirect, type Actions } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; -import { zod } from "sveltekit-superforms/adapters"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; import { formSchema as addFormSchema } from "./(components)/add-tenant-form"; import { formSchema as editFormSchema } from "./(components)/edit-tenant-form"; import { formSchema as deleteFormSchema } from "./(components)/delete-tenant-form"; @@ -53,7 +53,8 @@ export const actions: Actions = { }); } - const resp = await event.fetch(`/api/tenants`, { + // Create tenant first + const tenantResponse = await event.fetch(`/api/tenants`, { method: "POST", headers: { "Content-Type": "application/json", @@ -61,16 +62,13 @@ export const actions: Actions = { credentials: "same-origin", body: JSON.stringify({ shortName: form.data.shortName, - inviteAdmin: form.data.email, }), }); - if (resp.status < 400) { - return { form }; - } else { + if (tenantResponse.status >= 400) { let error = "Unknown error"; try { - const body = await resp.json(); + const body = await tenantResponse.json(); error = body.error; } catch (e) { log.error("Failed to parse add tenant error response", { error: e }); @@ -80,6 +78,38 @@ export const actions: Actions = { error, }); } + + // If admin invitation requested, create user invitation + if (form.data.inviteAdmin && form.data.email) { + const tenantData = await tenantResponse.json(); + const tenantId = tenantData.tenant?.id; + + if (tenantId) { + const userInviteResponse = await event.fetch(`/api/auth/invite`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ + email: form.data.email, + name: form.data.email.split("@")[0], + role: "TENANT_ADMIN", + tenantId: tenantId, + }), + }); + + if (userInviteResponse.status >= 400) { + log.warn("Tenant created but user invitation failed", { + tenantId, + email: form.data.email, + }); + // Don't fail the entire operation, just log the warning + } + } + } + + return { form }; }, edit: async (event) => { const form = await superValidate(event, zod(editFormSchema)); diff --git a/src/routes/(pages)/dashboard/tenants/+page.svelte b/src/routes/(pages)/dashboard/tenants/+page.svelte index 04daf10..ce5521b 100644 --- a/src/routes/(pages)/dashboard/tenants/+page.svelte +++ b/src/routes/(pages)/dashboard/tenants/+page.svelte @@ -11,10 +11,10 @@ import { ROUTES } from "$lib/const/routes"; import { tenants as tenantsStore } from "$lib/stores/tenants"; import { type TTenant } from "$lib/types/tenant"; - import EditIcon from "@lucide/svelte/icons/pencil"; + // import EditIcon from "@lucide/svelte/icons/pencil"; import SelectIcon from "@lucide/svelte/icons/plug-zap"; import PlusIcon from "@lucide/svelte/icons/plus"; - import UnknownItemIcon from "@lucide/svelte/icons/ticket-x"; + import UnknownItemIcon from "@lucide/svelte/icons/landmark"; import DeleteIcon from "@lucide/svelte/icons/trash-2"; import { onMount } from "svelte"; import { AddTenantForm } from "./(components)/add-tenant-form"; @@ -72,6 +72,7 @@ {#each items as item (item.id)} @@ -81,15 +82,16 @@ "noopener,noreferrer", )} actions={[ - { - type: "action", - icon: EditIcon, - label: m["edit"](), - onClick: () => { - curItem = item; - openDialog("edit"); - }, - }, + // Editiing will we re-introduced, when we support custom domains + // { + // type: "action", + // icon: EditIcon, + // label: m["edit"](), + // onClick: () => { + // curItem = item; + // openDialog("edit"); + // }, + // }, { type: "action", icon: SelectIcon, @@ -126,6 +128,7 @@ entity={curItem} done={() => { closeDialog("edit"); + tenantsStore.reload(); curItem = null; invalidate(ROUTES.DASHBOARD.TENANTS); }} diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts index c512b11..93a5a45 100644 --- a/src/routes/(pages)/login/+page.server.ts +++ b/src/routes/(pages)/login/+page.server.ts @@ -1,7 +1,7 @@ import { fail } from "@sveltejs/kit"; import { superValidate } from "sveltekit-superforms"; -import { zod } from "sveltekit-superforms/adapters"; -import type { Actions, PageServerLoad } from "./$types"; +import { zod4 as zod } from "sveltekit-superforms/adapters"; +import type { Actions } from "./$types"; import { formSchema } from "./schema"; import type { WebAuthnCredential } from "$lib/server/auth/webauthn-service"; import type { TUser } from "$lib/types/user"; @@ -9,12 +9,6 @@ import logger from "$lib/logger"; const log = logger.setContext(import.meta.filename); -export const load: PageServerLoad = async () => { - return { - form: await superValidate(zod(formSchema)), - }; -}; - export const actions: Actions = { default: async (event) => { const form = await superValidate(event, zod(formSchema)); @@ -53,6 +47,7 @@ export const actions: Actions = { "Content-Type": "application/json", }, body: JSON.stringify(body), + credentials: "same-origin", }); let user: TUser | null = null; diff --git a/src/routes/(pages)/login/+page.svelte b/src/routes/(pages)/login/+page.svelte index 63f25ab..5e39d05 100644 --- a/src/routes/(pages)/login/+page.svelte +++ b/src/routes/(pages)/login/+page.svelte @@ -5,8 +5,8 @@ import { PageWithClaim } from "$lib/components/ui/page"; import LoginForm from "./login-form.svelte"; import type { EventReporter } from "$lib/components/ui/form/form-root.svelte"; - - const { data } = $props(); + import { Button } from "$lib/components/ui/button"; + import { ROUTES } from "$lib/const/routes"; const formId = "create-account-form"; let isSubmitting = $state(false); @@ -26,6 +26,9 @@ + {#snippet left()} + + {/snippet} @@ -36,7 +39,7 @@ - + > } } = - $props(); + let { formId, onEvent }: { formId: string; onEvent: EventReporter } = $props(); - const form = superForm(data.form, { - validators: zodClient(formSchema), - onChange: (event) => { - if (event.paths.includes("email")) { - setProperPasskeyState(); - } + const form = superForm( + { + email: "", + type: "passkey", + id: "", + passphrase: "passphrase", + clientDataBase64: "", + authenticatorDataBase64: "", + signatureBase64: "", }, - onResult: async (event) => { - if (event.result.type === "success") { - auth.setUser(event.result.data?.user); - await goto(ROUTES.DASHBOARD.MAIN); - } else { - toast.error(m["login.error"]()); - } - onEvent({ isSubmitting: false }); + { + validators: zodClient(formSchema), + onChange: (event) => { + if (event.paths.includes("email")) { + setProperPasskeyState(); + } + }, + onResult: async (event) => { + if (event.result.type === "success") { + auth.setUser(event.result.data?.user); + await goto(resolve(ROUTES.DASHBOARD.MAIN)); + } else { + toast.error(m["login.error"]()); + } + onEvent({ isSubmitting: false }); + }, + onSubmit: () => onEvent({ isSubmitting: true }), }, - onSubmit: () => onEvent({ isSubmitting: true }), - }); + ); onMount(() => { $formData.type = "passkey"; @@ -88,12 +95,16 @@ logger.error("Failed to fetch challenge", { email: $formData.email }); } else { $passkeyLoading = "user"; - const credentialResp = await getCredential({ ...challenge, email: $formData.email }).catch( - (error) => { - $passkeyLoading = "error"; - logger.error("Failed to get credential", { ...challenge, error }); - }, - ); + + // Call WebAuthn with PRF enabled (uses email as salt for multi-passkey support) + const credentialResp = await getCredential({ + ...challenge, + email: $formData.email, + enablePRF: true, // Always enable PRF for staff authentication + }).catch((error) => { + $passkeyLoading = "error"; + logger.error("Failed to get credential", { ...challenge, error }); + }); if (!credentialResp) { $passkeyLoading = "error"; @@ -118,6 +129,31 @@ signatureBase64, }; + // 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", { + passkeyId, + prfOutputLength: credentialResp.prfOutput.byteLength, + }); + } else { + logger.warn("No PRF output in login response - crypto features may not work", { + email: $formData.email, + passkeyId, + }); + } + + auth.setPasskeyAuthData({ + authenticatorData: authenticatorDataBase64, + passkeyId, + email: $formData.email, + prfOutput: prfOutputBase64, + }); + // Update UI to show passkey is ready $passkeyLoading = "success"; @@ -130,9 +166,6 @@ const { form: formData, enhance } = form; const passkeyLoading: Writable = writable("initial"); - - type FormDataPassphrase = Extract; - type FormDataPasskey = Extract; @@ -157,14 +190,13 @@ {#snippet children({ props })} {m["form.passphrase"]()} - + {...props} + bind:value={$formData.passphrase} + type="password" + minlength={30} + maxlength={100} + /> {/snippet} @@ -181,54 +213,34 @@ - + {m["login.or"]()}