mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Merge remote-tracking branch 'origin/main' into secure-staff-public-keys
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -660,7 +660,6 @@ export class UserService {
|
||||
|
||||
log.info("User deleted successfully", {
|
||||
userId,
|
||||
deletedUser: deletedUsers[0],
|
||||
deletedPasskeysCount,
|
||||
tenantId: user.tenantId,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
+8
-13
@@ -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"]());
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
+53
-7
@@ -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}`}
|
||||
>
|
||||
|
||||
+26
-30
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Vendored
-2
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+10
-3
@@ -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,
|
||||
});
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+4
-12
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user