mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-13 12:47:39 +02:00
Merge remote-tracking branch 'origin/main' into feat/custom-domains
This commit is contained in:
@@ -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
@@ -34,4 +34,6 @@ vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.claude/
|
||||
|
||||
security-audit*
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_session" ADD COLUMN "passkey_id" text;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_session" ADD COLUMN "passkey_id" text;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+708
-766
File diff suppressed because it is too large
Load Diff
+26
-25
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"welcome": "Welcome to OpenReception",
|
||||
"home": "Home",
|
||||
"poweredBy": "Powered by",
|
||||
"i18n": {
|
||||
"label": "Select Language",
|
||||
@@ -35,6 +36,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"setupPasskey": {
|
||||
"title": "Setup Passkey",
|
||||
"description": "Create your passkey to secure your account.",
|
||||
"action": "Set Passkey",
|
||||
"success": "Passkey set. Login with it now.",
|
||||
"successKeyPairSaved": "Key pair saved successfully.",
|
||||
"confirmPrfRetrival": "Now use your newly created passkey to enable encryption with it.",
|
||||
"error": "Passkey could not be set",
|
||||
"errorKeyPairDataMissing": "Could not generate key pair. Missing data.",
|
||||
"errorKeyPairNotSaved": "Key pair could not be saved.",
|
||||
"errorPrfOutputNotTriggered": "You have to confirm using the passkey.",
|
||||
"errorAuthenticatorNotSupported": "This authenticator does not support the required security extension (PRF). Please use a modern authenticator like YubiKey 5.2.3+, Apple Touch ID or Android.",
|
||||
"errorGettingPrfOutput": "Failed to retrieve security data from passkey. Please use a modern authenticator that supports PRF extension."
|
||||
},
|
||||
"slogan": "End-to-End encrypted appointment booking platform.",
|
||||
"logo": "OpenReception Logo: A 3D pixel art with calendar that's also a lock",
|
||||
"form": {
|
||||
@@ -62,11 +77,20 @@
|
||||
"url": "Invalid URL Format",
|
||||
"languages": "Select at least one language",
|
||||
"language": "Select a default language",
|
||||
"deleteAfterDays": "Choose a point in time"
|
||||
"deleteAfterDays": "Choose a point in time",
|
||||
"phoneNoRequired": "Phone number is required",
|
||||
"phoneNoInvalid": "Phone number format is invalid",
|
||||
"pinLength": "PIN must be 6 digits",
|
||||
"pinDigitsOnly": "PIN must contain only digits",
|
||||
"pinInsecure": "This PIN is too insecure"
|
||||
},
|
||||
"noPassAtAll": "Either passphrase or passkey is required",
|
||||
"passkey": "Passkey",
|
||||
"name": "Name"
|
||||
"salutation": "Salutation",
|
||||
"name": "Name",
|
||||
"phone": "Phone Number",
|
||||
"pin": "PIN",
|
||||
"pinHint": "Save your PIN in a secure place, like a password-manager."
|
||||
},
|
||||
"login": {
|
||||
"or": "Or",
|
||||
@@ -85,9 +109,9 @@
|
||||
},
|
||||
"confirm": {
|
||||
"success": {
|
||||
"title": "You’re all set!",
|
||||
"description": "You’ve successfully secured your account",
|
||||
"action": "Proceed"
|
||||
"title": "E-Mail address confirmed!",
|
||||
"description": "Now you just need to setup a passkey.",
|
||||
"action": "Setup Passkey"
|
||||
},
|
||||
"error": {
|
||||
"title": "Verification failed",
|
||||
@@ -148,7 +172,7 @@
|
||||
"title": "Add Tenant",
|
||||
"description": "Create a site for someone to start collecting appointments.",
|
||||
"name": {
|
||||
"description": "Must be unique on this server. Used as a subdomain {domain}",
|
||||
"description": "Must be unique on this server. Cannot be changed. Used as a subdomain {domain}",
|
||||
"errors": {
|
||||
"urlFormat": "Only letters, numbers, and hyphens are allowed",
|
||||
"startEndDash": "Must not start or end with a dash"
|
||||
@@ -328,6 +352,7 @@
|
||||
"delete": "Delete",
|
||||
"select": "Select",
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"hello": "Hello",
|
||||
"tenants": {
|
||||
"currentTenant": "You are currently managing tenant {name}.",
|
||||
@@ -345,6 +370,44 @@
|
||||
"sidebar": {
|
||||
"title": "Zum ersten Mal hier?",
|
||||
"description": "Tippe {name} um die Seitenleiste umzustellen"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Get ready to collect appointments",
|
||||
"description": "Follow this step-by-step to get the best results:",
|
||||
"notification": {
|
||||
"ongoing": {
|
||||
"title": "Onboarding not complete",
|
||||
"description": "Review next steps",
|
||||
"action": "Proceed"
|
||||
},
|
||||
"done": {
|
||||
"title": "Onboarding complete!",
|
||||
"description": "You can now collect appointments",
|
||||
"action": "Open Homepage"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"settings": {
|
||||
"title": "Review and save settings",
|
||||
"description": "Make sure your organization's details are correct, before you add agents or channels.",
|
||||
"action": "Go to Settings"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Set up your agents",
|
||||
"description": "Agents let your clients choose which person, team or group they want to make an appointment with.",
|
||||
"action": "Add Agent"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Set up your first channel",
|
||||
"description": "Channels are your way to collect different types of appointments",
|
||||
"action": "Add channel"
|
||||
},
|
||||
"staff": {
|
||||
"title": "Add & Activate Staff Members",
|
||||
"description": "Make sure your team can decrypt appointment data by adding staff members. Only when the first person added has logged in, appointments can be received.",
|
||||
"action": "Add Staff Member"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"close": "Schließen",
|
||||
@@ -505,7 +568,7 @@
|
||||
"absences": {
|
||||
"title": "Absences",
|
||||
"add": {
|
||||
"title": "Add Anbsence",
|
||||
"title": "Add Absence",
|
||||
"description": "Absences are a way to mark you agents as being unavailbale.",
|
||||
"fields": {
|
||||
"agent": {
|
||||
@@ -646,7 +709,270 @@
|
||||
"title": "Require Phone Number",
|
||||
"label": "Require clients to provide a phone number when booking an appointment."
|
||||
}
|
||||
},
|
||||
"action": "Save"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"overview": {
|
||||
"title": "Your Account"
|
||||
},
|
||||
"general": {
|
||||
"navItem": "Settings",
|
||||
"title": "Account Settings",
|
||||
"description": "Change your name."
|
||||
},
|
||||
"change-email": {
|
||||
"navItem": "E-Mail Address",
|
||||
"title": "Change E-Mail Address",
|
||||
"description": "Change your e-mail address."
|
||||
},
|
||||
"passkeys": {
|
||||
"navItem": "Passkeys",
|
||||
"title": "Manage Passkeys",
|
||||
"description": "You can add up to three passkeys."
|
||||
},
|
||||
"change-passphrase": {
|
||||
"navItem": "Passphrase",
|
||||
"title": "Change Passphrase",
|
||||
"description": "Global Admins can set strong passphrases as a backup login method."
|
||||
}
|
||||
},
|
||||
"staff": {
|
||||
"title": "Staff",
|
||||
"roles": {
|
||||
"GLOBAL_ADMIN": "Global Admin",
|
||||
"TENANT_ADMIN": "Tenant Admin",
|
||||
"STAFF": "Appointment Manager"
|
||||
},
|
||||
"permissions": {
|
||||
"agents": "Manages agents",
|
||||
"channels": "Manages channels",
|
||||
"absences": "Manages absences",
|
||||
"settings": "Manages settings",
|
||||
"staff": "Manages staff",
|
||||
"appointments": "Manages appointments"
|
||||
},
|
||||
"form": {
|
||||
"fields": {
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "Will be used in the invitation e-mail.",
|
||||
"placeholder": "Select langauge"
|
||||
},
|
||||
"role": {
|
||||
"title": "Role",
|
||||
"placeholder": "Select role"
|
||||
}
|
||||
}
|
||||
},
|
||||
"add": {
|
||||
"title": "Add Member",
|
||||
"description": "Staff members manage your appointments and settings. They are not public.",
|
||||
"action": "Add Member",
|
||||
"success": "Member added",
|
||||
"errors": {
|
||||
"unknown": "Could not add member"
|
||||
}
|
||||
},
|
||||
"list": {
|
||||
"needsAccess": "Can't see appointments",
|
||||
"empty": {
|
||||
"title": "No staff members yet",
|
||||
"description": "Staff members manage your appointments and settings"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Member",
|
||||
"description": "Update the members details.",
|
||||
"success": "Member updated",
|
||||
"error": "Could not update member.",
|
||||
"action": "Edit Member"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Remove Member",
|
||||
"description": "Type {email} to remove this member. This step cannot be undone.",
|
||||
"action": "Remove Member",
|
||||
"success": "Member removed",
|
||||
"error": "Could not remove member."
|
||||
},
|
||||
"access": {
|
||||
"title": "Grant access to appointments",
|
||||
"description": "Do you want to grant {name} access to all client data?",
|
||||
"action": "Grant access",
|
||||
"loading": "Granting access ({success}/{total} clients)",
|
||||
"success": "Access granted",
|
||||
"error": "Granting access to all clients failed. You can retry anytime.",
|
||||
"unavailable": {
|
||||
"title": "You can't grant access",
|
||||
"description": "Only Appointment Managers and Tenant Admins can be granted access to client data."
|
||||
}
|
||||
},
|
||||
"loading": "Loading staff members"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"public": {
|
||||
"links": {
|
||||
"website": "Go to Website",
|
||||
"imprint": "Imprint",
|
||||
"privacyStatement": "Privacy Statement"
|
||||
},
|
||||
"bookAppointment": "Book Appointment",
|
||||
"appointment": {
|
||||
"requiresConfirmation": "Requires cofirmation by {name}"
|
||||
},
|
||||
"tenantNotReady": {
|
||||
"title": "Not ready to accept appointments.",
|
||||
"description": "If this is yours, please login and complete onboarding."
|
||||
},
|
||||
"steps": {
|
||||
"channel": {
|
||||
"title": "Please select an appointment type:",
|
||||
"empty": "Currently we don't have available appointments. Please check again later."
|
||||
},
|
||||
"agent": {
|
||||
"title": "Please select who you want your appointment with:",
|
||||
"anyone": {
|
||||
"title": "No preference",
|
||||
"description": "Choose this option to get the fastest appointment."
|
||||
}
|
||||
},
|
||||
"slot": {
|
||||
"title": "Please select an available appointment:",
|
||||
"today": "Today",
|
||||
"selectDate": "Select a date",
|
||||
"selectTime": "Select a time:",
|
||||
"loading": "Loading available appointments...",
|
||||
"empty": "No appointments available. Please select another date.",
|
||||
"action": "Select appointment"
|
||||
},
|
||||
"data": {
|
||||
"title": "Please add your information:",
|
||||
"alert": {
|
||||
"title": "Your data is safe",
|
||||
"description": "Only you and {name} will be able to access this information. It is end-to-end encrypted."
|
||||
},
|
||||
"email": {
|
||||
"notifyMe": "Notify me of changes by e-mail",
|
||||
"tooltip": "If enabled, your e-mail address will appear temporarily in server logs of the mailserver. If disabled, you will have to login on this page to check for updates."
|
||||
},
|
||||
"phone": {
|
||||
"description": "Format: +1234567890. {name} may contact you by phone, if there are questions or changes regarding your appointment.",
|
||||
"optional": "(optional)"
|
||||
},
|
||||
"action": "Add personal data"
|
||||
},
|
||||
"auth": {
|
||||
"register": {
|
||||
"title": "It's my first time here",
|
||||
"description": "Please add an PIN number so you can change your appointment later.",
|
||||
"hint": "Proceeding will create an account",
|
||||
"action": "Show summary",
|
||||
"success": "Account created successfully!",
|
||||
"error": "Failed to create account. Please try again.",
|
||||
"alreadyExists": "This email is already registered. Please use the login option to book additional appointments."
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"description": "Please enter the PIN number, you've set:",
|
||||
"action": "Show summary",
|
||||
"throttled": "Too many failed attempts. Please wait a few minutes before trying again.",
|
||||
"retry": "Please try again in {seconds} seconds"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"title": "Please review your appointment, before confirming.",
|
||||
"book": {
|
||||
"action": "Book appointment"
|
||||
},
|
||||
"request": {
|
||||
"action": "Request appointment",
|
||||
"hint": "This appointment will have to be manually confirmed. You will receive an E-Mail once it's confirmed."
|
||||
},
|
||||
"error": "Operation failed. Please retry."
|
||||
},
|
||||
"complete": {
|
||||
"book": {
|
||||
"title": "Appointment booked",
|
||||
"description": "We’ve sent you an e-mail with the details."
|
||||
},
|
||||
"request": {
|
||||
"title": "Appointment requested",
|
||||
"description": "{name} will confirm or deny your request soon. You will be informed via e-mail."
|
||||
},
|
||||
"action": "Proceed to Homepage"
|
||||
}
|
||||
},
|
||||
"anyAgent": "Anyone available",
|
||||
"register": {
|
||||
"error": "Encryption could not be set up. Please retry"
|
||||
},
|
||||
"login": {
|
||||
"success": "Login successful",
|
||||
"error": "Login failed. Please retry"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"today": "Today",
|
||||
"loading": "Loading calendar",
|
||||
"decrypting": "decrypting...",
|
||||
"decryptingError": "Unable to decrypt data. Please log-in again",
|
||||
"shownAppointments": {
|
||||
"title": "Appointments",
|
||||
"options": {
|
||||
"all": "all",
|
||||
"available": "available",
|
||||
"booked": "booked",
|
||||
"reserved": "reserved"
|
||||
}
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
"poweredBy": "Powered by",
|
||||
"greeting": "Hello {name},",
|
||||
"oclock": "",
|
||||
"appointmentBooked": {
|
||||
"subject": "{channel} at {tenant} booked successfully",
|
||||
"introduction": "your appointment for has been booked.",
|
||||
"action": "Cancel appointment",
|
||||
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
|
||||
},
|
||||
"appointmentReminder": {
|
||||
"subject": "Reminder: Your appointment with {tenant}",
|
||||
"introduction": "your appointment with {tenant} is soon.",
|
||||
"action": "Cancel appointment",
|
||||
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
|
||||
},
|
||||
"appointmentRejected": {
|
||||
"subject": "Your request for {channel} at {tenant} was rejected",
|
||||
"introduction": "Your appointment was rejected.",
|
||||
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
|
||||
},
|
||||
"appointmentRequest": {
|
||||
"subject": "{channel} with {tenant} requested",
|
||||
"introduction": "your appointment was requested. You will be notified once it is confirmed.",
|
||||
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
|
||||
},
|
||||
"confirmation": {
|
||||
"subject": "Confirm your E-Mail Address",
|
||||
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
|
||||
"action": "Confirm E-Mail Address",
|
||||
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
|
||||
"reason": "You are receiving this email because someone registered an account with your e-mail address."
|
||||
},
|
||||
"pinReset": {
|
||||
"subject": "PIN successfully changed",
|
||||
"introduction": "you've successfully changed your PIN on the {tenant} appointment booking platform. You can now log-in with your new PIN.",
|
||||
"action": "Login",
|
||||
"reason": "You are receiving this email because someone changed the PIN-Code for your account."
|
||||
},
|
||||
"userInvite": {
|
||||
"subject": "Confirm your E-Mail Address",
|
||||
"introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
|
||||
"action": "Confirm E-Mail Address",
|
||||
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
|
||||
"reason": "You are receiving this email because someone registered an account with your e-mail address."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -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 {
|
||||
|
||||
Vendored
+9
-1
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"] },
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SETUP_STATES_LIST = ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user