diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 643097f..2513c4e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -22,9 +22,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Security audit - run: npm audit --audit-level=high - - name: Run linting run: npm run lint check: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c37478..b9e7ad7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build Docker image - run: docker build -t openreception/open-reception:${{ github.ref_name }} . + run: docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:${{ github.ref_name }} . - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master diff --git a/Caddyfile b/Caddyfile index cad756b..4a3c3a8 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,27 +1,14 @@ # Production Caddyfile for appointment booking application -# Replace your-domain.com with your actual domain +# Replace your-admin-domain.com with your actual admin domain { on_demand_tls { ask http://app:3000/api/public/domains - burst 5 - interval 60s } } :443 { reverse_proxy app:3000 - # Security headers - header { - -Server - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Content-Type-Options "nosniff" - X-Frame-Options "DENY" - X-XSS-Protection "1; mode=block" - Referrer-Policy "strict-origin-when-cross-origin" - Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'" - } - encode gzip log { @@ -41,17 +28,6 @@ your-admin-domain.com { reverse_proxy app:3000 - # Security headers - header { - -Server - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Content-Type-Options "nosniff" - X-Frame-Options "DENY" - X-XSS-Protection "1; mode=block" - Referrer-Policy "strict-origin-when-cross-origin" - Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'" - } - encode gzip log { diff --git a/Dockerfile b/Dockerfile index aec12d0..bdb274c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,9 @@ COPY package*.json ./ RUN npm ci --ignore-scripts && \ npm cache clean --force +# Set node env +ENV NODE_ENV=production + # Copy source and build COPY . . RUN npm run build diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 65cda14..db303b1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,9 +1,8 @@ services: postgres: image: postgres:16-alpine - container_name: open-reception-postgres + container_name: postgres restart: unless-stopped - user: postgres environment: POSTGRES_DB_FILE: /run/secrets/postgres_db POSTGRES_USER_FILE: /run/secrets/postgres_user @@ -15,7 +14,6 @@ services: - postgres_password volumes: - postgres_data:/var/lib/postgresql/data - - ./init-db:/docker-entrypoint-initdb.d:ro networks: - open-reception-internal healthcheck: @@ -24,10 +22,10 @@ services: "CMD-SHELL", "pg_isready -U $$(cat /run/secrets/postgres_user) -d $$(cat /run/secrets/postgres_db)", ] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s + interval: 5s + timeout: 3s + retries: 10 + start_period: 60s security_opt: - no-new-privileges:true cap_drop: @@ -44,23 +42,25 @@ services: read_only: true app: - image: openreception/open-reception:${VERSION:-latest} - container_name: open-reception-app + # use specific version here to watch for breaking changes + image: openreception/open-reception:latest + container_name: app restart: unless-stopped user: "1001:1001" environment: NODE_ENV: production - SMTP_HOST: /run/secrets/smtp_host - SMTP_PORT: /run/secrets/smtp_port - SMTP_SECURE: /run/secrets/smtp_secure - SMTP_USER: /run/secrets/smtp_user - SMTP_PASS: /run/secrets/smtp_pass - SMTP_FROM_NAME: /run/secrets/smtp_from_name - SMTP_FROM_EMAIL: /run/secrets/smtp_from_email secrets: - postgres_db - postgres_user - postgres_password + - smtp_host + - smtp_port + - smtp_secure + - smtp_user + - smtp_pass + - smtp_from_name + - smtp_from_email + - jwt_secret depends_on: postgres: condition: service_healthy @@ -71,8 +71,8 @@ services: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"] interval: 30s timeout: 10s - retries: 3 - start_period: 40s + retries: 5 + start_period: 60s security_opt: - no-new-privileges:true cap_drop: @@ -85,26 +85,30 @@ services: export POSTGRES_USER=$$(cat /run/secrets/postgres_user) export POSTGRES_PASSWORD=$$(cat /run/secrets/postgres_password) export DATABASE_URL=\"postgres://$$POSTGRES_USER:$$POSTGRES_PASSWORD@postgres:5432/$$POSTGRES_DB\" + export SMTP_HOST=$$(cat /run/secrets/smtp_host) + export SMTP_PORT=$$(cat /run/secrets/smtp_port) + export SMTP_SECURE=$$(cat /run/secrets/smtp_secure) + export SMTP_USER=$$(cat /run/secrets/smtp_user) + export SMTP_PASS=$$(cat /run/secrets/smtp_pass) + export SMTP_FROM_NAME=$$(cat /run/secrets/smtp_from_name) + export SMTP_FROM_EMAIL=$$(cat /run/secrets/smtp_from_email) + export JWT_SECRET=$$(cat /run/secrets/jwt_secret) exec node build/index.js " read_only: true caddy: - image: caddy:2-alpine - container_name: open-reception-caddy + image: caddy:2 + container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config + - caddy-data:/data" networks: - open-reception-internal - depends_on: - app: - condition: service_healthy security_opt: - no-new-privileges:true cap_drop: @@ -119,10 +123,10 @@ secrets: file: ./secrets/postgres_user.txt postgres_password: file: ./secrets/postgres_password.txt - smtp_port: - file: ./secrets/smtp_port.txt smtp_host: file: ./secrets/smtp_host.txt + smtp_port: + file: ./secrets/smtp_port.txt smtp_secure: file: ./secrets/smtp_secure.txt smtp_user: @@ -133,14 +137,22 @@ secrets: file: ./secrets/smtp_from_name.txt smtp_from_email: file: ./secrets/smtp_from_email.txt + jwt_secret: + file: ./secrets/jwt_secret.txt volumes: postgres_data: driver: local - caddy_data: - driver: local - caddy_config: + driver_opts: + type: none + o: bind + device: /opt/openreception/postgres + caddy-data: driver: local + driver_opts: + type: "none" + o: "bind" + device: "/opt/openreception/caddy/data" networks: open-reception-internal: diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..20af379 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,29 @@ +# Deploy OpenReception to a single server + +This guide will lead you through setting up OpenReception on linux a server of your choice. + +> Too complicated? Managed Hosting is available at [open-reception.com](https://open-reception.com). + +## Requirements + +This setup requires + +- shell access to a root server +- docker with docker compose installed +- a small server with 2 cores and 2gb of ram should get you started + +## Recommendations/ Considerations + +- Update your operating system on a regular basis. Use automatic security updates like `unattended-upgrades` for the very short term issues. +- Encrypt the drive where your database is stored to prevent data theft by just pulling the hard drive. You can use `luks`. +- Protect access with `fail2ban`, `ufw`, `crowdsec`, proper user permissions and other best-practices. +- Make regular backups of your database and test them regularly. You can use `restic`. +- Monitor your server loads and logs over time to detect peak usages (with insufficient ram or cpu power) and malicious activities. + +## Setup + +1. Use our [production docker-compose example](../docker-compose.prod.yml) +1. Use our [Caddyfile example](../Caddyfile) +1. Adjust the settings in these two files above to your needs. +1. Run `docker-compose up -d` or `docker compose up -d` depending on your docker installation. +1. Proceed to secure your instance by opening your admin domain in a browser. diff --git a/package.json b/package.json index 49e9cf2..c17fac7 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "docker:dev:down": "docker compose -f docker-compose.dev.yml down", "docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f", "docker:dev:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans", - "docker:build": "docker build -t openreception/open-reception:latest .", + "docker:build": "docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:latest .", "docker:build:tag": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version", "docker:push": "docker push openreception/open-reception:$npm_package_version && docker push openreception/open-reception:latest", "docker:build-and-push": "npm run docker:build && npm run docker:build:tag && npm run docker:push", diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 7e0fc0a..eaecd38 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -924,8 +924,9 @@ } }, "calendar": { + "title": "Kalender", "today": "Heute", - "loading": "Loading calendar", + "loading": "Kalender wird geladen", "decrypting": "wird entschlüsselt...", "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.", "decryptingErrorNoKeyShare": "Entschlüsselung fehlgeschlagen. Bitten Sie ein Teammitglied, Ihnen Zugriff zu gewähren.", @@ -957,11 +958,14 @@ "error": "Fehler beim Bestätigen des Termins" }, "addAppointment": { + "title": "Neuen Termin erstellen", "preview": "Neuer Termin {time} Uhr {channel}", "steps": { "selectClient": { "hasNoEmail": "Klient hat keine E-Mail Adresse", - "action": "Klient suchen" + "action": "Klient suchen", + "confirmNewClient": "Dieser Klient hat noch kein Konto. Möchten Sie für ihn ein Konto erstellen?", + "proceedExistingClient": "Klient ausgewählt." }, "agents": { "title": "Akteur auswählen" diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index bd746e7..f2c0ee5 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -933,6 +933,7 @@ } }, "calendar": { + "title": "Calendar", "today": "Today", "loading": "Loading calendar", "decrypting": "decrypting...", @@ -966,11 +967,14 @@ "error": "Failed to confirm appointment" }, "addAppointment": { + "title": "Add Appointment", "preview": "Add {time} {channel}", "steps": { "selectClient": { "hasNoEmail": "Client has no e-mail", - "action": "Search Client" + "action": "Search Client", + "confirmNewClient": "This client does not have an account yet. Do you want to create an account for them?", + "proceedExistingClient": "Client selected." }, "agents": { "title": "Select an Agent" diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 60ec5ad..fdce037 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -49,8 +49,9 @@ import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment"; import { OptimizedArgon2 } from "$lib/crypto/hashing"; +import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils"; +import type { ClientTunnelResponse } from "$lib/server/services/appointment-service"; import { pinThrottleStore } from "$lib/stores/pin-throttle"; -import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils"; // Type definitions for unified cryptography interface ClientKeyPair { @@ -105,6 +106,17 @@ interface MyAppointmentsResponse { encryptedData: EncryptedData; }>; } + +type StaffKeyShares = Array<{ userId: string; encryptedTunnelKey: string }>; + +type EncryptableTunnelConfig = { + emailHash: string; + decryptedTunnelKey: CryptoKey; + staffKeyShares: StaffKeyShares; + clientPublicKey: string; + tunnelId: string; +}; + /** * Generate deterministic SHA-256 hash of email for privacy-preserving lookups */ @@ -201,11 +213,7 @@ export class UnifiedAppointmentCrypto { // Note: clientKeyShare will be used during actual appointment creation await this.encryptTunnelKeyForClient(); - console.log("✅ New client initialized", { - tunnelId: this.tunnelId, - emailHashPrefix: this.emailHash.slice(0, 8), - staffCount: staffPublicKeys.length, - }); + console.log("✅ New client initialized", {}); this.clientAuthenticated = true; } catch (error) { @@ -494,6 +502,143 @@ export class UnifiedAppointmentCrypto { } } + private createTunnelForNewClient = async (params: { + tenantId: string; + email: string; + }): Promise => { + const usedPin = crypto.randomUUID().slice(0, 6); + + // Create tunnel + await this.initNewClient(params.email, usedPin, params.tenantId); + + if (!this.tunnelId) { + throw new Error("Failed to use initialized client tunnel"); + } + + if (!this.tunnelKey) { + throw new Error("Failed to use initialized client tunnel key"); + } + + if (!this.clientKeyPair?.publicKey) { + throw new Error("Failed to use initialized client public key"); + } + + return { + tunnelId: this.tunnelId, + emailHash: await hashEmail(params.email), + clientPublicKey: this.clientKeyPair?.publicKey, + decryptedTunnelKey: this.tunnelKey, + staffKeyShares: await this.getStaffKeyShares(params.tenantId), + }; + }; + + private useTunnelForExistingClient = async (params: { + tunnel: ClientTunnelResponse; + tenantId: string; + email: string; + }): Promise => { + const decryptedTunnelKey = await this.decryptTunnelKeyByStaff( + params.tunnel.currentStaffEncryptedTunnelKey!, + ); + return { + tunnelId: params.tunnel.id, + emailHash: await hashEmail(params.email), + clientPublicKey: params.tunnel.clientPublicKey, + decryptedTunnelKey, + staffKeyShares: await this.getStaffKeyShares(params.tenantId, decryptedTunnelKey), + }; + }; + + /** + * Creates a new encrypted appointment that is created by a staff member + */ + async createAppointmentByStaff(params: { + appointmentData: AppointmentDataByStaff; + appointmentDate: Date; + agentId: string; + channelId: string; + duration: number; + tenantId: string; + email?: string; + hasNoEmail: boolean; + tunnel: ClientTunnelResponse | undefined; + }): Promise { + if (!this.staffAuthenticated || !this.staffKeyPair) { + throw new Error("Staff Member not authenticated"); + } + + try { + // If new client, create new tunnel and then get it + const usedEmail = params.email ?? `${crypto.randomUUID()}@client.noemail`; + const tunnelConfig = !params.tunnel + ? await this.createTunnelForNewClient({ tenantId: params.tenantId, email: usedEmail }) + : await this.useTunnelForExistingClient({ + tunnel: params.tunnel, + tenantId: params.tenantId, + email: usedEmail, + }); + + // Encrypt appointment data + const encryptedAppointment = await this.encryptAppointmentData( + params.appointmentData, + tunnelConfig.decryptedTunnelKey, + ); + + // Set tunnelKey & clientKeyPair for encryptTunnelKeyForClient + this.tunnelKey = tunnelConfig.decryptedTunnelKey; + this.clientKeyPair = { + publicKey: tunnelConfig.clientPublicKey, + privateKey: "", // Not needed here + }; + + // Call endpoint + const sendEmail = params.appointmentData.shareEmail && Boolean(params.email); + const requestData = { + clientEmail: params.appointmentData.shareEmail ? usedEmail : undefined, + hasNoEmail: params.hasNoEmail, + emailHash: await hashEmail(usedEmail), + appointmentDate: params.appointmentDate.toISOString(), + duration: params.duration, + agentId: params.agentId, + channelId: params.channelId, + encryptedAppointment, + sendEmail, + clientLanguage: params.appointmentData.locale, + tunnelId: tunnelConfig.tunnelId, + clientPublicKey: tunnelConfig.clientPublicKey, + staffKeyShares: tunnelConfig.staffKeyShares, + privateKeyShare: await this.getClientKeyShare(), + clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(), + }; + const response = await fetch(`/api/tenants/${params.tenantId}/appointments/staff-create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + throw new Error("Appointment could not be created"); + } + + const result = await response.json(); + + console.log("✅ Encrypted appointment created:", result.id); + return result.id; + } catch (error) { + console.error("❌ Error creating appointment:", error); + throw error; + } finally { + // Clear sensitive data from class properties to prevent leaks on reuse + this.tunnelKey = null; + this.clientKeyPair = null; + this.emailHash = null; + this.tunnelId = null; + this.serverPrivateKeyShare = null; + this.clientAuthenticated = false; + this.pin = null; + } + } + /** * Allows to check, if a client is authenticated */ @@ -964,8 +1109,10 @@ export class UnifiedAppointmentCrypto { */ private async encryptAppointmentData( data: AppointmentData | AppointmentDataByStaff, + tunnelKey?: CryptoKey, ): Promise { - if (!this.tunnelKey) throw new Error("No tunnel key available"); + const usedTunnelKey = tunnelKey ?? this.tunnelKey; + if (!usedTunnelKey) throw new Error("No tunnel key available"); const encoder = new TextEncoder(); const plaintext = encoder.encode(JSON.stringify(data)); @@ -973,7 +1120,7 @@ export class UnifiedAppointmentCrypto { const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, - this.tunnelKey, + usedTunnelKey, plaintext, ); @@ -1185,35 +1332,12 @@ export class UnifiedAppointmentCrypto { // 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", @@ -1222,19 +1346,6 @@ export class UnifiedAppointmentCrypto { // 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 }, @@ -1245,13 +1356,6 @@ export class UnifiedAppointmentCrypto { // 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, @@ -1482,10 +1586,11 @@ export class UnifiedAppointmentCrypto { private async getStaffKeyShares( tenantId: string, - ): Promise> { + decryptedTunnelKey?: CryptoKey, + ): Promise { // Fetch and encrypt tunnel key for all staff members const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId); - return await this.encryptTunnelKeyForStaff(staffPublicKeys); + return await this.encryptTunnelKeyForStaff(staffPublicKeys, decryptedTunnelKey); } private async getClientKeyShare(): Promise { diff --git a/src/lib/components/ui/slot-template/slot-template.svelte b/src/lib/components/ui/slot-template/slot-template.svelte index 04d297f..a58fad3 100644 --- a/src/lib/components/ui/slot-template/slot-template.svelte +++ b/src/lib/components/ui/slot-template/slot-template.svelte @@ -12,6 +12,7 @@ import { Input } from "../input"; import { Text } from "../typography"; import { durations, times, weekdays } from "./utils"; + import { localTimeToUTC, utcTimeToLocal } from "$lib/utils/datetime"; let { form, @@ -70,8 +71,9 @@ ); }; - const getSelectedTime = (key: keyof typeof times) => { - return times[key]?.label || ""; + const getSelectedTime = (utcTime: string) => { + const localTime = utcTimeToLocal(utcTime) as keyof typeof times; + return times[localTime]?.label ?? ""; }; @@ -137,7 +139,7 @@ {#each Object.entries(times) as [time, value] (time)} - {value.label} + {value.label} {/each} @@ -160,7 +162,7 @@ {#each Object.entries(times) as [time, value] (time)} - {value.label} + {value.label} {/each} diff --git a/src/lib/server/auth/authorization-service.ts b/src/lib/server/auth/authorization-service.ts index 53682ac..e401ecb 100644 --- a/src/lib/server/auth/authorization-service.ts +++ b/src/lib/server/auth/authorization-service.ts @@ -13,9 +13,7 @@ export class AuthorizationService { } if (user.role !== requiredRole) { - logger.warn( - `Access denied: User ${user.email} has role ${user.role}, required ${requiredRole}`, - ); + logger.warn(`Access denied: User ${user.id} has role ${user.role}, required ${requiredRole}`); throw new AuthorizationError(); } @@ -29,7 +27,7 @@ export class AuthorizationService { if (!allowedRoles.includes(user.role as UserRole)) { logger.warn( - `Access denied: User ${user.email} has role ${user.role}, allowed roles: ${allowedRoles.join(", ")}`, + `Access denied: User ${user.id} has role ${user.role}, allowed roles: ${allowedRoles.join(", ")}`, ); throw new AuthorizationError(); } @@ -51,13 +49,13 @@ export class AuthorizationService { if (user.role === "TENANT_ADMIN" || user.role === "STAFF") { if (!user.tenantId) { - logger.warn(`Access denied: User ${user.email} has no tenant assigned`); + logger.warn(`Access denied: User ${user.id} has no tenant assigned`); throw new AuthorizationError("No tenant access"); } if (user.tenantId !== tenantId) { logger.warn( - `Access denied: User ${user.email} trying to access tenant ${tenantId}, but belongs to ${user.tenantId}`, + `Access denied: User ${user.id} trying to access tenant ${tenantId}, but belongs to ${user.tenantId}`, ); throw new AuthorizationError("Tenant access denied"); } @@ -66,7 +64,7 @@ export class AuthorizationService { return; } - logger.warn(`Access denied: User ${user.email} has invalid role ${user.role}`); + logger.warn(`Access denied: User ${user.id} has invalid role ${user.role}`); throw new AuthorizationError("Invalid role"); } diff --git a/src/lib/server/auth/session-service.ts b/src/lib/server/auth/session-service.ts index f45542d..c246c88 100644 --- a/src/lib/server/auth/session-service.ts +++ b/src/lib/server/auth/session-service.ts @@ -43,7 +43,7 @@ export class SessionService { userAgent?: string, passkeyId?: string, ): Promise { - logger.info(`Creating session for user: ${userId}`); + logger.debug(`Creating session for user: ${userId}`); const existingUser = await db.select().from(user).where(eq(user.id, userId)).limit(1); @@ -88,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}`, { passkeyId }); + logger.debug(`Session created successfully for user: ${userId}`, { passkeyId }); return { sessionToken: updatedSession.sessionToken, @@ -246,7 +246,7 @@ export class SessionService { }) .where(eq(userSession.id, session.user_session.id)); - logger.info("Session tokens refreshed successfully"); + logger.debug("Session tokens refreshed successfully"); return { accessToken: newTokens.accessToken, @@ -256,27 +256,27 @@ export class SessionService { } static async logout(sessionToken: string): Promise { - logger.info(`Logging out session: ${sessionToken}`); + logger.debug(`Logging out session: ${sessionToken}`); await db.delete(userSession).where(eq(userSession.sessionToken, sessionToken)); - logger.info(`Session logged out successfully: ${sessionToken}`); + logger.debug(`Session logged out successfully: ${sessionToken}`); } static async logoutAllSessions(userId: string): Promise { - logger.info(`Logging out all sessions for user: ${userId}`); + logger.debug(`Logging out all sessions for user: ${userId}`); await db.delete(userSession).where(eq(userSession.userId, userId)); - logger.info(`All sessions logged out for user: ${userId}`); + logger.debug(`All sessions logged out for user: ${userId}`); } static async cleanupExpiredSessions(): Promise { - logger.info("Cleaning up expired sessions"); + logger.debug("Cleaning up expired sessions"); await db.delete(userSession).where(lt(userSession.expiresAt, new Date())); - logger.info("Expired sessions cleaned up"); + logger.debug("Expired sessions cleaned up"); } static async getUserSession(sessionId: string): Promise { @@ -309,10 +309,10 @@ export class SessionService { } static async revokeSession(sessionId: string): Promise { - logger.info(`Revoking session: ${sessionId}`); + logger.debug(`Revoking session: ${sessionId}`); await db.delete(userSession).where(eq(userSession.id, sessionId)); - logger.info(`Session revoked: ${sessionId}`); + logger.debug(`Session revoked: ${sessionId}`); } } diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index 650803b..b298178 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -155,8 +155,7 @@ export class WebAuthnService { }) .where(eq(userPasskey.id, normalizedCredentialId)); - logger.info("WebAuthn authentication successful", { - credentialId: normalizedCredentialId.substring(0, 20) + "...", + logger.debug("WebAuthn authentication successful", { userId: passkey.userId, counterUpdated: `${passkey.counter} → ${newCounter}`, }); @@ -244,12 +243,9 @@ export class WebAuthnService { "base64", ); - logger.info("Registration verified successfully", { - credentialId: normalizedCredentialId.substring(0, 20) + "...", - credentialIdLength: normalizedCredentialId.length, + logger.debug("Registration verified successfully", { counter: registrationInfo.credential.counter, publicKeyLength: registrationInfo.credential.publicKey.length, - publicKeyBytesFirst10: Array.from(registrationInfo.credential.publicKey.slice(0, 10)), }); return { diff --git a/src/lib/server/email/mailer.ts b/src/lib/server/email/mailer.ts index 164c19a..a526a4f 100644 --- a/src/lib/server/email/mailer.ts +++ b/src/lib/server/email/mailer.ts @@ -2,6 +2,7 @@ import nodemailer from "nodemailer"; import { env } from "$env/dynamic/private"; import type Mail from "nodemailer/lib/mailer"; import type { SelectUser } from "../db/central-schema"; +import logger from "$lib/logger"; /** * Simple client data type for email sending @@ -92,10 +93,11 @@ export async function sendEmail( textContent: string, ): Promise { const transporter = createTransporter(); + logger.setContext("sendMail"); if (!recipient.email) { // Recipient has not stored an email address, so no mail will be send - // TODO: Log this event + logger.info("Recipient has no email address, skipping email sending"); return; } @@ -121,9 +123,9 @@ export async function sendEmail( try { await transporter.sendMail(mailOptions); - console.log(`Email sent successfully to ${recipient.email}`); + logger.debug(`Email sent successfully to ${recipient.email}`); } catch (error) { - console.error("Failed to send email:", error); + logger.error("Failed to send email:", { error }); throw new Error(`Failed to send email to ${recipient.email}`); } } @@ -134,11 +136,12 @@ export async function sendEmail( */ export async function testEmailConnection(): Promise { try { + logger.setContext("testEmailConnection"); const transporter = createTransporter(); await transporter.verify(); return true; } catch (error) { - console.error("SMTP connection test failed:", error); + logger.error("SMTP connection test failed:", { error }); return false; } } diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 6be65e3..5a03571 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -348,7 +348,7 @@ export class AppointmentService { */ public async deleteAppointmentByStaff( appointmentId: string, - clientEmail: string, + clientEmail: string | undefined, clientLanguage: string = "de", ): Promise { const log = logger.setContext("AppointmentService"); @@ -396,18 +396,22 @@ export class AppointmentService { const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage); // Send cancellation email to client (async, don't wait) - const clientData = { - email: clientEmail, - language: clientLanguage, - }; + if (clientEmail) { + 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), - }); - }); + 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); @@ -509,11 +513,10 @@ export class AppointmentService { ): Promise { const log = logger.setContext("AppointmentService"); - log.info("Adding appointment to existing tunnel", { + log.debug("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(); @@ -589,7 +592,7 @@ export class AppointmentService { requiresConfirmation, }; - log.info("Successfully added appointment to tunnel", { + log.debug("Successfully added appointment to tunnel", { tenantId: this.tenantId, tunnelId: appointmentData.tunnelId, appointmentId: result.id, @@ -607,11 +610,10 @@ export class AppointmentService { ): Promise { const log = logger.setContext("AppointmentService"); - log.info("Creating new client appointment tunnel", { + log.debug("Creating new client appointment tunnel", { tenantId: this.tenantId, tunnelId: clientData.tunnelId, appointmentDate: clientData.appointmentDate, - emailHashPrefix: clientData.emailHash.slice(0, 8), }); // Check if there are any authorized users (ACCESS_GRANTED) in this tenant @@ -760,7 +762,7 @@ export class AppointmentService { requiresConfirmation: result.requiresConfirmation, }; - log.info("Successfully created new client appointment tunnel", { + log.debug("Successfully created new client appointment tunnel", { tenantId: this.tenantId, tunnelId: clientData.tunnelId, appointmentId: result.appointment.id, @@ -951,10 +953,9 @@ export class AppointmentService { challengeResponse: string, ): Promise { const log = logger.setContext("AppointmentService"); - log.info("Client deleting appointment with authentication", { + log.debug("Client deleting appointment with authentication", { tenantId: this.tenantId, appointmentId, - emailHashPrefix: emailHash.slice(0, 8), }); // 1. Verify challenge-response @@ -1054,7 +1055,7 @@ export class AppointmentService { // 4. Delete the appointment await db.delete(tenantSchema.appointment).where(eq(tenantSchema.appointment.id, appointmentId)); - log.info("Appointment deleted successfully by client", { + log.debug("Appointment deleted successfully by client", { tenantId: this.tenantId, appointmentId, emailHashPrefix: emailHash.slice(0, 8), @@ -1069,7 +1070,7 @@ export class AppointmentService { }, }); - log.info("Staff notification sent for client-initiated deletion", { + log.debug("Staff notification sent for client-initiated deletion", { tenantId: this.tenantId, appointmentId, channelId: appointment.channelId, diff --git a/src/lib/server/services/central-database-migration-service.ts b/src/lib/server/services/central-database-migration-service.ts index f67316f..0fe4641 100644 --- a/src/lib/server/services/central-database-migration-service.ts +++ b/src/lib/server/services/central-database-migration-service.ts @@ -194,8 +194,6 @@ export class CentralDatabaseMigrationService { logger.info("Central database created and initialized", { database: config.database, - host: config.host, - port: config.port, }); } diff --git a/src/lib/server/services/client-pin-reset-service.ts b/src/lib/server/services/client-pin-reset-service.ts index 95918d3..1f5da4f 100644 --- a/src/lib/server/services/client-pin-reset-service.ts +++ b/src/lib/server/services/client-pin-reset-service.ts @@ -90,8 +90,6 @@ export class ClientPinResetService { .returning({ token: tenantSchema.clientPinResetToken.token }); log.info("PIN reset token created", { - emailHash: emailHash.slice(0, 8), - tokenId: resetToken.token.slice(0, 8), expiresAt, }); @@ -226,9 +224,7 @@ export class ClientPinResetService { .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; diff --git a/src/lib/server/services/invite-service.ts b/src/lib/server/services/invite-service.ts index 6a872d4..909d062 100644 --- a/src/lib/server/services/invite-service.ts +++ b/src/lib/server/services/invite-service.ts @@ -39,13 +39,10 @@ export class InviteService { const [createdInvite] = await db.insert(userInvite).values(inviteData).returning(); - logger.info("User invitation created", { + logger.debug("User invitation created", { inviteId: createdInvite.id, - inviteCode: createdInvite.inviteCode, - email: createdInvite.email, tenantId: createdInvite.tenantId, role: createdInvite.role, - invitedBy: createdInvite.invitedBy, }); return createdInvite; @@ -124,7 +121,6 @@ export class InviteService { logger.info("Invitation marked as used", { inviteCode, createdUserId, - email: updatedInvite.email, }); return updatedInvite; diff --git a/src/lib/server/services/staff-crypto.service.ts b/src/lib/server/services/staff-crypto.service.ts index 870b2ed..2419e23 100644 --- a/src/lib/server/services/staff-crypto.service.ts +++ b/src/lib/server/services/staff-crypto.service.ts @@ -97,7 +97,6 @@ export class StaffCryptoService { log.info("Staff keypair stored successfully", { tenantId, userId, - passkeyId, hasPublicKey: !!publicKey, hasPrivateKeyShare: !!privateKeyShare, }); @@ -321,7 +320,6 @@ export class StaffCryptoService { log.info("Staff crypto deletion completed", { tenantId, userId, - passkeyId, deleted, }); diff --git a/src/lib/server/services/staff-service.ts b/src/lib/server/services/staff-service.ts index 970a85e..07d2b27 100644 --- a/src/lib/server/services/staff-service.ts +++ b/src/lib/server/services/staff-service.ts @@ -250,7 +250,7 @@ export class StaffService { deletedKeySharesCount, }; - logger.info("Staff member deleted successfully", { + logger.debug("Staff member deleted successfully", { staffId, tenantId, deletedUser: userDeletionResult.deletedUser, diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 9fb43be..b9ade13 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -660,7 +660,6 @@ export class UserService { log.info("User deleted successfully", { userId, - deletedUser: deletedUsers[0], deletedPasskeysCount, tenantId: user.tenantId, }); diff --git a/src/lib/stores/pin-throttle.ts b/src/lib/stores/pin-throttle.ts index de8677e..e96fb4f 100644 --- a/src/lib/stores/pin-throttle.ts +++ b/src/lib/stores/pin-throttle.ts @@ -36,8 +36,8 @@ const createPinThrottleStore = () => { // Throttle expired, clear storage localStorage.removeItem(STORAGE_KEY); } - } catch (e) { - console.error("Failed to parse throttle state:", e); + } catch (error) { + console.error("Failed to parse throttle state:", error); localStorage.removeItem(STORAGE_KEY); } } diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts index 76b3be2..d678e70 100644 --- a/src/lib/utils/datetime.ts +++ b/src/lib/utils/datetime.ts @@ -20,15 +20,7 @@ export const calendarItemToDate = (item: TCalendarSlot) => { const parsedDate = new Date(item.start); if (!Number.isNaN(parsedDate.getTime())) { const [year, month, day] = item.date.split("-").map(Number); - return new Date( - year, - month - 1, - day, - parsedDate.getUTCHours(), - parsedDate.getUTCMinutes(), - 0, - 0, - ); + return new Date(year, month - 1, day, parsedDate.getHours(), parsedDate.getMinutes(), 0, 0); } } @@ -43,6 +35,32 @@ export const toInputDateTime = (dateStr: string) => { return toCalendarDateTime(parseAbsoluteToLocal(dateStr)); }; +export const localTimeToUTC = (localTime: string) => { + const [hours, minutes, seconds] = localTime.split(":").map(Number); + + const now = new Date(); + now.setHours(hours, minutes, seconds, 0); + + const utcHours = String(now.getUTCHours()).padStart(2, "0"); + const utcMinutes = String(now.getUTCMinutes()).padStart(2, "0"); + const utcSeconds = String(now.getUTCSeconds()).padStart(2, "0"); + + return `${utcHours}:${utcMinutes}:${utcSeconds}`; +}; + +export function utcTimeToLocal(utcTime: string): string { + const [hours, minutes, seconds] = utcTime.split(":").map(Number); + + const now = new Date(); + now.setUTCHours(hours, minutes, seconds, 0); + + const localHours = String(now.getHours()).padStart(2, "0"); + const localMinutes = String(now.getMinutes()).padStart(2, "0"); + const localSeconds = String(now.getSeconds()).padStart(2, "0"); + + return `${localHours}:${localMinutes}:${localSeconds}`; +} + export const getDefaultStartTime = () => { const date = new Date(); date.setHours(7); diff --git a/src/lib/utils/localizations.ts b/src/lib/utils/localizations.ts index 0094bbb..868abca 100644 --- a/src/lib/utils/localizations.ts +++ b/src/lib/utils/localizations.ts @@ -1,6 +1,7 @@ import { getLocale } from "$i18n/runtime"; import type { supportedLocales } from "$lib/const/locales"; import type { TPublicTenant } from "$lib/types/public"; +import type { TTenant } from "$lib/types/tenant"; export const removeEmptyTranslations = (object: { [key: string]: string } | undefined) => { if (!object) return object; @@ -25,3 +26,16 @@ export const getPublicLocale = (tenant: TPublicTenant): string => { } return tenant.defaultLanguage as unknown as string; }; + +export const getDefaultAppointmentLocale = (tenant: TTenant | null): string => { + const locale = getLocale(); + if (!tenant) return locale; + + const availableLanguages = tenant.languages; + if (availableLanguages.includes(locale)) { + return locale; + } + + // TODO: Not ideal + return availableLanguages[0]; +}; 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 index e1f0b6a..eb7927c 100644 --- 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 @@ -1,5 +1,5 @@ import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte"; -import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import { renderOutputToHtml } 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"; @@ -30,10 +30,6 @@ export const GET: RequestHandler = async () => { }, }); 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 index fcb1fbf..62eb0e9 100644 --- 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 @@ -1,5 +1,5 @@ import AppointmentReminder from "$lib/emails/AppointmentReminder.svelte"; -import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import { renderOutputToHtml } 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"; @@ -30,10 +30,6 @@ export const GET: RequestHandler = async () => { }, }); 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 index 86914e3..823fabe 100644 --- 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 @@ -1,5 +1,5 @@ import AppointmentRequested from "$lib/emails/AppointmentRequest.svelte"; -import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import { renderOutputToHtml } 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"; @@ -29,10 +29,6 @@ export const GET: RequestHandler = async () => { }, }); 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 index ce1aed6..2e3d817 100644 --- 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 @@ -1,5 +1,5 @@ import Confirmation from "$lib/emails/Confirmation.svelte"; -import { htmlToText, renderOutputToHtml } from "$lib/emails/utils"; +import { renderOutputToHtml } from "$lib/emails/utils"; import type { RequestHandler } from "@sveltejs/kit"; import { render } from "svelte/server"; @@ -17,10 +17,6 @@ export const GET: RequestHandler = async () => { }, }); 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 index 327f7f3..9af62da 100644 --- 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 @@ -1,5 +1,5 @@ import PinReset from "$lib/emails/PinReset.svelte"; -import { renderOutputToHtml, htmlToText } from "$lib/emails/utils"; +import { 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"; @@ -17,10 +17,7 @@ export const GET: RequestHandler = async () => { }, }); 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 index 15d9f58..bc111c6 100644 --- 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 @@ -1,5 +1,5 @@ import UserInvite from "$lib/emails/UserInvite.svelte"; -import { htmlToText, renderOutputToHtml } from "$lib/emails/utils"; +import { 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"; @@ -19,10 +19,6 @@ export const GET: RequestHandler = async () => { }, }); 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)/book-appointment/[[id]]/(components)/select-slot.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte index 167f21c..fbde4d7 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte @@ -113,8 +113,8 @@ date.year, date.month - 1, date.day, - slotDate.getUTCHours(), - slotDate.getUTCMinutes(), + slotDate.getHours(), + slotDate.getMinutes(), 0, 0, ); @@ -157,11 +157,11 @@ const formatSlotTime = (slot: TPublicSlot) => { const slotDate = new Date(slot.from); const displayDate = new Date( - 2000, - 0, - 1, - slotDate.getUTCHours(), - slotDate.getUTCMinutes(), + slotDate.getFullYear(), + slotDate.getMonth(), + slotDate.getDate(), + slotDate.getHours(), + slotDate.getMinutes(), 0, 0, ); @@ -180,12 +180,7 @@
- {#if schedule === undefined}{/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 index 4880106..b20f3e3 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts @@ -98,6 +98,11 @@ export const fetchSchedule = async (opts: { if (!browser) return; const curDate = today(getLocalTimeZone()); + + // Return empty list for past months + if (opts.year < curDate.year || (opts.year === curDate.year && opts.month < curDate.month)) { + return []; + } const startDay = curDate.year === opts.year && curDate.month === opts.month ? curDate.day : 1; const lastDayOfMonth = new Date(opts.year, opts.month, 0).getDate(); diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index 0af77cf..58fadcb 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -148,14 +148,12 @@ prfOutput = prfOutputResp; logger.info("PRF output retrieved successfully", { - passkeyId: passkeyResp.id, prfOutputLength: prfOutputResp.byteLength, }); } catch (error) { $passkeyLoading = "error"; logger.error("Failed to get PRF output", { email: $formData.email, - passkeyId: passkeyResp.id, error, }); toast.error(m["setupPasskey.errorGettingPrfOutput"]()); diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 2c65d89..5b16fef 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -28,7 +28,7 @@ } if (!intervalSession) { refreshSession(); - intervalSession = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes + intervalSession = setInterval(refreshSession, 5 * 60 * 1000); // 5 minutes } const unsubscribe = () => { diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte index a4b77a2..c0636cc 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte @@ -87,33 +87,37 @@ {#if item.appointment.appointment} -
+
{item.decrypted.name}
-
- - {#if item.decrypted.phone} - - {/if} -
+ {#if item.decrypted.email || item.decrypted.phone} +
+ {#if item.decrypted.email} + + {/if} + {#if item.decrypted.phone} + + {/if} +
+ {/if}
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte index 0a5e145..41afaeb 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte @@ -74,7 +74,7 @@ staffKeyShare: item.appointment.staffKeyShare, }); } catch (err) { - console.error("Error decrypting appointment:", err); + console.error("Error decrypting appointment:", item.id); error = err instanceof Error ? err.message : "Decryption failed"; } }; diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte index 5f01406..81a1259 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/AddAppointment.svelte @@ -1,15 +1,20 @@ - + {#if step === "email"} {:else if step === "agent" && item.availableAgents} {:else if step === "summary"} - {:else if step === "client"} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte index 013027b..8aa5260 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte @@ -4,18 +4,24 @@ import { Text } from "$lib/components/ui/typography"; import { agents as agentsStore } from "$lib/stores/agents"; import { toDisplayDateTime } from "$lib/utils/datetime"; - import { Calendar, Mail, Phone, User, UserStar } from "@lucide/svelte"; + import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte"; import type { TAddAppointment, TAddAppointmentStep } from "./types"; + import * as Select from "$lib/components/ui/select"; + import { tenants } from "$lib/stores/tenants"; + import { languageSwitchLocales } from "$lib/const/locales"; let { step, newAppointment, + onChangeLocale, }: { step: TAddAppointmentStep; newAppointment: TAddAppointment; + onChangeLocale: (locale: string) => void; } = $props(); let agent = $derived($agentsStore.agents.find((a) => a.id === newAppointment.agentId)); + let languages = $derived($tenants.currentTenant?.languages);
@@ -41,8 +47,29 @@
{/if} {#if newAppointment.email} +
+ + + + {@const locale = newAppointment.locale + ? languageSwitchLocales[newAppointment.locale as keyof typeof languageSwitchLocales] + : undefined} + {locale ? locale.label : "Select language"} + + + {#each languages as language (language)} + + {languageSwitchLocales[language as keyof typeof languageSwitchLocales].label} + + {/each} + + +