Merge remote-tracking branch 'origin/main' into secure-staff-public-keys

This commit is contained in:
Karl Ludwig Weise
2026-03-16 09:27:17 +01:00
72 changed files with 583 additions and 454 deletions
-3
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -25
View File
@@ -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 {
+3
View File
@@ -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
+42 -30
View File
@@ -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:
+29
View File
@@ -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.
+1 -1
View File
@@ -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",
+6 -2
View File
@@ -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"
+5 -1
View File
@@ -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"
+158 -53
View File
@@ -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<EncryptableTunnelConfig> => {
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<EncryptableTunnelConfig> => {
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<string> {
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<EncryptedData> {
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<Array<{ userId: string; encryptedTunnelKey: string }>> {
decryptedTunnelKey?: CryptoKey,
): Promise<StaffKeyShares> {
// 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<string> {
@@ -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 ?? "";
};
</script>
@@ -137,7 +139,7 @@
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={time}>{value.label}</Select.Item>
<Select.Item value={localTimeToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
@@ -160,7 +162,7 @@
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={time}>{value.label}</Select.Item>
<Select.Item value={localTimeToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
+5 -7
View File
@@ -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");
}
+11 -11
View File
@@ -43,7 +43,7 @@ export class SessionService {
userAgent?: string,
passkeyId?: string,
): Promise<SessionData> {
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<void> {
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<void> {
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<void> {
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<SelectUserSession | null> {
@@ -309,10 +309,10 @@ export class SessionService {
}
static async revokeSession(sessionId: string): Promise<void> {
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}`);
}
}
+2 -6
View File
@@ -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 {
+7 -4
View File
@@ -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<void> {
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<boolean> {
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;
}
}
+23 -22
View File
@@ -348,7 +348,7 @@ export class AppointmentService {
*/
public async deleteAppointmentByStaff(
appointmentId: string,
clientEmail: string,
clientEmail: string | undefined,
clientLanguage: string = "de",
): Promise<void> {
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<AppointmentResponse> {
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<AppointmentResponse> {
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<void> {
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,
@@ -194,8 +194,6 @@ export class CentralDatabaseMigrationService {
logger.info("Central database created and initialized", {
database: config.database,
host: config.host,
port: config.port,
});
}
@@ -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;
+1 -5
View File
@@ -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;
@@ -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,
});
+1 -1
View File
@@ -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,
-1
View File
@@ -660,7 +660,6 @@ export class UserService {
log.info("User deleted successfully", {
userId,
deletedUser: deletedUsers[0],
deletedPasskeysCount,
tenantId: user.tenantId,
});
+2 -2
View File
@@ -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);
}
}
+27 -9
View File
@@ -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);
+14
View File
@@ -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];
};
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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 @@
</Text>
<Card.Root class="flex flex-col gap-4 rounded-lg border shadow-sm">
<div class="flex gap-3">
<Button
size="sm"
variant="outline"
onclick={setToToday}
disabled={selectedDate?.toString() === today(getLocalTimeZone()).toString()}
>
<Button size="sm" variant="outline" onclick={setToToday}>
{m["public.steps.slot.today"]()}
</Button>
{#if schedule === undefined}{/if}
@@ -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();
@@ -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"]());
+1 -1
View File
@@ -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 = () => {
@@ -87,33 +87,37 @@
</script>
{#if item.appointment.appointment}
<div class="flex flex-col items-start gap-3">
<div class="flex flex-col items-start gap-2">
<div class="flex gap-2 p-1">
<User class="size-4 " />
<Text style="sm">
{item.decrypted.name}
</Text>
</div>
<div class="flex flex-col items-start gap-2">
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`mailto:${item.decrypted.email}`}
>
<Mail class="size-4 " />
{item.decrypted.email}
</Button>
{#if item.decrypted.phone}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`tel:${item.decrypted.phone}`}
>
<Phone class="size-4 " />
{item.decrypted.phone}
</Button>
{/if}
</div>
{#if item.decrypted.email || item.decrypted.phone}
<div class="flex flex-col items-start gap-2">
{#if item.decrypted.email}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`mailto:${item.decrypted.email}`}
>
<Mail class="size-4 " />
{item.decrypted.email}
</Button>
{/if}
{#if item.decrypted.phone}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`tel:${item.decrypted.phone}`}
>
<Phone class="size-4 " />
{item.decrypted.phone}
</Button>
{/if}
</div>
{/if}
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
@@ -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";
}
};
@@ -1,15 +1,20 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { type AppointmentDataByStaff } from "$lib/client/appointment-crypto";
import { CenterState } from "$lib/components/templates/empty-state";
import Button from "$lib/components/ui/button/button.svelte";
import { staffCrypto } from "$lib/stores/staff-crypto";
import { tenants } from "$lib/stores/tenants";
import type { TCalendarSlot } from "$lib/types/calendar";
import { calendarItemToDate } from "$lib/utils/datetime";
import { getDefaultAppointmentLocale } from "$lib/utils/localizations";
import { BanIcon, Check } from "@lucide/svelte";
import { get } from "svelte/store";
import { ClientDataForm } from "./client-data-form";
import { SearchClientForm } from "./search-client-form";
import SelectAgent from "./SelectAgent.svelte";
import Summary from "./Summary.svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import { ClientDataForm } from "./client-data-form";
let {
tenantId,
@@ -22,7 +27,11 @@
} = $props();
let step: TAddAppointmentStep = $state("email");
let newAppointment: TAddAppointment = $state({ dateTime: calendarItemToDate(item) });
let newAppointment: TAddAppointment = $state({
locale: getDefaultAppointmentLocale(get(tenants).currentTenant),
dateTime: calendarItemToDate(item),
});
let isSubmitting = $state(false);
const proceed = (data: TAddAppointment) => {
switch (true) {
@@ -56,19 +65,56 @@
};
const addAppointment = async () => {
console.log("adding appointment", newAppointment);
updateCalendar();
step = "success";
if (
newAppointment.name &&
newAppointment.agentId &&
typeof newAppointment.hasNoEmail !== "undefined"
) {
isSubmitting = true;
const appointmentData: AppointmentDataByStaff = {
name: newAppointment.name,
shareEmail: newAppointment.shareEmail || false,
email: newAppointment.email,
phone: newAppointment.phone,
locale: newAppointment.locale,
};
await $staffCrypto.crypto
?.createAppointmentByStaff({
appointmentData,
tenantId,
appointmentDate: newAppointment.dateTime,
duration: item.duration,
hasNoEmail: newAppointment.hasNoEmail,
agentId: newAppointment.agentId,
channelId: item.channelId,
tunnel: newAppointment.tunnel,
email: newAppointment.email,
})
.then(() => {
step = "success";
updateCalendar();
})
.catch(() => {
step = "error";
})
.finally(() => {
isSubmitting = false;
});
}
};
const onChangeLocale = (locale: string) => {
newAppointment = { ...newAppointment, locale };
};
</script>
<Summary {step} {newAppointment} />
<Summary {step} {newAppointment} {onChangeLocale} />
{#if step === "email"}
<SearchClientForm {tenantId} {newAppointment} {proceed} />
{:else if step === "agent" && item.availableAgents}
<SelectAgent availableAgents={item.availableAgents} {newAppointment} {proceed} />
{:else if step === "summary"}
<Button onclick={addAppointment} class="w-full">
<Button onclick={addAppointment} class="w-full" isLoading={isSubmitting} disabled={isSubmitting}>
{m["calendar.addAppointment.steps.summary.action"]()}
</Button>
{:else if step === "client"}
@@ -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);
</script>
<div class="flex flex-col items-start gap-3">
@@ -41,8 +47,29 @@
</div>
{/if}
{#if newAppointment.email}
<div class="flex w-auto items-center justify-start gap-2">
<Languages class="size-4" />
<Select.Root type="single" onValueChange={onChangeLocale} value={newAppointment.locale}>
<Select.Trigger
class="h-1! w-full grow border-0 py-0 pl-1 font-medium shadow-none"
size="sm"
>
{@const locale = newAppointment.locale
? languageSwitchLocales[newAppointment.locale as keyof typeof languageSwitchLocales]
: undefined}
{locale ? locale.label : "Select language"}
</Select.Trigger>
<Select.Content>
{#each languages as language (language)}
<Select.Item value={language}>
{languageSwitchLocales[language as keyof typeof languageSwitchLocales].label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
class="h-auto w-auto justify-start gap-2 rounded-sm"
variant="link"
href={`mailto:${newAppointment.email}`}
>
@@ -7,6 +7,9 @@
import { formSchema } from ".";
import type { TAddAppointment } from "../types";
import Button from "$lib/components/ui/button/button.svelte";
import { hashEmail } from "$lib/client/appointment-crypto";
import { fetchClientTunnels } from "../../../../staff/(components)/utils";
import { toast } from "svelte-sonner";
let {
tenantId,
@@ -27,25 +30,23 @@
if (validation.valid) {
cancel();
isSubmitting = true;
console.log("tenantId", tenantId);
console.log("email", $formData.email);
const hashedEmail = await hashEmail($formData.email);
console.log("hashed", hashedEmail);
proceed({ ...newAppointment, email: $formData.email, hasNoEmail: false });
// const success = await confirmAppointment({
// tenant: tenantId,
// appointment: item.appointment.id,
// email: item.decrypted.shareEmail ? item.decrypted.email : undefined,
// locale: "de", // TODO: Use client language as soon as available in appointment
// });
// if (success) {
// toast.success(m["calendar.confirmAppointment.success"]());
// updateCalendar();
// close();
// } else {
// toast.error(m["calendar.confirmAppointment.error"]());
// }
// Find client tunnel, if it exists
const tunnels = await fetchClientTunnels(tenantId);
const hashedEmail = await hashEmail($formData.email);
const tunnel = tunnels.find((t) => t.emailHash === hashedEmail);
if (tunnel) {
toast.success(m["calendar.addAppointment.steps.selectClient.proceedExistingClient"]());
proceed({ ...newAppointment, email: $formData.email, hasNoEmail: false, tunnel });
} else {
const isOk = confirm(
m["calendar.addAppointment.steps.selectClient.confirmNewClient"](),
);
if (isOk) {
proceed({ ...newAppointment, email: $formData.email, hasNoEmail: false, tunnel });
}
}
isSubmitting = false;
}
},
@@ -56,17 +57,6 @@
const { form: formData, enhance, validateForm } = form;
// TODO: Use the version from appointment-crypto
const hashEmail = async (email: string): Promise<string> => {
const emailNormalized = email.toLowerCase().trim();
const encoder = new TextEncoder();
const data = encoder.encode(emailNormalized);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
const proceedWithoutEmail = () => {
proceed({ ...newAppointment, email: undefined, hasNoEmail: true });
};
@@ -77,7 +67,13 @@
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
<Input
{...props}
bind:value={$formData.email}
type="email"
autocomplete="off"
autocapitalize="off"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
@@ -1,3 +1,5 @@
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
export type TAddAppointment = {
dateTime: Date;
agentId?: string;
@@ -6,6 +8,8 @@ export type TAddAppointment = {
shareEmail?: boolean;
name?: string;
phone?: string;
tunnel?: ClientTunnelResponse;
locale?: string;
};
export type TAddAppointmentStep = "email" | "agent" | "client" | "summary" | "success" | "error";
@@ -45,7 +45,7 @@ function timeToMinutes(time: string): number {
if (time.includes("T")) {
const parsedDate = new Date(time);
if (!Number.isNaN(parsedDate.getTime())) {
return parsedDate.getUTCHours() * 60 + parsedDate.getUTCMinutes();
return parsedDate.getHours() * 60 + parsedDate.getMinutes();
}
}
@@ -29,6 +29,7 @@
import CalendarFilters from "./(components)/CalendarFilters.svelte";
import CalendarHeader from "./(components)/CalendarHeader.svelte";
import { fetchCalendar, openAppointmentById } from "./(components)/utils";
import { utcTimeToLocal } from "$lib/utils/datetime";
const convertDate = (dateStr: string) => {
const zonedDateTime = parseAbsoluteToLocal(dateStr);
@@ -54,14 +55,14 @@
.map((c) => c.slotTemplates.map((t) => t.from))
.flat()
.map((time) => {
const [hourStr] = time.split(":");
const [hourStr] = utcTimeToLocal(time).split(":");
return parseInt(hourStr, 10);
});
const to = channels
.map((c) => c.slotTemplates.map((t) => t.to))
.flat()
.map((time) => {
const [hourStr] = time.split(":");
const [hourStr] = utcTimeToLocal(time).split(":");
return parseInt(hourStr, 10);
});
return { from: Math.min(...from), to: Math.max(...to) };
@@ -135,21 +136,23 @@
if (["all", "available"].includes(shownAppointments)) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
channelData.availableSlots.forEach((slot) => {
if (
shownAgents.length === 0 ||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
) {
channelItems.push({
id: `${channelId}-${slot.from}`,
date: dayEntry.date,
start: slot.from,
duration: slot.duration,
channelId,
color: channelData.channel.color,
column: 0,
status: "available",
availableAgents: slot.availableAgents,
});
if (new Date(slot.to) > new Date()) {
if (
shownAgents.length === 0 ||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
) {
channelItems.push({
id: `${channelId}-${slot.from}`,
date: dayEntry.date,
start: slot.from,
duration: slot.duration,
channelId,
color: channelData.channel.color,
column: 0,
status: "available",
availableAgents: slot.availableAgents,
});
}
}
});
}
@@ -192,6 +195,10 @@
});
</script>
<svelte:head>
<title>{m["calendar.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}>
<MaxPageWidth maxWidth="xl">
<div class="flex flex-col gap-10">
@@ -239,7 +246,7 @@
{@const channel = channels.find((c) => c.id === curEmptySlot.channelId)}
<ResponsiveDialog
id="current-calendar-slot"
title="Add Appointment"
title={m["calendar.addAppointment.title"]()}
description={channel ? getCurrentTranlslation(channel.names) : undefined}
triggerHidden={true}
>
@@ -1,8 +1,9 @@
import type { TNewSlotTemplate } from "$lib/types/channel";
import { localTimeToUTC } from "$lib/utils/datetime";
export const DEFAULT_SLOT_TEMPLATE: TNewSlotTemplate = {
weekdays: 31,
from: "09:00:00",
to: "17:00:00",
from: localTimeToUTC("09:00:00"),
to: localTimeToUTC("17:00:00"),
duration: 15,
};
@@ -137,13 +137,11 @@
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,
});
}
+1 -2
View File
@@ -206,8 +206,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
// Send invitation email
await sendUserInviteEmail(email, name, tenant, role, registrationUrl, language);
logger.info("User invitation sent successfully", {
invitedBy: locals.user.id,
logger.debug("User invitation sent successfully", {
invitedEmail: email,
tenantId,
role,
-1
View File
@@ -284,7 +284,6 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress,
logger.info("Login successful", {
userId: sessionData.user.id,
email: sessionData.user.email,
role: sessionData.user.role,
authMethod: body.passphrase ? "passphrase" : "webauthn",
});
@@ -149,7 +149,6 @@ export async function DELETE({ params, locals }: RequestEvent) {
log.info("Passkey deleted successfully", {
passkeyId,
userId,
deviceName: deletedPasskey.deviceName,
});
// If user has a tenant (STAFF or TENANT_ADMIN), also delete associated crypto data (CASCADE)
@@ -165,7 +164,6 @@ export async function DELETE({ params, locals }: RequestEvent) {
);
log.info("Staff crypto data cascaded deletion completed", {
passkeyId,
userId,
tenantId,
deleted,
@@ -262,11 +262,9 @@ export async function POST({ request, params, locals }: RequestEvent) {
privateKeyShare,
);
log.info("Crypto keys stored successfully", {
passkeyId,
log.debug("Crypto keys stored successfully", {
userId,
tenantId,
prfHash: prfHash.substring(0, 16) + "...",
});
return json({
-2
View File
@@ -11,8 +11,6 @@ export async function GET() {
envOkay = false;
if (!process.env.DATABASE_URL?.startsWith("postgres:")) envOkay = false;
console.warn("Environment is okay", envOkay);
return json({
envOkay,
});
+4 -1
View File
@@ -1,4 +1,5 @@
import { dev } from "$app/environment";
import logger from "$lib/logger";
import { db } from "$lib/server/db";
import { tenant } from "$lib/server/db/central-schema";
import { eq } from "drizzle-orm";
@@ -30,7 +31,9 @@ export const getTenantIdByDomain = async (
.limit(1);
return tenants[0]?.id || null;
} catch (error) {
console.log("error", error);
logger
.setContext("API.Public.Utils")
.error("Failed to get tenant ID by domain", { domain, error });
throw error;
}
}
+1 -1
View File
@@ -497,7 +497,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
}
log.info("Attempting tenant deletion", {
log.debug("Attempting tenant deletion", {
tenantId,
requestedBy: locals.user?.id,
userRole: locals.user?.role,
@@ -133,7 +133,7 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
const body = await request.json();
const { emailHash, challengeId, challengeResponse } = requestSchema.parse(body);
log.info("Client deleting appointment", {
log.debug("Client deleting appointment", {
tenantId,
appointmentId,
emailHashPrefix: emailHash.slice(0, 8),
@@ -148,10 +148,9 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
challengeResponse,
);
log.info("Appointment deleted successfully by client", {
log.debug("Appointment deleted successfully by client", {
tenantId,
appointmentId,
emailHashPrefix: emailHash.slice(0, 8),
});
return json({
@@ -8,8 +8,8 @@ import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
const requestSchema = z.object({
clientEmail: z.string().email(),
clientLanguage: z.string().optional().default("de"),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
});
// Register OpenAPI documentation for DELETE
@@ -105,11 +105,17 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete", () => {
expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith(
mockAppointmentId,
mockClientEmail,
"de",
"en",
);
});
it("should return 422 when clientEmail is missing", async () => {
it("should allow clientEmail to be is missing", async () => {
const mockDeleteAppointmentByStaff = vi.fn().mockResolvedValue(undefined);
vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({
deleteAppointmentByStaff: mockDeleteAppointmentByStaff,
} as any);
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({}),
@@ -124,7 +130,8 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete", () => {
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(422);
expect(response.status).toBe(200);
expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith(mockAppointmentId, undefined, "en");
});
it("should return 422 when clientEmail is invalid", async () => {
@@ -123,14 +123,13 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const body = await request.json();
const { clientEmail, clientLanguage } = requestSchema.parse(body);
log.info("Denying appointment", {
log.debug("Denying appointment", {
tenantId,
appointmentId,
clientEmailPrefix: clientEmail ? clientEmail.slice(0, 3) : undefined,
});
await appointmentService.denyAppointment(appointmentId, clientEmail, clientLanguage);
log.info("Appointment denied successfully", {
log.debug("Appointment denied successfully", {
tenantId,
appointmentId,
});
@@ -16,6 +16,7 @@ vi.mock("$lib/server/utils/permissions", () => ({
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
})),
@@ -208,11 +208,10 @@ export const POST: RequestHandler = async ({ request, params }) => {
throw new ValidationError("Bees incoming");
}
logger.info("Adding appointment to existing tunnel", {
logger.debug("Adding appointment to existing tunnel", {
tenantId,
tunnelId: validatedData.tunnelId,
appointmentDate: validatedData.appointmentDate,
emailHashPrefix: validatedData.emailHash.slice(0, 8),
});
const db = await getTenantDb(tenantId);
@@ -230,7 +229,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
logger.warn("Client tunnel not found", {
tenantId,
tunnelId: validatedData.tunnelId,
emailHashPrefix: validatedData.emailHash.slice(0, 8),
});
throw new NotFoundError("Tunnel not found or access denied");
}
@@ -335,14 +333,14 @@ export const POST: RequestHandler = async ({ request, params }) => {
if (requiresConfirmation) {
await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment request email sent", {
logger.debug("Appointment request email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
});
} else {
await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle);
logger.info("Appointment confirmation email sent", {
logger.debug("Appointment confirmation email sent", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
@@ -352,7 +350,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
}
} catch (emailError) {
logger.error("Failed to send appointment notification email", {
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
tenantId,
error: String(emailError),
@@ -144,7 +144,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
if (!throttleResult.allowed) {
logger.warn("PIN challenge throttled", {
tenantId,
emailHashPrefix: emailHash.slice(0, 8),
retryAfterMs: throttleResult.retryAfterMs,
failedAttempts: throttleResult.failedAttempts,
});
@@ -163,7 +162,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
);
}
logger.info("Creating challenge for existing client", {
logger.debug("Creating challenge for existing client", {
tenantId,
emailHashPrefix: emailHash.slice(0, 8),
});
@@ -184,7 +183,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
if (tunnelResult.length === 0) {
logger.warn("Client tunnel not found for challenge", {
tenantId,
emailHashPrefix: emailHash.slice(0, 8),
});
return json({ error: "Tenant or client not found" }, { status: 404 });
}
@@ -223,7 +221,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
privateKeyShare: tunnel.privateKeyShare,
};
logger.info("Successfully created challenge", {
logger.debug("Successfully created challenge", {
tenantId,
tunnelId: tunnel.id,
challengeLength: challenge.length,
@@ -305,12 +305,9 @@ export const POST: RequestHandler = async ({ request, params }) => {
throw new ValidationError("Bees incoming");
}
logger.info("Creating new client appointment tunnel", {
logger.debug("Creating new client appointment tunnel", {
tenantId,
tunnelId: validatedData.tunnelId,
appointmentDate: validatedData.appointmentDate,
duration: validatedData.duration,
emailHashPrefix: validatedData.emailHash.slice(0, 8),
});
const appointmentService = await AppointmentService.forTenant(tenantId);
@@ -13,6 +13,7 @@ vi.mock("$lib/server/services/appointment-service", () => ({
vi.mock("$lib/logger", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
@@ -116,12 +117,9 @@ describe("Create New Client API Route", () => {
expect(data).toEqual(mockAppointment);
expect(mockService.createNewClientWithAppointment).toHaveBeenCalledWith(validRequestBody);
expect(consumeBookingAccessToken).toHaveBeenCalledOnce();
expect(logger.info).toHaveBeenCalledWith("Creating new client appointment tunnel", {
expect(logger.debug).toHaveBeenCalledWith("Creating new client appointment tunnel", {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
duration: validRequestBody.duration,
emailHashPrefix: "test-ema",
});
});
@@ -244,12 +242,9 @@ describe("Create New Client API Route", () => {
expect(data.error).toBe(
"Cannot create client appointments: No authorized users found in tenant",
);
expect(logger.info).toHaveBeenCalledWith("Creating new client appointment tunnel", {
expect(logger.debug).toHaveBeenCalledWith("Creating new client appointment tunnel", {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
duration: validRequestBody.duration,
emailHashPrefix: "test-ema",
});
});
@@ -311,12 +306,9 @@ describe("Create New Client API Route", () => {
const event = createMockRequestEvent();
await POST(event);
expect(logger.info).toHaveBeenCalledWith("Creating new client appointment tunnel", {
expect(logger.debug).toHaveBeenCalledWith("Creating new client appointment tunnel", {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
duration: 10,
emailHashPrefix: "test-ema", // First 8 chars of "test-email-hash"
});
});
@@ -376,7 +376,6 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
logger.info("PIN reset token created for new client", {
tenantId,
tokenId: pinResetToken.slice(0, 8),
});
} catch (error) {
logger.error("Failed to create PIN reset token for new client", {
@@ -390,7 +389,12 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
}
// Send email notification if requested and client has email
if (validatedData.sendEmail && validatedData.clientEmail && !validatedData.hasNoEmail) {
if (
existingTunnel &&
validatedData.sendEmail &&
validatedData.clientEmail &&
!validatedData.hasNoEmail
) {
try {
await appointmentService.sendAppointmentNotification(
result.id,
@@ -422,62 +422,6 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => {
});
describe("Email Sending", () => {
it("should send email for new client when sendEmail is true", async () => {
mockAppointmentService.getClientTunnels.mockResolvedValue([]);
mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({
id: "appointment-123",
appointmentDate: "2026-01-15T14:00:00.000Z",
status: "NEW",
requiresConfirmation: true,
});
mockPinResetService.createResetToken.mockResolvedValue("reset-token-123");
mockAppointmentService.sendAppointmentNotification.mockResolvedValue(undefined);
const request = new Request("http://localhost/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientEmail: "test@example.com",
emailHash,
appointmentDate: "2026-01-15T14:00:00.000Z",
duration: 30,
channelId: "channel-123",
agentId: "agent-123",
tunnelId: "tunnel-123",
clientPublicKey: "public-key",
privateKeyShare: "private-key-share",
clientEncryptedTunnelKey: "encrypted-tunnel-key",
staffKeyShares: [
{
userId: "staff-123",
encryptedTunnelKey: "encrypted-for-staff",
},
],
encryptedAppointment: {
encryptedPayload: "encrypted-payload",
iv: "iv",
authTag: "auth-tag",
},
sendEmail: true,
}),
});
await POST({
params: { id: tenantId },
request,
locals: { user: { id: "staff-123", role: "STAFF" } } as any,
} as any);
// Email sending is async, so we just verify it was called
expect(mockAppointmentService.sendAppointmentNotification).toHaveBeenCalledWith(
"appointment-123",
"channel-123",
"test@example.com",
"de",
false,
);
});
it("should not send email when sendEmail is false", async () => {
mockAppointmentService.getClientTunnels.mockResolvedValue([]);
mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({
@@ -246,8 +246,6 @@ export const POST: RequestHandler = async ({ params, locals, request }) => {
log.info("No new key shares to add - all already exist", {
tenantId,
staffUserId,
totalRequested: keyShares.length,
previousConfirmationState: staffUser[0].confirmationState,
});
await ensureAccessGranted();
@@ -284,12 +282,11 @@ export const POST: RequestHandler = async ({ params, locals, request }) => {
await ensureAccessGranted();
log.info("Staff key shares added successfully", {
log.debug("Staff key shares added successfully", {
tenantId,
staffUserId,
addedCount: result.length,
skippedCount: duplicateKeyShares.length,
requesterId: locals.user?.id,
});
return json({
@@ -139,7 +139,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
const body = await request.json();
const { challengeId, challengeResponse } = requestSchema.parse(body);
logger.info("Verifying challenge for existing client", {
logger.debug("Verifying challenge for existing client", {
tenantId,
challengeId,
});
@@ -166,7 +166,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
logger.warn("Challenge response mismatch", {
tenantId,
challengeId,
emailHashPrefix: storedChallenge.emailHash.slice(0, 8),
});
// Record failed attempt for throttling
@@ -191,14 +190,13 @@ export const POST: RequestHandler = async ({ request, params }) => {
logger.warn("Client tunnel not found for verification", {
tenantId,
challengeId,
emailHashPrefix: storedChallenge.emailHash.slice(0, 8),
});
throw new NotFoundError("Tenant or client not found");
}
const tunnel = tunnelResult[0];
logger.info("Challenge response validated successfully", {
logger.debug("Challenge response validated successfully", {
tenantId,
challengeId,
tunnelId: tunnel.id,
@@ -220,7 +218,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
bookingAccessToken,
};
logger.info("Successfully verified challenge", {
logger.debug("Successfully verified challenge", {
tenantId,
tunnelId: tunnel.id,
});
@@ -157,10 +157,9 @@ export const POST: RequestHandler = async ({ params, request }) => {
validatedData.newClientEncryptedTunnelKey,
);
logger.info("PIN reset completed successfully", {
logger.debug("PIN reset completed successfully", {
tenantId,
tunnelId,
tokenId: validatedData.token.slice(0, 8),
});
return json({
@@ -152,10 +152,9 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const expiresAt = new Date(Date.now() + expirationMinutes * 60 * 1000);
logger.info("PIN reset token created for QR code", {
logger.debug("PIN reset token created for QR code", {
tenantId,
emailHash: validatedData.emailHash.slice(0, 8),
tokenId: token.slice(0, 8),
initiatedBy: locals.user?.id,
});
@@ -166,12 +166,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const baseUrl = env.PUBLIC_APP_URL || "http://localhost:5173";
const resetUrl = `${baseUrl}/reset-pin/${token}`;
logger.info("PIN reset token created for email", {
logger.debug("PIN reset token created for email", {
tenantId,
emailHash: validatedData.emailHash.slice(0, 8),
tokenId: token.slice(0, 8),
initiatedBy: locals.user?.id,
resetUrl,
});
// TODO: Send actual email once we have a way to get the client's email
@@ -243,7 +243,7 @@ export const DELETE: RequestHandler = async ({ params, url, locals }) => {
const notificationService = await NotificationService.forTenant(tenantId);
const deletedCount = await notificationService.deleteAllNotifications(locals.user.id, readOnly);
log.info("Notifications deleted successfully", {
log.debug("Notifications deleted successfully", {
tenantId,
staffId: locals.user.id,
deletedCount,
@@ -182,7 +182,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
const notificationService = await NotificationService.forTenant(tenantId);
await notificationService.deleteNotification(notificationId, locals.user.id);
log.info("Notification deleted successfully", {
log.debug("Notification deleted successfully", {
tenantId,
staffId: locals.user.id,
notificationId,
@@ -233,7 +233,7 @@ export const PUT: RequestHandler = async ({ params, locals }) => {
const notificationService = await NotificationService.forTenant(tenantId);
await notificationService.markAsRead(notificationId, locals.user.id);
log.info("Notification marked as read successfully", {
log.debug("Notification marked as read successfully", {
tenantId,
staffId: locals.user.id,
notificationId,
@@ -131,7 +131,6 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
const validation = requestSchema.safeParse(body);
if (!validation.success) {
console.error("Validation error:", validation.error.issues);
throw new ValidationError(
"Invalid request data: " + validation.error.issues.map((e) => e.message).join(", "),
);
@@ -174,7 +173,6 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
log.info("Staff crypto keys stored successfully", {
tenantId,
staffId,
passkeyId,
});
return json({
+1 -1
View File
@@ -111,7 +111,7 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => {
// Protected auth paths require any authenticated user (no specific role required)
// The authentication check above is sufficient
logger.debug(`User authenticated: ${sessionData?.user.email ?? "unauthenticated"} for ${path}`);
logger.debug(`User authenticated: ${sessionData?.user.id ?? "unauthenticated"} for ${path}`);
return resolve(event);
};