Merge remote-tracking branch 'origin/main' into feat/custom-domains

This commit is contained in:
Karl Ludwig Weise
2026-01-23 20:09:31 +01:00
381 changed files with 30561 additions and 3514 deletions
+48 -3
View File
@@ -6,7 +6,24 @@ on:
types: [opened, synchronize, reopened]
jobs:
test-and-build:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
check:
runs-on: ubuntu-latest
steps:
@@ -24,9 +41,21 @@ jobs:
- name: Run type checking
run: npm run check
tests:
runs-on: ubuntu-latest
- name: Run linting
run: npm run lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit -- --run
@@ -41,6 +70,22 @@ jobs:
SMTP_FROM_EMAIL: "noreply@example.com"
JWT_SECRET: "test"
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
env:
+3 -1
View File
@@ -34,4 +34,6 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
CLAUDE.md
.claude/
.claude/
security-audit*
+1 -1
View File
@@ -9,7 +9,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- postgres_data_dev:/var/lib/postgresql/data
- postgres_run_dev:/var/run/postgresql
+2
View File
@@ -1,5 +1,7 @@
# Email System Documentation
> This doc is partially outdated. New templates can be viewed locally. See routes dir. Example: http://localhost:5173/local-only/e-mail-templates/user-invite Text versions are generated automatically and will be printed to console, when local page is opened.
The Open Reception appointment booking platform includes a comprehensive email system for automated communications with clients and staff members. This document provides technical details and administration guidance for managing the email system.
## Overview
+106 -32
View File
@@ -21,43 +21,66 @@ Browser (Staff App) ↔ StaffCryptoService API ↔ Tenant Database
- **Browser-Based Cryptography**: All key generation and crypto operations in browser
- **Split-Key Architecture**: Private keys split using XOR between database and passkey shards
- **WebAuthn Integration**: Hardware-backed deterministic key derivation from passkey data
- **Zero-Knowledge Server**: Server never sees complete private keys
- **WebAuthn PRF Extension**: Hardware-backed deterministic secret derivation using PRF (Pseudo-Random Function)
- **Zero-Knowledge Server**: Server never sees complete private keys or PRF outputs
- **Per-Passkey Keys**: Each staff passkey has its own unique keypair
- **ML-KEM-768 Encryption**: Post-quantum cryptography using Kyber
- **Modern Authenticator Required**: Requires CTAP 2.1+ authenticators with PRF support
## Browser Integration Workflow
### 1. Staff Passkey Registration & Key Generation
When a staff member registers a new passkey, the browser automatically generates crypto keys:
When a staff member registers a new passkey, the browser automatically generates crypto keys using the WebAuthn PRF extension:
```javascript
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import { generatePasskey, getPRFOutputAfterRegistration } from "$lib/utils/passkey";
async function registerStaffPasskey(userId, tenantId) {
// 1. Complete WebAuthn passkey registration
const credential = await navigator.credentials.create({
publicKey: registrationOptions,
async function registerStaffPasskey(userId, tenantId, email) {
// 1. Complete WebAuthn passkey registration WITH PRF extension
const credential = await generatePasskey({
id: rpId,
challenge: challengeBase64,
email: email,
enablePRF: true, // CRITICAL: Enable PRF for zero-knowledge key derivation
});
// Verify PRF extension is enabled
const extensionResults = credential.getClientExtensionResults();
if (!extensionResults.prf?.enabled) {
throw new Error(
"PRF extension not supported. Please use a modern authenticator " +
"(YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)",
);
}
// 2. Generate Kyber keypair in browser
const keyPair = KyberCrypto.generateKeyPair();
// 3. Derive deterministic shard from WebAuthn authenticatorData
// 3. IMMEDIATELY retrieve PRF output after passkey creation
// This is the ONLY time we can get the PRF output for this passkey
const prfOutput = await getPRFOutputAfterRegistration({
passkeyId: credential.id,
rpId: rpId,
challengeBase64: freshChallengeBase64, // Fresh challenge from server
email: email,
});
// 4. Derive deterministic shard from PRF output (SECRET, zero-knowledge!)
const crypto = new UnifiedAppointmentCrypto();
const passkeyBasedShard = await crypto.derivePasskeyBasedShard(
credential.id,
credential.response.authenticatorData,
const passkeyBasedShard = await crypto.derivePasskeyBasedShardWithPRF(
prfOutput, // 32-byte secret from PRF extension
userId,
);
// 4. Create database shard using XOR
// 5. Create database shard using XOR
const dbShard = new Uint8Array(keyPair.privateKey.length);
for (let i = 0; i < keyPair.privateKey.length; i++) {
dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i];
}
// 5. Store via StaffCryptoService API
// 6. Store via StaffCryptoService API
await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -68,50 +91,101 @@ async function registerStaffPasskey(userId, tenantId) {
}),
});
console.log("Staff crypto keys generated and stored");
console.log("Staff crypto keys generated and stored (PRF-based)");
}
```
**SECURITY NOTE**: The PRF output is a **secret** 32-byte value that only the passkey owner can derive. Unlike the old `authenticatorData` approach (which used public `RP_ID_HASH`), PRF provides true zero-knowledge security - an attacker with database access **cannot** reconstruct the private key.
### 2. Staff Authentication & Key Reconstruction
When a staff member authenticates, their private key is reconstructed from shards:
When a staff member authenticates, their private key is reconstructed from shards using the WebAuthn PRF extension:
```javascript
async function authenticateStaff(userId, tenantId) {
// 1. Perform WebAuthn authentication
const assertion = await navigator.credentials.get({
publicKey: authenticationOptions,
const crypto = new UnifiedAppointmentCrypto();
// This internally performs the following steps:
// 1. Perform WebAuthn authentication WITH PRF extension
// 2. Get database shard from API
// 3. Derive same passkey-based shard from PRF output
// 4. Reconstruct private key using XOR
await crypto.authenticateStaff(userId, tenantId);
console.log("Staff authenticated and keys reconstructed (PRF-based)");
return crypto;
}
```
**Internal PRF Authentication Flow** (handled by `UnifiedAppointmentCrypto.authenticateStaff()`):
```javascript
async authenticateStaff(staffId, tenantId) {
// 1. Get passkey list from session storage
const availablePasskeys = this.getAvailablePasskeys(staffId);
const passkeyId = availablePasskeys[0]; // User can select which passkey to use
// 2. Perform WebAuthn authentication with PRF extension
const prfSalt = new TextEncoder().encode(`open-reception-prf:${email}`);
const credential = await navigator.credentials.get({
publicKey: {
challenge: challengeBuffer,
rpId: rpId,
allowCredentials: [{ id: passkeyIdBuffer, type: "public-key" }],
userVerification: "required",
extensions: {
prf: {
eval: { first: prfSalt }, // Request PRF output
},
},
},
});
// 2. Get database shard from API
const response = await fetch(`/api/tenants/${tenantId}/staff/${userId}/key-shard`);
const { publicKey, privateKeyShare, passkeyId } = await response.json();
// 3. Extract PRF output from WebAuthn response
const extensionResults = credential.getClientExtensionResults();
const prfOutput = extensionResults.prf?.results?.first;
// 3. Derive same passkey-based shard
const crypto = new UnifiedAppointmentCrypto();
const passkeyBasedShard = await crypto.derivePasskeyBasedShard(
passkeyId,
assertion.response.authenticatorData,
if (!prfOutput) {
throw new Error("PRF extension not supported by this passkey");
}
// 4. Get database shard from API
const response = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passkeyId }),
});
const { publicKey, privateKeyShare } = await response.json();
// 5. Derive passkey-based shard from PRF output (same as during registration)
const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(
prfOutput, // Secret 32-byte PRF output
staffId,
);
// 4. Reconstruct private key using XOR
// 6. Reconstruct private key using XOR
const dbShard = base64ToBuffer(privateKeyShare);
const privateKey = new Uint8Array(dbShard.length);
for (let i = 0; i < dbShard.length; i++) {
privateKey[i] = dbShard[i] ^ passkeyBasedShard[i];
}
// 5. Store reconstructed keypair for session
crypto.staffKeyPair = {
// 7. Store reconstructed keypair for session
this.staffKeyPair = {
publicKey: base64ToBuffer(publicKey),
privateKey: privateKey,
};
console.log("Staff authenticated and keys reconstructed");
return crypto;
}
```
**SECURITY NOTE**: The PRF output is derived fresh during each authentication using the same deterministic salt (`open-reception-prf:${email}`). This guarantees:
- **Determinism**: Same passkey + same salt = same PRF output = same private key
- **Zero-Knowledge**: PRF output never leaves the browser, server cannot derive it
- **Hardware-Backed**: PRF computation happens inside the authenticator's secure element
- **Multi-Passkey Support**: Using email as salt allows all passkeys for the same user to work interchangeably
## StaffCryptoService API
The backend API provides these endpoints for managing staff cryptographic keys:
+6
View File
@@ -0,0 +1,6 @@
ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DATA TYPE text;--> statement-breakpoint
ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DEFAULT 'SETTINGS'::text;--> statement-breakpoint
DROP TYPE "public"."setup_state";--> statement-breakpoint
CREATE TYPE "public"."setup_state" AS ENUM('SETTINGS', 'AGENTS', 'CHANNELS', 'STAFF', 'READY');--> statement-breakpoint
ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DEFAULT 'SETTINGS'::"public"."setup_state";--> statement-breakpoint
ALTER TABLE "tenant" ALTER COLUMN "setup_state" SET DATA TYPE "public"."setup_state" USING "setup_state"::"public"."setup_state";
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "user_session" ADD COLUMN "passkey_id" text;
+6
View File
@@ -0,0 +1,6 @@
CREATE TABLE "challenge_throttle" (
"id" text PRIMARY KEY NOT NULL,
"failed_attempts" integer DEFAULT 0 NOT NULL,
"last_attempt_at" timestamp DEFAULT now() NOT NULL,
"reset_at" timestamp NOT NULL
);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "user_session" ADD COLUMN "passkey_id" text;
+793
View File
@@ -0,0 +1,793 @@
{
"id": "c06d9c90-44ff-4e2e-a834-8533afcfe755",
"prevId": "c7e97210-1d91-4d46-942d-157da03e44e5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.tenant": {
"name": "tenant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"short_name": {
"name": "short_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"long_name": {
"name": "long_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"languages": {
"name": "languages",
"type": "json",
"primaryKey": false,
"notNull": true
},
"defaultLanguage": {
"name": "defaultLanguage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'en'"
},
"logo": {
"name": "logo",
"type": "varchar(100000)",
"primaryKey": false,
"notNull": false
},
"database_url": {
"name": "database_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"setup_state": {
"name": "setup_state",
"type": "setup_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'SETTINGS'"
},
"links": {
"name": "links",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'::json"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_database_url_idx": {
"name": "tenant_database_url_idx",
"columns": [
{
"expression": "database_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": ["short_name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant_config": {
"name": "tenant_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "config_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_config_tenant_name_idx": {
"name": "tenant_config_tenant_name_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tenant_config_tenant_id_tenant_id_fk": {
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'STAFF'"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"confirmation_state": {
"name": "confirmation_state",
"type": "confirmation_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'INVITED'"
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_valid_until": {
"name": "token_valid_until",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"passphrase_hash": {
"name": "passphrase_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"recovery_passphrase": {
"name": "recovery_passphrase",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_tenant_id_tenant_id_fk": {
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_invite": {
"name": "user_invite",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invite_code": {
"name": "invite_code",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+832
View File
@@ -0,0 +1,832 @@
{
"id": "6f7d3653-5075-4baf-ad67-77afa7ae10ff",
"prevId": "c06d9c90-44ff-4e2e-a834-8533afcfe755",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.challenge_throttle": {
"name": "challenge_throttle",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"failed_attempts": {
"name": "failed_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_attempt_at": {
"name": "last_attempt_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"reset_at": {
"name": "reset_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant": {
"name": "tenant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"short_name": {
"name": "short_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"long_name": {
"name": "long_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"languages": {
"name": "languages",
"type": "json",
"primaryKey": false,
"notNull": true
},
"defaultLanguage": {
"name": "defaultLanguage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'en'"
},
"logo": {
"name": "logo",
"type": "varchar(100000)",
"primaryKey": false,
"notNull": false
},
"database_url": {
"name": "database_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"setup_state": {
"name": "setup_state",
"type": "setup_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'SETTINGS'"
},
"links": {
"name": "links",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'::json"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_database_url_idx": {
"name": "tenant_database_url_idx",
"columns": [
{
"expression": "database_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": ["short_name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant_config": {
"name": "tenant_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "config_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_config_tenant_name_idx": {
"name": "tenant_config_tenant_name_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tenant_config_tenant_id_tenant_id_fk": {
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'STAFF'"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"confirmation_state": {
"name": "confirmation_state",
"type": "confirmation_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'INVITED'"
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_valid_until": {
"name": "token_valid_until",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"passphrase_hash": {
"name": "passphrase_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"recovery_passphrase": {
"name": "recovery_passphrase",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_tenant_id_tenant_id_fk": {
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_invite": {
"name": "user_invite",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invite_code": {
"name": "invite_code",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+838
View File
@@ -0,0 +1,838 @@
{
"id": "945e895d-77bd-4acb-a4d3-20f652924edf",
"prevId": "6f7d3653-5075-4baf-ad67-77afa7ae10ff",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.challenge_throttle": {
"name": "challenge_throttle",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"failed_attempts": {
"name": "failed_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_attempt_at": {
"name": "last_attempt_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"reset_at": {
"name": "reset_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant": {
"name": "tenant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"short_name": {
"name": "short_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"long_name": {
"name": "long_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"languages": {
"name": "languages",
"type": "json",
"primaryKey": false,
"notNull": true
},
"defaultLanguage": {
"name": "defaultLanguage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'en'"
},
"logo": {
"name": "logo",
"type": "varchar(100000)",
"primaryKey": false,
"notNull": false
},
"database_url": {
"name": "database_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"setup_state": {
"name": "setup_state",
"type": "setup_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'SETTINGS'"
},
"links": {
"name": "links",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'::json"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_database_url_idx": {
"name": "tenant_database_url_idx",
"columns": [
{
"expression": "database_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": ["short_name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant_config": {
"name": "tenant_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "config_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_config_tenant_name_idx": {
"name": "tenant_config_tenant_name_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tenant_config_tenant_id_tenant_id_fk": {
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'STAFF'"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"confirmation_state": {
"name": "confirmation_state",
"type": "confirmation_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'INVITED'"
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_valid_until": {
"name": "token_valid_until",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"passphrase_hash": {
"name": "passphrase_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"recovery_passphrase": {
"name": "recovery_passphrase",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_tenant_id_tenant_id_fk": {
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_invite": {
"name": "user_invite",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invite_code": {
"name": "invite_code",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+21
View File
@@ -50,6 +50,27 @@
"when": 1760360156572,
"tag": "0006_legal_dracula",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1761303767674,
"tag": "0007_milky_wasp",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1767362589999,
"tag": "0008_wild_whirlwind",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1768324950524,
"tag": "0009_bizarre_saracen",
"breakpoints": true
}
]
}
+708 -766
View File
File diff suppressed because it is too large Load Diff
+26 -25
View File
@@ -38,38 +38,38 @@
"db:clean": "npm run docker:dev:down && npm run db:drop && npm run docker:dev:up"
},
"devDependencies": {
"@eslint/compat": "^1.3.0",
"@eslint/js": "^9.29.0",
"@inlang/paraglide-js": "2.3.2",
"@internationalized/date": "^3.8.2",
"@lucide/svelte": "^0.544.0",
"@playwright/test": "^1.56.1",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.10",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.8",
"@eslint/compat": "^2.0.1",
"@eslint/js": "^9.39.2",
"@inlang/paraglide-js": "2.9.1",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.562.0",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.11.4",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.3",
"eslint-plugin-svelte": "^3.14.0",
"formsnap": "^2.0.1",
"globals": "^16.2.0",
"jsdom": "^26.1.0",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.13",
"svelte": "^5.34.7",
"svelte-check": "^4.2.2",
"svelte-sonner": "^1.0.5",
"sveltekit-superforms": "^2.27.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.4",
"svelte-check": "^4.3.5",
"svelte-sonner": "^1.0.7",
"sveltekit-superforms": "^2.29.1",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.10",
@@ -77,13 +77,14 @@
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.1",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5",
"vite": "^6.4.1",
"vitest": "^3.2.4"
},
"dependencies": {
"@noble/hashes": "^1.8.0",
"@noble/post-quantum": "^0.4.1",
"@sveltejs/adapter-node": "^5.2.12",
"@simplewebauthn/server": "^11.0.0",
"@sveltejs/adapter-node": "^5.5.1",
"argon2": "^0.43.0",
"argon2-browser": "^1.18.0",
"cropperjs": "^2.0.1",
@@ -93,12 +94,12 @@
"drizzle-orm": "^0.44.2",
"jose": "^6.0.11",
"mode-watcher": "^1.0.8",
"nodemailer": "^7.0.7",
"nodemailer": "^7.0.11",
"postgres": "^3.4.7",
"secrets.js-34r7h": "^2.0.2",
"uuidv7": "^1.0.2",
"winston": "^3.17.0",
"zod": "^3.25.76"
"zod": "^4.1.12"
},
"license": "AGPL-3.0"
}
+19 -1
View File
@@ -1 +1,19 @@
cache
# IF GIT SHOWED THAT THIS FILE CHANGED
#
# 1. RUN THE FOLLOWING COMMAND
#
# ---
# git rm --cached '**/*.inlang/.gitignore'
# ---
#
# 2. COMMIT THE CHANGE
#
# ---
# git commit -m "fix: remove tracked .gitignore from inlang project"
# ---
#
# Inlang handles the gitignore itself starting with version ^2.5.
#
# everything is ignored except settings.json
*
!settings.json
+332 -6
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"welcome": "Willkommen bei OpenReception",
"home": "Startseite",
"poweredBy": "Betrieben mit",
"i18n": {
"label": "Sprache wählen",
@@ -61,10 +62,19 @@
"url": "Ungültige URL",
"languages": "Wähle mindestens eine Sprache",
"language": "Wähle eine Sprache",
"deleteAfterDays": "Wähle einen Zeitpunkt"
"deleteAfterDays": "Wähle einen Zeitpunkt",
"phoneNoRequired": "Telefonnummer ist erforderlich",
"phoneNoInvalid": "Format der Telefonnummer ist ungültig",
"pinLength": "PIN muss 6 Ziffern lang sein",
"pinDigitsOnly": "PIN darf nur Ziffern enthalten",
"pinInsecure": "Diese PIN ist zu unsicher"
},
"passkey": "Passkey",
"name": "Name"
"salutation": "Anrede",
"name": "Name",
"phone": "Telefonnummer",
"pin": "PIN",
"pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager."
},
"login": {
"or": "Oder",
@@ -77,9 +87,9 @@
},
"confirm": {
"success": {
"title": "Du bist bereit!",
"description": "Du hast Dein Konto erfolgreich gesichert",
"action": "Weiter"
"title": "E-Mail Adresse bestätigt!",
"description": "Jetzt musst Du nur noch einen Passkey hinzufügen.",
"action": "Passkey hinzufügen"
},
"error": {
"title": "Bestätigung fehlgeschlagen",
@@ -94,6 +104,20 @@
"success": "E-Mail gesendet"
}
},
"setupPasskey": {
"title": "Passkey einrichten",
"description": "Erstelle Deinen Passkey, um Dein Konto zu sichern.",
"action": "Passkey setzen",
"success": "Passkey eingerichtet. Logge Dich damit jetzt ein.",
"successKeyPairSaved": "Schlüsselpaar erfolgreich gespeichert.",
"confirmPrfRetrival": "Jetzt verwende den neu erstellten Passkey, um damit die Verschlüsselung zu aktivieren.",
"error": "Passkey konnte nicht gesetzt werden",
"errorKeyPairDataMissing": "Konnte Schlüsselpaar nicht generieren. Fehlende Daten.",
"errorKeyPairNotSaved": "Schlüsselpaar konnte nicht gespeichert werden.",
"errorPrfOutputNotTriggered": "Sie müssen der Verwendung des Passkeys zustimmen.",
"errorAuthenticatorNotSupported": "Dieser Authentifikator unterstützt nicht die erforderliche Sicherheitserweiterung (PRF). Bitte verwenden Sie einen modernen Authentifikator wie YubiKey 5.2.3+, Apple Touch ID oder Android.",
"errorGettingPrfOutput": "Fehler beim Abrufen der Sicherheitsdaten vom Passkey. Bitte verwenden Sie einen modernen Authentifikator, der die PRF-Erweiterung unterstützt."
},
"logout": {
"title": "Erfolgreich abgemeldet",
"description": "Du wurdest abgemeldet.",
@@ -140,7 +164,7 @@
"title": "Mandant hinzufügen",
"description": "Erstelle eine Seite für jemanden, der Termine sammeln möchte.",
"name": {
"description": "Muss einmalig auf diesem Server sein. Wird als URL verwendet {domain}",
"description": "Muss einmalig auf diesem Server sein. Kann niemals geändert werden. Wird als URL verwendet {domain}",
"errors": {
"urlFormat": "Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt",
"startEndDash": "Darf nicht mit einem Bindestrich beginnen oder enden"
@@ -320,6 +344,7 @@
"select": "Auswählen",
"close": "Schließen",
"dashboard": {
"title": "Übersicht",
"hello": "Hallo",
"tenants": {
"currentTenant": "Du verwaltest gerade den Mandanten {name}.",
@@ -337,6 +362,44 @@
"sidebar": {
"title": "Zum ersten Mal hier?",
"description": "Tippe {name} um die Seitenleiste umzustellen"
},
"onboarding": {
"title": "Mach' Dich bereit für Termine",
"description": "Für das beste Ergebnis folge dieser Anleitung Schritt für Schritt:",
"notification": {
"ongoing": {
"title": "Einrichtung noch nicht abgeschlossen",
"description": "Nächste Schritte anzeigen",
"action": "Weiter"
},
"done": {
"title": "Einrichtung abgeschlossen!",
"description": "Sie können jetzt Termine entgegennehmen",
"action": "Startseite öffnen"
}
},
"sections": {
"settings": {
"title": "Prüfe und speichere Einstellungen",
"description": "Stelle sicher, dass die Einstellungen Deiner Organisation korrekt sind, bevor Du Akteure oder Kanäle einrichtest.",
"action": "Gehe zu Einstellungen"
},
"agents": {
"title": "Akteur:in hinzufügen",
"description": "Akteure sind die Personen, Teams oder Gruppen mit denen Deine Kunden einen Termin vereinbaren können.",
"action": "Add Agent"
},
"channels": {
"title": "Ersten Kanal hinzufügen",
"description": "Kanäle sind Deine Möglichkeit, verschiedene Arten von Terminen anzubieten.",
"action": "Kanal hinzufügen"
},
"staff": {
"title": "Personal hinzufügen & aktivieren",
"description": "Stelle sicher, dass Dein Team Zugriff auf die verschlüsselten Termine hat. Erst wenn sich die erste Person angemeldet hat, können Termine entgegengenommen werden.",
"action": "Person hinzufügen"
}
}
}
},
"agents": {
@@ -637,7 +700,270 @@
"title": "Telefonnummer erforderlich",
"label": "Kunden müssen eine Telefonnummer angeben, wenn sie einen Termin buchen."
}
},
"action": "Speichern"
}
},
"account": {
"overview": {
"title": "Dein Konto"
},
"general": {
"navItem": "Einstellungen",
"title": "Kontoeinstellungen",
"description": "Ändere Deinen Namen."
},
"change-email": {
"navItem": "E-Mail Adresse",
"title": "E-Mail Adresse ändern",
"description": "Ändere Deine E-Mail Adresse."
},
"passkeys": {
"navItem": "Passkeys",
"title": "Passkeys verwalten",
"description": "Du kannst bis zu drei Passkeys für Dein Konto hinzufügen."
},
"change-passphrase": {
"navItem": "Passphrase",
"title": "Change Passphrase",
"description": "Globale Admins können einen Passphrase als Backup-Methode verwenden."
}
},
"staff": {
"title": "Personal",
"roles": {
"GLOBAL_ADMIN": "Globaler Admin",
"TENANT_ADMIN": "Mandanten-Admin",
"STAFF": "Mitarbeiter:in"
},
"permissions": {
"agents": "verwaltet Akteure",
"channels": "verwaltet Kanäle",
"absences": "verwaltet Abwesenheiten",
"settings": "verwaltet Einstellungen",
"staff": "verwaltet Personal",
"appointments": "verwaltet Termine"
},
"form": {
"fields": {
"language": {
"title": "Sprache",
"description": "Wird in der Einlaungs-E-Mail verwendet.",
"placeholder": "Sprache auswählen"
},
"role": {
"title": "Rolle",
"placeholder": "Rolle auswählen"
}
}
},
"add": {
"title": "Mitarbeiter:in hinzufügen",
"description": "Mitarbeiter:innen verwalten Termine und Einstellungen. Diese Konten sind nicht öffentlich.",
"action": "Mitarbeiter:in hinzufügen",
"success": "Mitarbeiter:in hinzugefügt",
"errors": {
"unknown": "Konnte Mitarbeiter:in nicht hinzufügen"
}
},
"list": {
"needsAccess": "Kann Termine nicht sehen",
"empty": {
"title": "Bisher kein Personal",
"description": "Personal verwaltet Termine und Einstellungen. Diese Konten sind nicht öffentlich."
}
},
"edit": {
"title": "Konto bearbeiten",
"description": "Aktualisiere das Konto dieser Mitarbeiter:in.",
"success": "Konto aktualisiert",
"error": "Konnte Konto nicht aktualisieren.",
"action": "Übernehmen"
},
"delete": {
"title": "Konto entfernen",
"description": "Tippe {email} um dieses Konto zu entfernen. Dieser Schritt kann nicht rückgängig gemacht werden.",
"action": "Konto entfernen",
"success": "Konto entfernt",
"error": "Konnte Konto nicht entfernen."
},
"access": {
"title": "Zugriff auf Klientendaten gewähren",
"description": "Willst Du {name} Zugriff auf alle Kundendaten geben?",
"action": "Zugriff gewähren",
"loading": "Zugriff wird gewährt ({success}/{total} Klienten)",
"success": "Zugriff gewährt",
"error": "Konnte Zugriff nicht für alle Klienten gewähren. Bitte erneut versuchen.",
"unavailable": {
"title": "Du kannst keinen Zugriff gewähren",
"description": "Nur Mitarbeiter:innen und Mandanten-Admins können Zugriff gewähren."
}
},
"loading": "Lade Personal"
},
"yes": "Ja",
"no": "Nein",
"public": {
"links": {
"website": "Webseite aufrufen",
"imprint": "Impressum",
"privacyStatement": "Datenschutz"
},
"bookAppointment": "Termin buchen",
"appointment": {
"requiresConfirmation": "Erfordert Bestätigung durch {name}"
},
"tenantNotReady": {
"title": "Diese Seite ist noch nicht bereit",
"description": "Falls dies Ihre Seite ist, loggen Sie sich ein und schließen Sie das Onboarding ab."
},
"steps": {
"channel": {
"title": "Bitte wählen Sie eine Terminart aus:",
"empty": "Aktuell keine Termine verfügbar. Bitte versuchen Sie es später erneut."
},
"agent": {
"title": "Mit wem möchten Sie Ihren Termin vereinbaren?",
"anyone": {
"title": "Keinen Wunsch äußern",
"description": "Wählen Sie diese Option, wenn Sie den frühsten Termin suchen."
}
},
"slot": {
"title": "Bitte wählen Sie einen verfügbaren Termin aus:",
"today": "Heute",
"selectDate": "Wähle Sie ein Datum aus",
"selectTime": "Wähle Sie eine Uhrzeit aus:",
"loading": "Lade verfügbare Termine...",
"empty": "Keine Termine verfügbar. Bitte wählen Sie ein anderes Datum.",
"action": "Termin auswählen"
},
"data": {
"title": "Bitte fügen Sie Ihre Informationen hinzu:",
"alert": {
"title": "Ihre Daten sind sicher",
"description": "Nur Sie und {name} können diese Informationen sehen. Die Daten sind Ende-zu-Ende verschlüsselt."
},
"email": {
"notifyMe": "Benachrichtigen Sie mich über Änderungen per E-Mail",
"tooltip": "Wenn aktiviert, wird Ihre Mail vorübergehend im Logbuch des Mailservers auftauchen. Wenn deaktiviert müssen Sie sich auf dieser Seite Einloggen, um Änderungen einzusehen."
},
"phone": {
"description": "Format: +491234567890. {name} kontaktiert Sie ggf. telefonisch, falls es Fragen/Änderungen zu Ihrem Termin gibt.",
"optional": "(optional)"
},
"action": "Daten hinzufügen"
},
"auth": {
"register": {
"title": "Ich bin das erste Mal hier",
"description": "Bitte legen Sie eine PIN fest:",
"hint": "Fortfahren erstellt ein Konto für Sie.",
"action": "Zussammenfassung anzeigen",
"success": "Konto erfolgreich erstellt!",
"error": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"alreadyExists": "Diese E-Mail ist bereits registriert. Bitte nutzen Sie die Login-Option, um weitere Termine zu buchen."
},
"login": {
"title": "Anmelden",
"description": "Bitte geben Sie Ihre PIN ein:",
"action": "Zusammenfassung anzeigen",
"throttled": "Zu viele Anmeldeversuche. Bitte warten Sie einige Minuten, bevor Sie es erneut versuchen.",
"retry": "Versuchen Sie es erneut in {seconds} Sekunden"
}
},
"summary": {
"title": "Bitte prüfen Sie Ihren Termin, bevor Sie buchen",
"book": {
"action": "Termin buchen"
},
"request": {
"action": "Termin anfragen",
"hint": "Dieser Termin muss manuell bestätigt werden. Sie erhalten per E-Mail informiert."
},
"error": "Buchung fehlgeschlagen. Bitte erneut versuchen."
},
"complete": {
"book": {
"title": "Termin gebucht",
"description": "Wir haben Ihnen eine E-Mail mit den Details gesendet."
},
"request": {
"title": "Termin angefragt",
"description": "{name} wird Ihren Termin in Kürze bestätigen oder absagen. Sie werden per E-Mail informiert."
},
"action": "Weiter zur Startseite"
}
},
"anyAgent": "Jede verfügbare Person",
"register": {
"error": "Verschlüsselung konnte nicht vorbereitet werden. Bitte erneut versuchen"
},
"login": {
"success": "Anmeldung erfolgreich",
"error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen"
}
},
"calendar": {
"today": "Heute",
"loading": "Loading calendar",
"decrypting": "wird entschlüsselt...",
"decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.",
"shownAppointments": {
"title": "Termine",
"options": {
"all": "alle",
"available": "verfügbar",
"booked": "gebucht",
"reserved": "reserviert"
}
}
},
"emails": {
"poweredBy": "Betrieben mit",
"greeting": "Hallo {name},",
"oclock": "Uhr",
"appointmentBooked": {
"subject": "{channel} bei {tenant} gebucht",
"introduction": "Ihr Termin wurde gebucht.",
"action": "Termin absagen",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat."
},
"appointmentReminder": {
"subject": "Erinnerung: Ihr Termin bei {tenant}",
"introduction": "Ihr Termin wurde bei {tenant} steht in Kürze bevor.",
"action": "Termin absagen",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei gebucht hat."
},
"appointmentRequest": {
"subject": "{channel} bei {tenant} angefragt",
"introduction": "Ihr Termin wurde angefragt. Sie werden benachrichtigt, sobald der Termin bestätigt wurde.",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
},
"appointmentRejected": {
"subject": "Ihre Anfrage für {channel} bei {tenant} wurde abgelehnt",
"introduction": "Ihr Termin wurde abgelehnt.",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
},
"confirmation": {
"subject": "E-Mail Adresse bestätigen",
"introduction": "willkommen bei unserem Terminbuchungsportal. Bitte bestätige Deine E-Mail Adresse.",
"action": "E-Mail Adresse bestätigen",
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
"reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
},
"pinReset": {
"subject": "PIN erfolgreich geändert",
"introduction": "Sie haben erfolgreich Ihre PIN beim Terminbuchungsportal von {tenant} geändert und können sich ab sofort mit Ihrer PIN anmelden.",
"action": "Anmelden",
"reason": "Sie erhalten diese E-Mail, weil jemand für die PIN für Ihr Konto geändert hat."
},
"userInvite": {
"subject": "E-Mail Adresse bestätigen für {tenant}",
"introduction": "willkommen beim Terminbuchungsportal von {tenant}. Bitte bestätige Deine E-Mail Adresse.",
"action": "E-Mail Adresse bestätigen",
"hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
"reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
}
}
}
+333 -7
View File
@@ -1,6 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"welcome": "Welcome to OpenReception",
"home": "Home",
"poweredBy": "Powered by",
"i18n": {
"label": "Select Language",
@@ -35,6 +36,20 @@
}
}
},
"setupPasskey": {
"title": "Setup Passkey",
"description": "Create your passkey to secure your account.",
"action": "Set Passkey",
"success": "Passkey set. Login with it now.",
"successKeyPairSaved": "Key pair saved successfully.",
"confirmPrfRetrival": "Now use your newly created passkey to enable encryption with it.",
"error": "Passkey could not be set",
"errorKeyPairDataMissing": "Could not generate key pair. Missing data.",
"errorKeyPairNotSaved": "Key pair could not be saved.",
"errorPrfOutputNotTriggered": "You have to confirm using the passkey.",
"errorAuthenticatorNotSupported": "This authenticator does not support the required security extension (PRF). Please use a modern authenticator like YubiKey 5.2.3+, Apple Touch ID or Android.",
"errorGettingPrfOutput": "Failed to retrieve security data from passkey. Please use a modern authenticator that supports PRF extension."
},
"slogan": "End-to-End encrypted appointment booking platform.",
"logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock",
"form": {
@@ -62,11 +77,20 @@
"url": "Invalid URL Format",
"languages": "Select at least one language",
"language": "Select a default language",
"deleteAfterDays": "Choose a point in time"
"deleteAfterDays": "Choose a point in time",
"phoneNoRequired": "Phone number is required",
"phoneNoInvalid": "Phone number format is invalid",
"pinLength": "PIN must be 6 digits",
"pinDigitsOnly": "PIN must contain only digits",
"pinInsecure": "This PIN is too insecure"
},
"noPassAtAll": "Either passphrase or passkey is required",
"passkey": "Passkey",
"name": "Name"
"salutation": "Salutation",
"name": "Name",
"phone": "Phone Number",
"pin": "PIN",
"pinHint": "Save your PIN in a secure place, like a password-manager."
},
"login": {
"or": "Or",
@@ -85,9 +109,9 @@
},
"confirm": {
"success": {
"title": "Youre all set!",
"description": "Youve successfully secured your account",
"action": "Proceed"
"title": "E-Mail address confirmed!",
"description": "Now you just need to setup a passkey.",
"action": "Setup Passkey"
},
"error": {
"title": "Verification failed",
@@ -148,7 +172,7 @@
"title": "Add Tenant",
"description": "Create a site for someone to start collecting appointments.",
"name": {
"description": "Must be unique on this server. Used as a subdomain {domain}",
"description": "Must be unique on this server. Cannot be changed. Used as a subdomain {domain}",
"errors": {
"urlFormat": "Only letters, numbers, and hyphens are allowed",
"startEndDash": "Must not start or end with a dash"
@@ -328,6 +352,7 @@
"delete": "Delete",
"select": "Select",
"dashboard": {
"title": "Dashboard",
"hello": "Hello",
"tenants": {
"currentTenant": "You are currently managing tenant {name}.",
@@ -345,6 +370,44 @@
"sidebar": {
"title": "Zum ersten Mal hier?",
"description": "Tippe {name} um die Seitenleiste umzustellen"
},
"onboarding": {
"title": "Get ready to collect appointments",
"description": "Follow this step-by-step to get the best results:",
"notification": {
"ongoing": {
"title": "Onboarding not complete",
"description": "Review next steps",
"action": "Proceed"
},
"done": {
"title": "Onboarding complete!",
"description": "You can now collect appointments",
"action": "Open Homepage"
}
},
"sections": {
"settings": {
"title": "Review and save settings",
"description": "Make sure your organization's details are correct, before you add agents or channels.",
"action": "Go to Settings"
},
"agents": {
"title": "Set up your agents",
"description": "Agents let your clients choose which person, team or group they want to make an appointment with.",
"action": "Add Agent"
},
"channels": {
"title": "Set up your first channel",
"description": "Channels are your way to collect different types of appointments",
"action": "Add channel"
},
"staff": {
"title": "Add & Activate Staff Members",
"description": "Make sure your team can decrypt appointment data by adding staff members. Only when the first person added has logged in, appointments can be received.",
"action": "Add Staff Member"
}
}
}
},
"close": "Schließen",
@@ -505,7 +568,7 @@
"absences": {
"title": "Absences",
"add": {
"title": "Add Anbsence",
"title": "Add Absence",
"description": "Absences are a way to mark you agents as being unavailbale.",
"fields": {
"agent": {
@@ -646,7 +709,270 @@
"title": "Require Phone Number",
"label": "Require clients to provide a phone number when booking an appointment."
}
},
"action": "Save"
}
},
"account": {
"overview": {
"title": "Your Account"
},
"general": {
"navItem": "Settings",
"title": "Account Settings",
"description": "Change your name."
},
"change-email": {
"navItem": "E-Mail Address",
"title": "Change E-Mail Address",
"description": "Change your e-mail address."
},
"passkeys": {
"navItem": "Passkeys",
"title": "Manage Passkeys",
"description": "You can add up to three passkeys."
},
"change-passphrase": {
"navItem": "Passphrase",
"title": "Change Passphrase",
"description": "Global Admins can set strong passphrases as a backup login method."
}
},
"staff": {
"title": "Staff",
"roles": {
"GLOBAL_ADMIN": "Global Admin",
"TENANT_ADMIN": "Tenant Admin",
"STAFF": "Appointment Manager"
},
"permissions": {
"agents": "Manages agents",
"channels": "Manages channels",
"absences": "Manages absences",
"settings": "Manages settings",
"staff": "Manages staff",
"appointments": "Manages appointments"
},
"form": {
"fields": {
"language": {
"title": "Language",
"description": "Will be used in the invitation e-mail.",
"placeholder": "Select langauge"
},
"role": {
"title": "Role",
"placeholder": "Select role"
}
}
},
"add": {
"title": "Add Member",
"description": "Staff members manage your appointments and settings. They are not public.",
"action": "Add Member",
"success": "Member added",
"errors": {
"unknown": "Could not add member"
}
},
"list": {
"needsAccess": "Can't see appointments",
"empty": {
"title": "No staff members yet",
"description": "Staff members manage your appointments and settings"
}
},
"edit": {
"title": "Edit Member",
"description": "Update the members details.",
"success": "Member updated",
"error": "Could not update member.",
"action": "Edit Member"
},
"delete": {
"title": "Remove Member",
"description": "Type {email} to remove this member. This step cannot be undone.",
"action": "Remove Member",
"success": "Member removed",
"error": "Could not remove member."
},
"access": {
"title": "Grant access to appointments",
"description": "Do you want to grant {name} access to all client data?",
"action": "Grant access",
"loading": "Granting access ({success}/{total} clients)",
"success": "Access granted",
"error": "Granting access to all clients failed. You can retry anytime.",
"unavailable": {
"title": "You can't grant access",
"description": "Only Appointment Managers and Tenant Admins can be granted access to client data."
}
},
"loading": "Loading staff members"
},
"yes": "Yes",
"no": "No",
"public": {
"links": {
"website": "Go to Website",
"imprint": "Imprint",
"privacyStatement": "Privacy Statement"
},
"bookAppointment": "Book Appointment",
"appointment": {
"requiresConfirmation": "Requires cofirmation by {name}"
},
"tenantNotReady": {
"title": "Not ready to accept appointments.",
"description": "If this is yours, please login and complete onboarding."
},
"steps": {
"channel": {
"title": "Please select an appointment type:",
"empty": "Currently we don't have available appointments. Please check again later."
},
"agent": {
"title": "Please select who you want your appointment with:",
"anyone": {
"title": "No preference",
"description": "Choose this option to get the fastest appointment."
}
},
"slot": {
"title": "Please select an available appointment:",
"today": "Today",
"selectDate": "Select a date",
"selectTime": "Select a time:",
"loading": "Loading available appointments...",
"empty": "No appointments available. Please select another date.",
"action": "Select appointment"
},
"data": {
"title": "Please add your information:",
"alert": {
"title": "Your data is safe",
"description": "Only you and {name} will be able to access this information. It is end-to-end encrypted."
},
"email": {
"notifyMe": "Notify me of changes by e-mail",
"tooltip": "If enabled, your e-mail address will appear temporarily in server logs of the mailserver. If disabled, you will have to login on this page to check for updates."
},
"phone": {
"description": "Format: +1234567890. {name} may contact you by phone, if there are questions or changes regarding your appointment.",
"optional": "(optional)"
},
"action": "Add personal data"
},
"auth": {
"register": {
"title": "It's my first time here",
"description": "Please add an PIN number so you can change your appointment later.",
"hint": "Proceeding will create an account",
"action": "Show summary",
"success": "Account created successfully!",
"error": "Failed to create account. Please try again.",
"alreadyExists": "This email is already registered. Please use the login option to book additional appointments."
},
"login": {
"title": "Login",
"description": "Please enter the PIN number, you've set:",
"action": "Show summary",
"throttled": "Too many failed attempts. Please wait a few minutes before trying again.",
"retry": "Please try again in {seconds} seconds"
}
},
"summary": {
"title": "Please review your appointment, before confirming.",
"book": {
"action": "Book appointment"
},
"request": {
"action": "Request appointment",
"hint": "This appointment will have to be manually confirmed. You will receive an E-Mail once it's confirmed."
},
"error": "Operation failed. Please retry."
},
"complete": {
"book": {
"title": "Appointment booked",
"description": "Weve sent you an e-mail with the details."
},
"request": {
"title": "Appointment requested",
"description": "{name} will confirm or deny your request soon. You will be informed via e-mail."
},
"action": "Proceed to Homepage"
}
},
"anyAgent": "Anyone available",
"register": {
"error": "Encryption could not be set up. Please retry"
},
"login": {
"success": "Login successful",
"error": "Login failed. Please retry"
}
},
"calendar": {
"today": "Today",
"loading": "Loading calendar",
"decrypting": "decrypting...",
"decryptingError": "Unable to decrypt data. Please log-in again",
"shownAppointments": {
"title": "Appointments",
"options": {
"all": "all",
"available": "available",
"booked": "booked",
"reserved": "reserved"
}
}
},
"emails": {
"poweredBy": "Powered by",
"greeting": "Hello {name},",
"oclock": "",
"appointmentBooked": {
"subject": "{channel} at {tenant} booked successfully",
"introduction": "your appointment for has been booked.",
"action": "Cancel appointment",
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
},
"appointmentReminder": {
"subject": "Reminder: Your appointment with {tenant}",
"introduction": "your appointment with {tenant} is soon.",
"action": "Cancel appointment",
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
},
"appointmentRejected": {
"subject": "Your request for {channel} at {tenant} was rejected",
"introduction": "Your appointment was rejected.",
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
},
"appointmentRequest": {
"subject": "{channel} with {tenant} requested",
"introduction": "your appointment was requested. You will be notified once it is confirmed.",
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
},
"confirmation": {
"subject": "Confirm your E-Mail Address",
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
"action": "Confirm E-Mail Address",
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
"reason": "You are receiving this email because someone registered an account with your e-mail address."
},
"pinReset": {
"subject": "PIN successfully changed",
"introduction": "you've successfully changed your PIN on the {tenant} appointment booking platform. You can now log-in with your new PIN.",
"action": "Login",
"reason": "You are receiving this email because someone changed the PIN-Code for your account."
},
"userInvite": {
"subject": "Confirm your E-Mail Address",
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
"action": "Confirm E-Mail Address",
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
"reason": "You are receiving this email because someone registered an account with your e-mail address."
}
}
}
+11 -11
View File
@@ -36,11 +36,11 @@
--sidebar-ring: oklch(0.704 0.04 256.788);
/* custom colors */
--lighter: oklch(77.983% 0.03673 254.95);
--light: oklch(69.121% 0.03859 257.443);
--medium: oklch(62.379% 0.01913 256.381);
--dark: oklch(43.309% 0.00977 254.027);
--darker: oklch(27.232% 0.00797 264.468);
--lighter: oklch(74.262% 0.05601 254.57);
--light: oklch(65.382% 0.05802 257.123);
--medium: oklch(57.414% 0.04099 256.06);
--dark: oklch(40.615% 0.0213 256.41);
--darker: oklch(26.345% 0.01439 261.705);
}
.dark {
@@ -69,7 +69,7 @@
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary: oklch(27.237% 0.04051 269.508);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
@@ -77,11 +77,11 @@
--sidebar-ring: oklch(0.551 0.027 264.364);
/* custom colors */
--lighter: oklch(27.232% 0.00797 264.468);
--light: oklch(43.309% 0.00977 254.027);
--medium: oklch(62.379% 0.01913 256.381);
--dark: oklch(69.121% 0.03859 257.443);
--darker: oklch(77.983% 0.03673 254.95);
--lighter: oklch(26.345% 0.01439 261.705);
--light: oklch(40.615% 0.0213 256.41);
--medium: oklch(57.414% 0.04099 256.06);
--dark: oklch(65.382% 0.05802 257.123);
--darker: oklch(74.262% 0.05601 254.57);
}
@theme {
+9 -1
View File
@@ -3,12 +3,20 @@
import type { SelectUser } from "$lib/server/db/central-schema";
import type { Locale } from "$i18n/runtime";
interface SessionInfo {
passkeyId?: string;
session: {
sessionId: string;
exp: number;
};
}
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user?: SelectUser & { userId: string; sessionId: string; exp: number };
user?: SelectUser & SessionInfo;
locale?: Locale;
}
// interface PageData {}
+1
View File
@@ -9,6 +9,7 @@
<link rel="icon" type="image/png" sizes="192x192" href="%sveltekit.assets%/favicon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="%sveltekit.assets%/favicon-512.png" />
<link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#1A1B4D" />
<script src="/lib/argon2/argon2-bundled.min.js"></script>
<meta name="msapplication-TileColor" color="#1A1B4D" />
%sveltekit.head%
</head>
+28 -1
View File
@@ -3,6 +3,27 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handle } from "./hooks.server";
import { mockCookies } from "$lib/tests/const";
// Mock the sequence function to avoid the request store issue
vi.mock("@sveltejs/kit/hooks", () => ({
sequence: (...handlers: any[]) => {
// Return a simple sequential handler that doesn't require request store
return async ({ event, resolve }: any) => {
let currentResolve = resolve;
// Chain handlers in reverse order
for (let i = handlers.length - 1; i >= 0; i--) {
const handler = handlers[i];
const previousResolve = currentResolve;
currentResolve = async (event: any) => {
return handler({ event, resolve: previousResolve });
};
}
return currentResolve(event);
};
},
}));
// Mock the startup service
vi.mock("$lib/server/services/startup-service", () => ({
StartupService: {
@@ -72,6 +93,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
};
};
@@ -142,6 +165,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
@@ -189,6 +214,8 @@ describe("hooks.server", () => {
isDataRequest: false,
isSubRequest: false,
platform: {} as any,
tracing: { enabled: false, root: "", current: "" },
isRemoteRequest: false,
});
beforeEach(() => {
@@ -205,7 +232,7 @@ describe("hooks.server", () => {
expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block");
expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
expect(response.headers.get("Content-Security-Policy")).toContain(
"script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests",
);
});
+485 -121
View File
@@ -48,7 +48,8 @@
*/
import { OptimizedArgon2 } from "$lib/crypto/hashing";
import { KyberCrypto, AESCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
import { pinThrottleStore } from "$lib/stores/pin-throttle";
import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils";
// Type definitions for unified cryptography
interface ClientKeyPair {
@@ -67,9 +68,11 @@ interface EncryptedData {
authTag: string;
}
interface AppointmentData {
export interface AppointmentData {
salutation?: string;
name: string;
email: string;
shareEmail: boolean;
phone?: string;
}
@@ -103,6 +106,7 @@ export class UnifiedAppointmentCrypto {
private emailHash: string | null = null;
private tunnelId: string | null = null;
private clientAuthenticated: boolean = false;
private serverPrivateKeyShare: string | null = null; // Server share of the private key
// Staff-specific properties
private staffKeyPair: StaffKeyPair | null = null;
@@ -118,6 +122,26 @@ export class UnifiedAppointmentCrypto {
// ===== CLIENT (PATIENT) METHODS =====
/**
* Run a precheck
*/
async preCheck(email: string, tenantId: string): Promise<boolean> {
try {
const emailHash = await this.hashEmail(email);
const response = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emailHash }),
});
return response.ok;
} catch (error) {
console.error("❌ Error running pre-check:", error);
return false;
}
}
/**
* Initializes a new client with E2E encryption
*/
@@ -135,9 +159,12 @@ export class UnifiedAppointmentCrypto {
// 4. Generate tunnel key for AES encryption
this.tunnelKey = await this.generateTunnelKey();
// 5. Split private key into Shamir shares (2-of-2)
// Note: privateKeyShare will be used during actual appointment creation
await this.createPrivateKeyShare(this.clientKeyPair.privateKey, pin);
// 5. Create server share of private key (PIN-based split)
// Server share will be sent to server during appointment creation
this.serverPrivateKeyShare = await this.createPrivateKeyShare(
this.clientKeyPair.privateKey,
pin,
);
// 6. Fetch staff public keys from server
const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId);
@@ -179,6 +206,22 @@ export class UnifiedAppointmentCrypto {
});
if (!challengeResponse.ok) {
if (challengeResponse.status === 429) {
const errorData = await challengeResponse.json();
const retryAfterMs = errorData.retryAfterMs || 60000;
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
// Store throttle state for frontend to enforce
pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0);
if (retryAfterSeconds > 0) {
throw new Error(
`Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`,
);
} else {
throw new Error("Too many failed attempts. Please try again later.");
}
}
throw new Error("Challenge could not be retrieved");
}
@@ -200,23 +243,43 @@ export class UnifiedAppointmentCrypto {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
emailHash: this.emailHash,
challengeId: challengeData.challengeId,
challengeResponse: decryptedChallenge,
}),
},
);
if (!verificationResponse.ok) {
const errorData = await verificationResponse.json();
console.error("❌ Challenge verification failed:", errorData);
if (verificationResponse.status === 429) {
const retryAfterMs = errorData.retryAfterMs || 60000;
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
// Store throttle state for frontend to enforce
pinThrottleStore.setThrottle(this.emailHash, retryAfterMs, errorData.failedAttempts || 0);
if (retryAfterSeconds > 0) {
throw new Error(
`Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`,
);
} else {
throw new Error("Too many failed attempts. Please try again later.");
}
}
throw new Error("Challenge verification failed");
}
const verificationData = await verificationResponse.json();
// 6. Decrypt tunnel key
// 6. Decrypt tunnel key and store tunnel ID
this.tunnelKey = await this.decryptTunnelKey(verificationData.encryptedTunnelKey, privateKey);
this.tunnelId = verificationData.tunnelId;
console.log("✅ Existing client authenticated");
this.clientAuthenticated = true;
// Clear throttle on successful authentication
pinThrottleStore.clearThrottle();
} catch (error) {
console.error("❌ Error during client login:", error);
throw error;
@@ -229,9 +292,12 @@ export class UnifiedAppointmentCrypto {
async createAppointment(
appointmentData: AppointmentData,
appointmentDate: string,
agentId: string,
channelId: string,
duration: number,
tenantId: string,
isFirstAppointment: boolean = false,
clientLanguage: string = "de",
): Promise<string> {
if (!this.clientAuthenticated || !this.tunnelKey) {
throw new Error("Client not authenticated");
@@ -250,22 +316,33 @@ export class UnifiedAppointmentCrypto {
? {
// New client
tunnelId: this.tunnelId,
agentId,
channelId,
appointmentDate,
duration,
emailHash: this.emailHash,
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
clientLanguage,
clientPublicKey: this.clientKeyPair?.publicKey,
privateKeyShare: await this.getPrivateKeyShare(),
encryptedAppointment,
staffKeyShares: await this.getStaffKeyShares(tenantId),
clientKeyShare: await this.getClientKeyShare(),
clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(),
salutation: appointmentData.salutation,
}
: {
// Existing client
emailHash: this.emailHash,
tunnelId: this.tunnelId!,
agentId,
channelId,
appointmentDate,
duration,
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
clientLanguage,
encryptedAppointment,
salutation: appointmentData.salutation,
};
const response = await fetch(endpoint, {
@@ -337,16 +414,14 @@ export class UnifiedAppointmentCrypto {
// ===== STAFF METHODS =====
/**
* Authenticate staff member using WebAuthn and reconstruct private key from shards
* Authenticate staff member using WebAuthn with PRF and reconstruct private key from shards
*
* SECURITY: Uses PRF Extension for zero-knowledge key derivation.
* Requires modern authenticator with PRF support (CTAP 2.1+).
*/
async authenticateStaff(staffId: string, tenantId: string): Promise<void> {
try {
console.log("🔐 Authenticasting staff member:", staffId, "for tenant:", tenantId);
// 1. Perform WebAuthn authentication
const webAuthnResponse = await this.performWebAuthnAuthentication(staffId);
// 2. Fetch database shard from server
// 1. Fetch database shard from server (to get passkeyId)
const shardResponse = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, {
method: "GET",
headers: { "Content-Type": "application/json" },
@@ -358,11 +433,11 @@ export class UnifiedAppointmentCrypto {
const shardData = await shardResponse.json();
// 3. Derive passkey-based shard from WebAuthn response
const passkeyBasedShard = await this.derivePasskeyBasedShard(
shardData.passkeyId,
webAuthnResponse.authenticatorData,
);
// 2. Get PRF output from session (stored during login)
const prfOutput = await this.getPRFOutputFromSession(staffId, shardData.passkeyId);
// 3. Derive passkey-based shard from PRF output
const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId);
// 4. Decode database shard
const dbShard = this.base64ToUint8Array(shardData.privateKeyShare);
@@ -384,7 +459,7 @@ export class UnifiedAppointmentCrypto {
this.staffAuthenticated = true;
this.keyExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes
console.log("✅ Staff authentication successful");
console.log("✅ Staff authentication successful with PRF");
} catch (error) {
console.error("❌ Staff authentication failed:", error);
throw new Error(
@@ -409,24 +484,59 @@ export class UnifiedAppointmentCrypto {
}
try {
// 1. Decrypt the symmetric key using staff's private key
const encapsulatedSecret = this.hexToUint8Array(encryptedData.staffKeyShare);
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
const staffKeyShareBytes = this.hexToUint8Array(encryptedData.staffKeyShare);
// ML-KEM-768 encapsulated secret is 1088 bytes
const ENCAPSULATED_SECRET_LENGTH = 1088;
const IV_LENGTH = 12;
if (staffKeyShareBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) {
throw new Error(
`staffKeyShare too short: ${staffKeyShareBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`,
);
}
const encapsulatedSecret = staffKeyShareBytes.slice(0, ENCAPSULATED_SECRET_LENGTH);
const iv = staffKeyShareBytes.slice(
ENCAPSULATED_SECRET_LENGTH,
ENCAPSULATED_SECRET_LENGTH + IV_LENGTH,
);
const encryptedTunnelKey = staffKeyShareBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH);
// 1. Decapsulate to get shared secret
const sharedSecret = KyberCrypto.decapsulate(
this.staffKeyPair.privateKey,
encapsulatedSecret,
);
// 2. Import the symmetric key
const symmetricKey = await crypto.subtle.importKey(
// 2. Use first 32 bytes of shared secret as AES key
const aesKeyBytes = sharedSecret.slice(0, 32);
// Import as CryptoKey for Web Crypto API
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"decrypt",
]);
// 3. Decrypt the tunnel key with AES-GCM (encrypted already includes authTag)
const decryptedTunnelKey = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
encryptedTunnelKey,
);
const tunnelKeyBytes = new Uint8Array(decryptedTunnelKey);
// 4. Import tunnel key as CryptoKey
const tunnelKey = await crypto.subtle.importKey(
"raw",
new Uint8Array(sharedSecret),
tunnelKeyBytes,
{ name: "AES-GCM" },
false,
["decrypt"],
);
// 3. Decrypt the appointment data
const iv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
// 5. Now decrypt the actual appointment data
const appointmentIv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload);
const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag);
@@ -436,8 +546,8 @@ export class UnifiedAppointmentCrypto {
encrypted.set(authTag, ciphertext.length);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(iv) },
symmetricKey,
{ name: "AES-GCM", iv: appointmentIv },
tunnelKey,
encrypted,
);
@@ -510,7 +620,6 @@ export class UnifiedAppointmentCrypto {
this.tenantId = null;
this.staffAuthenticated = false;
this.keyExpiry = null;
console.log("🔒 Staff logged out");
}
/**
@@ -521,92 +630,151 @@ export class UnifiedAppointmentCrypto {
this.clientKeyPair = null;
this.emailHash = null;
this.tunnelId = null;
this.serverPrivateKeyShare = null;
this.clientAuthenticated = false;
console.log("🔒 Client logged out");
}
// ===== SHARED PRIVATE METHODS =====
/**
* Perform WebAuthn authentication for staff
* Get PRF output for staff authentication
*
* SECURITY: PRF Extension is REQUIRED for zero-knowledge key derivation.
* This method retrieves PRF output from the auth session (stored during login).
*
* @param staffId - Staff member ID
* @param passkeyId - Passkey credential ID
* @returns PRF output as ArrayBuffer
* @throws Error if PRF output is not available in session
*/
private async performWebAuthnAuthentication(staffId: string): Promise<{
authenticatorData: ArrayBuffer;
signature: ArrayBuffer;
userHandle: ArrayBuffer | null;
}> {
// 1. Get authentication options from server
const optionsResponse = await fetch("/api/auth/webauthn/authenticate/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: staffId }),
});
private async getPRFOutputFromSession(staffId: string, passkeyId: string): Promise<ArrayBuffer> {
// Import auth store dynamically to avoid circular dependencies
const { auth } = await import("$lib/stores/auth");
if (!optionsResponse.ok) {
throw new Error("Failed to get WebAuthn options");
// Get PRF output from session storage (set during login)
const passkeyAuthData = auth.getPasskeyAuthData();
if (!passkeyAuthData) {
throw new Error(
"No passkey authentication data found in session. " +
"Please log in again with your passkey to access encrypted data.",
);
}
const options = await optionsResponse.json();
// 2. Perform WebAuthn authentication
const credential = (await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(this.base64ToUint8Array(options.challenge)),
allowCredentials: options.allowCredentials?.map((cred: { id: string; type: string }) => ({
id: new Uint8Array(this.base64ToUint8Array(cred.id)),
type: cred.type as PublicKeyCredentialType,
})),
timeout: options.timeout,
userVerification: options.userVerification,
},
})) as PublicKeyCredential;
if (!credential) {
throw new Error("WebAuthn authentication failed");
if (!passkeyAuthData.prfOutput) {
throw new Error(
"PRF output not available in session. " +
"This passkey may not support the PRF extension. " +
"Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android).",
);
}
const response = credential.response as AuthenticatorAssertionResponse;
// Verify that the passkeyId matches (security check)
if (passkeyAuthData.passkeyId !== passkeyId) {
throw new Error(
`Passkey ID mismatch: expected ${passkeyId}, got ${passkeyAuthData.passkeyId}. ` +
"Please log out and log in again.",
);
}
return {
authenticatorData: response.authenticatorData,
signature: response.signature,
userHandle: response.userHandle,
};
// Decode PRF output from base64
const prfBytes = this.base64ToUint8Array(passkeyAuthData.prfOutput);
return prfBytes.buffer as ArrayBuffer;
}
/**
* Derive a deterministic shard from passkey authentication data
* Store the staff key pair after the registration of a new staff member with PRF
*
* This method creates a consistent private key shard from WebAuthn authenticatorData.
* SECURITY: Uses PRF Extension for zero-knowledge key derivation.
* The prfOutput parameter MUST come from a WebAuthn assertion with PRF extension.
*
* @param tenantId - Tenant ID
* @param staffId - Staff member ID
* @param passkeyId - Passkey credential ID
* @param prfOutput - 32-byte PRF output from WebAuthn assertion (secret!)
* @param keyPair - ML-KEM-768 keypair generated in browser
* @throws Error if PRF output is invalid or API call fails
*/
public async storeStaffKeyPair(
tenantId: string,
staffId: string,
passkeyId: string,
prfOutput: ArrayBuffer,
keyPair: { publicKey: Uint8Array; privateKey: Uint8Array },
): Promise<void> {
// Derive passkey-based shard from PRF output
const passkeyBasedShard = await this.derivePasskeyBasedShardWithPRF(prfOutput, staffId);
// Create database shard by XORing private key with passkey-based shard
const dbShard = new Uint8Array(keyPair.privateKey.length);
for (let i = 0; i < keyPair.privateKey.length; i++) {
dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i];
}
// Store public key and database shard on server
await fetch(`/api/tenants/${tenantId}/staff/${staffId}/crypto`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
passkeyId,
publicKey: this.uint8ArrayToBase64(keyPair.publicKey),
privateKeyShare: this.uint8ArrayToBase64(dbShard),
}),
});
console.log("✅ Staff keypair stored with PRF-based security");
}
private uint8ArrayToBase64(array: Uint8Array): string {
return btoa(String.fromCharCode.apply(null, Array.from(array)));
}
/**
* Derive a deterministic shard from WebAuthn PRF Extension (CTAP 2.1+)
*
* This method uses the PRF (Pseudo-Random Function) extension to derive a secret shard.
* The same passkey will always produce the same shard, enabling key reconstruction.
*
* SECURITY: PRF provides ZERO-KNOWLEDGE guarantee:
* - PRF output is SECRET - only the passkey owner can derive it
* - Server NEVER sees the PRF output (only the database shard)
* - Database compromise does NOT reveal private keys (both shards needed)
*
* Used in Staff Key Management:
* - During registration: Create shard to XOR with private key for database storage
* - During authentication: Recreate same shard to reconstruct private key
*
* Uses HKDF (HMAC-based Key Derivation Function) with:
* - IKM: WebAuthn authenticatorData (contains randomness from authenticator)
* - Salt: "staff-crypto-shard-v1" (version-specific salt)
* - Info: "passkey:{passkeyId}" (domain separation per passkey)
* - Length: 2400 bytes (ML-KEM-768 private key size)
* Uses PRF Extension + HKDF expansion:
* - Input: 32-byte PRF output from authenticator (secret!)
* - HKDF Salt: "staff-prf-shard-v2" (version-specific)
* - HKDF Info: "staff:{staffId}" (domain separation)
* - Output: 2400 bytes (ML-KEM-768 private key size)
*
* @param prfOutput - 32-byte PRF output from assertion.getClientExtensionResults().prf.results.first
* @param staffId - Staff member ID for domain separation
* @returns 2400-byte shard for XOR-based key reconstruction
* @throws Error if PRF output is not exactly 32 bytes
*/
private async derivePasskeyBasedShard(
passkeyId: string,
authenticatorData: ArrayBuffer,
private async derivePasskeyBasedShardWithPRF(
prfOutput: ArrayBuffer,
staffId: string,
): Promise<Uint8Array> {
// Extract randomness from authenticator data
const inputKeyMaterial = new Uint8Array(authenticatorData);
// Validate PRF output length (should always be 32 bytes per CTAP 2.1 spec)
const prfBytes = new Uint8Array(prfOutput);
if (prfBytes.length !== 32) {
throw new Error(`Invalid PRF output length: ${prfBytes.length} bytes (expected 32)`);
}
// Import the IKM as a CryptoKey for HKDF
const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, "HKDF", false, [
"deriveBits",
]);
// Import the PRF output as a CryptoKey for HKDF expansion
const ikmKey = await crypto.subtle.importKey("raw", prfBytes, "HKDF", false, ["deriveBits"]);
// Salt for HKDF
const salt = new TextEncoder().encode("staff-crypto-shard-v1");
// Salt for HKDF (versioned to allow future rotation)
// v2: Uses email-based PRF salts for multi-passkey support
// Each passkey still produces unique PRF output (passkey private key is part of PRF)
const salt = new TextEncoder().encode("staff-prf-shard-v2");
// Info for HKDF (domain separation with passkey ID)
const info = new TextEncoder().encode(`passkey:${passkeyId}`);
// Info for HKDF (domain separation per staff member)
const info = new TextEncoder().encode(`staff:${staffId}`);
// Derive key material with the length needed for Kyber private key (2400 bytes for ML-KEM-768)
const keyMaterial = await crypto.subtle.deriveBits(
@@ -723,20 +891,54 @@ export class UnifiedAppointmentCrypto {
// ===== UTILITY METHODS =====
private generateTunnelId(): string {
return "tunnel_" + crypto.randomUUID();
return crypto.randomUUID();
}
/**
* Creates a server share of the private key using Shamir Secret Sharing (2-of-2)
*
* This implements a 2-party key splitting scheme where:
* - PIN-derived share (x=1): Deterministically derived from PIN + email hash (client can always recreate)
* - Server share (x=2): Stored in database
*
* Reconstruction requires both shares (2-of-2 threshold).
*
* Security properties:
* - Each share reveals NO information about the private key (information-theoretic security)
* - Both shares are required for reconstruction
* - Server share is useless without the PIN
*
* This allows the client to authenticate from any device:
* 1. Client provides email + PIN
* 2. Server returns the server share via challenge API
* 3. Client derives PIN share deterministically and reconstructs the private key
*
* @param privateKey - The ML-KEM-768 private key (hex string)
* @param pin - User's PIN for deterministic share derivation
* @returns Server share (hex string) to be stored in database
*/
private async createPrivateKeyShare(privateKey: string, pin: string): Promise<string> {
// Use Shamir Secret Sharing for private key
const privateKeyBytes = this.hexToUint8Array(privateKey);
const shares = ShamirSecretSharing.splitSecret(privateKeyBytes, 2, 2);
// One share is PIN-encrypted and stored on client
const clientShare = shares[0].y;
const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "");
const encryptedShare = await AESCrypto.encrypt(this.uint8ArrayToHex(clientShare), pinHash);
// Derive a deterministic y-value for the PIN-based share (x=1)
// ML-KEM-768 private key is 2400 bytes, so we need a hash of that length
const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "", {
hashLength: privateKeyBytes.length,
});
return this.uint8ArrayToHex(encryptedShare.encrypted);
// Use ShamirSecretSharing to create the shares with deterministic first share
const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(privateKeyBytes, pinHash);
// TEST: Immediately reconstruct to verify
const testReconstruct = ShamirSecretSharing.reconstructSecret(shares);
const match = privateKeyBytes.every((v, i) => v === testReconstruct[i]);
if (!match) {
console.error("❌ CRITICAL: Shamir reconstruction failed immediately after split!");
}
// Return the server share (x=2)
return this.uint8ArrayToHex(shares[1].y);
}
private async fetchStaffPublicKeys(tenantId: string): Promise<StaffPublicKey[]> {
@@ -760,15 +962,94 @@ export class UnifiedAppointmentCrypto {
): Promise<Array<{ userId: string; encryptedTunnelKey: string }>> {
if (!this.tunnelKey) throw new Error("No tunnel key available");
// Export tunnel key as raw bytes
const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey);
const tunnelKeyArray = new Uint8Array(tunnelKeyBytes);
const results = [];
for (const staff of staffKeys) {
const staffPublicKeyBytes = this.hexToUint8Array(staff.publicKey);
const encryptedKey = KyberCrypto.encapsulate(staffPublicKeyBytes);
// Public key is stored as Base64, not Hex
const staffPublicKeyBytes = this.base64ToUint8Array(staff.publicKey);
console.log("🔐 Encrypting tunnel key for staff:", {
userId: staff.userId,
publicKeyLength: staffPublicKeyBytes.length,
publicKeyHex:
Array.from(staffPublicKeyBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.substring(0, 64) + "...",
tunnelKeyLength: tunnelKeyArray.length,
});
// Kyber encapsulation creates a shared secret
const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(staffPublicKeyBytes);
console.log("🔑 Kyber encapsulation done:", {
sharedSecretLength: sharedSecret.length,
encapsulatedSecretLength: encapsulatedSecret.length,
});
// Use the first 32 bytes of shared secret as AES key (same as decryption)
const aesKeyBytes = sharedSecret.slice(0, 32);
console.log(
"🔑 AES key for encryption (hex):",
Array.from(aesKeyBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
// Import as CryptoKey for Web Crypto API
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"encrypt",
]);
// Generate IV for AES-GCM
const iv = BufferUtils.randomBytes(12);
console.log(
"📍 IV for encryption (hex):",
Array.from(iv)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
console.log(
"🔒 Tunnel key to encrypt (hex):",
Array.from(tunnelKeyArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
// Encrypt tunnel key with AES-GCM
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
aesKey,
tunnelKeyArray,
);
// encrypted contains ciphertext + 16-byte auth tag
const encryptedArray = new Uint8Array(encrypted);
console.log(
"🔒 Encrypted tunnel key (hex):",
Array.from(encryptedArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
// Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag)
const combined = new Uint8Array(
encapsulatedSecret.length + iv.length + encryptedArray.length,
);
combined.set(encapsulatedSecret, 0);
combined.set(iv, encapsulatedSecret.length);
combined.set(encryptedArray, encapsulatedSecret.length + iv.length);
results.push({
userId: staff.userId,
encryptedTunnelKey: this.uint8ArrayToHex(encryptedKey.encapsulatedSecret),
encryptedTunnelKey: this.uint8ArrayToHex(combined),
});
}
@@ -779,37 +1060,87 @@ export class UnifiedAppointmentCrypto {
if (!this.tunnelKey || !this.clientKeyPair)
throw new Error("Tunnel key or client key not available");
const clientPublicKeyBytes = this.hexToUint8Array(this.clientKeyPair.publicKey);
const encryptedKey = KyberCrypto.encapsulate(clientPublicKeyBytes);
// Export tunnel key as raw bytes
const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey);
const tunnelKeyArray = new Uint8Array(tunnelKeyBytes);
return this.uint8ArrayToHex(encryptedKey.encapsulatedSecret);
const clientPublicKeyBytes = this.hexToUint8Array(this.clientKeyPair.publicKey);
// Kyber encapsulation creates a shared secret
const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(clientPublicKeyBytes);
// Use the first 32 bytes of shared secret as AES key
const aesKeyBytes = sharedSecret.slice(0, 32);
// Import as CryptoKey for Web Crypto API
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"encrypt",
]);
// Generate IV for AES-GCM
const iv = BufferUtils.randomBytes(12);
// Encrypt tunnel key with AES-GCM
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, tunnelKeyArray);
// encrypted contains ciphertext + 16-byte auth tag
const encryptedArray = new Uint8Array(encrypted);
// Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag)
const combined = new Uint8Array(encapsulatedSecret.length + iv.length + encryptedArray.length);
combined.set(encapsulatedSecret, 0);
combined.set(iv, encapsulatedSecret.length);
combined.set(encryptedArray, encapsulatedSecret.length + iv.length);
return this.uint8ArrayToHex(combined);
}
private async reconstructPrivateKey(pin: string, serverShare: string): Promise<string> {
// For now, simplified reconstruction - in real implementation this would be more complex
// TODO: Implement proper Shamir reconstruction with PIN-derived decryption
if (!this.emailHash) {
throw new Error("Email hash not available for key reconstruction");
}
// 1. Derive PIN-based share deterministically (x=1, y=pinShareY)
const serverShareBytes = this.hexToUint8Array(serverShare);
const clientShareBytes = serverShareBytes; // Simplified for now
// ML-KEM-768 private key is 2400 bytes, derive hash of same length
const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash, {
hashLength: serverShareBytes.length,
});
// In future: derive pinHash and use it to decrypt the client share
// const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "");
// Reconstruct private key from both shares
// 2. Reconstruct using proper Shamir Secret Sharing
const shares = [
{ x: 1, y: clientShareBytes },
{ x: 1, y: pinHash },
{ x: 2, y: serverShareBytes },
];
const reconstructedKey = ShamirSecretSharing.reconstructSecret(shares);
const privateKey = ShamirSecretSharing.reconstructSecret(shares);
return this.uint8ArrayToHex(reconstructedKey);
return this.uint8ArrayToHex(privateKey);
}
private async decryptChallenge(encryptedChallenge: string, privateKey: string): Promise<string> {
const privateKeyBytes = this.hexToUint8Array(privateKey);
const challengeBytes = this.hexToUint8Array(encryptedChallenge);
const fullChallengeBytes = this.hexToUint8Array(encryptedChallenge);
const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, challengeBytes);
return this.uint8ArrayToHex(sharedSecret);
// Split the encryptedChallenge into:
// 1. Encapsulated secret (first 1088 bytes for ML-KEM-768)
// 2. Encrypted challenge (remaining bytes)
const encapsulatedSecret = fullChallengeBytes.slice(0, 1088);
const encryptedChallengeBuffer = fullChallengeBytes.slice(1088);
// Decapsulate to get the shared secret
const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret);
// XOR the encrypted challenge with the shared secret to decrypt
const challenge = new Uint8Array(encryptedChallengeBuffer.length);
for (let i = 0; i < encryptedChallengeBuffer.length; i++) {
challenge[i] = encryptedChallengeBuffer[i] ^ sharedSecret[i];
}
// The decrypted challenge is raw bytes (32 bytes from randomBytes)
// Convert to base64 string for server verification
const challengeBase64 = this.uint8ArrayToBase64(challenge);
// Return the base64-encoded challenge (server expects it in base64 format)
return challengeBase64;
}
private async decryptTunnelKey(
@@ -819,11 +1150,45 @@ export class UnifiedAppointmentCrypto {
const privateKeyBytes = this.hexToUint8Array(privateKey);
const encryptedKeyBytes = this.hexToUint8Array(encryptedTunnelKey);
const tunnelKeyBytes = KyberCrypto.decapsulate(privateKeyBytes, encryptedKeyBytes);
// Parse the encrypted data: encapsulatedSecret || iv || encryptedTunnelKey
const ENCAPSULATED_SECRET_LENGTH = 1088;
const IV_LENGTH = 12;
if (encryptedKeyBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) {
throw new Error(
`encryptedTunnelKey too short: ${encryptedKeyBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`,
);
}
const encapsulatedSecret = encryptedKeyBytes.slice(0, ENCAPSULATED_SECRET_LENGTH);
const iv = encryptedKeyBytes.slice(
ENCAPSULATED_SECRET_LENGTH,
ENCAPSULATED_SECRET_LENGTH + IV_LENGTH,
);
const encryptedTunnel = encryptedKeyBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH);
// 1. Decapsulate to get shared secret
const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret);
// 2. Use first 32 bytes as AES key
const aesKeyBytes = sharedSecret.slice(0, 32);
// 3. Import as CryptoKey
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"decrypt",
]);
// 4. Decrypt the tunnel key
const decryptedTunnelKey = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
encryptedTunnel,
);
// 5. Import tunnel key as CryptoKey
return await crypto.subtle.importKey(
"raw",
new Uint8Array(tunnelKeyBytes),
new Uint8Array(decryptedTunnelKey),
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"],
@@ -832,9 +1197,8 @@ export class UnifiedAppointmentCrypto {
// Helper methods for completing the API
private async getPrivateKeyShare(): Promise<string> {
if (!this.clientKeyPair) throw new Error("Client keypair not available");
// TODO: Return the actual server share from Shamir splitting
return this.clientKeyPair.privateKey; // Simplified for now
if (!this.serverPrivateKeyShare) throw new Error("Server private key share not available");
return this.serverPrivateKeyShare;
}
private async getStaffKeyShares(
@@ -66,6 +66,7 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
{#snippet child({ props })}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={item.url} {...props}>
<item.icon />
<Text style="md" class="ml-2">{item.title}</Text>
@@ -42,6 +42,7 @@
{#if $auth.user && item.roles.includes($auth.user?.role)}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isCurrentSection(item.url)} tooltipContent={item.title}>
<!-- eslint-disable svelte/no-navigation-without-resolve -->
{#snippet child({ props })}
<a
href={item.url}
@@ -55,6 +56,7 @@
{/if}
</a>
{/snippet}
<!-- eslint-enable svelte/no-navigation-without-resolve -->
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
@@ -12,6 +12,7 @@
import { nameToAvatarFallback } from "$lib/utils/name";
import { LanguageSwitch } from "$lib/components/templates/language-switch";
import { m } from "$i18n/messages";
import { resolve } from "$app/paths";
const sidebar = useSidebar();
</script>
@@ -61,13 +62,13 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<LanguageSwitch class="w-full" triggerClass="w-[100%] [&>svg]:ml-auto" />
<DropdownMenu.Item onclick={() => goto(ROUTES.DASHBOARD.ACCOUNT)}>
<DropdownMenu.Item onclick={() => goto(resolve(ROUTES.DASHBOARD.ACCOUNT.MAIN))}>
<AccountIcon />
{m["nav.account"]()}
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={() => goto(ROUTES.LOGOUT)}>
<DropdownMenu.Item onclick={() => goto(resolve(ROUTES.LOGOUT))}>
<LogOutIcon />
{m["nav.logout"]()}
</DropdownMenu.Item>
@@ -11,9 +11,10 @@
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import UnplugIcon from "@lucide/svelte/icons/unplug";
import UnknownTenantIcon from "@lucide/svelte/icons/shield-question-mark";
import UnknownTenantIcon from "@lucide/svelte/icons/landmark";
import Loader from "@lucide/svelte/icons/loader-2";
import { cn } from "$lib/utils";
import { resolve } from "$app/paths";
const sidebar = useSidebar();
@@ -49,7 +50,16 @@
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
{#if activeTenant}
<UnknownTenantIcon class="size-4" />
{#if activeTenant.logo}
<img
src={activeTenant.logo}
alt={activeTenant.shortName}
class="border-dark h-full w-full rounded-md border object-cover object-center"
loading="lazy"
/>
{:else}
<UnknownTenantIcon class="size-4" />
{/if}
{:else}
<UnplugIcon class="size-4" />
{/if}
@@ -91,14 +101,26 @@
class="gap-2 p-2"
>
<div class="flex size-6 items-center justify-center rounded-md border">
<UnknownTenantIcon class="size-3.5 shrink-0" />
{#if tenant.logo}
<img
src={tenant.logo}
alt={tenant.shortName}
class="border-dark h-full w-full rounded-sm border object-cover object-center"
loading="lazy"
/>
{:else}
<UnknownTenantIcon class="size-3.5 shrink-0" />
{/if}
</div>
{tenant.shortName}
</DropdownMenu.Item>
{/each}
{#if $tenants?.tenants.length > maxTenantsToShow}
<DropdownMenu.Separator />
<DropdownMenu.Item class="gap-2 p-2" onclick={() => goto(ROUTES.DASHBOARD.TENANTS)}>
<DropdownMenu.Item
class="gap-2 p-2"
onclick={() => goto(resolve(ROUTES.DASHBOARD.TENANTS))}
>
<div
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
>
@@ -6,12 +6,18 @@
import * as Sidebar from "$lib/components/ui/sidebar";
import type { HTMLAttributes } from "svelte/elements";
import { sidebar } from "$lib/stores/sidebar";
import type { Snippet } from "svelte";
let {
children,
sidebarRight,
headerRight,
breakcrumbs,
}: HTMLAttributes<HTMLDivElement> & { breakcrumbs?: Array<{ label: string; href: string }> } =
$props();
}: HTMLAttributes<HTMLDivElement> & {
breakcrumbs?: Array<{ label: string; href: string }>;
headerRight?: Snippet;
sidebarRight?: Snippet;
} = $props();
</script>
<Sidebar.Provider bind:open={$sidebar.isOpen} onOpenChange={(open) => sidebar.setOpen(open)}>
@@ -22,7 +28,7 @@
<header
class="mb-3 flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
>
<div class="flex items-center gap-2">
<div class="flex w-full items-center gap-2">
<Sidebar.Trigger
class="-ml-1"
onclick={() => {
@@ -52,10 +58,18 @@
</Breadcrumb.List>
</Breadcrumb.Root>
{/if}
{#if headerRight}
<div class="ml-auto">
{@render headerRight?.()}
</div>
{/if}
</div>
</header>
{@render children?.()}
</HorizontalPagePadding>
</PageWithClaim>
</Sidebar.Inset>
{#if sidebarRight}
{@render sidebarRight?.()}
{/if}
</Sidebar.Provider>
@@ -1,14 +1,19 @@
<script lang="ts">
import { Skeleton } from "$lib/components/ui/skeleton";
import { Text } from "$lib/components/ui/typography";
import { cn } from "$lib/utils";
import Loader from "@lucide/svelte/icons/loader-2";
let { class: className = "" }: { class?: string } = $props();
let { label, class: className = "" }: { label?: string; class?: string } = $props();
</script>
<div class={cn("my-10 flex grow flex-col justify-center gap-2 text-center", className)}>
<Loader class="mx-auto mb-3 size-20 animate-spin" strokeWidth={1} />
<Skeleton class="mx-auto h-10 w-45" />
<Skeleton class="mx-auto h-5 w-60" />
<Skeleton class="mx-auto h-5 w-35" />
{#if label}
<Text style="sm" class="text-muted-foreground text-center">{label}</Text>
{:else}
<Skeleton class="mx-auto h-10 w-45" />
<Skeleton class="mx-auto h-5 w-60" />
<Skeleton class="mx-auto h-5 w-35" />
{/if}
</div>
@@ -33,6 +33,7 @@
size?: VariantProps<typeof variants>["size"];
} = $props();
// svelte-ignore state_referenced_locally
const isSmaller = size === "sm";
</script>
@@ -3,39 +3,49 @@
import { getLocale, setLocale } from "$i18n/runtime.js";
import type { ButtonSize, ButtonVariant } from "$lib/components/ui/button";
import { ComboBox } from "$lib/components/ui/combobox";
import { languageSwitchLocales, supportedLocales } from "$lib/const/locales";
import { cn } from "$lib/utils";
let {
locales,
class: className = "",
triggerClass = "",
triggerSize = "default",
triggerVariant = "ghost",
}: {
locales?: (typeof supportedLocales)[];
triggerVariant?: ButtonVariant;
triggerSize?: ButtonSize;
class?: string;
triggerClass?: string;
} = $props();
const options = $derived(
locales
? Object.values(languageSwitchLocales).filter((it) =>
locales.includes(it.value as unknown as typeof supportedLocales),
)
: Object.values(languageSwitchLocales),
);
</script>
<div class={className}>
<ComboBox
options={[
{ label: "Deutsch", value: "de", keywords: ["german", "deutsch"] },
{ label: "English", value: "en", keywords: ["english"] },
]}
value={getLocale()}
onChange={(value) => {
setLocale(value as "de" | "en");
}}
labels={{
placeholder: m["i18n.label"](),
search: m["i18n.search"](),
notFound: m["i18n.notFound"](),
}}
{triggerVariant}
{triggerSize}
{triggerClass}
class={cn("w-auto")}
/>
</div>
{#if options.length > 1}
<div class={className}>
<ComboBox
{options}
value={getLocale()}
onChange={(value) => {
setLocale(value as "de" | "en");
}}
labels={{
placeholder: m["i18n.label"](),
search: m["i18n.search"](),
notFound: m["i18n.notFound"](),
}}
{triggerVariant}
{triggerSize}
{triggerClass}
class={cn("w-auto")}
/>
</div>
{/if}
@@ -15,6 +15,7 @@
icon: Component;
label: string;
isDestructive?: boolean;
isHidden?: boolean;
onClick: () => void;
}
| { type: "divider" };
@@ -63,7 +64,16 @@
{/if}
<div></div>
<div class="flex flex-col">
<Text style="md" class="font-medium">{title}</Text>
<div class="flex items-center gap-2">
<Text style="md" class="font-medium">{title}</Text>
{#if badges && badges.length > 0}
<div class="flex flex-wrap gap-1 py-1">
{#each badges as badge, index (`${badge.label}-${index}`)}
<Badge variant={badge.variant} class="uppercase">{badge.label}</Badge>
{/each}
</div>
{/if}
</div>
{#if description}
{#if descriptionOnClick}
<Button
@@ -80,13 +90,6 @@
</Text>
{/if}
{/if}
{#if badges && badges.length > 0}
<div class="flex flex-wrap gap-1 py-1">
{#each badges as badge, index (`${badge.label}-${index}`)}
<Badge variant={badge.variant} class="uppercase">{badge.label}</Badge>
{/each}
</div>
{/if}
</div>
</div>
{#if actions && actions.length > 0}
@@ -103,7 +106,7 @@
<DropdownMenu.Label>{m["actions"]()}</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each actions as action, index (`action-${index}`)}
{#if action.type === "action"}
{#if action.type === "action" && action.isHidden !== true}
<DropdownMenu.Item
onSelect={action.onClick}
class={cn(
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>
+14
View File
@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Separator } from "$lib/components/ui/separator/index.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="button-group-separator"
{orientation}
class={cn("bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", className)}
{...restProps}
/>
@@ -0,0 +1,30 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
child,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
...restProps,
class: cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
),
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -0,0 +1,46 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const buttonGroupVariants = tv({
base: "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
orientation = "horizontal",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
class={cn(buttonGroupVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,13 @@
import Root from "./button-group.svelte";
import Text from "./button-group-text.svelte";
import Separator from "./button-group-separator.svelte";
export {
Root,
Text,
Separator,
//
Root as ButtonGroup,
Text as ButtonGroupText,
Separator as ButtonGroupSeparator,
};
@@ -58,6 +58,7 @@
</script>
{#if href}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
bind:this={ref}
data-slot="button"
@@ -70,6 +71,7 @@
>
{@render children?.()}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
<button
bind:this={ref}
@@ -1,31 +1,50 @@
<script lang="ts">
import { Checkbox } from "../checkbox";
import * as Popover from "$lib/components/ui/popover/index.js";
import { cn } from "$lib/utils";
import type { HTMLAttributes } from "svelte/elements";
import { Text } from "../typography";
import type { OnChangeFn } from "vaul-svelte";
import { Checkbox } from "../checkbox";
import { Text } from "../typography";
import { Info } from "@lucide/svelte/icons";
import { buttonVariants } from "../button";
let {
class: className,
value = $bindable(false),
label,
tooltip,
onCheckedChange,
...restProps
}: HTMLAttributes<HTMLButtonElement> & {
value: boolean;
label: string;
tooltip?: string;
id?: string;
onCheckedChange?: OnChangeFn<boolean>;
} = $props();
</script>
<label class={cn("flex items-start gap-2", className)}>
<Checkbox
{...restProps}
checked={value}
value={value ? "true" : "false"}
{onCheckedChange}
class="mt-0.5"
/>
<Text style="sm" class="font-normal select-none">{label}</Text>
<label class={cn("flex items-center gap-2", className)}>
<div class="flex items-start gap-2">
<Checkbox
{...restProps}
checked={value}
value={value ? "true" : "false"}
{onCheckedChange}
class="mt-0.5"
/>
<Text style="sm" class="font-normal select-none">{label}</Text>
</div>
{#if tooltip}
<Popover.Root>
<Popover.Trigger class={cn(buttonVariants({ variant: "ghost", size: "xs" }), "p-1!")}>
<Info size="sm" />
</Popover.Trigger>
<Popover.Content class="w-65 leading-1">
<Text style="xs">
{tooltip}
</Text>
</Popover.Content>
</Popover.Root>
{/if}
</label>
@@ -3,6 +3,7 @@
Custom Changes
* use Text component for consistent typography
* custom margin and padding
* added leading-none
*/
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
@@ -1,4 +1,8 @@
<script lang="ts">
/*
Custom Changes
* added empty:hidden
*/
import * as FormPrimitive from "formsnap";
import { cn, type WithoutChild } from "$lib/utils.js";
import { Text } from "../typography";
@@ -16,7 +20,7 @@
<FormPrimitive.FieldErrors
bind:ref
class={cn("text-destructive -mt-1 ml-1 text-sm font-medium", className)}
class={cn("text-destructive -mt-1 ml-1 text-sm font-medium empty:hidden", className)}
{...restProps}
>
{#snippet children({ errors, errorProps })}
+9 -1
View File
@@ -13,6 +13,7 @@
class: className,
action,
children,
...restProps
}: {
formId?: string;
enhance: EnhanceFunction;
@@ -21,6 +22,13 @@
} & HTMLAttributes<HTMLFormElement> = $props();
</script>
<form id={formId} method="POST" {action} use:enhance class={cn("flex flex-col gap-3", className)}>
<form
id={formId}
method="POST"
{action}
use:enhance
class={cn("flex flex-col gap-3", className)}
{...restProps}
>
{@render children?.()}
</form>
+15
View File
@@ -0,0 +1,15 @@
import Root from "./input-otp.svelte";
import Group from "./input-otp-group.svelte";
import Slot from "./input-otp-slot.svelte";
import Separator from "./input-otp-separator.svelte";
export {
Root,
Group,
Slot,
Separator,
Root as InputOTP,
Group as InputOTPGroup,
Slot as InputOTPSlot,
Separator as InputOTPSeparator,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="input-otp-group"
class={cn("flex items-center", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "$lib/utils.js";
import DotIcon from "@lucide/svelte/icons/dot";
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="input-otp-separator" role="separator" {...restProps}>
{#if children}
{@render children?.()}
{:else}
<DotIcon />
{/if}
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
cell,
class: className,
...restProps
}: InputOTPPrimitive.CellProps = $props();
</script>
<InputOTPPrimitive.Cell
{cell}
bind:ref
data-slot="input-otp-slot"
class={cn(
"border-input aria-invalid:border-destructive dark:bg-input/30 relative flex size-9 items-center justify-center border-y border-r text-sm transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md",
cell.isActive &&
"border-ring ring-ring/50 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 ring-offset-background z-10 ring-[3px]",
className,
)}
{...restProps}
>
{cell.char ? "*" : null}
{#if cell.hasFakeCaret}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000"></div>
</div>
{/if}
</InputOTPPrimitive.Cell>
@@ -0,0 +1,22 @@
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: InputOTPPrimitive.RootProps = $props();
</script>
<InputOTPPrimitive.Root
bind:ref
bind:value
data-slot="input-otp"
class={cn(
"flex items-center gap-2 has-disabled:opacity-50 [&_input]:disabled:cursor-not-allowed",
className,
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
import Root from "./input-otp-customized.svelte";
export {
Root,
//
Root as InputOptCustomized,
};
@@ -0,0 +1,30 @@
<script lang="ts">
import * as InputOTP from "$lib/components/ui/input-otp/index.js";
import { PinInput as InputOTPPrimitive } from "bits-ui";
type Props = Omit<InputOTPPrimitive.RootProps, "maxlength">;
let { value = $bindable(), ...restProps }: Props = $props();
</script>
<InputOTP.Root
{...restProps}
maxlength={6}
type="password"
pushPasswordManagerStrategy="none"
bind:value
>
{#snippet children({ cells })}
<InputOTP.Group>
{#each cells.slice(0, 3) as cell (cell)}
<InputOTP.Slot aria-invalid={restProps["aria-invalid"]} {cell} />
{/each}
</InputOTP.Group>
<InputOTP.Separator />
<InputOTP.Group>
{#each cells.slice(3, 6) as cell (cell)}
<InputOTP.Slot aria-invalid={restProps["aria-invalid"]} {cell} />
{/each}
</InputOTP.Group>
{/snippet}
</InputOTP.Root>
+34
View File
@@ -0,0 +1,34 @@
import Root from "./item.svelte";
import Group from "./item-group.svelte";
import Separator from "./item-separator.svelte";
import Header from "./item-header.svelte";
import Footer from "./item-footer.svelte";
import Content from "./item-content.svelte";
import Title from "./item-title.svelte";
import Description from "./item-description.svelte";
import Actions from "./item-actions.svelte";
import Media from "./item-media.svelte";
export {
Root,
Group,
Separator,
Header,
Footer,
Content,
Title,
Description,
Actions,
Media,
//
Root as Item,
Group as ItemGroup,
Separator as ItemSeparator,
Header as ItemHeader,
Footer as ItemFooter,
Content as ItemContent,
Title as ItemTitle,
Description as ItemDescription,
Actions as ItemActions,
Media as ItemMedia,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-actions"
class={cn("flex items-center gap-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-content"
class={cn("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="item-description"
class={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-footer"
class={cn("flex basis-full items-center justify-between gap-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
role="list"
data-slot="item-group"
class={cn("group/item-group flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-header"
class={cn("flex basis-full items-center justify-between gap-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,42 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const itemMediaVariants = tv({
base: "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
image: "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
});
export type ItemMediaVariant = VariantProps<typeof itemMediaVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
variant = "default",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: ItemMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="item-media"
data-variant={variant}
class={cn(itemMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="item-separator"
orientation="horizontal"
class={cn("my-0", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="item-title"
class={cn("flex w-fit items-center gap-2 text-sm leading-snug font-medium", className)}
{...restProps}
>
{@render children?.()}
</div>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const itemVariants = tv({
base: "group/item [a]:hover:bg-accent/50 [a]:transition-colors focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]",
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "gap-4 p-4",
sm: "gap-2.5 px-4 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ItemSize = VariantProps<typeof itemVariants>["size"];
export type ItemVariant = VariantProps<typeof itemVariants>["variant"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
child,
variant,
size,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
variant?: ItemVariant;
size?: ItemSize;
} = $props();
const mergedProps = $derived({
class: cn(itemVariants({ variant, size }), className),
"data-slot": "item",
"data-variant": variant,
"data-size": size,
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -9,22 +9,30 @@
actions?: {
label: string;
onClick?: (e: MouseEvent) => void;
href?: string;
variant?: ButtonVariant;
}[];
isDone: boolean;
isLaterStep?: boolean;
};
type Props = {
title: string;
description?: string;
steps: Step[];
};
let { title, steps }: Props = $props();
let { title, description, steps }: Props = $props();
</script>
<div class="flex max-w-[50ch] flex-col gap-4">
<Headline level="h2" style="h4">{title}</Headline>
<div class="flex flex-col gap-0.5">
<Headline level="h2" style="h4">{title}</Headline>
{#if description}
<Text style="sm" class="text-muted-foreground">
{description}
</Text>
{/if}
</div>
<div class="flex flex-col gap-4">
{#each steps as step, index (`step-${index}`)}
<div class="flex flex-col gap-1">
@@ -47,15 +55,10 @@
{step.description}
</Text>
{/if}
{#if step.actions && !step.isDone}
{#if step.actions && !step.isDone && step.isLaterStep !== true}
<div class="mt-2 flex max-w-100 justify-between gap-2">
{#each step.actions as action, index (`action-${index}`)}
<Button
variant={action.variant}
href={action.href}
onclick={action.onClick}
class="w-full"
>
<Button variant={action.variant} onclick={action.onClick} class="w-full">
{action.label}
</Button>
{/each}
@@ -7,20 +7,33 @@
import { Text } from "../typography";
import HorizontalPagePadding from "./horizontal-page-padding.svelte";
import { LanguageSwitch } from "$lib/components/templates/language-switch";
import type { Snippet } from "svelte";
import type { supportedLocales } from "$lib/const/locales";
let {
ref = $bindable(null),
class: className,
children,
left,
footer,
languages,
isWithLanguageSwitch = false,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { isWithLanguageSwitch?: boolean } = $props();
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
left?: Snippet;
footer?: Snippet;
languages?: (typeof supportedLocales)[];
isWithLanguageSwitch?: boolean;
} = $props();
</script>
<div bind:this={ref} class={cn("flex min-h-dvh flex-col", className)} {...restProps}>
{#if isWithLanguageSwitch}
<HorizontalPagePadding class="flex pt-4">
<LanguageSwitch class="ml-auto" triggerSize="sm" />
{#if left}
{@render left()}
{/if}
<LanguageSwitch class="ml-auto" triggerSize="sm" locales={languages} />
</HorizontalPagePadding>
{/if}
{@render children?.()}
@@ -40,10 +53,15 @@
<div class="hidden 2xl:block">2xl</div>
</Text>
{/if}
<Text style="xs" class="mx-auto">
{m.poweredBy()}
<a href="https://open-reception.org" target="_blank" class="underline">OpenReception</a>
</Text>
<div class="mx-auto flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
{#if footer}
{@render footer()}
{/if}
<Text style="xs">
{m.poweredBy()}
<a href="https://open-reception.org" target="_blank" class="underline">OpenReception</a>
</Text>
</div>
{#if dev}
<button onclick={toggleMode} class="cursor-pointer text-xs">
{mode?.current === "dark" ? "dark" : "light"}Mode
+1 -1
View File
@@ -31,7 +31,7 @@
variant="outline"
onclick={onClick}
class={cn(
"flex w-full justify-start",
"flex h-[40px] w-full justify-start",
state === "error" ? "dark:border-destructive border-destructive text-destructive" : "",
className,
)}
+5 -3
View File
@@ -1,17 +1,19 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Root from "./popover.svelte";
import Close from "./popover-close.svelte";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
import Portal from "./popover-portal.svelte";
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal,
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -1,6 +1,8 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
import PopoverPortal from "./popover-portal.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
@@ -10,20 +12,20 @@
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
</PopoverPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,112 @@
<script lang="ts">
import { m } from "$i18n/messages";
import * as Card from "$lib/components/ui/card";
import { Headline, Text } from "$lib/components/ui/typography";
import { publicStore } from "$lib/stores/public";
import type { TPublicAppointment } from "$lib/types/public";
import { cn } from "$lib/utils";
import { getLocalTimeZone } from "@internationalized/date";
import { Eye, User, Calendar, FileText } from "@lucide/svelte";
import type { HTMLAttributes } from "svelte/elements";
import { resest } from "../../../../routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils";
import { Button } from "../button";
import LocalizedText from "./localized-text.svelte";
let {
class: className,
appointment,
}: HTMLAttributes<HTMLDivElement> & { appointment?: TPublicAppointment } = $props();
const tenant = $derived($publicStore.tenant);
const channels = $derived($publicStore.channels || []);
const channel = $derived(channels.find((ch) => ch.id === appointment?.channel));
</script>
{#if tenant && appointment}
<Card.Root class={cn("rounded-lg p-3", className)}>
<Card.Title class="flex gap-3">
{#if typeof tenant.logo === "string" && tenant.logo}
<img
src={tenant.logo}
alt={tenant.longName}
class="border-muted block size-15 rounded-lg border object-cover object-center"
loading="lazy"
/>
{/if}
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<Headline level="h1" style="h6">
{tenant?.longName}
</Headline>
<Text style="xs" color="medium" class="font-normal whitespace-pre-line">
{m["public.bookAppointment"]()}
</Text>
</div>
{#if channel}
<div class="flex flex-col gap-1">
<div>
<Text style="sm">
<LocalizedText translations={channel.names} />
</Text>
<Button
onclick={() => resest()}
variant="link"
size="xs"
class="text-normal text-muted-foreground h-auto p-0 font-normal"
>
{m.edit()}
</Button>
</div>
{#if channel.requiresConfirmation}
<div class="flex max-w-4/5 items-start gap-2">
<Eye class="text-muted-foreground mt-0.5 size-3 shrink-0" />
<Text style="xs" class="text-muted-foreground font-normal">
{m["public.appointment.requiresConfirmation"]({ name: tenant.longName })}
</Text>
</div>
{/if}
{#if appointment.agent || appointment.agent === null}
<div class="flex items-start gap-2">
<User class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{appointment.agent?.name || m["public.anyAgent"]()}
</Text>
</div>
{/if}
{#if appointment.slot}
<div class="flex items-start gap-2">
<Calendar class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{Intl.DateTimeFormat($publicStore.locale, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(appointment.slot.datetime.toDate(getLocalTimeZone()))}
</Text>
</div>
{/if}
{#if appointment.data}
<div class="flex items-start gap-2">
<FileText class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{appointment.data.name}<br />
<a class="break-all underline" href={`mailto:${appointment.data.email}`}>
{appointment.data.email}
</a>
{#if appointment.data.phone}
<br />
<a class="break-all underline" href={`tel:${appointment.data.phone}`}>
{appointment.data.phone}
</a>
{/if}
</Text>
</div>
{/if}
</div>
{/if}
</div>
</Card.Title>
</Card.Root>
{/if}
+5
View File
@@ -0,0 +1,5 @@
import LocalizedText from "./localized-text.svelte";
import AppointmentCard from "./appointment-card.svelte";
import SideBySide from "./side-by-side.svelte";
export { LocalizedText, AppointmentCard, SideBySide };
@@ -0,0 +1,20 @@
<script lang="ts">
import { publicStore } from "$lib/stores/public";
type TranslationsObject = { [key: string]: string };
let { translations }: { translations: TranslationsObject } = $props();
const tenantLocale = $derived($publicStore.locale);
const getTranslation = (): string => {
const translation = Object.entries(translations).find(
([key, value]) => key === tenantLocale && value,
);
if (translation) {
return translation[1] || "";
}
return "";
};
</script>
{getTranslation()}
@@ -0,0 +1,17 @@
<script lang="ts">
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { HorizontalPagePadding } from "$lib/components/ui/page";
import { cn } from "$lib/utils";
import * as Card from "$lib/components/ui/card";
import type { HTMLAttributes } from "svelte/elements";
let { children, ...restProps }: HTMLAttributes<HTMLDivElement> & {} = $props();
</script>
<HorizontalPagePadding class="py-3 md:flex md:grow md:flex-col md:justify-center">
<MaxPageWidth maxWidth="lg" class={cn("w-full gap-5 md:flex")} {...restProps}>
<Card.Root class="bg-muted/40 dark:bg-muted/20 w-full p-1 md:flex md:flex-row md:gap-5">
{@render children?.()}
</Card.Root>
</MaxPageWidth>
</HorizontalPagePadding>
@@ -0,0 +1,10 @@
import Root from "./radio-group.svelte";
import Item from "./radio-group-item.svelte";
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem,
};
@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>
@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn("grid gap-3", className)}
{...restProps}
/>
@@ -5,7 +5,7 @@
export function openDialog(id: string) {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
newState.set(id, true);
return newState;
});
@@ -13,7 +13,7 @@
export function closeDialog(id: string) {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
if (newState.has(id)) {
newState.set(id, false);
}
@@ -34,7 +34,7 @@
import * as Drawer from "$lib/components/ui/drawer";
import { onDestroy, type Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { MediaQuery } from "svelte/reactivity";
import { MediaQuery, SvelteMap } from "svelte/reactivity";
import { HorizontalPagePadding } from "../page";
import { ScrollArea } from "../scroll-area";
import { cn } from "$lib/utils";
@@ -80,7 +80,7 @@
onDestroy(() => {
responsiveDialogs.update((state) => {
const newState = new Map(state);
const newState = new SvelteMap(state);
newState.delete(id);
return newState;
});
@@ -44,6 +44,7 @@
{side}
>
<Sheet.Header class="sr-only">
<!-- TODO translate -->
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
@@ -77,6 +77,7 @@
return segments;
}
// svelte-ignore state_referenced_locally
const segments = processTranslation(translation, interpolations);
</script>
@@ -12,6 +12,7 @@
h3: "text-2xl font-semibold",
h4: "text-xl font-semibold",
h5: "text-lg font-semibold",
h6: "text-md font-semibold",
},
},
});
+7
View File
@@ -2,7 +2,14 @@ import { m } from "$i18n/messages";
export const supportedLocales = ["en", "de"] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
export const translatedLocales = {
en: m["locales.en"](),
de: m["locales.de"](),
};
export const languageSwitchLocales = {
de: { label: "Deutsch", value: "de", keywords: ["german", "deutsch"] },
en: { label: "English", value: "en", keywords: ["english"] },
};
+22
View File
@@ -0,0 +1,22 @@
export const INSECURE_PINS = new Set([
"000000",
"111111",
"222222",
"333333",
"444444",
"555555",
"666666",
"777777",
"888888",
"999999",
"123456",
"654321",
"123123",
"121212",
"000001",
"112233",
"123321",
"012345",
"234567",
"345678",
]);
+11 -2
View File
@@ -1,10 +1,13 @@
export const ROUTES = {
MAIN: "/",
BOOK_APPOINTMENT: "/book-appointment",
APPOINTMENT_BOOKED: "/book-appointment/complete",
SETUP: {
MAIN: "/setup",
CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account",
CHECK_EMAIL: "/setup/check-email",
},
SETUP_PASSKEY: "/confirm/setup-passkey",
RESEND_CONFIRMATION: "/confirm/resend",
LOGIN: "/login",
LOGOUT: "/logout",
@@ -17,6 +20,12 @@ export const ROUTES = {
CHANNELS: "/dashboard/channels",
ABSENCES: "/dashboard/absences",
SETTINGS: "/dashboard/settings",
ACCOUNT: "/dashboard/account",
ACCOUNT: {
MAIN: "/dashboard/account",
GENERAL: "/dashboard/account/general",
PASSKEYS: "/dashboard/account/passkeys",
CHANGE_EMAIL: "/dashboard/account/change-email",
CHANGE_PASSPHRASE: "/dashboard/account/change-passphrase",
},
},
};
} as const;
+1
View File
@@ -0,0 +1 @@
export const SETUP_STATES_LIST = ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"];
+70 -29
View File
@@ -345,59 +345,96 @@ describe("AESCrypto", () => {
});
describe("ShamirSecretSharing", () => {
describe("splitSecret()", () => {
it("should split secret into shares", () => {
describe("splitSecretWithDeterministicShare()", () => {
it("should split secret into 2 shares with deterministic first share", () => {
const secret = BufferUtils.from("test secret");
const shares = ShamirSecretSharing.splitSecret(secret, 2, 3);
const deterministicShare = BufferUtils.randomBytes(secret.length);
expect(shares).toHaveLength(3);
shares.forEach((share, index) => {
expect(share.x).toBe(index + 1);
expect(share.y).toBeInstanceOf(Uint8Array);
expect(share.y.length).toBe(4 + secret.length); // 4 bytes for length + secret
});
const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(
secret,
deterministicShare,
);
expect(shares).toHaveLength(2);
expect(shares[0].x).toBe(1);
expect(shares[1].x).toBe(2);
expect(shares[0].y).toBeInstanceOf(Uint8Array);
expect(shares[1].y).toBeInstanceOf(Uint8Array);
expect(shares[0].y.length).toBe(secret.length);
expect(shares[1].y.length).toBe(secret.length);
// First share should be the deterministic one
expect(shares[0].y).toEqual(deterministicShare);
});
it("should throw error for empty secret", () => {
const secret = new Uint8Array([]);
expect(() => ShamirSecretSharing.splitSecret(secret, 2, 3)).toThrow(
"Secret cannot be empty for Shamir secret sharing",
);
const deterministicShare = new Uint8Array([]);
expect(() =>
ShamirSecretSharing.splitSecretWithDeterministicShare(secret, deterministicShare),
).toThrow("Secret cannot be empty");
});
it("should handle large secrets", () => {
const secret = BufferUtils.randomBytes(1000);
const shares = ShamirSecretSharing.splitSecret(secret, 3, 5);
const deterministicShare = BufferUtils.randomBytes(secret.length);
expect(shares).toHaveLength(5);
shares.forEach((share) => {
expect(share.y.length).toBe(4 + secret.length);
});
const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(
secret,
deterministicShare,
);
expect(shares).toHaveLength(2);
expect(shares[0].y.length).toBe(secret.length);
expect(shares[1].y.length).toBe(secret.length);
});
it("should throw error if deterministic share length differs", () => {
const secret = BufferUtils.from("test secret");
const deterministicShare = BufferUtils.randomBytes(5); // Different length
expect(() =>
ShamirSecretSharing.splitSecretWithDeterministicShare(secret, deterministicShare),
).toThrow("Deterministic share must have same length as secret");
});
});
describe("reconstructSecret()", () => {
it("should reconstruct secret from shares", () => {
const originalSecret = BufferUtils.from("test secret for reconstruction");
const shares = ShamirSecretSharing.splitSecret(originalSecret, 2, 5);
const deterministicShare = BufferUtils.randomBytes(originalSecret.length);
// Use first 2 shares (minimum threshold)
const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2));
const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(
originalSecret,
deterministicShare,
);
// Use both shares
const reconstructed = ShamirSecretSharing.reconstructSecret(shares);
expect(reconstructed).toEqual(originalSecret);
});
it("should reconstruct secret from any subset of shares", () => {
it("should reconstruct secret consistently with same deterministic share", () => {
const originalSecret = BufferUtils.from("another test secret");
const shares = ShamirSecretSharing.splitSecret(originalSecret, 3, 5);
const deterministicShare = BufferUtils.randomBytes(originalSecret.length);
// Test different combinations
const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares.slice(0, 3));
const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares.slice(1, 4));
const reconstructed3 = ShamirSecretSharing.reconstructSecret(shares.slice(2, 5));
// Split and reconstruct multiple times with same deterministic share
const shares1 = ShamirSecretSharing.splitSecretWithDeterministicShare(
originalSecret,
deterministicShare,
);
const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares1);
const shares2 = ShamirSecretSharing.splitSecretWithDeterministicShare(
originalSecret,
deterministicShare,
);
const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares2);
expect(reconstructed1).toEqual(originalSecret);
expect(reconstructed2).toEqual(originalSecret);
expect(reconstructed3).toEqual(originalSecret);
// Deterministic shares should be identical
expect(shares1[0].y).toEqual(shares2[0].y);
});
it("should throw error with insufficient shares", () => {
@@ -415,8 +452,12 @@ describe("ShamirSecretSharing", () => {
];
secrets.forEach((secret) => {
const shares = ShamirSecretSharing.splitSecret(secret, 2, 3);
const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2));
const deterministicShare = BufferUtils.randomBytes(secret.length);
const shares = ShamirSecretSharing.splitSecretWithDeterministicShare(
secret,
deterministicShare,
);
const reconstructed = ShamirSecretSharing.reconstructSecret(shares);
expect(reconstructed).toEqual(secret);
});
});
+3 -1
View File
@@ -171,12 +171,14 @@ export class OptimizedArgon2 {
): Promise<CryptoBuffer> {
const { argon2id } = await import("@noble/hashes/argon2");
return argon2id(BufferUtils.from(pin), new Uint8Array(salt), {
const result = argon2id(BufferUtils.from(pin), new Uint8Array(salt), {
m: options.memoryCost,
t: options.timeCost,
p: options.parallelism,
dkLen: options.hashLength,
});
return result;
}
/**
+288 -36
View File
@@ -183,15 +183,22 @@ export class AESCrypto {
// Use Web Crypto API if available (browser), otherwise fall back to Node.js
if (typeof crypto !== "undefined" && crypto.subtle) {
// Browser implementation using Web Crypto API
const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [
"encrypt",
]);
// Ensure native Uint8Array with ArrayBuffer for Web Crypto API compatibility
const nativeKey = new Uint8Array(key);
const cryptoKey = await crypto.subtle.importKey(
"raw",
nativeKey,
{ name: "AES-GCM" },
false,
["encrypt"],
);
const additionalData = BufferUtils.from("appointment-data");
const additionalData = new Uint8Array(BufferUtils.from("appointment-data"));
const nativeIv = new Uint8Array(iv);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv, additionalData },
{ name: "AES-GCM", iv: nativeIv, additionalData },
cryptoKey,
BufferUtils.from(data),
new Uint8Array(BufferUtils.from(data)),
);
// Extract tag from encrypted data (last 16 bytes)
@@ -238,17 +245,24 @@ export class AESCrypto {
): Promise<string> {
if (typeof crypto !== "undefined" && crypto.subtle) {
// Browser implementation using Web Crypto API
const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [
"decrypt",
]);
// Ensure native Uint8Array with ArrayBuffer for Web Crypto API compatibility
const nativeKey = new Uint8Array(key);
const cryptoKey = await crypto.subtle.importKey(
"raw",
nativeKey,
{ name: "AES-GCM" },
false,
["decrypt"],
);
const additionalData = BufferUtils.from("appointment-data");
const additionalData = new Uint8Array(BufferUtils.from("appointment-data"));
const nativeIv = new Uint8Array(iv);
// Combine encrypted data and tag for Web Crypto API
const encryptedWithTag = BufferUtils.concat([encrypted, tag]);
const encryptedWithTag = new Uint8Array(BufferUtils.concat([encrypted, tag]));
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, additionalData },
{ name: "AES-GCM", iv: nativeIv, additionalData },
cryptoKey,
encryptedWithTag,
);
@@ -280,45 +294,151 @@ export interface ShamirShare {
}
/**
* Simple Shamir Secret Sharing implementation
* Note: This is a basic implementation for demonstration purposes
* Shamir Secret Sharing implementation using Lagrange interpolation over GF(256)
*
* This implementation uses polynomial interpolation in Galois Field 256 to split
* a secret into n shares where any k shares can reconstruct the secret, but
* k-1 shares reveal no information about the secret (information-theoretic security).
*
* Mathematical basis:
* - Secret is encoded as f(0) where f is a polynomial of degree k-1
* - Each share is a point (x, f(x)) on the polynomial
* - Lagrange interpolation reconstructs f(0) from k points
*/
export class ShamirSecretSharing {
// Cache for GF(256) multiplicative inverses
private static invCache: Map<number, number> = new Map();
/**
* Splits a secret into multiple shares
* @param secret - The secret to split
* @param threshold - Minimum number of shares needed to reconstruct
* @param totalShares - Total number of shares to create
* Splits a secret into multiple shares using Shamir's Secret Sharing scheme
*
* For a k-of-n threshold scheme:
* - Generates a random polynomial f(x) of degree k-1 where f(0) = secret
* - Creates n shares as points (i, f(i)) for i = 1, 2, ..., n
* - Any k shares can reconstruct the secret via Lagrange interpolation
*
* @param secret - The secret to split (any length)
* @param threshold - Minimum number of shares needed to reconstruct (k)
* @param totalShares - Total number of shares to create (n)
* @returns Array of ShamirShare objects
*/
static splitSecret(secret: CryptoBuffer, threshold: number, totalShares: number): ShamirShare[] {
return this.splitSecretInternal(secret, threshold, totalShares);
}
/**
* Splits a secret into shares with a deterministic first share (2-of-2 scheme)
*
* Special case for client key splitting where:
* - Share 1 (x=1): Deterministically derived from PIN + email (y₁ provided)
* - Share 2 (x=2): Calculated to maintain the secret relationship
*
* This enables cross-device authentication:
* - Client can always recreate share 1 from their PIN
* - Server stores share 2 in database
* - Both shares required for reconstruction (2-of-2 threshold)
*
* @param secret - The private key to split
* @param deterministicShareY - The deterministic y-value for x=1 (from PIN derivation)
* @returns Array with 2 shares: [pinShare, serverShare]
*/
static splitSecretWithDeterministicShare(
secret: CryptoBuffer,
deterministicShareY: Uint8Array,
): ShamirShare[] {
if (!secret || secret.length === 0) {
throw new Error("Secret cannot be empty");
}
if (deterministicShareY.length !== secret.length) {
throw new Error("Deterministic share must have same length as secret");
}
// For 2-of-2 Shamir: f(x) = a₀ + a₁·x where f(0) = secret
// We have: f(1) = deterministicShareY (given)
// From f(1) = a₀ + a₁·1: a₁ = f(1) - a₀ = deterministicShareY - secret (in GF(256))
// Calculate f(2) = a₀ + a₁·2 = secret + 2·(deterministicShareY - secret)
// = 2·deterministicShareY - secret (in GF(256))
const serverShareY = new Uint8Array(secret.length);
for (let i = 0; i < secret.length; i++) {
// a₁ = deterministicShareY[i] - secret[i] in GF(256)
// Since subtraction in GF(256) is XOR (same as addition):
const a1 = this.gf256Add(deterministicShareY[i], secret[i]);
// f(2) = secret[i] + 2·a₁ in GF(256)
const twoTimesA1 = this.gf256Mul(2, a1);
serverShareY[i] = this.gf256Add(secret[i], twoTimesA1);
}
return [
{ x: 1, y: deterministicShareY },
{ x: 2, y: serverShareY },
];
}
/**
* Internal implementation for splitting secrets
*/
private static splitSecretInternal(
secret: CryptoBuffer,
threshold: number,
totalShares: number,
): ShamirShare[] {
if (!secret || secret.length === 0) {
throw new Error("Secret cannot be empty for Shamir secret sharing");
}
const originalLength = secret.length;
const lengthBytes = new Uint8Array(4);
new DataView(lengthBytes.buffer).setUint32(0, originalLength, true);
if (threshold < 2) {
throw new Error("Threshold must be at least 2");
}
if (threshold > totalShares) {
throw new Error("Threshold cannot be greater than total shares");
}
if (totalShares > 255) {
throw new Error("Cannot create more than 255 shares (GF(256) limitation)");
}
const shares: ShamirShare[] = [];
for (let i = 0; i < totalShares; i++) {
const shareData = new Uint8Array(4 + secret.length);
shareData.set(lengthBytes, 0);
shareData.set(secret, 4);
// Process each byte of the secret independently
for (let shareIndex = 0; shareIndex < totalShares; shareIndex++) {
const x = shareIndex + 1; // x-coordinates: 1, 2, 3, ..., n (never 0, as f(0) = secret)
const y = new Uint8Array(secret.length);
shares.push({
x: i + 1,
y: shareData,
});
for (let byteIndex = 0; byteIndex < secret.length; byteIndex++) {
// For this byte, create a polynomial f(x) = a₀ + a₁x + a₂x² + ... + a_{k-1}x^{k-1}
// where a₀ = secret[byteIndex] and a₁, ..., a_{k-1} are random
const coefficients = new Uint8Array(threshold);
coefficients[0] = secret[byteIndex]; // f(0) = secret byte
// Generate random coefficients for higher degree terms
for (let i = 1; i < threshold; i++) {
coefficients[i] = Math.floor(Math.random() * 256);
}
// Evaluate polynomial at x: f(x) = a₀ + a₁x + a₂x² + ... in GF(256)
let result = 0;
for (let i = threshold - 1; i >= 0; i--) {
result = this.gf256Add(this.gf256Mul(result, x), coefficients[i]);
}
y[byteIndex] = result;
}
shares.push({ x, y });
}
return shares;
}
/**
* Reconstructs a secret from shares
* @param shareArray - Array of shares to reconstruct from
* Reconstructs a secret from shares using Lagrange interpolation
*
* Given k shares (x₁, y₁), (x₂, y₂), ..., (xₖ, yₖ), reconstructs f(0) where:
* f(0) = Σᵢ yᵢ · Lᵢ(0)
* where Lᵢ(0) = Πⱼ≠ᵢ (0 - xⱼ) / (xᵢ - xⱼ) (computed in GF(256))
*
* @param shareArray - Array of shares to reconstruct from (must have at least threshold shares)
* @returns The reconstructed secret
*/
static reconstructSecret(shareArray: ShamirShare[]): CryptoBuffer {
@@ -326,13 +446,145 @@ export class ShamirSecretSharing {
throw new Error("Need at least 2 shares to reconstruct the secret");
}
const firstShare = shareArray[0];
const secretLength = shareArray[0].y.length;
const secret = new Uint8Array(secretLength);
const lengthBytes = firstShare.y.slice(0, 4);
const originalLength = new DataView(lengthBytes.buffer).getUint32(0, true);
// Reconstruct each byte independently
for (let byteIndex = 0; byteIndex < secretLength; byteIndex++) {
let secretByte = 0;
const secret = firstShare.y.slice(4, 4 + originalLength);
// Lagrange interpolation: f(0) = Σᵢ yᵢ · Lᵢ(0)
for (let i = 0; i < shareArray.length; i++) {
const xi = shareArray[i].x;
const yi = shareArray[i].y[byteIndex];
// Calculate Lagrange basis polynomial Lᵢ(0)
let basis = 1;
for (let j = 0; j < shareArray.length; j++) {
if (i !== j) {
const xj = shareArray[j].x;
// Lᵢ(0) *= (0 - xⱼ) / (xᵢ - xⱼ) in GF(256)
// In GF(256), subtraction is XOR (same as addition)
const numerator = this.gf256Add(0, xj); // 0 - xⱼ = 0 ⊕ xⱼ = xⱼ
const denominator = this.gf256Add(xi, xj); // xᵢ - xⱼ = xᵢ ⊕ xⱼ
basis = this.gf256Mul(basis, this.gf256Div(numerator, denominator));
}
}
// Add yᵢ · Lᵢ(0) to the result
secretByte = this.gf256Add(secretByte, this.gf256Mul(yi, basis));
}
secret[byteIndex] = secretByte;
}
return secret;
}
/**
* Addition in GF(256) - simply XOR
*/
private static gf256Add(a: number, b: number): number {
return (a ^ b) & 0xff;
}
/**
* Multiplication in GF(256) using the rijndael polynomial
* This is the same multiplication used in AES
*/
private static gf256Mul(a: number, b: number): number {
let result = 0;
a = a & 0xff;
b = b & 0xff;
for (let i = 0; i < 8; i++) {
if (b & 1) {
result ^= a;
}
const hiBitSet = a & 0x80;
a = (a << 1) & 0xff;
if (hiBitSet) {
a ^= 0x1b; // Rijndael's Galois field polynomial
}
b >>= 1;
}
return result & 0xff;
}
/**
* Division in GF(256) - multiply by multiplicative inverse
*/
private static gf256Div(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero in GF(256)");
}
return this.gf256Mul(a, this.gf256Inv(b));
}
/**
* Multiplicative inverse in GF(256) using Extended Euclidean Algorithm
*/
private static gf256Inv(a: number): number {
if (a === 0) {
throw new Error("Zero has no multiplicative inverse in GF(256)");
}
// Check cache first
if (this.invCache.has(a)) {
return this.invCache.get(a)!;
}
// Extended Euclidean Algorithm in GF(256)
let u = a & 0xff;
let v = 0x11b; // GF(256) irreducible polynomial: x^8 + x^4 + x^3 + x + 1
let g1 = 1;
let g2 = 0;
let iterations = 0;
const maxIterations = 16; // Should never need more than this
while (u !== 1 && iterations < maxIterations) {
iterations++;
if (u === 0) {
// This shouldn't happen, but safety check
throw new Error(`GF(256) inverse failed for ${a}`);
}
const j = this.bitLength(u) - this.bitLength(v);
if (j < 0) {
[u, v] = [v, u];
[g1, g2] = [g2, g1];
continue;
}
u ^= v << j;
g1 ^= g2 << j;
}
if (iterations >= maxIterations) {
throw new Error(`GF(256) inverse calculation did not converge for ${a}`);
}
const result = g1 & 0xff;
// Cache the result
this.invCache.set(a, result);
return result;
}
/**
* Helper: Calculate bit length of a number
*/
private static bitLength(n: number): number {
let length = 0;
while (n > 0) {
length++;
n >>= 1;
}
return length;
}
}
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
cancelUrl,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
cancelUrl: string;
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentBooked.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailButton href={cancelUrl}>{m["emails.appointmentBooked.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.appointmentBooked.reason"]()}
</EmailText>
</EmailLayout>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentRejected.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.appointmentRejected.reason"]()}
</EmailText>
</EmailLayout>
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
cancelUrl,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
cancelUrl: string;
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentReminder.introduction"]({ tenant: tenant.longName })}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailButton href={cancelUrl}>{m["emails.appointmentReminder.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.appointmentReminder.reason"]()}
</EmailText>
</EmailLayout>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentRequest.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.appointmentRequest.reason"]()}
</EmailText>
</EmailLayout>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectUserEmail } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
confirmUrl,
expirationMinutes,
}: {
locale: SupportedLocale;
user: SelectUserEmail;
confirmUrl: string;
expirationMinutes: number;
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
<EmailText variant="md">
{m["emails.confirmation.introduction"]()}
</EmailText>
<EmailButton href={confirmUrl}>{m["emails.confirmation.action"]()}</EmailButton>
<EmailText variant="md">
{m["emails.confirmation.hint"]({ expirationMinutes })}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.confirmation.reason"]()}
</EmailText>
</EmailLayout>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import { type SelectTenant } from "$lib/server/db/central-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
tenant,
loginUrl,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
loginUrl: string;
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.pinReset.introduction"]({ tenant: tenant.longName })}
</EmailText>
<EmailButton href={loginUrl}>{m["emails.pinReset.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.pinReset.reason"]()}
</EmailText>
</EmailLayout>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import { type SelectTenant } from "$lib/server/db/central-schema";
import type { SelectUserEmail } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
tenant,
confirmUrl,
expirationMinutes,
}: {
locale: SupportedLocale;
user: SelectUserEmail;
tenant: SelectTenant;
confirmUrl: string;
expirationMinutes: number;
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
<EmailText variant="md">
{m["emails.userInvite.introduction"]({ tenant: tenant.longName })}
</EmailText>
<EmailButton href={confirmUrl}>{m["emails.userInvite.action"]()}</EmailButton>
<EmailText variant="md">
{m["emails.userInvite.hint"]({ expirationMinutes })}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.userInvite.reason"]()}
</EmailText>
</EmailLayout>
@@ -0,0 +1,31 @@
<script lang="js">
let { children, href } = $props();
</script>
<div class="button-container">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a class="button" {href}>{@render children?.()}</a>
</div>
<svelte:head>
<style>
.button-container {
margin: 24px 0;
margin: 1.5rem 0;
}
.button {
font-size: 16px;
line-height: 1.5;
border: 1px solid #000000;
padding: 12px 32px;
padding: 0.75rem 2rem;
text-decoration: none;
display: inline-block;
border-radius: 12px;
}
.button:hover {
cursor: pointer;
background-color: rgb(0 0 0 / 0.1);
}
</style>
</svelte:head>
@@ -0,0 +1,7 @@
<script lang="js">
let { children, variant = "h2", class: className = "" } = $props();
</script>
<svelte:element this={variant} class={[className].filter((it) => it).join(" ")}>
{@render children?.()}
</svelte:element>
@@ -0,0 +1,71 @@
<script lang="js">
import { m } from "$i18n/messages";
import EmailText from "./EmailText.svelte";
let { children } = $props();
</script>
<div class="page">
<div class="container">
<div class="content">
{@render children?.()}
</div>
<EmailText variant="sm" color="text-light" class="branding">
{m["emails.poweredBy"]()} <a href="https://open-reception.org">OpenReception</a>
</EmailText>
</div>
</div>
<svelte:head>
<style>
body {
margin: 0;
min-height: 100%;
min-height: 100vh;
box-sizing: border-box;
font-family: "Inter", Arial, sans-serif;
}
a {
color: inherit;
text-decoration: underline;
}
h1 {
font-size: 18px;
line-height: 1.5;
font-weight: 600;
}
h2 {
font-size: 16px;
line-height: 1.5;
font-weight: 600;
margin-top: 24px;
margin-bottom: 8px;
}
h3 {
font-size: 14px;
line-height: 1.5;
font-weight: 500;
}
h2 + p {
margin-top: 0;
}
.page {
padding: 0 16px;
padding: 0 1rem;
min-height: 100%;
min-height: 100vh;
box-sizing: border-box;
width: 100%;
font-family: "Inter", Arial, sans-serif;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.branding {
margin: 16px 0;
margin: 1rem 0;
width: 100%;
}
</style>
</svelte:head>
@@ -0,0 +1,32 @@
<script lang="js">
let { children, variant, color = "text-darkest", class: className = "" } = $props();
</script>
<p class={[variant, color, className].filter((it) => it).join(" ")}>{@render children?.()}</p>
<svelte:head>
<style>
.lg {
font-size: 16px;
line-height: 1.5;
}
.md {
font-size: 14px;
line-height: 1.5;
}
.sm {
font-size: 12px;
line-height: 1.5;
}
.xs {
font-size: 10px;
line-height: 1.25;
}
.text-darkest {
color: #000000;
}
.text-light {
color: #888888;
}
</style>
</svelte:head>

Some files were not shown because too many files have changed in this diff Show More