Merge remote-tracking branch 'origin/main' into 27-auto-delete-or-cleanup-older-appointments

This commit is contained in:
Karl Ludwig Weise
2026-06-18 17:28:59 +02:00
94 changed files with 6086 additions and 1146 deletions
+1
View File
@@ -0,0 +1 @@
24
+1
View File
@@ -8,6 +8,7 @@ Follow our [deployment guide](./docs/deployment.md) to deploy to you own server.
## Local development
- Ensure you have Node 24 installed (either manually, or optionally by running `nvm install` and `nvm use`).
- Create your own local `.env` file. You can base it on [.env.example](./.env.example)
- `npm install`
- `npm run docker:dev:up`
-3
View File
@@ -12,7 +12,6 @@ services:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- postgres_data_dev:/var/lib/postgresql/data
- postgres_run_dev:/var/run/postgresql
- ./init-db:/docker-entrypoint-initdb.d:ro
networks:
- appointment-booking-dev
@@ -43,8 +42,6 @@ services:
volumes:
postgres_data_dev:
driver: local
postgres_run_dev:
driver: local
networks:
appointment-booking-dev:
+1 -1
View File
@@ -51,7 +51,7 @@ async function registerStaffPasskey(userId, tenantId, email) {
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)",
"(https://open-reception.org/getting-started/#passkeys)",
);
}
@@ -0,0 +1,2 @@
ALTER TABLE "challenge_throttle" ADD COLUMN "tenant_id" uuid;--> statement-breakpoint
ALTER TABLE "challenge_throttle" ADD CONSTRAINT "challenge_throttle_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,2 @@
ALTER TABLE "user" DROP COLUMN "token";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "token_valid_until";
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "user_invite" ALTER COLUMN "tenant_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "user_invite" ALTER COLUMN "invited_by" DROP NOT NULL;
+865
View File
@@ -0,0 +1,865 @@
{
"id": "4ab7df30-6490-4189-93b7-e1b25b0c8e90",
"prevId": "ef7f86cc-6f1f-45cc-833b-0645ede95732",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.challenge_throttle": {
"name": "challenge_throttle",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"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": {
"challenge_throttle_tenant_id_tenant_id_fk": {
"name": "challenge_throttle_tenant_id_tenant_id_fk",
"tableFrom": "challenge_throttle",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"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"
},
"domain": {
"name": "domain",
"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_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"]
},
"tenant_domain_unique": {
"name": "tenant_domain_unique",
"nullsNotDistinct": false,
"columns": ["domain"]
}
},
"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": {}
}
}
+853
View File
@@ -0,0 +1,853 @@
{
"id": "f0ff401a-6299-471e-9812-80c678b9d47e",
"prevId": "4ab7df30-6490-4189-93b7-e1b25b0c8e90",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.challenge_throttle": {
"name": "challenge_throttle",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"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": {
"challenge_throttle_tenant_id_tenant_id_fk": {
"name": "challenge_throttle_tenant_id_tenant_id_fk",
"tableFrom": "challenge_throttle",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"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"
},
"domain": {
"name": "domain",
"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_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"]
},
"tenant_domain_unique": {
"name": "tenant_domain_unique",
"nullsNotDistinct": false,
"columns": ["domain"]
}
},
"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'"
},
"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": {}
}
}
+853
View File
@@ -0,0 +1,853 @@
{
"id": "8da6b050-6e61-46c0-8480-8b0c2ac62e55",
"prevId": "f0ff401a-6299-471e-9812-80c678b9d47e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.challenge_throttle": {
"name": "challenge_throttle",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"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": {
"challenge_throttle_tenant_id_tenant_id_fk": {
"name": "challenge_throttle_tenant_id_tenant_id_fk",
"tableFrom": "challenge_throttle",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"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"
},
"domain": {
"name": "domain",
"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_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"]
},
"tenant_domain_unique": {
"name": "tenant_domain_unique",
"nullsNotDistinct": false,
"columns": ["domain"]
}
},
"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'"
},
"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": false
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+21
View File
@@ -78,6 +78,27 @@
"when": 1769196961847,
"tag": "0010_rich_stardust",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1779177151355,
"tag": "0011_nebulous_stephen_strange",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780157869249,
"tag": "0012_polite_impossible_man",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1780252077820,
"tag": "0013_abnormal_vengeance",
"breakpoints": true
}
]
}
+72 -68
View File
@@ -1,12 +1,12 @@
{
"name": "open-reception",
"version": "1.0.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-reception",
"version": "1.0.0",
"version": "1.1.1",
"license": "AGPL-3.0",
"dependencies": {
"@noble/hashes": "^1.8.0",
@@ -22,7 +22,7 @@
"drizzle-orm": "^0.45.2",
"jose": "^6.0.11",
"mode-watcher": "^1.0.8",
"nodemailer": "^8.0.5",
"nodemailer": "^8.0.9",
"postgres": "^3.4.7",
"secrets.js-34r7h": "^2.0.2",
"uuidv7": "^1.0.2",
@@ -37,7 +37,7 @@
"@lucide/svelte": "^0.562.0",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.57.1",
"@sveltejs/kit": "^2.65.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
@@ -58,7 +58,7 @@
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.53.6",
"svelte": "^5.56.3",
"svelte-check": "^4.3.5",
"svelte-sonner": "^1.0.7",
"sveltekit-superforms": "^2.29.1",
@@ -1604,17 +1604,17 @@
}
},
"node_modules/@inlang/sdk": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.9.0.tgz",
"integrity": "sha512-CfaKtwnPZpVcq3/+/IpMO1NrbyrODNzfwGvMuxoUQjojnC7+9Omtuz1Ol3Hy+tfS6i9zmSvu3c5Ru1/Zzq8QhA==",
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.9.3.tgz",
"integrity": "sha512-E/SxcSji8WIt4DqQG9APlOs6tVtJxrrOUS3dE4ho3pWRCLLIY0PIVzgNwSukuFT+m8LuJDFwpRY5VY3ryzyGWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.8",
"@lix-js/sdk": "0.4.10",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^13.0.0"
"uuid": "^14.0.0"
},
"engines": {
"node": ">=20.0.0"
@@ -1695,9 +1695,9 @@
"license": "MIT"
},
"node_modules/@lix-js/sdk": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.8.tgz",
"integrity": "sha512-H0nbC7ruhom1WQHmaXje6hyp8LYKqHw4VsJIm+i3QQMmz70Qr4MEIiy2Epma2AFSVD2rdIfIY4mQ+8WdCtjR3Q==",
"version": "0.4.10",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.10.tgz",
"integrity": "sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1707,26 +1707,12 @@
"js-sha256": "^0.11.0",
"kysely": "^0.28.12",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
"uuid": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@lix-js/sdk/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@lix-js/server-protocol-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
@@ -2419,9 +2405,9 @@
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -2453,17 +2439,17 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.57.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
"version": "2.65.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.65.2.tgz",
"integrity": "sha512-ZIkyEmxT1gcq50Opn1ZIIx6vc/yt2zNN0rF5hS6op95gqHtNw8QMKDhjJI+RyjMcbvECRw+FzEeAoBe/MOz9AA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@sveltejs/acorn-typescript": "^1.0.9",
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"acorn": "^8.16.0",
"cookie": "^0.6.0",
"devalue": "^5.6.4",
"devalue": "^5.8.1",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
@@ -3283,7 +3269,7 @@
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.1.tgz",
"integrity": "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3549,9 +3535,9 @@
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -4256,9 +4242,9 @@
}
},
"node_modules/devalue": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"license": "MIT"
},
"node_modules/dlv": {
@@ -4785,12 +4771,20 @@
}
},
"node_modules/esrap": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
"integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
"version": "2.2.11",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz",
"integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
}
},
"node_modules/esrecurse": {
@@ -5437,10 +5431,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -5571,9 +5575,9 @@
"license": "MIT"
},
"node_modules/kysely": {
"version": "0.28.14",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz",
"integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==",
"version": "0.28.17",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
"integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
"devOptional": true,
"license": "MIT",
"engines": {
@@ -6134,9 +6138,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz",
"integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -6352,9 +6356,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"funding": [
{
"type": "opencollective",
@@ -7225,23 +7229,23 @@
}
},
"node_modules/svelte": {
"version": "5.53.6",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz",
"integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==",
"version": "5.56.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz",
"integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.3",
"devalue": "^5.8.1",
"esm-env": "^1.2.1",
"esrap": "^2.2.2",
"esrap": "^2.2.11",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -7880,9 +7884,9 @@
"license": "MIT"
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
+7 -4
View File
@@ -1,7 +1,7 @@
{
"name": "open-reception",
"private": true,
"version": "1.0.0",
"version": "1.1.1",
"type": "module",
"description": "End-to-end encrypted appointment booking platform",
"scripts": {
@@ -28,8 +28,11 @@
"docker:dev:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans",
"docker:build": "docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:latest .",
"docker:build:tag": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version",
"docker:build:tag:prerelease": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version",
"docker:push": "docker push openreception/open-reception:$npm_package_version && docker push openreception/open-reception:latest",
"docker:push:prerelease": "docker push openreception/open-reception:$npm_package_version",
"docker:build-and-push": "npm run docker:build && npm run docker:build:tag && npm run docker:push",
"docker:build-and-push-prerelease": "npm run docker:build && npm run docker:build:tag:prerelease && npm run docker:push:prerelease",
"docker:prod:up": "docker compose -f docker-compose.prod.yml up -d",
"docker:prod:down": "docker compose -f docker-compose.prod.yml down",
"docker:prod:logs": "docker compose -f docker-compose.prod.yml logs -f",
@@ -45,7 +48,7 @@
"@lucide/svelte": "^0.562.0",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.57.1",
"@sveltejs/kit": "^2.65.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
@@ -66,7 +69,7 @@
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.53.6",
"svelte": "^5.56.3",
"svelte-check": "^4.3.5",
"svelte-sonner": "^1.0.7",
"sveltekit-superforms": "^2.29.1",
@@ -94,7 +97,7 @@
"drizzle-orm": "^0.45.2",
"jose": "^6.0.11",
"mode-watcher": "^1.0.8",
"nodemailer": "^8.0.5",
"nodemailer": "^8.0.9",
"postgres": "^3.4.7",
"secrets.js-34r7h": "^2.0.2",
"uuidv7": "^1.0.2",
+24 -11
View File
@@ -23,6 +23,11 @@
"success": "E-Mail wurde gesendet"
},
"confirm": {
"claim": {
"title": "Konto einrichten",
"description": "Klicken Sie, um Ihr neues Konto einzurichten.",
"action": "Konto einrichten"
},
"success": {
"title": "Gute Arbeit!",
"description": "Du hast Dir diese Instanz gesichert.",
@@ -75,7 +80,9 @@
"domain": "Domain",
"phone": "Telefonnummer",
"pin": "PIN",
"pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager."
"pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager.",
"locale": "Sprache",
"localePlaceholder": "Sprache wählen"
},
"login": {
"or": "Oder",
@@ -120,9 +127,16 @@
"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.",
"success": "Erfolgreich abgemeldet",
"title": "Abmelden",
"success": {
"title": "Du wurdest abgemeldet.",
"description": "Du wurdest abgemeldet."
},
"error": {
"title": "Abmelden fehlgeschlagen",
"description": "Du konntest nicht abgemeldet werden."
},
"retry": "Erneut versuchen",
"action": "Zur Anmeldung"
},
"passkey": {
@@ -1029,18 +1043,17 @@
"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."
},
"notification": {
"subject": "Aktivität in Deinem Terminbuchungsportal",
"introduction": "in Deinem Terminbuchungsportal gab es eine neue Aktivität. Bitte logge Dich ein, um die Details zu sehen.",
"action": "Dashboard öffnen",
"reason": "Du erhältst diese E-Mail, weil Du für einen Kanal Benachrichtigungen aktiviert hast."
},
"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",
"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."
}
},
"notifications": {
+24 -11
View File
@@ -24,6 +24,11 @@
"success": "E-Mail was resent"
},
"confirm": {
"claim": {
"title": "Claim your account",
"description": "Click to claim your new account",
"action": "Claim account"
},
"success": {
"title": "Good job!",
"description": "Youve secured this instance.",
@@ -91,7 +96,9 @@
"domain": "Domain",
"phone": "Phone Number",
"pin": "PIN",
"pinHint": "Save your PIN in a secure place, like a password-manager."
"pinHint": "Save your PIN in a secure place, like a password-manager.",
"locale": "Language",
"localePlaceholder": "Select Language"
},
"login": {
"or": "Or",
@@ -128,9 +135,16 @@
}
},
"logout": {
"title": "Logout complete",
"description": "You are logged-out.",
"success": "Logout successful",
"title": "Logout",
"success": {
"title": "You are logged out.",
"description": "You are logged-out."
},
"error": {
"title": "Logout failed",
"description": "You could not be logged out."
},
"retry": "Retry",
"action": "Go to Login"
},
"passkey": {
@@ -1038,18 +1052,17 @@
"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."
},
"notification": {
"subject": "Activity in your appointment booking platform",
"introduction": "There is new activity in your appointment booking platform. Please log in to view details.",
"action": "Open Dashboard",
"reason": "You are receiving this email because you have notifications enabled for a channel."
},
"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."
}
},
"notifications": {
+4 -2
View File
@@ -485,7 +485,7 @@ export class UnifiedAppointmentCrypto {
method: "POST",
headers: {
"Content-Type": "application/json",
...(isFirstAppointment && this.bookingAccessToken
...(this.bookingAccessToken
? { Authorization: `Bearer ${this.bookingAccessToken}` }
: {}),
},
@@ -959,7 +959,7 @@ export class UnifiedAppointmentCrypto {
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).",
"Please use a modern authenticator (https://open-reception.org/getting-started/#passkeys).",
);
}
@@ -994,6 +994,7 @@ export class UnifiedAppointmentCrypto {
staffId: string,
passkeyId: string,
prfOutput: ArrayBuffer,
email: string,
keyPair: { publicKey: Uint8Array; privateKey: Uint8Array },
): Promise<void> {
// Derive passkey-based shard from PRF output
@@ -1013,6 +1014,7 @@ export class UnifiedAppointmentCrypto {
passkeyId,
publicKey: this.uint8ArrayToBase64(keyPair.publicKey),
privateKeyShare: this.uint8ArrayToBase64(dbShard),
email,
}),
});
@@ -70,7 +70,7 @@
{activeTenant.shortName}
</Text>
<Text style="xs" class="truncate">
{activeTenant.shortName}.{window.location.hostname}
{activeTenant.domain}
</Text>
{:else}
<Text style="md" class="truncate font-medium">
@@ -0,0 +1,68 @@
<script lang="ts">
import { Text } from "$lib/components/ui/typography";
import { languageSwitchLocales } from "$lib/const/locales";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte";
import type { AppointmentDetailItems } from ".";
import CopyButton from "./copy-button.svelte";
let {
items,
}: {
items: AppointmentDetailItems;
} = $props();
</script>
{#if items.length > 0}
<div class="flex flex-col items-start gap-2">
{#each items as item (item.type)}
{#if item.value}
<div class="flex items-center gap-2">
{#if item.type === "agent"}
<UserStar class="size-4 shrink-0" />
<Text style="sm">
{item.value}
</Text>
{:else if item.type === "client-name"}
<User class="size-4 shrink-0" />
<Text style="sm">
{item.value}
</Text>
<CopyButton value={item.value} />
{:else if item.type === "client-locale"}
{@const locale =
languageSwitchLocales[item.value as keyof typeof languageSwitchLocales]}
{#if locale}
<Languages class="size-4 shrink-0" />
<Text style="sm">
{locale.label}
</Text>
{/if}
{:else if item.type === "client-email"}
<Mail class="size-4 shrink-0" />
<a href={`mailto:${item.value}`} class="-mt-0.5 underline">
<Text style="sm">
{item.value}
</Text>
</a>
<CopyButton value={item.value} />
{:else if item.type === "client-phone"}
<Phone class="size-4 shrink-0" />
<a href={`tel:${item.value}`} class="-mt-0.5 underline">
<Text style="sm">
{item.value}
</Text>
</a>
<CopyButton value={item.value.toString()} />
{:else if item.type === "date"}
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(item.value)}
</Text>
<CopyButton value={item.value.toLocaleString()} />
{/if}
</div>
{/if}
{/each}
</div>
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
import { Check, Copy } from "@lucide/svelte";
let {
value,
class: className,
}: {
value: string;
class?: string;
} = $props();
let isCopied = $state(false);
const copy = (
e:
| (MouseEvent & {
currentTarget: EventTarget & HTMLButtonElement;
})
| (MouseEvent & {
currentTarget: EventTarget & HTMLAnchorElement;
}),
) => {
e.stopPropagation();
navigator.clipboard.writeText(value);
isCopied = true;
setTimeout(() => {
isCopied = false;
}, 1500);
};
</script>
{#if value}
<Button variant="ghost" onclick={copy} class={cn("size-2 rounded-sm px-px", className)}>
{#if isCopied}
<Check />
{:else}
<Copy />
{/if}
</Button>
{/if}
@@ -0,0 +1,32 @@
import type { SupportedLocale } from "$lib/const/locales";
import AppointmentDetails from "./appointment-details.svelte";
export { AppointmentDetails };
export type AppointmentDetailItems = AppointmentDetailItem[];
export type AppointmentDetailItem =
| {
type: "date";
value: Date | undefined;
}
| {
type: "client-name";
value: string | undefined;
}
| {
type: "client-locale";
value: SupportedLocale | undefined;
}
| {
type: "client-email";
value: string | undefined;
}
| {
type: "client-phone";
value: string | undefined;
}
| {
type: "agent";
value: string | undefined;
};
+20 -2
View File
@@ -43,6 +43,10 @@
</script>
<script lang="ts">
/**
Custom changes:
* Added derivedHref to prevent malicious js injection via href prop.
*/
let {
class: className,
variant = "default",
@@ -55,15 +59,29 @@
isLoading,
...restProps
}: ButtonProps = $props();
let derivedHref = $derived.by(() => {
if (
href &&
(href.startsWith("http://") ||
href.startsWith("https://") ||
href.startsWith("mailto:") ||
href.startsWith("tel:") ||
href.startsWith("/"))
) {
return href;
}
return undefined;
});
</script>
{#if href}
{#if derivedHref}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
href={disabled ? undefined : derivedHref}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
@@ -4,7 +4,7 @@
import { Input } from "$lib/components/ui/input/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { toInputDateTime } from "$lib/utils/datetime";
import type { DateValue } from "@internationalized/date";
import type { DateValue, TimeFields } from "@internationalized/date";
import { getLocalTimeZone } from "@internationalized/date";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import type { ChangeEventHandler, HTMLInputAttributes } from "svelte/elements";
@@ -12,16 +12,21 @@
type Props = Omit<HTMLInputAttributes, "type" | "files"> & {
id: string;
type: "date" | "datetime-local";
defaultTime: TimeFields;
value?: string;
onChanged?: (value: Date) => void;
};
let { id, type, value = $bindable(), ...restProps }: Props = $props();
let { id, type, defaultTime, value = $bindable(), ...restProps }: Props = $props();
let open = $state(false);
let derivedValue = $derived(toInputDateTime(value));
const updateValue = (newValue: DateValue) => {
value = newValue.toDate(getLocalTimeZone()).toISOString();
if (restProps.onChanged) {
restProps.onChanged(new Date(value));
}
};
const onChangeDate = (dateValue: DateValue | undefined) => {
@@ -55,8 +60,8 @@
};
$effect(() => {
if (type === "date" && derivedValue.hour !== 0) {
updateValue(derivedValue.copy().set({ hour: 0, minute: 0, second: 0, millisecond: 0 }));
if (type === "date" && ![0, 23].includes(derivedValue.hour)) {
updateValue(derivedValue.copy().set(defaultTime));
}
});
</script>
@@ -2,7 +2,6 @@
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";
@@ -11,15 +10,11 @@
let {
locale,
user,
tenant,
confirmUrl,
expirationMinutes,
dashboardUrl,
}: {
locale: SupportedLocale;
user: SelectUserEmail;
tenant: SelectTenant;
confirmUrl: string;
expirationMinutes: number;
dashboardUrl: string;
} = $props();
$effect(() => {
@@ -28,17 +23,12 @@
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name }, { locale })}</EmailText>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name })}</EmailText>
<EmailText variant="md">
{m["emails.userInvite.introduction"]({ tenant: tenant.longName }, { locale })}
</EmailText>
<EmailButton href={confirmUrl} {locale}>
{m["emails.userInvite.action"]({}, { locale })}
</EmailButton>
<EmailText variant="md">
{m["emails.userInvite.hint"]({ expirationMinutes }, { locale })}
{m["emails.notification.introduction"]()}
</EmailText>
<EmailButton href={dashboardUrl}>{m["emails.notification.action"]()}</EmailButton>
<EmailText variant="md" color="text-light">
{m["emails.userInvite.reason"]({}, { locale })}
{m["emails.notification.reason"]()}
</EmailText>
</EmailLayout>
@@ -20,8 +20,6 @@ const mockUser: SelectUser = {
lastLoginAt: new Date(),
isActive: true,
confirmationState: "ACCESS_GRANTED" as const,
token: null,
tokenValidUntil: null,
passphraseHash: null,
recoveryPassphrase: null,
language: "de",
@@ -0,0 +1,180 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("$env/dynamic/private", () => ({
env: {
JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
},
}));
import { normalizeEmail } from "$lib/utils";
import {
generateRegistrationBootstrapToken,
verifyRegistrationBootstrapToken,
} from "../registration-bootstrap";
const VALID_USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const VALID_EMAIL = "user@example.com";
describe("registration-bootstrap", () => {
describe("normalizeEmail", () => {
it("should lowercase and trim", () => {
expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com");
});
it("should not change already normalised email", () => {
expect(normalizeEmail("user@example.com")).toBe("user@example.com");
});
});
describe("generateRegistrationBootstrapToken", () => {
it("should return a JWT string", async () => {
const token = await generateRegistrationBootstrapToken({
userId: VALID_USER_ID,
email: VALID_EMAIL,
});
expect(typeof token).toBe("string");
expect(token!.split(".")).toHaveLength(3);
});
it("should normalise email before encoding", async () => {
const token = await generateRegistrationBootstrapToken({
userId: VALID_USER_ID,
email: "USER@EXAMPLE.COM",
});
const payload = await verifyRegistrationBootstrapToken(token!);
expect(payload!.email).toBe("user@example.com");
});
});
describe("verifyRegistrationBootstrapToken", () => {
it("should verify a valid token and return userId and email", async () => {
const token = await generateRegistrationBootstrapToken({
userId: VALID_USER_ID,
email: VALID_EMAIL,
});
const payload = await verifyRegistrationBootstrapToken(token!);
expect(payload).not.toBeNull();
expect(payload!.userId).toBe(VALID_USER_ID);
expect(payload!.email).toBe(VALID_EMAIL);
});
it("should return null for undefined input", async () => {
const result = await verifyRegistrationBootstrapToken(undefined);
expect(result).toBeNull();
});
it("should return null for an empty string", async () => {
const result = await verifyRegistrationBootstrapToken("");
expect(result).toBeNull();
});
it("should return null for a malformed token", async () => {
const result = await verifyRegistrationBootstrapToken("not.a.jwt");
expect(result).toBeNull();
});
it("should return null for a token signed with a different secret", async () => {
// Manually build a token with a different secret via jose
const { SignJWT } = await import("jose");
const wrongSecret = new TextEncoder().encode("wrong-secret-value");
const token = await new SignJWT({
userId: VALID_USER_ID,
email: VALID_EMAIL,
type: "webauthn-registration-bootstrap",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(wrongSecret);
const result = await verifyRegistrationBootstrapToken(token);
expect(result).toBeNull();
});
it("should return null for a token with wrong type claim", async () => {
const { SignJWT } = await import("jose");
const secret = new TextEncoder().encode(
"test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
);
const token = await new SignJWT({
userId: VALID_USER_ID,
email: VALID_EMAIL,
type: "wrong-type",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
const result = await verifyRegistrationBootstrapToken(token);
expect(result).toBeNull();
});
it("should return null for a token missing userId", async () => {
const { SignJWT } = await import("jose");
const secret = new TextEncoder().encode(
"test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
);
const token = await new SignJWT({
email: VALID_EMAIL,
type: "webauthn-registration-bootstrap",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
const result = await verifyRegistrationBootstrapToken(token);
expect(result).toBeNull();
});
it("should return null for a token missing email", async () => {
const { SignJWT } = await import("jose");
const secret = new TextEncoder().encode(
"test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
);
const token = await new SignJWT({
userId: VALID_USER_ID,
type: "webauthn-registration-bootstrap",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(secret);
const result = await verifyRegistrationBootstrapToken(token);
expect(result).toBeNull();
});
it("should normalise email in the returned payload", async () => {
const token = await generateRegistrationBootstrapToken({
userId: VALID_USER_ID,
email: " USER@EXAMPLE.COM ",
});
const payload = await verifyRegistrationBootstrapToken(token!);
expect(payload!.email).toBe("user@example.com");
});
it("should distinguish tokens for different users", async () => {
const tokenA = await generateRegistrationBootstrapToken({
userId: VALID_USER_ID,
email: VALID_EMAIL,
});
const tokenB = await generateRegistrationBootstrapToken({
userId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
email: "other@example.com",
});
const payloadA = await verifyRegistrationBootstrapToken(tokenA!);
const payloadB = await verifyRegistrationBootstrapToken(tokenB!);
expect(payloadA!.userId).not.toBe(payloadB!.userId);
expect(payloadA!.email).not.toBe(payloadB!.email);
});
});
});
@@ -0,0 +1,80 @@
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
import { env } from "$env/dynamic/private";
import { UniversalLogger } from "$lib/logger";
import { normalizeEmail } from "$lib/utils";
const logger = new UniversalLogger().setContext("RegistrationBootstrap");
const REGISTRATION_BOOTSTRAP_TYPE = "webauthn-registration-bootstrap";
const REGISTRATION_BOOTSTRAP_EXPIRES = "15m";
type RegistrationBootstrapPayload = {
userId: string;
email: string;
type: typeof REGISTRATION_BOOTSTRAP_TYPE;
};
const getJwtSecret = (): Uint8Array | null => {
if (!env.JWT_SECRET) {
logger.error("JWT_SECRET missing while handling registration bootstrap token");
return null;
}
return new TextEncoder().encode(env.JWT_SECRET);
};
export async function generateRegistrationBootstrapToken(input: {
userId: string;
email: string;
}): Promise<string | null> {
const jwtSecret = getJwtSecret();
if (!jwtSecret) {
return null;
}
const now = Math.floor(Date.now() / 1000);
const payload: RegistrationBootstrapPayload = {
userId: input.userId,
email: normalizeEmail(input.email),
type: REGISTRATION_BOOTSTRAP_TYPE,
};
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(now)
.setExpirationTime(REGISTRATION_BOOTSTRAP_EXPIRES)
.sign(jwtSecret);
}
export async function verifyRegistrationBootstrapToken(
token?: string,
): Promise<{ userId: string; email: string } | null> {
if (!token) {
return null;
}
const jwtSecret = getJwtSecret();
if (!jwtSecret) {
return null;
}
try {
const { payload } = await jwtVerify(token, jwtSecret);
const typedPayload = payload as JWTPayload & Partial<RegistrationBootstrapPayload>;
if (
typedPayload.type !== REGISTRATION_BOOTSTRAP_TYPE ||
typeof typedPayload.userId !== "string" ||
typeof typedPayload.email !== "string"
) {
return null;
}
return {
userId: typedPayload.userId,
email: normalizeEmail(typedPayload.email),
};
} catch {
return null;
}
}
+4 -8
View File
@@ -120,8 +120,6 @@ export const user = pgTable(
lastLoginAt: timestamp("last_login_at"),
isActive: boolean("is_active").default(true),
confirmationState: confirmationStateEnum("confirmation_state").default("INVITED"),
token: text("token"),
tokenValidUntil: timestamp("token_valid_until"),
/** Hashed passphrase for password authentication (optional, alternative to WebAuthn) */
passphraseHash: text("passphrase_hash"),
/** Recovery passphrase for WebAuthn-only users (stored in plain text, shown only once) */
@@ -204,13 +202,9 @@ export const userInvite = pgTable(
/** Role to assign to user when they register */
role: userRoleEnum("role").notNull(),
/** Tenant the user is being invited to */
tenantId: uuid("tenant_id")
.notNull()
.references(() => tenant.id, { onDelete: "cascade" }),
tenantId: uuid("tenant_id").references(() => tenant.id, { onDelete: "cascade" }),
/** User who sent the invitation */
invitedBy: uuid("invited_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
invitedBy: uuid("invited_by").references(() => user.id, { onDelete: "cascade" }),
/** Language preference for the invitation */
language: text("language").notNull().default("de"),
/** Whether the invitation has been used */
@@ -240,6 +234,8 @@ export const userInvite = pgTable(
export const challengeThrottle = pgTable("challenge_throttle", {
/** Primary key - identifier (email hash for PIN challenges, email for passkey challenges) */
id: text("id").primaryKey(),
/** Tenant ID for scoping throttles to single tenants to restrict global lock-out. Might be null for global throttles on administrative accounts */
tenantId: uuid("tenant_id").references(() => tenant.id, { onDelete: "cascade" }),
/** Number of failed attempts */
failedAttempts: integer("failed_attempts").default(0).notNull(),
/** When the throttle was last updated */
+23 -18
View File
@@ -9,6 +9,7 @@ import {
text,
time,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
@@ -287,24 +288,28 @@ export type SelectNotification = InferSelectModel<typeof notification>;
* Enables end-to-end encryption for appointments in tenant database
* @table staffCrypto
*/
export const staffCrypto = pgTable("staff_crypto", {
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to central user table */
userId: uuid("user_id").notNull(),
/** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */
publicKey: text("public_key").notNull(),
/** Database-stored shard of the private key (Base64 encoded) */
privateKeyShare: text("private_key_share").notNull(),
/** Associated passkey ID for key derivation */
passkeyId: text("passkey_id").notNull(),
/** Timestamp when the key was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
/** Timestamp when the key was last updated */
updatedAt: timestamp("updated_at").defaultNow().notNull(),
/** Whether this key is currently active */
isActive: boolean("is_active").default(true).notNull(),
});
export const staffCrypto = pgTable(
"staff_crypto",
{
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to central user table */
userId: uuid("user_id").notNull(),
/** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */
publicKey: text("public_key").notNull(),
/** Database-stored shard of the private key (Base64 encoded) */
privateKeyShare: text("private_key_share").notNull(),
/** Associated passkey ID for key derivation */
passkeyId: text("passkey_id").notNull(),
/** Timestamp when the key was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
/** Timestamp when the key was last updated */
updatedAt: timestamp("updated_at").defaultNow().notNull(),
/** Whether this key is currently active */
isActive: boolean("is_active").default(true).notNull(),
},
(table) => [uniqueIndex("staff_crypto_ua_idx").on(table.userId, table.isActive)],
);
/**
* ClientAppointmentTunnels table - represents encrypted appointment tunnels for clients
@@ -467,7 +467,13 @@ describe("Email System", () => {
const confirmationCode = "ABC123";
const expirationMinutes = 15;
await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
await sendConfirmationEmail(
staffUser,
mockTenant,
confirmationCode,
expirationMinutes,
new URL("https://example.com"),
);
expect(mockSendMail).toHaveBeenCalled();
});
@@ -506,7 +512,13 @@ describe("Email System", () => {
const confirmationCode = "XYZ789";
const expirationMinutes = 10;
await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
await sendConfirmationEmail(
staffUser,
mockTenant,
confirmationCode,
expirationMinutes,
new URL("https://example.com"),
);
expect(mockSendMail).toHaveBeenCalled();
});
@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { generateBaseUrl } from "../email-service";
import type { SelectTenant } from "$lib/server/db/central-schema";
// Mock NODE_ENV
const mockEnv = vi.hoisted(() => ({
@@ -22,365 +21,16 @@ describe("generateBaseUrl", () => {
vi.clearAllMocks();
});
describe("Development/Local Environment", () => {
it("should return localhost URL regardless of tenant", () => {
const requestUrl = new URL("http://localhost:5173");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
links: { website: "", imprint: "", privacyStatement: "" },
domain: "tenant.example.com",
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://localhost:5173");
});
it("should return localhost URL for null tenant", () => {
const requestUrl = new URL("http://localhost:3000");
const result = generateBaseUrl(requestUrl, null);
expect(result).toBe("http://localhost:3000");
});
it("should preserve port for localhost", () => {
const requestUrl = new URL("http://localhost:8080");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "test",
longName: "Test Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "tenant.example.com",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://localhost:8080");
});
it("should handle 127.x.x.x addresses", () => {
const requestUrl = new URL("http://127.0.0.1:3000");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "tenant.example.com",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://127.0.0.1:3000");
});
it("should handle 192.168.x.x addresses", () => {
const requestUrl = new URL("http://192.168.1.100:8080");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "tenant.example.com",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://192.168.1.100:8080");
});
it("should return localhost with port", () => {
const requestUrl = new URL("http://localhost:5173");
const result = generateBaseUrl(requestUrl);
expect(result).toBe("http://localhost:5173");
});
describe("Production Environment", () => {
beforeEach(() => {
mockEnv.NODE_ENV = "production";
});
it("should return tenant domain", () => {
const requestUrl = new URL("https://example.com");
it("should return main domain for null tenant", () => {
const requestUrl = new URL("https://example.com");
const result = generateBaseUrl(requestUrl, null);
expect(result).toBe("https://example.com");
});
it("should return main domain with port for null tenant", () => {
const requestUrl = new URL("https://example.com:8443");
const result = generateBaseUrl(requestUrl, null);
expect(result).toBe("https://example.com:8443");
});
it("should create subdomain URL for tenant on main domain", () => {
const requestUrl = new URL("https://example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
domain: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://acme.example.com");
});
it("should create subdomain URL with port", () => {
const requestUrl = new URL("https://example.com:8443");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "acme",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://acme.example.com:8443");
});
it("should replace existing subdomain with tenant shortName", () => {
const requestUrl = new URL("https://old-tenant.example.com");
const tenant: SelectTenant = {
id: "tenant-2",
shortName: "new-tenant",
longName: "New Tenant Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "new-tenant",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://new-tenant.old-tenant.example.com");
});
it("should replace existing subdomain with port", () => {
const requestUrl = new URL("https://old-tenant.example.com:8443");
const tenant: SelectTenant = {
id: "tenant-2",
shortName: "new-tenant",
longName: "New Tenant Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "new-tenant",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://new-tenant.old-tenant.example.com:8443");
});
it("should handle complex subdomains (keep last two parts)", () => {
const requestUrl = new URL("https://admin.api.example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "tenant",
longName: "Tenant Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "tenant",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://tenant.admin.api.example.com");
});
it("should handle http protocol", () => {
const requestUrl = new URL("http://example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "acme",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("http://acme.example.com");
});
it("should return main domain when tenant has no shortName", () => {
const requestUrl = new URL("https://example.com");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "", // Empty shortName
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://example.com");
});
});
describe("Edge Cases", () => {
it("should handle single domain names in production", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://app");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "acme",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://acme.app");
});
it("should handle localhost in production (still treated as development)", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://localhost:8443");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "tenant.example.com",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://localhost:8443");
});
it("should handle IP addresses as development", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://192.168.1.100:8443");
const tenant: SelectTenant = {
id: "tenant-1",
shortName: "acme",
longName: "ACME Corp",
descriptions: { en: "" },
languages: ["en"],
defaultLanguage: "en",
databaseUrl: "",
setupState: "SETTINGS",
logo: null,
links: { website: "", imprint: "", privacyStatement: "" },
domain: "tenant.example.com",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateBaseUrl(requestUrl, tenant);
expect(result).toBe("https://192.168.1.100:8443");
});
it("should handle default HTTP port (80)", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("http://example.com:80");
// URL constructor should handle port 80 correctly
const result = generateBaseUrl(requestUrl, null);
// Default HTTP port shouldn't be included in URL
expect(result).toBe("http://example.com");
});
it("should handle default HTTPS port (443)", () => {
mockEnv.NODE_ENV = "production";
const requestUrl = new URL("https://example.com:443");
// URL constructor should handle port 443 correctly
const result = generateBaseUrl(requestUrl, null);
// Default HTTPS port shouldn't be included in URL
expect(result).toBe("https://example.com");
});
const result = generateBaseUrl(requestUrl);
expect(result).toBe("https://example.com");
});
});
+37 -65
View File
@@ -22,8 +22,8 @@ import { AgentService } from "../services/agent-service";
import { TenantService } from "../db/tenant-service";
import Confirmation from "$lib/emails/Confirmation.svelte";
import PinReset from "$lib/emails/PinReset.svelte";
import UserInvite from "$lib/emails/UserInvite.svelte";
import { dev } from "$app/environment";
import Notification from "$lib/emails/Notification.svelte";
export type SelectClient = {
email: string;
@@ -147,7 +147,7 @@ export async function sendPinResetEmail(
locale,
user,
tenant,
loginUrl: generateBaseUrl(requestUrl, tenant) ?? "http://localhost:5173",
loginUrl: generateBaseUrl(requestUrl),
},
});
const html = renderOutputToHtml(emailRender);
@@ -399,23 +399,10 @@ export async function sendAppointmentUpdatedEmail(
* @param {SelectTenant | null} tenant - Tenant information (null for global admin)
* @returns {string} The appropriate base URL
*/
export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string {
const protocol = requestUrl.protocol;
const port = requestUrl.port ? `:${requestUrl.port}` : "";
const hostname = requestUrl.hostname;
// In development, always use the original hostname regardless of tenant
if (hostname === "localhost" || hostname.startsWith("127.") || hostname.startsWith("192.168.")) {
return `${protocol}//${hostname}${port}`;
}
// In production, handle tenant subdomains.
// Exclude the system tenant when determining if we should use the tenant's domain for the URL, as the system tenant does not have a domain and should use the main domain.
if (tenant?.domain && tenant.id !== "system") {
return `${protocol}//${tenant.domain}.${hostname}${port}`;
}
// For global admin or no tenant, use main domain
export function generateBaseUrl(url: URL): string {
const protocol = url.protocol;
const port = url.port ? `:${url.port}` : "";
const hostname = url.hostname;
return `${protocol}//${hostname}${port}`;
}
@@ -434,10 +421,10 @@ export async function sendConfirmationEmail(
tenant: SelectTenant,
confirmationCode: string,
expirationMinutes: number = 15,
requestUrl?: URL,
requestUrl: URL,
): Promise<void> {
// Generate appropriate base URL if request URL is provided
const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173";
const baseUrl = generateBaseUrl(requestUrl);
const confirmUrl = `${baseUrl}/confirm/${confirmationCode}`;
const recipient = user;
// Generate email
@@ -459,50 +446,6 @@ export async function sendConfirmationEmail(
await sendEmail(recipient as any, subject, html, text, tenant.longName);
}
/**
* Send user invitation email for existing tenant
* @param {string} userEmail - Email address of the invited user
* @param {string} userName - Name of the invited user
* @param {SelectTenant} tenant - Tenant information for branding
* @param {string} role - Role to assign to the user (TENANT_ADMIN or STAFF) - for logging only
* @param {string} registrationUrl - URL for user to register (contains secure invite code)
* @param {Language} [language="en"] - Email language
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendUserInviteEmail(
userEmail: string,
userName: string,
tenant: SelectTenant,
role: "TENANT_ADMIN" | "STAFF",
registrationUrl: string,
language: Language = "en",
): Promise<void> {
const { recipient, locale } = await getRecipient({ email: userEmail, name: userName, language });
// Generate email
const subject = m["emails.userInvite.subject"](
{
tenant: tenant.longName,
},
{ locale },
);
const emailRender = render(UserInvite, {
props: {
locale,
user: recipient as SelectUserEmail,
tenant,
confirmUrl: registrationUrl,
expirationMinutes: 30,
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text, tenant.longName);
}
/**
* Send appointment cancellation email
* @param {SelectClient | SelectUser} user - Database user object or client data
@@ -541,3 +484,32 @@ export async function sendAppointmentCancelledEmail(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text, tenant.longName);
}
/**
* Send notification email
* @param {SelectUser} user - Database user object or client data
* @param {{ domain: string; longName: string }} tenant - Tenant information for branding
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendNotificationEmail(
user: SelectUser,
tenant: { domain: string; longName: string },
): Promise<void> {
// Create recipient directly for SelectClient type, use helper for SelectUser
const { recipient, locale } = await getRecipient(user);
// Generate email
const subject = m["emails.notification.subject"]();
const emailRender = render(Notification, {
props: {
locale,
user,
dashboardUrl: dev ? `http://localhost:5173/dashboard` : `https://${tenant.domain}/dashboard`,
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendEmail(recipient as any, subject, html, text, tenant.longName);
}
@@ -852,7 +852,11 @@ describe("AppointmentService", () => {
);
expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123");
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin");
expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith(
"email-hash-123",
"pin",
"tenant-123",
);
});
it("should throw NotFoundError when challenge is not found", async () => {
@@ -921,6 +925,7 @@ describe("AppointmentService", () => {
expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledWith(
"email-hash-123",
"pin",
"tenant-123",
);
});
@@ -306,7 +306,7 @@ describe("TenantAdminService", () => {
const mockTenant = createMockTenant(tenantId);
const updateData = {
longName: "Updated Clinic Name",
description: ["Updated description"],
descriptions: { en: "Updated description" },
logo: "logo data",
};
@@ -10,6 +10,7 @@ vi.mock("../../db", () => ({
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
limit: vi.fn(),
},
}));
@@ -89,16 +90,24 @@ describe("UserService", () => {
name: "Test Admin",
email: "test@example.com",
language: "de" as const,
role: "GLOBAL_ADMIN" as const,
};
const mockCreatedAdminInvite = {
id: "invite-123",
tenantId: "018f-a1b2-c3d4-a5f6-789abcdef019",
inviteCode: "018f-a1b2-c3d4-e5f6-789abcdef012",
used: false,
expiresAt: new Date("2024-01-01T12:10:00Z"),
};
const mockCreatedAdmin = {
id: "018f-a1b2-c3d4-e5f6-789abcdef012",
name: "Test Admin",
email: "test@example.com",
token: "018f-a1b2-c3d4-e5f6-789abcdef012",
tokenValidUntil: new Date("2024-01-01T12:10:00Z"),
confirmationState: "INVITED" as const,
isActive: false,
confirmationState: "ACCESS_GRANTED" as const,
role: "GLOBAL_ADMIN" as const,
isActive: true,
};
const mockInsertBuilder = {
@@ -106,17 +115,25 @@ describe("UserService", () => {
returning: vi.fn().mockResolvedValue([mockCreatedAdmin]),
};
mockCentralDb.insert.mockReturnValue(mockInsertBuilder);
const mockInviteInsertBuilder = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockCreatedAdminInvite]),
};
const result = await UserService.createUser(adminData);
mockCentralDb.insert
.mockReturnValueOnce(mockInviteInsertBuilder)
.mockReturnValueOnce(mockInsertBuilder);
const result = await UserService.createUser(adminData, new URL("http://localhost:5173"));
expect(mockCentralDb.insert).toHaveBeenCalled();
expect(mockInsertBuilder.values).toHaveBeenCalledWith({
...adminData,
token: "018f-a1b2-c3d4-e5f6-789abcdef012",
tokenValidUntil: expect.any(Date),
confirmationState: "INVITED" as const,
isActive: false,
confirmationState: "ACCESS_GRANTED" as const,
isActive: true,
role: "GLOBAL_ADMIN" as const,
tenantId: undefined,
recoveryPassphrase: expect.any(String),
});
expect(result).toEqual(mockCreatedAdmin);
});
@@ -141,12 +158,12 @@ describe("UserService", () => {
mockCentralDb.update.mockReturnValue(mockUpdateBuilder);
await UserService.resendConfirmationEmail(email);
await UserService.resendConfirmationEmail(email, new URL("http://localhost:5173"));
expect(mockCentralDb.update).toHaveBeenCalled();
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({
token: "018f-a1b2-c3d4-e5f6-789abcdef012",
tokenValidUntil: expect.any(Date),
inviteCode: "018f-a1b2-c3d4-e5f6-789abcdef012",
expiresAt: expect.any(Date),
});
expect(mockUpdateBuilder.where).toHaveBeenCalled();
expect(mockUpdateBuilder.returning).toHaveBeenCalled();
@@ -163,7 +180,9 @@ describe("UserService", () => {
mockCentralDb.update.mockReturnValue(mockUpdateBuilder);
await expect(UserService.resendConfirmationEmail(email)).rejects.toThrow(NotFoundError);
await expect(
UserService.resendConfirmationEmail(email, new URL("http://localhost:5173")),
).rejects.toThrow(NotFoundError);
});
});
@@ -178,6 +197,20 @@ describe("UserService", () => {
limit: vi.fn().mockResolvedValue([{ id: "user-123", recoveryPassphrase: "recovery-123" }]),
};
const mockUserInviteBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([
{
id: "invite-123",
tenantId: "tenant-123",
inviteCode: token,
used: false,
expiresAt: new Date("2024-01-01T12:10:00Z"),
},
]),
};
const mockCountSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ count: 1 }]),
@@ -195,6 +228,7 @@ describe("UserService", () => {
// First call for user lookup, second call for tenant admin count, third for total count
mockCentralDb.select
.mockReturnValueOnce(mockUserInviteBuilder)
.mockReturnValueOnce(mockSelectBuilder)
.mockReturnValueOnce(mockCountSelectBuilder)
.mockReturnValueOnce(mockTotalCountSelectBuilder);
@@ -202,7 +236,7 @@ describe("UserService", () => {
const result = await UserService.confirm(token);
expect(mockCentralDb.select).toHaveBeenCalledTimes(3);
expect(mockCentralDb.select).toHaveBeenCalledTimes(4);
expect(mockCentralDb.update).toHaveBeenCalled();
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({
confirmationState: "ACCESS_GRANTED" as const,
@@ -1021,13 +1021,13 @@ export class AppointmentService {
});
// Record failed attempt for throttling
await challengeThrottleService.recordFailedAttempt(emailHash, "pin");
await challengeThrottleService.recordFailedAttempt(emailHash, "pin", this.tenantId);
throw new ValidationError("Invalid challenge response");
}
// Clear throttle on successful verification
await challengeThrottleService.clearThrottle(emailHash, "pin");
await challengeThrottleService.clearThrottle(emailHash, "pin", this.tenantId);
const db = await this.getDb();
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
export const NEW_CLIENT_BOOTSTRAP_DIFFICULTY = 4;
export const NEW_CLIENT_BOOTSTRAP_DIFFICULTY = 5;
export function createBootstrapBinding(input: {
tenantId: string;
+39 -8
View File
@@ -9,10 +9,10 @@
import { centralDb } from "$lib/server/db";
import { challengeThrottle } from "$lib/server/db/central-schema";
import { eq, lt, sql } from "drizzle-orm";
import { eq, lt, sql, and } from "drizzle-orm";
import { logger } from "$lib/logger";
export type ThrottleType = "pin" | "passkey";
export type ThrottleType = "pin" | "passkey" | "passphrase";
interface ThrottleResult {
allowed: boolean;
@@ -51,15 +51,25 @@ class ChallengeThrottleService {
* Check if a challenge request should be throttled
* @param identifier - Email hash for PIN challenges, email for passkey challenges
* @param type - Type of challenge (pin or passkey)
* @param tenantId - Tenant ID for scoping the throttle check
*/
async checkThrottle(identifier: string, type: ThrottleType): Promise<ThrottleResult> {
async checkThrottle(
identifier: string,
type: ThrottleType,
tenantId?: string,
): Promise<ThrottleResult> {
const now = new Date();
// Get throttle record from central DB
const records = await centralDb
.select()
.from(challengeThrottle)
.where(eq(challengeThrottle.id, identifier))
.where(
and(
eq(challengeThrottle.id, identifier),
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
),
)
.limit(1);
if (records.length === 0) {
@@ -72,7 +82,14 @@ class ChallengeThrottleService {
// Check if throttle has expired
if (now > record.resetAt) {
// Throttle expired, clean up and allow
await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier));
await centralDb
.delete(challengeThrottle)
.where(
and(
eq(challengeThrottle.id, identifier),
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
),
);
return { allowed: true, retryAfterMs: 0, failedAttempts: 0 };
}
@@ -104,8 +121,13 @@ class ChallengeThrottleService {
* Record a failed challenge attempt
* @param identifier - Email hash for PIN challenges, email for passkey challenges
* @param type - Type of challenge (pin or passkey)
* @param tenantId - Tenant ID for scoping the throttle record
*/
async recordFailedAttempt(identifier: string, type: ThrottleType): Promise<void> {
async recordFailedAttempt(
identifier: string,
type: ThrottleType,
tenantId?: string,
): Promise<void> {
const now = new Date();
const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS);
@@ -116,6 +138,7 @@ class ChallengeThrottleService {
failedAttempts: 1,
lastAttemptAt: now,
resetAt,
tenantId: tenantId,
})
.onConflictDoUpdate({
target: challengeThrottle.id,
@@ -135,9 +158,17 @@ class ChallengeThrottleService {
* Clear throttle for successful authentication
* @param identifier - Email hash for PIN challenges, email for passkey challenges
* @param type - Type of challenge (pin or passkey)
* @param tenantId - Tenant ID for scoping the throttle clearance
*/
async clearThrottle(identifier: string, type: ThrottleType): Promise<void> {
await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier));
async clearThrottle(identifier: string, type: ThrottleType, tenantId?: string): Promise<void> {
await centralDb
.delete(challengeThrottle)
.where(
and(
eq(challengeThrottle.id, identifier),
tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined,
),
);
logger.debug(`Cleared ${type} challenge throttle`, {
identifier: identifier.slice(0, 8),
+1 -1
View File
@@ -33,7 +33,7 @@ export class InviteService {
tenantId,
invitedBy,
language,
expiresAt: sql`timezone('utc', now()) + interval '30 minutes'`,
expiresAt: sql`timezone('utc', now()) + interval '10 minutes'`,
used: false,
};
@@ -1,14 +1,17 @@
import { getTenantDb } from "../db";
import { centralDb, getTenantDb } from "../db";
import {
notification,
channelStaff,
notificationTypes,
type NotificationType,
} from "../db/tenant-schema";
import { eq, and, desc } from "drizzle-orm";
import { user } from "$lib/server/db/central-schema";
import { eq, and, desc, inArray } from "drizzle-orm";
import logger from "$lib/logger";
import { z } from "zod";
import { ValidationError, NotFoundError } from "../utils/errors";
import { sendNotificationEmail } from "../email/email-service";
import { TenantAdminService } from "./tenant-admin-service";
const notificationCreationSchema = z.object({
channelId: z.uuid({ message: "Invalid UUID format" }),
@@ -105,6 +108,30 @@ export class NotificationService {
.values(notificationsToCreate)
.returning({ id: notification.id });
// Send e-mail notifications
if (request.type === "APPOINTMENT_REQUESTED") {
const userAccounts = await centralDb
.select()
.from(user)
.where(
inArray(
user.id,
notificationsToCreate.map((n) => n.staffId),
),
);
if (userAccounts.length > 0) {
const adminService = await TenantAdminService.getTenantById(this.tenantId);
const tenant = adminService.tenantData;
if (tenant) {
await Promise.all(
userAccounts.map((staff) =>
sendNotificationEmail(staff, { domain: tenant.domain, longName: tenant.longName }),
),
);
}
}
}
log.info("Created notifications for channel", {
channelId: request.channelId,
count: createdNotifications.length,
+6 -3
View File
@@ -213,9 +213,12 @@ export class StaffService {
const deletedInvites = await tx
.delete(userInvite)
.where(
or(
eq(userInvite.createdUserId, staffId),
eq(userInvite.email, userToDelete[0].email),
and(
eq(userInvite.tenantId, tenantId),
or(
eq(userInvite.createdUserId, staffId),
eq(userInvite.email, userToDelete[0].email),
),
),
);
logger.debug("Deleted user invites", {
+56 -16
View File
@@ -9,7 +9,21 @@ import logger from "$lib/logger";
import { and, count, eq, ne, not, or } from "drizzle-orm";
import { z } from "zod";
import { ConflictError, NotFoundError, ValidationError } from "../utils/errors";
import { redactDbUrl } from "../utils/url";
import { isLinkValid, redactDbUrl } from "../utils/url";
type UpdateTenantBody = Partial<
Pick<
InsertTenant,
| "longName"
| "shortName"
| "descriptions"
| "languages"
| "logo"
| "domain"
| "links"
| "defaultLanguage"
>
>;
if (!process.env.BUILDING && !env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
@@ -219,32 +233,45 @@ export class TenantAdminService {
/**
* Update tenant data (longName, shortName, descriptions, languages, logo)
*/
async updateTenantData(
updateData: Partial<
Pick<
InsertTenant,
"longName" | "shortName" | "descriptions" | "languages" | "logo" | "domain"
>
>,
) {
async updateTenantData(updateData: UpdateTenantBody) {
const filteredUpdateData: UpdateTenantBody = {
longName: updateData.longName,
descriptions: updateData.descriptions,
languages: updateData.languages,
defaultLanguage: updateData.defaultLanguage,
logo: updateData.logo,
links: updateData.links,
domain: updateData.domain,
};
const log = logger.setContext("TenantAdminService");
log.debug("Updating tenant data", {
tenantId: this.tenantId,
updateFields: Object.keys(updateData),
updateFields: Object.keys(filteredUpdateData),
});
if (updateData.shortName) {
if (filteredUpdateData.shortName) {
throw new ValidationError("Shortname cannot be changed");
}
// Check each link
if (filteredUpdateData.links) {
for (const link of Object.values(filteredUpdateData.links)) {
if (!isLinkValid(link)) {
throw new ValidationError("Links must start with http or https or be empty");
}
}
}
// Check if domain is already in use
if (updateData.domain) {
if (filteredUpdateData.domain) {
const domainExists = await centralDb
.select()
.from(centralSchema.tenant)
.where(
and(
eq(centralSchema.tenant.domain, updateData.domain),
eq(centralSchema.tenant.domain, filteredUpdateData.domain),
ne(centralSchema.tenant.id, this.tenantId),
),
);
@@ -257,11 +284,24 @@ export class TenantAdminService {
const result = await centralDb
.update(centralSchema.tenant)
.set({
...updateData,
...filteredUpdateData,
updatedAt: new Date(),
})
.where(eq(centralSchema.tenant.id, this.tenantId))
.returning();
.returning({
id: centralSchema.tenant.id,
createdAt: centralSchema.tenant.createdAt,
updatedAt: centralSchema.tenant.updatedAt,
languages: centralSchema.tenant.languages,
defaultLanguage: centralSchema.tenant.defaultLanguage,
shortName: centralSchema.tenant.shortName,
longName: centralSchema.tenant.longName,
descriptions: centralSchema.tenant.descriptions || {},
domain: centralSchema.tenant.domain,
logo: centralSchema.tenant.logo,
setupState: centralSchema.tenant.setupState,
links: centralSchema.tenant.links,
});
if (!result[0]) {
log.warn("Tenant update failed: Tenant not found", { tenantId: this.tenantId });
@@ -270,7 +310,7 @@ export class TenantAdminService {
log.debug("Tenant data updated successfully", {
tenantId: this.tenantId,
updateFields: Object.keys(updateData),
updateFields: Object.keys(filteredUpdateData),
});
return result[0];
+120 -81
View File
@@ -22,6 +22,7 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
import { AppointmentService } from "./appointment-service";
export type InsertUser = InferInsertModel<typeof centralSchema.user>;
export type InsertUserInvite = InferInsertModel<typeof centralSchema.userInvite>;
export type InsertUserPasskey = InferInsertModel<typeof centralSchema.userPasskey>;
export type UserTransaction = PgTransaction<
PostgresJsQueryResultHKT,
@@ -45,10 +46,8 @@ const userCreationSchema = z.object({
name: z.string().min(5),
email: z.email(),
role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(),
tenantId: z.string().uuid().optional(),
tenantId: z.uuid().optional(),
passphrase: z.string().min(12).optional(),
token: z.uuidv7().optional(),
tokenValidUntil: z.date().optional(),
language: z.enum(["de", "en"]).optional().default("de"),
confirmationState: z.enum(["INVITED", "CONFIRMED", "ACCESS_GRANTED"]).optional(),
// Note: passphraseHash and recoveryPassphrase are handled internally, not via user input
@@ -99,7 +98,7 @@ export class UserService {
/**
* Create a new user
*/
static async createUser(userData: UserCreation, requestUrl?: URL) {
static async createUser(userData: UserCreation, requestUrl: URL) {
const log = logger.setContext("UserService");
log.debug("Creating new user account", {
email: userData.email,
@@ -122,8 +121,14 @@ export class UserService {
throw new ValidationError("Passphrase must be at least 12 characters long");
}
userData.token = uuidv7();
userData.tokenValidUntil = addMinutes(new Date(), 10);
const userInviteForDb: InsertUserInvite = {
email: userData.email,
name: userData.name,
role: userData.role!,
tenantId: userData.tenantId!,
expiresAt: addMinutes(new Date(), 10),
inviteCode: uuidv7(),
};
// Prepare user data for database
const userDataForDb: InsertUser = {
@@ -131,8 +136,6 @@ export class UserService {
email: userData.email,
role: userData.role,
tenantId: userData.tenantId,
token: userData.token,
tokenValidUntil: userData.tokenValidUntil,
language: userData.language || "de",
confirmationState: userData.confirmationState || "INVITED",
isActive: false,
@@ -162,43 +165,49 @@ export class UserService {
}
try {
const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
const [inviteResult] = await centralDb
.insert(centralSchema.userInvite)
.values(userInviteForDb)
.returning();
const [insertedUser] = await centralDb
.insert(centralSchema.user)
.values(userDataForDb)
.returning();
log.debug("User account created successfully", {
userId: result[0].id,
email: result[0].email,
tokenValidUntil: result[0].tokenValidUntil,
hasPassphrase: !!result[0].passphraseHash,
hasRecoveryPassphrase: !!result[0].recoveryPassphrase,
userId: insertedUser.id,
email: insertedUser.email,
hasPassphrase: !!insertedUser.passphraseHash,
hasRecoveryPassphrase: !!insertedUser.recoveryPassphrase,
});
// Send confirmation email to user (token is used as confirmation code)
try {
if (result[0].email && result[0].token) {
const tenant = await getTenantForUser(result[0]);
if (insertedUser?.email && inviteResult?.inviteCode) {
const tenant = await getTenantForUser(insertedUser);
await sendConfirmationEmail(
result[0],
insertedUser,
tenant,
result[0].token,
inviteResult.inviteCode,
10, // 10 minutes expiration to match tokenValidUntil
requestUrl,
);
log.debug("Confirmation email sent successfully", {
userId: result[0].id,
email: result[0].email,
tenantId: result[0].tenantId,
userId: insertedUser.id,
email: insertedUser.email,
tenantId: insertedUser.tenantId,
});
}
} catch (emailError) {
log.warn("Failed to send confirmation email", {
userId: result[0].id,
email: result[0].email,
userId: insertedUser?.id,
email: insertedUser?.email,
error: String(emailError),
});
// Don't throw - user creation succeeded, email is just a bonus
}
return result[0];
return insertedUser;
} catch (error) {
log.error("Failed to create user account", { email: userData.email, error: String(error) });
throw error;
@@ -208,9 +217,9 @@ export class UserService {
/**
* Resend the confirmation email for a user
* @param email - Email of the user to confirm
* @param requestUrl - Optional request URL for generating correct baseUrl
* @param requestUrl - request URL for generating correct baseUrl
*/
static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise<void> {
static async resendConfirmationEmail(email: string, requestUrl: URL): Promise<void> {
const log = logger.setContext("UserService");
log.debug("Resending confirmation email", { email });
@@ -219,9 +228,9 @@ export class UserService {
try {
const result = await centralDb
.update(centralSchema.user)
.set({ token, tokenValidUntil })
.where(eq(centralSchema.user.email, email))
.update(centralSchema.userInvite)
.set({ inviteCode: token, expiresAt: tokenValidUntil })
.where(eq(centralSchema.userInvite.email, email))
.returning();
if (result.length !== 1) {
@@ -294,7 +303,32 @@ export class UserService {
}
| undefined = undefined;
const userData = await centralDb
const [matchingInvite] = await centralDb
.select({
id: centralSchema.userInvite.id,
email: centralSchema.userInvite.email,
tenantId: centralSchema.userInvite.tenantId,
role: centralSchema.userInvite.role,
name: centralSchema.userInvite.name,
language: centralSchema.userInvite.language,
})
.from(centralSchema.userInvite)
.where(
and(
eq(centralSchema.userInvite.inviteCode, linkToken),
gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`),
),
)
.limit(1);
if (!matchingInvite) {
log.warn("User confirmation failed: Invalid or expired invite code", {
token: linkToken.substring(0, 8) + "...",
});
throw new NotFoundError("Invalid or expired invite code");
}
const [userData] = await centralDb
.select({
id: centralSchema.user.id,
recoveryPassphrase: centralSchema.user.recoveryPassphrase,
@@ -305,64 +339,38 @@ export class UserService {
.from(centralSchema.user)
.where(
and(
eq(centralSchema.user.token, linkToken),
gt(centralSchema.user.tokenValidUntil, sql`timezone('utc', now())`),
eq(centralSchema.user.email, matchingInvite.email),
matchingInvite.tenantId
? eq(centralSchema.user.tenantId, matchingInvite.tenantId)
: undefined,
),
)
.limit(1);
if (userData.length === 0) {
const inviteData = await centralDb
.select({
id: centralSchema.userInvite.id,
tenantId: centralSchema.userInvite.tenantId,
role: centralSchema.userInvite.role,
email: centralSchema.userInvite.email,
name: centralSchema.userInvite.name,
language: centralSchema.userInvite.language,
})
.from(centralSchema.userInvite)
.where(
and(
eq(centralSchema.userInvite.inviteCode, linkToken),
gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`),
),
)
.limit(1);
if (!userData) {
resultData = { ...matchingInvite, recoveryPassphrase: null };
const userDataForDb: InsertUser = {
name: resultData.name!,
email: resultData.email,
role: resultData.role,
tenantId: resultData.tenantId,
language: resultData.language || "de",
confirmationState: "CONFIRMED",
isActive: true,
};
const retVal = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
resultData.id = retVal[0].id;
if (inviteData.length === 0) {
log.warn("User confirmation failed: Invalid or expired token", {
token: linkToken.substring(0, 8) + "...",
});
throw new NotFoundError("Invalid or timed-out token");
} else {
resultData = { ...inviteData[0], recoveryPassphrase: null };
const userDataForDb: InsertUser = {
name: resultData.name!,
email: resultData.email,
role: resultData.role,
tenantId: resultData.tenantId,
language: resultData.language || "de",
confirmationState: "CONFIRMED",
isActive: true,
};
const retVal = await centralDb
.insert(centralSchema.user)
.values(userDataForDb)
.returning();
resultData.id = retVal[0].id;
await InviteService.markInviteAsUsed(linkToken, resultData.id);
log.debug("Invitation marked as used", {
inviteCode: linkToken,
userId: resultData.id,
});
await InviteService.markInviteAsUsed(linkToken, resultData.id);
log.debug("Invitation marked as used", {
inviteCode: linkToken,
userId: resultData.id,
});
const adminService = await TenantAdminService.getTenantById(resultData.tenantId!);
adminService.validateSetupState();
}
const adminService = await TenantAdminService.getTenantById(resultData.tenantId!);
adminService.validateSetupState();
} else {
resultData = userData[0];
resultData = userData;
}
// Check if this is the first tenant admin for the tenant
@@ -461,6 +469,37 @@ export class UserService {
}
}
/**
* Get user by ID
*/
static async getUserById(userId: string) {
const log = logger.setContext("UserService");
log.debug("Getting user by ID", { userId });
try {
const result = await centralDb
.select()
.from(centralSchema.user)
.where(eq(centralSchema.user.id, userId))
.limit(1);
if (!result[0]) {
log.warn("User not found by ID", { userId });
throw new NotFoundError(`No user account for ${userId}.`);
}
log.debug("User found by ID", {
userId: result[0].id,
confirmationState: result[0].confirmationState,
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
log.error("Failed to get user by ID", { userId, error: String(error) });
throw error;
}
}
/**
* Get admin by email
*/
+6
View File
@@ -4,3 +4,9 @@ export const redactDbUrl = (input: string) => {
url.password = "redacted-pw";
return url.toString();
};
export const isLinkValid = (link: string | undefined) => {
return (
link?.startsWith("http://") || link?.startsWith("https://") || link === "" || link === undefined
);
};
+10
View File
@@ -11,3 +11,13 @@ export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
export function normalizeEmail(value: string): string;
export function normalizeEmail(value?: string | null): string | undefined;
export function normalizeEmail(value?: string | null): string | undefined {
if (!value) {
return undefined;
}
return value.trim().toLowerCase();
}
+34 -5
View File
@@ -1,4 +1,11 @@
import logger from "$lib/logger";
import { normalizeEmail } from "$lib/utils";
type WebAuthnAllowCredential = {
id: string;
type: "public-key";
transports?: AuthenticatorTransport[];
};
export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
try {
@@ -40,13 +47,16 @@ export function base64ToArrayBuffer(base64: string) {
return bytes.buffer;
}
export const fetchChallenge = async (email: string) => {
export const fetchChallenge = async (email: string, userId?: string) => {
const resp = await fetch("/api/auth/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
body: JSON.stringify({
email,
...(userId ? { userId } : {}),
}),
});
let data;
@@ -81,17 +91,20 @@ export const fetchChallenge = async (email: string) => {
return {
id: data.rpId,
challenge: data.challenge,
allowCredentials: data.allowCredentials,
};
};
export const getCredentialOptions = ({
id,
challenge,
userId,
email,
enablePRF = false,
}: {
id: string;
challenge: string;
userId: ArrayBuffer;
email: string;
enablePRF?: boolean;
}): {
@@ -107,7 +120,7 @@ export const getCredentialOptions = ({
name: "Open Reception",
},
user: {
id: new Uint8Array(16),
id: userId,
name: email,
displayName: email,
},
@@ -132,6 +145,12 @@ export const getCredentialOptions = ({
return options;
};
const createWebAuthnUserId = async (email: string): Promise<ArrayBuffer> => {
const normalizedEmail = normalizeEmail(email) ?? "";
const emailBytes = new TextEncoder().encode(normalizedEmail);
return crypto.subtle.digest("SHA-256", emailBytes);
};
export type GeneratePasskeyResponse = {
response: AuthenticatorAttestationResponse;
id: string;
@@ -149,7 +168,8 @@ export const generatePasskey = async ({
email: string;
enablePRF?: boolean;
}): Promise<GeneratePasskeyResponse> => {
const options = getCredentialOptions({ id, challenge, email, enablePRF });
const userId = await createWebAuthnUserId(email);
const options = getCredentialOptions({ id, challenge, userId, email, enablePRF });
return (await navigator.credentials.create(options)) as GeneratePasskeyResponse;
};
@@ -226,7 +246,7 @@ export const getPRFOutputAfterRegistration = async ({
if (!prfResults || !prfResults.results || !prfResults.results.first) {
throw new Error(
"PRF extension not supported by this passkey. " +
"Please use a modern authenticator (YubiKey 5.2.3+, Titan Gen2, Windows Hello, Touch ID, or Android)",
"Please use a modern authenticator (https://open-reception.org/getting-started/#passkeys)",
);
}
@@ -236,6 +256,7 @@ export const getPRFOutputAfterRegistration = async ({
return prfOutput;
} else {
// Convert ArrayBufferView to ArrayBuffer
console.log("⚠️ Detected non-ArrayBuffer PRF output, trying to convert manually");
return prfOutput.buffer.slice(
prfOutput.byteOffset,
prfOutput.byteOffset + prfOutput.byteLength,
@@ -252,11 +273,13 @@ export const getCredential = async ({
challenge,
email,
enablePRF = false,
allowCredentials,
}: {
id: string;
challenge: string;
email: string;
enablePRF?: boolean;
allowCredentials?: WebAuthnAllowCredential[];
}): Promise<GetCredentialResponse> => {
// Build WebAuthn options manually to support PRF
const challengeBuffer = base64UrlToArrayBuffer(challenge);
@@ -265,6 +288,11 @@ export const getCredential = async ({
challenge: challengeBuffer,
rpId: id,
userVerification: "preferred",
allowCredentials: allowCredentials?.map((credential) => ({
id: base64UrlToArrayBuffer(credential.id),
type: credential.type,
transports: credential.transports,
})),
};
// Add PRF extension if enabled
@@ -307,6 +335,7 @@ export const getCredential = async ({
if (prfResult instanceof ArrayBuffer) {
prfOutput = prfResult;
} else {
console.log("⚠️ Detected non-ArrayBuffer PRF output, trying to convert manually");
prfOutput = prfResult.buffer.slice(
prfResult.byteOffset,
prfResult.byteOffset + prfResult.byteLength,
@@ -1,27 +0,0 @@
import UserInvite from "$lib/emails/UserInvite.svelte";
import { renderOutputToHtml } from "$lib/emails/utils";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { RequestHandler } from "@sveltejs/kit";
import { render } from "svelte/server";
export const GET: RequestHandler = async () => {
const emailRender = render(UserInvite, {
props: {
locale: "en",
user: {
email: "max.mustermann@example.com",
name: "Max Mustermann",
language: "en",
},
tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
confirmUrl: "https://open-reception.org/confirm/abc123",
expirationMinutes: 30,
},
});
const html = renderOutputToHtml(emailRender);
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
};
@@ -47,6 +47,8 @@
>
{m["login.action"]()}
</Form.Button>
<Button href={resolve(ROUTES.LOGIN)} variant="link">{m["clients.login.altAction"]()}</Button>
<Button href={resolve(ROUTES.DASHBOARD.MAIN)} variant="link">
{m["clients.login.altAction"]()}
</Button>
</CenteredCard.Action>
</CenteredCard.Root>
@@ -1,49 +1,70 @@
import logger from "$lib/logger";
import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
import { removeAuthCookies } from "$lib/server/utils/cookies";
import type { PageServerLoad } from "./$types";
import type { Actions, PageServerLoad } from "./$types";
import type { Error, Success } from "./types";
const log = logger.setContext(import.meta.filename);
type Error = { success: false; isSetup: boolean };
type Success = {
success: boolean;
isSetup: boolean;
id: string;
email: string;
tenantId: string | null;
};
export const load: PageServerLoad = async (event) => {
// Remove any existing access token cookie
removeAuthCookies(event);
};
const confirmation: Promise<Success | Error> = event
.fetch("/api/auth/confirm", {
export const actions: Actions = {
default: async (event) => {
const resp = await event.fetch("/api/auth/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token: event.params.token }),
})
.then(async (resp) => {
const success = resp.status < 400;
try {
const body = await resp.json();
return {
success,
isSetup: body.isSetup ?? false,
id: body.id,
email: body.email,
tenantId: body.tenantId,
};
} catch (error) {
log.error("Failed to parse confirm token response", { error });
return { success: false, isSetup: false };
}
});
return {
streaming: {
confirmation,
},
};
const success = resp.status < 400;
try {
const rawBody = await resp.text();
const body = rawBody ? JSON.parse(rawBody) : {};
if (success && typeof body.id === "string" && typeof body.email === "string") {
const registrationBootstrapToken = await generateRegistrationBootstrapToken({
userId: body.id,
email: body.email,
});
if (registrationBootstrapToken) {
event.cookies.set("webauthn-registration-bootstrap", registrationBootstrapToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 15,
});
}
return {
confirmation: {
success: true,
isSetup: body.isSetup ?? false,
id: body.id,
email: body.email,
tenantId: body.tenantId ?? null,
} satisfies Success,
};
}
return {
confirmation: { success: false, isSetup: false } satisfies Error,
};
} catch (error) {
log.error("Failed to parse confirm token response", {
error,
status: resp.status,
contentType: resp.headers.get("content-type"),
});
return {
confirmation: { success: false, isSetup: false } satisfies Error,
};
}
},
};
+82 -58
View File
@@ -1,17 +1,18 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { CenterLoadingState, CenterState } from "$lib/components/templates/empty-state";
import { Button } from "$lib/components/ui/button";
import { PageWithClaim } from "$lib/components/ui/page";
import { Skeleton } from "$lib/components/ui/skeleton";
import Ban from "@lucide/svelte/icons/ban";
import Check from "@lucide/svelte/icons/check";
import { ROUTES } from "$lib/const/routes";
import { enhance } from "$app/forms";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { m } from "$i18n/messages.js";
import { CenteredCard } from "$lib/components/layouts";
import { CenterState } from "$lib/components/templates/empty-state";
import { Button } from "$lib/components/ui/button";
import { PageWithClaim } from "$lib/components/ui/page";
import { ROUTES } from "$lib/const/routes";
import { Ban, Check, Drum } from "@lucide/svelte";
import type { Error, Success } from "./types.js";
const { data } = $props();
let isSubmitting = $state(false);
let confirmation: Error | Success | undefined = $state();
</script>
<svelte:head>
@@ -21,68 +22,91 @@
<PageWithClaim isWithLanguageSwitch>
<CenteredCard.Root>
<CenteredCard.Main>
{#await data.streaming.confirmation}
<CenterLoadingState />
{:then confirmation}
{#if confirmation.success}
{#if confirmation.isSetup}
<CenterState
Icon={Check}
headline={m["setup.confirm.success.title"]()}
description={m["setup.confirm.success.description"]()}
/>
{:else}
<CenterState
Icon={Check}
headline={m["confirm.success.title"]()}
description={m["confirm.success.description"]()}
/>
{/if}
{#if !confirmation}
<CenterState
Icon={Drum}
headline={m["setup.confirm.claim.title"]()}
description={m["setup.confirm.claim.description"]()}
/>
{:else if confirmation.success}
{#if confirmation.isSetup}
<CenterState
Icon={Check}
headline={m["setup.confirm.success.title"]()}
description={m["setup.confirm.success.description"]()}
/>
{:else}
<CenterState
Icon={Ban}
headline={m["confirm.error.title"]()}
description={m["confirm.error.description"]()}
Icon={Check}
headline={m["confirm.success.title"]()}
description={m["confirm.success.description"]()}
/>
{/if}
{/await}
{:else}
<CenterState
Icon={Ban}
headline={m["confirm.error.title"]()}
description={m["confirm.error.description"]()}
/>
{/if}
</CenteredCard.Main>
<CenteredCard.Action>
{#await data.streaming.confirmation}
<Skeleton class="mx-auto h-2 w-3/4" />
<Skeleton class="mx-auto h-2 w-1/4" />
<Skeleton class="h-10 w-full" />
{:then confirmation}
{#if confirmation.success}
{#if confirmation.isSetup}
<CenteredCard.ActionHint>
{m["setup.confirm.success.hint"]()}
</CenteredCard.ActionHint>
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
{m["setup.confirm.success.action"]()}
</Button>
{:else}
<Button
size="lg"
class="w-full"
onclick={() =>
{#if !confirmation}
<form
method="POST"
use:enhance={() => {
isSubmitting = true;
return async ({ result, update }) => {
isSubmitting = false;
if (result.type === "success" && result.data?.confirmation) {
confirmation = result.data.confirmation as Success | Error;
}
await update();
};
}}
>
<Button
size="lg"
class="w-full"
type="submit"
disabled={isSubmitting}
isLoading={isSubmitting}
>
{m["setup.confirm.claim.action"]()}
</Button>
</form>
{:else if confirmation.success}
{#if confirmation.isSetup}
<CenteredCard.ActionHint>
{m["setup.confirm.success.hint"]()}
</CenteredCard.ActionHint>
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
{m["setup.confirm.success.action"]()}
</Button>
{:else}
<Button
size="lg"
class="w-full"
onclick={() => {
if (confirmation?.success) {
goto(resolve(ROUTES.SETUP_PASSKEY), {
state: {
id: confirmation.id,
email: confirmation.email,
tenantId: confirmation.tenantId,
},
})}
>
{m["confirm.success.action"]()}
</Button>
{/if}
{:else}
<Button size="lg" class="w-full" href={ROUTES.RESEND_CONFIRMATION}>
{m["confirm.error.action"]()}
});
}
}}
>
{m["confirm.success.action"]()}
</Button>
{/if}
{/await}
{:else}
<Button size="lg" class="w-full" href={ROUTES.RESEND_CONFIRMATION}>
{m["confirm.error.action"]()}
</Button>
{/if}
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -0,0 +1,8 @@
export type Error = { success: false; isSetup: boolean };
export type Success = {
success: true;
isSetup: boolean;
id: string;
email: string;
tenantId: string | null;
};
@@ -80,7 +80,7 @@
const { KyberCrypto } = await import("$lib/crypto/utils");
kyberKeyPair = KyberCrypto.generateKeyPair();
const challenge = await fetchChallenge($formData.email);
const challenge = await fetchChallenge($formData.email, $formData.userId);
if (!challenge) {
logger.error("Failed to fetch challenge", { email: $formData.email });
@@ -134,7 +134,7 @@
// This is the only time we can retrieve the PRF output
// Uses email as salt for multi-passkey support
try {
const prfChallenge = await fetchChallenge($formData.email);
const prfChallenge = await fetchChallenge($formData.email, $formData.userId);
if (!prfChallenge) {
throw new Error("Failed to fetch PRF challenge");
}
@@ -196,7 +196,14 @@
if (tenantId && passkeyId && prfOutput && kyberKeyPair) {
const crypto = new UnifiedAppointmentCrypto();
return await crypto
.storeStaffKeyPair(tenantId, $formData.userId, passkeyId, prfOutput, kyberKeyPair)
.storeStaffKeyPair(
tenantId,
$formData.userId,
passkeyId,
prfOutput,
$formData.email,
kyberKeyPair,
)
.then(() => {
toast.success(m["setupPasskey.successKeyPairSaved"]());
})
@@ -14,13 +14,15 @@
import { reasons } from "../utils";
import { formSchema } from "./schema";
import { getDefaultStartTime, getDefaultEndTime } from "$lib/utils/datetime";
import { untrack } from "svelte";
let { done }: { done: () => void } = $props();
const agents = $derived($agentsStore.agents ?? []);
const initialAgent = untrack(() => (agents.length === 1 ? agents[0].id : ""));
const form = superForm(
{
agent: "",
agent: initialAgent,
absenceType: "VACATION",
startDate: getDefaultStartTime(),
endDate: getDefaultEndTime(),
@@ -43,6 +45,7 @@
let isSubmitting = $state(false);
let isAllDay = $state(false);
let endDateTouched = $state(false);
const { form: formData, enhance } = form;
</script>
@@ -111,6 +114,9 @@
label={m["absences.add.fields.isAllDay.label"]()}
onCheckedChange={(v) => {
isAllDay = v;
if (v) {
setTimeout(() => (endDateTouched = false), 100);
}
}}
class="mt-2 mb-1"
/>
@@ -122,6 +128,26 @@
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.startDate}
defaultTime={{ hour: 0, minute: 0, second: 0 }}
onChanged={() => {
if (!endDateTouched && $formData.endDate <= $formData.startDate) {
if (!isAllDay) {
const startDate = new Date($formData.startDate);
const endDate = new Date($formData.endDate);
startDate.setHours(
endDate.getHours(),
endDate.getMinutes(),
endDate.getSeconds(),
endDate.getMilliseconds(),
);
$formData.endDate = startDate.toISOString();
} else {
const startDate = new Date($formData.startDate);
startDate.setHours(23, 59, 59, 999);
$formData.endDate = startDate.toISOString();
}
}
}}
/>
{/snippet}
</Form.Control>
@@ -135,6 +161,8 @@
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.endDate}
defaultTime={{ hour: 23, minute: 59, second: 59 }}
onChanged={() => (endDateTouched = true)}
/>
{/snippet}
</Form.Control>
@@ -14,6 +14,7 @@
import { formSchema } from ".";
import { reasons } from "../utils";
import { toInputDateTime } from "$lib/utils/datetime";
import { SvelteDate } from "svelte/reactivity";
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
@@ -50,8 +51,14 @@
// svelte-ignore state_referenced_locally
let endDate = $state(toInputDateTime(entity.endDate));
let isAllDay = $state(
startDate.hour === 0 && startDate.minute === 0 && endDate.hour === 0 && endDate.minute === 0,
startDate.hour === 0 &&
startDate.minute === 0 &&
startDate.second === 0 &&
endDate.hour === 23 &&
endDate.minute === 59 &&
endDate.second === 59,
);
let endDateTouched = $state(false);
const { form: formData, enhance } = form;
</script>
@@ -120,6 +127,9 @@
label={m["absences.add.fields.isAllDay.label"]()}
onCheckedChange={(v) => {
isAllDay = v;
if (v) {
setTimeout(() => (endDateTouched = false), 100);
}
}}
class="mt-2 mb-1"
/>
@@ -131,6 +141,26 @@
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.startDate}
defaultTime={{ hour: 0, minute: 0, second: 0 }}
onChanged={() => {
if (!endDateTouched && $formData.endDate <= $formData.startDate) {
if (!isAllDay) {
const startDate = new SvelteDate($formData.startDate);
const endDate = new Date($formData.endDate);
startDate.setHours(
endDate.getHours(),
endDate.getMinutes(),
endDate.getSeconds(),
endDate.getMilliseconds(),
);
$formData.endDate = startDate.toISOString();
} else {
const startDate = new SvelteDate($formData.startDate);
startDate.setHours(23, 59, 59, 999);
$formData.endDate = startDate.toISOString();
}
}
}}
/>
{/snippet}
</Form.Control>
@@ -144,6 +174,8 @@
{...props}
type={isAllDay ? "date" : "datetime-local"}
bind:value={$formData.endDate}
defaultTime={{ hour: 23, minute: 59, second: 59 }}
onChanged={() => (endDateTouched = true)}
/>
{/snippet}
</Form.Control>
@@ -21,6 +21,7 @@
import { DeleteAbsenceForm } from "./(components)/delete-absence-form";
import EditAbsenceForm from "./(components)/edit-absence-form/edit-absence-form.svelte";
import { reasons } from "./(components)/utils";
import { getLocalTimeZone } from "@internationalized/date";
const { data } = $props();
const agents = $derived($agentsStore.agents ?? []);
@@ -35,9 +36,34 @@
const renderDescription = (item: TAbsence) => {
const startDate = toDisplayDateTime(new Date(item.startDate));
const fullStartDay = toDisplayDateTime(new Date(item.startDate), {
year: "numeric",
month: "long",
day: "numeric",
timeZone: getLocalTimeZone(),
});
const endDate = toDisplayDateTime(new Date(item.endDate));
const fullEndDay = toDisplayDateTime(new Date(item.endDate), {
year: "numeric",
month: "long",
day: "numeric",
timeZone: getLocalTimeZone(),
});
const reason = reasons.find((r) => r.value === item.absenceType);
return `${reason?.label}: ${startDate === endDate ? startDate : `${startDate} - ${endDate}`}`;
const isAllDay =
new Date(item.startDate).getHours() === 0 &&
new Date(item.startDate).getMinutes() === 0 &&
new Date(item.endDate).getHours() === 23 &&
new Date(item.endDate).getMinutes() === 59;
const isSameDay =
new Date(item.startDate).toDateString() === new Date(item.endDate).toDateString();
if (isSameDay && isAllDay) {
return `${reason?.label}: ${fullStartDay}`;
} else if (isAllDay) {
return `${reason?.label}: ${fullStartDay} - ${fullEndDay}`;
} else {
return `${reason?.label}: ${startDate} - ${endDate}`;
}
};
</script>
@@ -1,10 +1,10 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { AppointmentDetails } from "$lib/components/ui/appointment-details";
import { Button } from "$lib/components/ui/button";
import { Text } from "$lib/components/ui/typography";
import { type SupportedLocale } from "$lib/const/locales";
import { type CurAppointmentItem } from "$lib/stores/calendar";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { Calendar, Mail, Phone, User } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { cancelAppointment, confirmAppointment, denyAppointment } from "./utils";
@@ -87,42 +87,30 @@
{#if item.appointment.appointment}
<div class="flex flex-col items-start gap-2">
<div class="flex gap-2 p-1">
<User class="size-4 " />
<Text style="sm">
{item.decrypted.name}
</Text>
</div>
{#if item.decrypted.email || item.decrypted.phone}
<div class="flex flex-col items-start gap-2">
{#if item.decrypted.email}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`mailto:${item.decrypted.email}`}
>
<Mail class="size-4 " />
{item.decrypted.email}
</Button>
{/if}
{#if item.decrypted.phone}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`tel:${item.decrypted.phone}`}
>
<Phone class="size-4 " />
{item.decrypted.phone}
</Button>
{/if}
</div>
{/if}
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(item.appointment.appointment.dateTime)}
</Text>
</div>
<AppointmentDetails
items={[
{
type: "client-name",
value: item.decrypted.name,
},
{
type: "client-locale",
value: item.decrypted.locale as SupportedLocale,
},
{
type: "client-email",
value: item.decrypted.email,
},
{
type: "client-phone",
value: item.decrypted.phone,
},
{
type: "date",
value: item.appointment.appointment.dateTime,
},
]}
/>
<div class="mt-5 flex w-full flex-col gap-4">
<Text style="xs" class="text-muted-foreground text-center">
{m["calendar.notificationHint"]()}
@@ -128,7 +128,8 @@
{/each}
</div>
{#if curTimeIndicator && toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString() && latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize}
<!-- Current Time Indicator -->
{#if curTimeIndicator && toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString() && latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize && earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
@@ -4,11 +4,8 @@
import { CenterState } from "$lib/components/templates/empty-state";
import Button from "$lib/components/ui/button/button.svelte";
import { staffCrypto } from "$lib/stores/staff-crypto";
import { tenants } from "$lib/stores/tenants";
import type { TCalendarSlot } from "$lib/types/calendar";
import { getDefaultAppointmentLocale } from "$lib/utils/localizations";
import { BanIcon, Check } from "@lucide/svelte";
import { get } from "svelte/store";
import { ClientDataForm } from "./client-data-form";
import { SearchClientForm } from "./search-client-form";
import SelectAgent from "./SelectAgent.svelte";
@@ -31,7 +28,6 @@
let step: TAddAppointmentStep = $state("email");
let newAppointment: TAddAppointment = $state({
locale: getDefaultAppointmentLocale(get(tenants).currentTenant),
dateTime: localTime,
});
let isSubmitting = $state(false);
@@ -105,13 +101,9 @@
});
}
};
const onChangeLocale = (locale: string) => {
newAppointment = { ...newAppointment, locale };
};
</script>
<Summary {step} {newAppointment} {onChangeLocale} />
<Summary {step} {newAppointment} />
{#if step === "email"}
<SearchClientForm {tenantId} {newAppointment} {proceed} />
{:else if step === "agent" && item.availableAgents}
@@ -1,94 +1,49 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { AppointmentDetails } from "$lib/components/ui/appointment-details";
import { Separator } from "$lib/components/ui/separator";
import { Text } from "$lib/components/ui/typography";
import { type SupportedLocale } from "$lib/const/locales";
import { agents as agentsStore } from "$lib/stores/agents";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import * as Select from "$lib/components/ui/select";
import { tenants } from "$lib/stores/tenants";
import { languageSwitchLocales } from "$lib/const/locales";
let {
step,
newAppointment,
onChangeLocale,
}: {
step: TAddAppointmentStep;
newAppointment: TAddAppointment;
onChangeLocale: (locale: string) => void;
} = $props();
let agent = $derived($agentsStore.agents.find((a) => a.id === newAppointment.agentId));
let languages = $derived($tenants.currentTenant?.languages);
</script>
<div class="flex flex-col items-start gap-3">
<div class="flex gap-2">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(newAppointment.dateTime)}
</Text>
</div>
{#if newAppointment.name}
<div class="flex gap-2">
<User class="size-4 " />
<Text style="sm">
{newAppointment.name}
</Text>
</div>
{/if}
{#if newAppointment.email}
<div class="flex w-auto items-center justify-start gap-2">
<Languages class="size-4" />
<Select.Root type="single" onValueChange={onChangeLocale} value={newAppointment.locale}>
<Select.Trigger
class="h-1! w-full grow border-0 py-0 pl-1 font-medium shadow-none"
size="sm"
>
{@const locale = newAppointment.locale
? languageSwitchLocales[newAppointment.locale as keyof typeof languageSwitchLocales]
: undefined}
{locale ? locale.label : "Select language"}
</Select.Trigger>
<Select.Content>
{#each languages as language (language)}
<Select.Item value={language}>
{languageSwitchLocales[language as keyof typeof languageSwitchLocales].label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm"
variant="link"
href={`mailto:${newAppointment.email}`}
>
<Mail class="size-4 " />
{newAppointment.email}
</Button>
{/if}
{#if newAppointment.phone}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`tel:${newAppointment.phone}`}
>
<Phone class="size-4 " />
{newAppointment.phone}
</Button>
{/if}
{#if agent}
<div class="flex gap-2">
<UserStar class="size-4 " />
<Text style="sm">
{agent.name}
</Text>
</div>
{/if}
</div>
<AppointmentDetails
items={[
{
type: "agent",
value: agent?.name,
},
{
type: "client-name",
value: newAppointment.name,
},
{
type: "client-locale",
value: newAppointment.locale as SupportedLocale,
},
{
type: "client-email",
value: newAppointment.email,
},
{
type: "client-phone",
value: newAppointment.phone,
},
{
type: "date",
value: newAppointment.dateTime,
},
]}
/>
<div class="my-5">
{#if step !== "summary"}
<Separator />
@@ -1,12 +1,17 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import * as Select from "$lib/components/ui/select";
import { languageSwitchLocales } from "$lib/const/locales";
import { tenants } from "$lib/stores/tenants";
import { getDefaultAppointmentLocale } from "$lib/utils/localizations";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import type { TAddAppointment } from "../types";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
let {
newAppointment,
@@ -16,9 +21,14 @@
proceed: (data: TAddAppointment) => void;
} = $props();
let languages = $derived($tenants.currentTenant?.languages);
const form = superForm(
// eslint-disable-next-line no-constant-condition
{ name: "", phone: true ? "" : undefined, shareEmail: false },
{
locale: getDefaultAppointmentLocale(get(tenants).currentTenant),
name: "",
phone: "" as string | undefined,
shareEmail: false,
},
{
validators: zodClient(formSchema),
onSubmit: async ({ cancel }) => {
@@ -31,6 +41,7 @@
cancel();
proceed({
...newAppointment,
locale: $formData.locale,
name: $formData.name,
phone: $formData.phone,
shareEmail: $formData.shareEmail,
@@ -44,6 +55,29 @@
</script>
<Form.Root {enhance} class="w-full">
<Form.Field {form} name="locale">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.locale"]()}</Form.Label>
<Select.Root type="single" {...props} bind:value={$formData.locale}>
<Select.Trigger class="w-full">
{@const locale = $formData.locale
? languageSwitchLocales[$formData.locale as keyof typeof languageSwitchLocales]
: undefined}
{locale ? locale.label : m["form.localePlaceholder"]()}
</Select.Trigger>
<Select.Content>
{#each languages as language (language)}
<Select.Item value={language}>
{languageSwitchLocales[language as keyof typeof languageSwitchLocales].label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
@@ -25,7 +25,7 @@
import { Funnel } from "@lucide/svelte";
import { onMount } from "svelte";
import AddAppointment from "./(components)/add-appointment/AddAppointment.svelte";
import AppointmentDetail from "./(components)/AppointmentDetail.svelte";
import Appointment from "./(components)/Appointment.svelte";
import CalendarDay from "./(components)/CalendarDay.svelte";
import CalendarFilters from "./(components)/CalendarFilters.svelte";
import CalendarHeader from "./(components)/CalendarHeader.svelte";
@@ -239,7 +239,7 @@
description={channel ? getCurrentTranlslation(channel.names) : undefined}
triggerHidden={true}
>
<AppointmentDetail {tenantId} item={curItem} {updateCalendar} close={closeAppointmentDetail} />
<Appointment {tenantId} item={curItem} {updateCalendar} close={closeAppointmentDetail} />
</ResponsiveDialog>
{/if}
@@ -2,7 +2,16 @@ import { m } from "$i18n/messages";
import { z } from "zod";
const optionalUrl = (errorMessage: string) =>
z.union([z.literal(""), z.url({ message: errorMessage })]).optional();
z
.union([
z.literal(""),
z
.url({ message: errorMessage })
.refine((url) => url.startsWith("http://") || url.startsWith("https://"), {
message: errorMessage,
}),
])
.optional();
export const formSchema = z
.object({
+4 -4
View File
@@ -198,12 +198,12 @@
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description>
<Text style="xs" color="medium" class="mt-1 ml-1 leading-none">
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
{m["login.usePasskey"]()}
</Button>
</Form.Description>
</Text>
</Form.Field>
{/if}
{#if $formData.type === "passkey"}
@@ -240,7 +240,7 @@
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
<Text style="xs" color="medium" class="mt-1 ml-1 leading-none">
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
{m["login.usePassphrase"]()}
</Button>.
</Text>
-27
View File
@@ -1,27 +0,0 @@
import { auth } from "$lib/stores/auth";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
event.cookies.delete("access_token", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict",
});
const success: Promise<boolean> = event
.fetch("/api/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (resp) => {
return resp.status < 400;
});
auth.reset();
return { streaming: { success } };
};
+55 -11
View File
@@ -8,15 +8,41 @@
import { ROUTES } from "$lib/const/routes.js";
import { auth } from "$lib/stores/auth.js";
import { staffCrypto } from "$lib/stores/staff-crypto.js";
import { TriangleAlert } from "@lucide/svelte";
import Check from "@lucide/svelte/icons/check";
import { onMount } from "svelte";
const { data } = $props();
let success: boolean | undefined = $state();
onMount(() => {
auth.reset();
staffCrypto.clear();
logout();
});
const logout = async () => {
success = undefined;
fetch("/api/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (resp) => {
if (resp.status === 200 || resp.status === 401) {
success = true;
} else {
throw new Error(resp.statusText);
}
})
.catch((error) => {
console.log("Logout failed:", error);
success = false;
})
.finally(() => {
auth.reset();
staffCrypto.clear();
});
};
</script>
<svelte:head>
@@ -29,24 +55,42 @@
{/snippet}
<CenteredCard.Root>
<CenteredCard.Main>
{#await data.streaming.success}
{#if success === undefined}
<CenterLoadingState />
{:then}
{:else if success === true}
<CenterState
Icon={Check}
headline={m["logout.title"]()}
description={m["logout.description"]()}
headline={m["logout.success.title"]()}
description={m["logout.success.description"]()}
/>
{/await}
{:else}
<CenterState
Icon={TriangleAlert}
headline={m["logout.error.title"]()}
description={m["logout.error.description"]()}
/>
{/if}
</CenteredCard.Main>
<CenteredCard.Action>
{#await data.streaming.success}
{#if success === undefined}
<Skeleton class="h-10 w-full" />
{:then}
{:else}
{#if success === false}
<Button
size="lg"
class="w-full"
variant="outline"
onclick={logout}
isLoading={success === undefined}
disabled={success === undefined}
>
{m["logout.retry"]()}
</Button>
{/if}
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
{m["logout.action"]()}
</Button>
{/await}
{/if}
</CenteredCard.Action>
</CenteredCard.Root>
</PageWithClaim>
@@ -25,6 +25,13 @@ export const actions: Actions = {
});
}
if (await UserService.adminExists()) {
log.error("Create global admin forbidden. Admin already exists.", { errors: form.errors });
return fail(403, {
form: { ...form, data: { ...form.data, type: "passkey" } },
});
}
// IMPORTANT: Verify passkey BEFORE creating user to avoid orphaned users
let verificationResult:
| { credentialID: string; credentialPublicKey: string; counter: number }
@@ -174,13 +174,13 @@
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description>
<Text style="xs" color="medium">
{m["form.passphraseRequirements"]()}
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
{m["login.usePasskey"]()}
</Button>.
</Form.Description>
</Text>
</Form.Field>
{/if}
{#if $formData.type === "passkey"}
@@ -211,9 +211,9 @@
</Form.Field>
<Label class="mb-2">{m["form.passkey"]()}</Label>
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
<Text style="md" color="medium">
<Text style="xs" color="medium">
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
{m["login.usePassphrase"]()}
</Button>.
</Text>
+83 -10
View File
@@ -1,12 +1,21 @@
import { json } from "@sveltejs/kit";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import { UserService } from "$lib/server/services/user-service";
import { BackendError, InternalError, logError, NotFoundError } from "$lib/server/utils/errors";
import {
BackendError,
InternalError,
logError,
NotFoundError,
ValidationError,
} from "$lib/server/utils/errors";
import type { Cookies } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
import { env } from "$env/dynamic/private";
import { challengeThrottleService } from "$lib/server/services/challenge-throttle";
import { verifyRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
import { normalizeEmail } from "$lib/utils";
const logger = new UniversalLogger().setContext("AuthChallengeAPI");
@@ -28,6 +37,12 @@ registerOpenAPIRoute("/auth/challenge", "POST", {
description: "User's email address",
example: "admin@example.com",
},
userId: {
type: "string",
format: "uuid",
description:
"Optional user ID for registration setup flows. If provided, it must match the confirmed account.",
},
},
required: ["email"],
},
@@ -126,18 +141,67 @@ function getRpId(requestUrl: URL): string {
return "localhost";
}
async function validateRegistrationBootstrapSession(input: {
cookies: Cookies;
userId: string;
userEmail: string;
requestEmail: string;
requestUserId?: string;
}): Promise<void> {
const bootstrapPayload = await verifyRegistrationBootstrapToken(
input.cookies.get("webauthn-registration-bootstrap"),
);
const bootstrapIsValid =
!!bootstrapPayload &&
bootstrapPayload.userId === input.userId &&
bootstrapPayload.email === normalizeEmail(input.userEmail) &&
bootstrapPayload.email === normalizeEmail(input.requestEmail) &&
(!input.requestUserId || input.requestUserId === input.userId);
if (!bootstrapIsValid) {
logger.warn("Registration challenge rejected due to invalid bootstrap session", {
email: input.requestEmail,
requestUserId: input.requestUserId,
targetUserId: input.userId,
hasBootstrapCookie: !!input.cookies.get("webauthn-registration-bootstrap"),
});
throw new ValidationError("Invalid or missing registration bootstrap session");
}
}
function parseChallengeRequest(body: unknown): { requestEmail: string; requestUserId?: string } {
if (!body || typeof body !== "object") {
throw new ValidationError("Valid email is required");
}
const parsedBody = body as { email?: unknown; userId?: unknown };
const requestEmail =
typeof parsedBody.email === "string" ? normalizeEmail(parsedBody.email) : undefined;
if (!requestEmail) {
throw new ValidationError("Valid email is required");
}
const requestUserId = typeof parsedBody.userId === "string" ? parsedBody.userId : undefined;
return { requestEmail, requestUserId };
}
export const POST: RequestHandler = async ({ request, cookies, url }) => {
try {
const body = await request.json();
const { requestEmail, requestUserId } = parseChallengeRequest(body);
logger.debug("Generating WebAuthn challenge", { email: body.email });
logger.debug("Generating WebAuthn challenge", { email: requestEmail, requestUserId });
// Check throttling for passkey challenges
const throttleResult = await challengeThrottleService.checkThrottle(body.email, "passkey");
const throttleResult = await challengeThrottleService.checkThrottle(requestEmail, "passkey");
if (!throttleResult.allowed) {
logger.warn("Passkey challenge throttled", {
email: body.email,
email: requestEmail,
retryAfterMs: throttleResult.retryAfterMs,
failedAttempts: throttleResult.failedAttempts,
});
@@ -161,7 +225,7 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
let isRegistration = false;
try {
user = await UserService.getUserByEmail(body.email);
user = await UserService.getUserByEmail(requestEmail);
const passkeys = await UserService.getUserPasskeys(user.id);
if (passkeys.length === 0) {
isRegistration = true; // User exists but has no passphrase - must register
@@ -171,18 +235,28 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
// User doesn't exist yet - this is a registration flow
isRegistration = true;
logger.debug("User not found - generating challenge for registration", {
email: body.email,
email: requestEmail,
});
} else {
throw error;
}
}
if (isRegistration && user) {
await validateRegistrationBootstrapSession({
cookies,
userId: user.id,
userEmail: user.email,
requestEmail,
requestUserId,
});
}
// Generate challenge
const challenge = WebAuthnService.generateChallenge();
if (isRegistration) {
cookies.set("webauthn-registration-email", body.email, {
cookies.set("webauthn-registration-email", requestEmail, {
httpOnly: true,
secure: true,
sameSite: "strict",
@@ -209,11 +283,10 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
// Get user's registered passkeys for login
const passkeys = await WebAuthnService.getUserPasskeys(user.id);
// Format passkeys for WebAuthn API
allowCredentials = passkeys.map((passkey) => ({
id: passkey.id,
type: "public-key" as const,
transports: ["usb", "nfc", "ble", "internal"], // All possible transports
transports: ["usb", "nfc", "ble", "internal"],
}));
logger.debug("WebAuthn challenge generated for login", {
@@ -224,7 +297,7 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
});
} else {
logger.debug("WebAuthn challenge generated for registration", {
email: body.email,
email: requestEmail,
challenge: challenge.substring(0, 8) + "...",
});
}
@@ -0,0 +1,258 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
// Must mock env before any module that reads it at module scope
vi.mock("$env/dynamic/private", () => ({
env: {
JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
NODE_ENV: "test",
},
}));
vi.mock("$lib/server/auth/webauthn-service", () => ({
WebAuthnService: {
generateChallenge: vi.fn().mockReturnValue("challenge-base64url-value"),
getUserPasskeys: vi.fn().mockResolvedValue([]),
},
}));
vi.mock("$lib/server/services/user-service", () => ({
UserService: {
getUserByEmail: vi.fn(),
getUserPasskeys: vi.fn(),
},
}));
vi.mock("$lib/server/services/challenge-throttle", () => ({
challengeThrottleService: {
checkThrottle: vi.fn().mockResolvedValue({ allowed: true, failedAttempts: 0, retryAfterMs: 0 }),
},
}));
import { POST } from "../+server";
import { UserService } from "$lib/server/services/user-service";
import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const USER_EMAIL = "user@example.com";
const mockUser = {
id: USER_ID,
email: USER_EMAIL,
name: "Test User",
role: "STAFF" as const,
tenantId: "t1",
};
// Helper to build a minimal SvelteKit RequestEvent
const buildEvent = (body: object, cookieMap: Record<string, string> = {}): any => ({
request: new Request("http://localhost/api/auth/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
cookies: {
get: (name: string) => cookieMap[name],
set: vi.fn(),
delete: vi.fn(),
},
url: new URL("http://localhost/api/auth/challenge"),
locals: {},
params: {},
route: { id: "/api/auth/challenge" } as any,
fetch: {} as any,
getClientAddress: () => "127.0.0.1",
isDataRequest: false,
isSubRequest: false,
platform: undefined,
setHeaders: vi.fn(),
});
describe("POST /api/auth/challenge registration bootstrap session", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(UserService.getUserByEmail).mockRejectedValue(
Object.assign(new Error("not found"), { name: "NotFoundError" }),
);
vi.mocked(UserService.getUserPasskeys).mockResolvedValue([]);
});
describe("login flow (user has passkeys)", () => {
it("should not require bootstrap cookie and return 200", async () => {
vi.mocked(UserService.getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(UserService.getUserPasskeys).mockResolvedValue([{ id: "pk1" }] as any);
const response = await POST(buildEvent({ email: USER_EMAIL }));
const data = await response.json();
expect(response.status).toBe(200);
expect(data.isRegistration).toBe(false);
});
});
describe("new user (does not exist yet)", () => {
it("should not require bootstrap cookie and return 200 with isRegistration=true", async () => {
// getUserByEmail throws NotFoundError → user does not exist
const err = new Error("Not found");
err.name = "NotFoundError";
// We need to throw a proper NotFoundError from the errors module
// Mock it as NotFoundError via the errors class
const { NotFoundError } = await import("$lib/server/utils/errors");
vi.mocked(UserService.getUserByEmail).mockRejectedValue(new NotFoundError("not found"));
const response = await POST(buildEvent({ email: "newuser@example.com" }));
const data = await response.json();
expect(response.status).toBe(200);
expect(data.isRegistration).toBe(true);
});
});
describe("existing user without passkeys (setup-passkey flow)", () => {
beforeEach(() => {
vi.mocked(UserService.getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(UserService.getUserPasskeys).mockResolvedValue([]);
});
it("should return 422 when no bootstrap cookie is present", async () => {
const response = await POST(buildEvent({ email: USER_EMAIL, userId: USER_ID }));
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when bootstrap cookie contains wrong userId", async () => {
const token = await generateRegistrationBootstrapToken({
userId: "ffffffff-ffff-ffff-ffff-ffffffffffff",
email: USER_EMAIL,
});
const response = await POST(
buildEvent(
{ email: USER_EMAIL, userId: USER_ID },
{ "webauthn-registration-bootstrap": token! },
),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when bootstrap cookie contains wrong email", async () => {
const token = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: "other@example.com",
});
const response = await POST(
buildEvent(
{ email: USER_EMAIL, userId: USER_ID },
{ "webauthn-registration-bootstrap": token! },
),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when requestUserId does not match user record", async () => {
const token = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
const response = await POST(
buildEvent(
{ email: USER_EMAIL, userId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
{ "webauthn-registration-bootstrap": token! },
),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when bootstrap cookie is a forged JWT (wrong secret)", async () => {
const { SignJWT } = await import("jose");
const wrongSecret = new TextEncoder().encode("wrong-secret");
const token = await new SignJWT({
userId: USER_ID,
email: USER_EMAIL,
type: "webauthn-registration-bootstrap",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(wrongSecret);
const response = await POST(
buildEvent(
{ email: USER_EMAIL, userId: USER_ID },
{ "webauthn-registration-bootstrap": token },
),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 200 with valid bootstrap cookie matching user and email", async () => {
const token = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
const response = await POST(
buildEvent(
{ email: USER_EMAIL, userId: USER_ID },
{ "webauthn-registration-bootstrap": token! },
),
);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.isRegistration).toBe(true);
expect(data.challenge).toBeDefined();
});
it("should accept email in non-normalised form when bootstrap token was created with same email", async () => {
const token = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL, // stored as lowercase
});
// Request email in uppercase should be normalised and still match
const response = await POST(
buildEvent(
{ email: "USER@EXAMPLE.COM", userId: USER_ID },
{ "webauthn-registration-bootstrap": token! },
),
);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.isRegistration).toBe(true);
});
it("should accept a valid bootstrap cookie without optional userId in body", async () => {
const token = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
// No userId in request body → the check (!requestUserId || ...) should still pass
const response = await POST(
buildEvent({ email: USER_EMAIL }, { "webauthn-registration-bootstrap": token! }),
);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.isRegistration).toBe(true);
});
});
});
+17 -1
View File
@@ -4,6 +4,7 @@ import { BackendError, InternalError, logError } from "$lib/server/utils/errors"
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
// Register OpenAPI documentation
registerOpenAPIRoute("/auth/confirm", "POST", {
@@ -73,7 +74,7 @@ registerOpenAPIRoute("/auth/confirm", "POST", {
},
});
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, cookies }) => {
const log = logger.setContext("API");
try {
@@ -89,6 +90,21 @@ export const POST: RequestHandler = async ({ request }) => {
tenantId: confirmationResult.tenantId,
};
const registrationBootstrapToken = await generateRegistrationBootstrapToken({
userId: confirmationResult.id,
email: confirmationResult.email,
});
if (registrationBootstrapToken) {
cookies.set("webauthn-registration-bootstrap", registrationBootstrapToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 15,
});
}
// Include recovery passphrase if it exists (for WebAuthn-only users)
if (confirmationResult.recoveryPassphrase) {
response.recoveryPassphrase = confirmationResult.recoveryPassphrase;
+10 -7
View File
@@ -1,6 +1,6 @@
import { dev } from "$app/environment";
import { UniversalLogger } from "$lib/logger";
import { sendUserInviteEmail } from "$lib/server/email/email-service";
import { sendConfirmationEmail } from "$lib/server/email/email-service";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { InviteService } from "$lib/server/services/invite-service";
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
@@ -128,7 +128,7 @@ registerOpenAPIRoute("/auth/invite", "POST", {
},
});
export const POST: RequestHandler = async ({ request, locals }) => {
export const POST: RequestHandler = async ({ request, locals, url }) => {
try {
// Verify user is authenticated
if (!locals.user) {
@@ -200,17 +200,20 @@ export const POST: RequestHandler = async ({ request, locals }) => {
language,
);
// Generate registration URL with secure invite code
const baseUrl = dev ? "http://localhost:5173" : `https://${tenant.domain}`;
const registrationUrl = `${baseUrl}/confirm/${invitation.inviteCode}`;
// Send invitation email
await sendUserInviteEmail(email, name, tenant, role, registrationUrl, language);
await sendConfirmationEmail(
{ id: invitation.id, email, language, name },
tenant,
invitation.inviteCode,
10, // 10 minutes expiration to match tokenValidUntil
dev ? url : new URL(`https://${tenant.domain}`),
);
logger.debug("User invitation sent successfully", {
invitedEmail: email,
tenantId,
role,
language,
});
return json({
@@ -17,7 +17,7 @@ vi.mock("$lib/server/services/invite-service", () => ({
}));
vi.mock("$lib/server/email/email-service", () => ({
sendUserInviteEmail: vi.fn(),
sendConfirmationEmail: vi.fn(),
}));
vi.mock("$env/dynamic/private", () => ({
@@ -28,7 +28,7 @@ vi.mock("$env/dynamic/private", () => ({
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
import { InviteService } from "$lib/server/services/invite-service";
import { sendUserInviteEmail } from "$lib/server/email/email-service";
import { sendConfirmationEmail } from "$lib/server/email/email-service";
import { mockCookies } from "$lib/tests/const";
describe("POST /api/auth/invite", () => {
@@ -88,7 +88,7 @@ describe("POST /api/auth/invite", () => {
vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any);
vi.mocked(InviteService.hasPendingInvite).mockResolvedValue(false);
vi.mocked(InviteService.createInvite).mockResolvedValue(mockInvitation as any);
vi.mocked(sendUserInviteEmail).mockResolvedValue();
vi.mocked(sendConfirmationEmail).mockResolvedValue();
});
it("should reject unauthenticated requests", async () => {
@@ -142,7 +142,7 @@ describe("POST /api/auth/invite", () => {
"admin-id",
"en",
);
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).toHaveBeenCalled();
});
it("should allow tenant admin to invite to their own tenant", async () => {
@@ -176,13 +176,22 @@ describe("POST /api/auth/invite", () => {
"admin-id",
"en",
);
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith(
"user@example.com",
"Test User",
mockTenant,
"STAFF",
expect.stringContaining("invite-code-123"),
"en",
expect(vi.mocked(sendConfirmationEmail)).toHaveBeenCalledWith(
{ email: "user@example.com", id: "invite-123", language: "en", name: "Test User" },
{
createdAt: expect.any(Date),
databaseUrl: "postgresql://test",
description: "A test corporation",
id: "12345678-1234-4234-8234-123456789012",
longName: "Test Corporation GmbH",
logo: null,
setupState: "NEW",
shortName: "testcorp",
updatedAt: expect.any(Date),
},
"invite-code-123",
10,
expect.any(URL),
);
});
@@ -209,7 +218,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should reject staff members from inviting", async () => {
@@ -235,7 +244,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should validate request data", async () => {
@@ -261,7 +270,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid request data");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should reject if user already has pending invitation", async () => {
@@ -294,6 +303,6 @@ describe("POST /api/auth/invite", () => {
"12345678-1234-4234-8234-123456789012",
);
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
});
+34 -4
View File
@@ -137,6 +137,32 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress,
authMethod: body.passphrase ? "passphrase" : "webauthn",
});
const throttleResult = await challengeThrottleService.checkThrottle(
body.email,
body.passphrase ? "passphrase" : "passkey",
);
if (!throttleResult.allowed) {
logger.warn("Login throttled", {
email: body.email,
retryAfterMs: throttleResult.retryAfterMs,
failedAttempts: throttleResult.failedAttempts,
});
return json(
{
error: "Too many failed attempts. Please try again later.",
retryAfterMs: throttleResult.retryAfterMs,
},
{
status: 429,
headers: {
"Retry-After": Math.ceil(throttleResult.retryAfterMs / 1000).toString(),
},
},
);
}
// Validate that either passphrase or credential is provided
if (!body.passphrase && !body.credential) {
return json(
@@ -180,12 +206,12 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress,
const tenantService = await TenantAdminService.getTenantById(user.tenantId);
const tenant = tenantService.tenantData;
if (!tenant || tenant.shortName !== subdomain) {
logger.warn("Login attempt from wrong subdomain", {
if (!tenant || tenant.domain !== hostname) {
logger.warn("Login attempt from wrong domain", {
userId: user.id,
email: user.email,
expectedSubdomain: tenant?.shortName,
actualSubdomain: subdomain,
expectedDomain: tenant?.domain,
hostname: hostname,
tenantId: user.tenantId,
});
return json({ error: "Unknown user does for tenant" }, { status: 401 });
@@ -222,9 +248,13 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress,
const isPassphraseValid = await verifyPassphrase(user.passphraseHash, body.passphrase);
if (!isPassphraseValid) {
await challengeThrottleService.recordFailedAttempt(body.email, "passphrase");
return json({ error: "Invalid passphrase" }, { status: 401 });
}
// Clear throttle on successful authentication
await challengeThrottleService.clearThrottle(body.email, "passphrase");
logger.debug("Passphrase authentication successful", { userId: user.id });
}
+35 -40
View File
@@ -1,9 +1,10 @@
import { json } from "@sveltejs/kit";
import { UserService } from "$lib/server/services/user-service";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import { NotFoundError, ValidationError } from "$lib/server/utils/errors";
import { AuthenticationError, BackendError, ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { checkPermission } from "$lib/server/utils/permissions";
import logger from "$lib/logger";
// Register OpenAPI documentation
@@ -18,12 +19,6 @@ registerOpenAPIRoute("/auth/passkeys", "POST", {
schema: {
type: "object",
properties: {
userId: {
type: "string",
format: "uuid",
description: "User ID to add the passkey to",
example: "01234567-89ab-cdef-0123-456789abcdef",
},
passkey: {
type: "object",
description: "WebAuthn passkey data",
@@ -40,7 +35,7 @@ registerOpenAPIRoute("/auth/passkeys", "POST", {
required: ["id", "publicKey"],
},
},
required: ["userId", "passkey"],
required: ["passkey"],
},
},
},
@@ -74,15 +69,6 @@ registerOpenAPIRoute("/auth/passkeys", "POST", {
},
},
},
"404": {
description: "User not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "User not found" },
},
},
},
"500": {
description: "Internal server error",
content: {
@@ -95,41 +81,54 @@ registerOpenAPIRoute("/auth/passkeys", "POST", {
},
});
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
const log = logger.setContext("API");
try {
const body = await request.json();
// Validate required fields
if (!body.userId || !body.passkey) {
return json({ error: "userId and passkey are required" }, { status: 400 });
if (!locals.user) {
throw new AuthenticationError();
}
checkPermission(locals, locals.user.tenantId, false, false);
const challenge = cookies.get("webauthn-challenge");
if (!challenge) {
throw new ValidationError("Missing registration challenge");
}
if (!body.passkey.id || !body.passkey.publicKey) {
return json({ error: "Passkey must include id and publicKey" }, { status: 400 });
// Validate required fields
if (!body.passkey) {
throw new ValidationError("passkey is required");
} else if (!body.passkey.id || !body.passkey.publicKey) {
throw new ValidationError("Passkey must include id and publicKey");
}
log.debug("Adding additional passkey to user", {
userId: body.userId,
userId: locals.user.id,
passkeyId: body.passkey.id,
deviceName: body.passkey.deviceName,
});
// Extract counter from WebAuthn credential
const counter = WebAuthnService.extractCounterFromCredential(body.passkey);
const verified = await WebAuthnService.verifyRegistration(
body.passkey.id,
body.passkey.attestationObject,
body.passkey.clientDataJSON,
challenge,
url,
);
// Add the passkey using the UserService
await UserService.addAdditionalPasskey(body.userId, {
id: body.passkey.id,
userId: body.userId,
publicKey: body.passkey.publicKey,
counter,
await UserService.addAdditionalPasskey(locals.user.id, {
id: verified.credentialID,
userId: locals.user.id,
publicKey: verified.credentialPublicKey,
counter: verified.counter,
deviceName: body.passkey.deviceName || "Unknown Device",
});
log.debug("Additional passkey added successfully", {
userId: body.userId,
userId: locals.user.id,
passkeyId: body.passkey.id,
});
@@ -143,19 +142,15 @@ export const POST: RequestHandler = async ({ request }) => {
} catch (error) {
log.error("Add passkey error:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "User not found" }, { status: 404 });
}
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
// Handle unique constraint violation (passkey already exists)
if (error instanceof Error && error.message.includes("unique constraint")) {
return json({ error: "This passkey is already registered" }, { status: 409 });
}
if (error instanceof BackendError) {
return error.toJson();
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
+21 -2
View File
@@ -6,6 +6,8 @@ import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { verifyRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
import { normalizeEmail } from "$lib/utils";
// Register OpenAPI documentation
registerOpenAPIRoute("/auth/register", "POST", {
@@ -110,21 +112,38 @@ export const POST: RequestHandler = async ({ params, cookies, request, url }) =>
// Add the passkey to the user account if provided
// Validate that this registration was preceded by a challenge request
const registrationEmail = cookies.get("webauthn-registration-email");
const registrationEmail = normalizeEmail(cookies.get("webauthn-registration-email") || "");
const requestEmail = normalizeEmail(body.email);
const bootstrapPayload = await verifyRegistrationBootstrapToken(
cookies.get("webauthn-registration-bootstrap"),
);
// Challenge can come from:
// 1. Request body (for tenant admins with PRF - second challenge overwrites cookie)
// 2. Cookie (for global admins without PRF - only one challenge)
const challengeFromSession = body.challenge || cookies.get("webauthn-challenge");
if (!registrationEmail || registrationEmail !== body.email) {
if (!registrationEmail || registrationEmail !== requestEmail) {
throw new ValidationError("Invalid or missing registration challenge cookie");
}
if (
!bootstrapPayload ||
bootstrapPayload.userId !== userId ||
bootstrapPayload.email !== requestEmail
) {
throw new ValidationError("Invalid or missing registration bootstrap session");
}
if (!challengeFromSession) {
throw new ValidationError("Invalid or missing WebAuthn challenge");
}
const targetUser = await UserService.getUserById(userId);
if (normalizeEmail(targetUser.email) !== requestEmail) {
throw new ValidationError("User mismatch");
}
log.debug("Using challenge for verification", {
challengeSource: body.challenge ? "request body" : "cookie",
challengePreview: challengeFromSession.substring(0, 20) + "...",
@@ -0,0 +1,316 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
// Must mock $env/dynamic/private before any import that reads it at module scope
vi.mock("$env/dynamic/private", () => ({
env: {
JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
NODE_ENV: "test",
},
}));
vi.mock("$lib/server/services/user-service", () => ({
UserService: {
getUserById: vi.fn(),
getUserByEmail: vi.fn(),
addPasskey: vi.fn(),
updateUser: vi.fn(),
},
}));
vi.mock("$lib/server/auth/webauthn-service", () => ({
WebAuthnService: {
verifyRegistration: vi.fn(),
},
}));
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
import { POST } from "../[id]/+server";
import { UserService } from "$lib/server/services/user-service";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const USER_EMAIL = "user@example.com";
const CHALLENGE = "challenge-base64url-value-for-testing";
const mockUser = {
id: USER_ID,
email: USER_EMAIL,
name: "Test User",
role: "STAFF" as const,
tenantId: null,
confirmationState: "EMAIL_CONFIRMED" as const,
};
const mockPasskeyBody = {
email: USER_EMAIL,
passkey: {
id: "cred-id-123",
attestationObject: "attestation-object-base64",
clientDataJSON: "client-data-json-base64",
deviceName: "Test Device",
},
};
const buildEvent = (
body: object,
cookieMap: Record<string, string> = {},
userId: string = USER_ID,
): any => ({
request: new Request(`http://localhost/api/auth/register/${userId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
cookies: {
get: (name: string) => cookieMap[name],
set: vi.fn(),
delete: vi.fn(),
},
url: new URL(`http://localhost/api/auth/register/${userId}`),
locals: {},
params: { id: userId },
route: { id: "/api/auth/register/[id]" } as any,
fetch: {} as any,
getClientAddress: () => "127.0.0.1",
isDataRequest: false,
isSubRequest: false,
platform: undefined,
setHeaders: vi.fn(),
});
describe("POST /api/auth/register/[id] registration bootstrap session", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(UserService.getUserById).mockResolvedValue(mockUser as any);
vi.mocked(UserService.getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(UserService.addPasskey).mockResolvedValue(undefined as any);
vi.mocked(UserService.updateUser).mockResolvedValue(undefined as any);
vi.mocked(WebAuthnService.verifyRegistration).mockResolvedValue({
credentialID: "cred-id-123",
credentialPublicKey: "public-key-bytes",
counter: 0,
} as any);
vi.mocked(AppointmentService.forTenant).mockResolvedValue({
hasAppointments: vi.fn().mockResolvedValue(false),
} as any);
});
describe("valid registration (all cookies correct)", () => {
it("should return 201 when bootstrap cookie and registration-email cookie are valid", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
const response = await POST(
buildEvent(mockPasskeyBody, {
"webauthn-registration-bootstrap": bootstrapToken!,
"webauthn-registration-email": USER_EMAIL,
"webauthn-challenge": CHALLENGE,
}),
);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.message).toMatch(/passkey/i);
});
it("should normalise email casing when comparing", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
vi.mocked(UserService.getUserById).mockResolvedValue({
...mockUser,
email: "USER@EXAMPLE.COM",
} as any);
const response = await POST(
buildEvent(
{ ...mockPasskeyBody, email: "USER@EXAMPLE.COM" },
{
"webauthn-registration-bootstrap": bootstrapToken!,
"webauthn-registration-email": "USER@EXAMPLE.COM",
"webauthn-challenge": CHALLENGE,
},
),
);
expect(response.status).toBe(201);
});
});
describe("missing or invalid bootstrap cookie", () => {
it("should return 422 when bootstrap cookie is absent", async () => {
const response = await POST(
buildEvent(mockPasskeyBody, {
"webauthn-registration-email": USER_EMAIL,
"webauthn-challenge": CHALLENGE,
}),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when bootstrap token has wrong userId", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: "ffffffff-ffff-ffff-ffff-ffffffffffff",
email: USER_EMAIL,
});
const response = await POST(
buildEvent(mockPasskeyBody, {
"webauthn-registration-bootstrap": bootstrapToken!,
"webauthn-registration-email": USER_EMAIL,
"webauthn-challenge": CHALLENGE,
}),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when bootstrap token has wrong email", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: "attacker@evil.com",
});
const response = await POST(
buildEvent(mockPasskeyBody, {
"webauthn-registration-bootstrap": bootstrapToken!,
"webauthn-registration-email": USER_EMAIL,
"webauthn-challenge": CHALLENGE,
}),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when bootstrap cookie is a forged JWT", async () => {
const { SignJWT } = await import("jose");
const wrongSecret = new TextEncoder().encode("wrong-secret");
const forgedToken = await new SignJWT({
userId: USER_ID,
email: USER_EMAIL,
type: "webauthn-registration-bootstrap",
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(wrongSecret);
const response = await POST(
buildEvent(mockPasskeyBody, {
"webauthn-registration-bootstrap": forgedToken,
"webauthn-registration-email": USER_EMAIL,
"webauthn-challenge": CHALLENGE,
}),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
it("should return 422 when URL userId does not match bootstrap userId", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
const differentUserId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const response = await POST(
buildEvent(
mockPasskeyBody,
{
"webauthn-registration-bootstrap": bootstrapToken!,
"webauthn-registration-email": USER_EMAIL,
"webauthn-challenge": CHALLENGE,
},
differentUserId, // URL param has a different user ID
),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/bootstrap/i);
});
});
describe("missing registration-email cookie", () => {
it("should return 422 when webauthn-registration-email cookie is absent", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
const response = await POST(
buildEvent(mockPasskeyBody, {
"webauthn-registration-bootstrap": bootstrapToken!,
// No webauthn-registration-email
"webauthn-challenge": CHALLENGE,
}),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/challenge/i);
});
});
describe("missing challenge cookie", () => {
it("should return 422 when neither challenge cookie nor body challenge is provided", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
const response = await POST(
buildEvent(mockPasskeyBody, {
"webauthn-registration-bootstrap": bootstrapToken!,
"webauthn-registration-email": USER_EMAIL,
// No webauthn-challenge
}),
);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toMatch(/challenge/i);
});
it("should use challenge from request body when challenge cookie is absent", async () => {
const bootstrapToken = await generateRegistrationBootstrapToken({
userId: USER_ID,
email: USER_EMAIL,
});
const response = await POST(
buildEvent(
{ ...mockPasskeyBody, challenge: CHALLENGE }, // challenge provided in body
{
"webauthn-registration-bootstrap": bootstrapToken!,
"webauthn-registration-email": USER_EMAIL,
},
),
);
expect(response.status).toBe(201);
});
});
});
+18 -5
View File
@@ -5,23 +5,36 @@ export async function POST({ request }) {
try {
const { level, message, meta } = await request.json();
// Block messages that are bigger than 4KB to prevent abuse
if (typeof message === "string" && message.length > 4096) {
logger.warn("Blocked oversized log message from client", { length: message.length });
return json({ success: false, error: "Message too long" }, { status: 400 });
}
const clientLogger = logger.setContext("CLIENT");
const sanitizedMessage = `${message}`
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\0/g, "\\0")
// eslint-disable-next-line no-control-regex
.replace(/[\x01-\x09\x0B\x0C\x0E-\x1F\x7F]/g, "");
switch (level) {
case "debug":
clientLogger.debug(message, meta);
clientLogger.debug(sanitizedMessage, meta);
break;
case "info":
clientLogger.info(message, meta);
clientLogger.info(sanitizedMessage, meta);
break;
case "warn":
clientLogger.warn(message, meta);
clientLogger.warn(sanitizedMessage, meta);
break;
case "error":
clientLogger.error(message, meta);
clientLogger.error(sanitizedMessage, meta);
break;
default:
clientLogger.info(message, meta);
clientLogger.info(sanitizedMessage, meta);
}
return json({ success: true });
+2 -2
View File
@@ -20,14 +20,14 @@ export const getTenantIdByDomain = async (
return tenants[0].id || null;
} else {
// Get tenant ID by domain in production
const shortName = domain.replace("https://", "").split(".")[0];
const cleanDomain = domain.replace("https://", "");
try {
const tenants = await db
.select({
id: tenant.id,
})
.from(tenant)
.where(eq(tenant.shortName, shortName))
.where(eq(tenant.domain, cleanDomain))
.limit(1);
return tenants[0]?.id || null;
} catch (error) {
+28 -2
View File
@@ -421,7 +421,20 @@ export const PUT: RequestHandler = async ({ locals, params, request }) => {
return json({
message: "Tenant metadata updated successfully",
tenant: updatedTenant,
tenant: {
id: updatedTenant.id,
createdAt: updatedTenant.createdAt,
updatedAt: updatedTenant.updatedAt,
languages: updatedTenant.languages,
defaultLanguage: updatedTenant.defaultLanguage,
shortName: updatedTenant.shortName,
longName: updatedTenant.longName,
descriptions: updatedTenant.descriptions || {},
domain: updatedTenant.domain,
logo: updatedTenant.logo,
setupState: updatedTenant.setupState,
links: updatedTenant.links,
},
});
} catch (error) {
logError(log)("Error updating tenant metadata", error, locals.user?.id, params.id);
@@ -471,7 +484,20 @@ export const GET: RequestHandler = async ({ params, locals }) => {
});
return json({
tenant: tenantData,
tenant: {
id: tenantData.id,
createdAt: tenantData.createdAt,
updatedAt: tenantData.updatedAt,
languages: tenantData.languages,
defaultLanguage: tenantData.defaultLanguage,
shortName: tenantData.shortName,
longName: tenantData.longName,
descriptions: tenantData.descriptions || {},
domain: tenantData.domain,
logo: tenantData.logo,
setupState: tenantData.setupState,
links: tenantData.links,
},
});
} catch (error) {
logError(log)("Error getting tenant details", error, locals.user?.id, params.id);
@@ -13,11 +13,10 @@ import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
/* No longer used?
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
summary: "Get appointment by ID",
description:
"Retrieves a specific appointment by its ID. Accessible to staff and tenant admins and also clients.",
"Retrieves a specific appointment by its ID. Accessible to dashboard users. Required for notification previews. Only returns a subset of appointment data.",
tags: ["Appointments"],
parameters: [
{
@@ -131,7 +130,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
},
},
},
});*/
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
@@ -223,6 +222,8 @@ export const GET: RequestHandler = async ({ params, locals }) => {
throw new ValidationError("Tenant ID and appointment ID are required");
}
checkPermission(locals, tenantId, true);
log.debug("Getting appointment by ID", {
tenantId,
appointmentId,
@@ -242,8 +243,14 @@ export const GET: RequestHandler = async ({ params, locals }) => {
requestedBy: locals.user?.id,
});
// Stripping data, because we only us this for notification previews
return json({
appointment,
appointment: {
id: appointment.id,
appointmentDate: appointment.appointmentDate,
channelId: appointment.channelId,
agentId: appointment.agentId,
},
});
} catch (error) {
logError(log)("Error getting appointment", error, locals.user?.id, params.id);
@@ -6,12 +6,19 @@ import { clientAppointmentTunnel, appointment, channel } from "$lib/server/db/te
import type { AppointmentResponse } from "$lib/types/appointment";
import { and, eq } from "drizzle-orm";
import {
AuthenticationError,
AuthorizationError,
ValidationError,
InternalError,
BackendError,
NotFoundError,
logError,
} from "$lib/server/utils/errors";
import {
EXISTING_CLIENT_BOOKING_SCOPE,
consumeBookingAccessToken,
verifyBookingAccessToken,
} from "$lib/server/auth/booking-access-token";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import {
sendAppointmentCreatedEmail,
@@ -39,6 +46,51 @@ const requestSchema = z.object({
}),
});
type AddToTunnelRequest = z.infer<typeof requestSchema>;
async function requireBootstrapBookingAccessToken(
request: Request,
tenantId: string,
): Promise<NonNullable<Awaited<ReturnType<typeof verifyBookingAccessToken>>>> {
const authorizationHeader = request.headers.get("Authorization");
if (!authorizationHeader?.startsWith("Bearer ")) {
throw new AuthenticationError("Bootstrap booking access token is required");
}
const token = authorizationHeader.substring("Bearer ".length).trim();
if (!token) {
throw new AuthenticationError("Bootstrap booking access token is required");
}
const tokenPayload = await verifyBookingAccessToken(token);
if (!tokenPayload) {
throw new AuthenticationError("Invalid or expired booking access token");
}
if (tokenPayload.scope !== EXISTING_CLIENT_BOOKING_SCOPE) {
throw new AuthorizationError("Booking access token is not valid for new client bootstrap");
}
if (tokenPayload.tenantId !== tenantId) {
throw new AuthorizationError("Booking access token is not valid for this tenant");
}
return tokenPayload;
}
function validateBootstrapTokenBinding(
tokenPayload: NonNullable<Awaited<ReturnType<typeof verifyBookingAccessToken>>>,
requestData: AddToTunnelRequest,
): void {
if (tokenPayload.tunnelId !== requestData.tunnelId) {
throw new AuthorizationError("Booking access token is not valid for this tunnel");
}
if (tokenPayload.emailHash && tokenPayload.emailHash !== requestData.emailHash) {
throw new AuthorizationError("Booking access token is not valid for this email hash");
}
}
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments/add-to-tunnel", "POST", {
summary: "Add appointment to existing tunnel",
@@ -205,6 +257,10 @@ export const POST: RequestHandler = async ({ request, params }) => {
const body = await request.json();
const validatedData = requestSchema.parse(body);
const tokenPayload = await requireBootstrapBookingAccessToken(request, tenantId);
validateBootstrapTokenBinding(tokenPayload, validatedData);
if (validatedData.salutation) {
throw new ValidationError("Bees incoming");
}
@@ -226,8 +282,8 @@ export const POST: RequestHandler = async ({ request, params }) => {
.where(eq(clientAppointmentTunnel.emailHash, validatedData.emailHash))
.limit(1);
if (tunnelResult.length === 0) {
logger.warn("Client tunnel not found", {
if (tunnelResult.length === 0 || tunnelResult[0].id !== validatedData.tunnelId) {
logger.warn("Client tunnel not found or access denied", {
tenantId,
tunnelId: validatedData.tunnelId,
});
@@ -324,7 +380,9 @@ export const POST: RequestHandler = async ({ request, params }) => {
(await notificationService).createNotification({
type: "APPOINTMENT_REQUESTED",
channelId: validatedData.channelId,
metaData: { appointmentId: result.id },
metaData: {
appointmentId: result.id,
},
});
}
if (validatedData.clientEmail) {
@@ -359,7 +417,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
});
// Don't throw - email failure shouldn't fail the appointment creation
}
await consumeBookingAccessToken(tokenPayload);
return json(response);
} catch (error) {
logError(logger)("Failed to add appointment to tunnel", error);
@@ -369,6 +427,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
}
if (error instanceof BackendError) {
logError(logger)(error.message, error);
return error.toJson();
}
@@ -107,7 +107,11 @@ export const POST: RequestHandler = async ({ request, params }) => {
emailHash: body.emailHash,
});
const throttleResult = await challengeThrottleService.checkThrottle(binding, "passkey");
const throttleResult = await challengeThrottleService.checkThrottle(
binding,
"passkey",
tenantId,
);
if (!throttleResult.allowed) {
return json(
{
@@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestEvent } from "@sveltejs/kit";
import { POST } from "../+server";
import { NEW_CLIENT_BOOTSTRAP_DIFFICULTY } from "$lib/server/services/bootstrap-challenge";
vi.mock("$lib/logger", () => ({
logger: {
@@ -60,7 +61,7 @@ describe("Bootstrap Challenge API", () => {
expect(response.status).toBe(200);
expect(data.challengeId).toBeTypeOf("string");
expect(data.nonce).toBeTypeOf("string");
expect(data.difficulty).toBe(4);
expect(data.difficulty).toBe(NEW_CLIENT_BOOTSTRAP_DIFFICULTY);
expect(challengeStore.store).toHaveBeenCalledOnce();
});
@@ -124,7 +124,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
}
if (storedChallenge.emailHash !== binding) {
await challengeThrottleService.recordFailedAttempt(binding, "passkey");
await challengeThrottleService.recordFailedAttempt(binding, "passkey", tenantId);
throw new ValidationError("Invalid bootstrap challenge binding");
}
@@ -136,11 +136,11 @@ export const POST: RequestHandler = async ({ request, params }) => {
});
if (!matchesPowDifficulty(digest, NEW_CLIENT_BOOTSTRAP_DIFFICULTY)) {
await challengeThrottleService.recordFailedAttempt(binding, "passkey");
await challengeThrottleService.recordFailedAttempt(binding, "passkey", tenantId);
throw new ValidationError("Invalid bootstrap proof of work");
}
await challengeThrottleService.clearThrottle(binding, "passkey");
await challengeThrottleService.clearThrottle(binding, "passkey", tenantId);
const bookingAccessToken = await generateNewClientBootstrapToken({
tenantId,
@@ -2,7 +2,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestEvent } from "@sveltejs/kit";
import { POST } from "../+server";
import { createBootstrapPowDigest } from "$lib/server/services/bootstrap-challenge";
import {
createBootstrapPowDigest,
NEW_CLIENT_BOOTSTRAP_DIFFICULTY,
} from "$lib/server/services/bootstrap-challenge";
vi.mock("$lib/logger", () => ({
logger: {
@@ -70,7 +73,7 @@ describe("Bootstrap Verify API", () => {
counter,
});
if (digest.startsWith("0000")) {
if (digest.startsWith("0".repeat(NEW_CLIENT_BOOTSTRAP_DIFFICULTY))) {
return counter;
}
}
@@ -139,7 +139,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
const { emailHash } = requestSchema.parse(body);
// Check throttling
const throttleResult = await challengeThrottleService.checkThrottle(emailHash, "pin");
const throttleResult = await challengeThrottleService.checkThrottle(emailHash, "pin", tenantId);
if (!throttleResult.allowed) {
logger.warn("PIN challenge throttled", {
@@ -169,7 +169,11 @@ export const POST: RequestHandler = async ({ request, params }) => {
});
// Record failed attempt for throttling
await challengeThrottleService.recordFailedAttempt(storedChallenge.emailHash, "pin");
await challengeThrottleService.recordFailedAttempt(
storedChallenge.emailHash,
"pin",
tenantId,
);
throw new ValidationError("Invalid challenge response");
}
@@ -209,7 +213,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
});
// Clear throttle on successful verification
await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin");
await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin", tenantId);
const response: ChallengeVerificationResponse = {
valid: true,
@@ -10,8 +10,8 @@ import { checkPermission } from "$lib/server/utils/permissions";
registerOpenAPIRoute("/tenants/{id}/calendar", "GET", {
summary: "Get tenant calendar",
description:
"Retrieves the appointment calendar for a specific tenant within a date range. Shows available time slots, existing appointments, and agent availability. This is a public endpoint that doesn't require authentication.",
tags: ["Calendar", "Public"],
"Retrieves the appointment calendar for a specific tenant within a date range. Shows available time slots, existing appointments, and agent availability. This is a private endpoint that requires authentication.",
tags: ["Calendar", "Private"],
parameters: [
{
name: "id",
+19 -17
View File
@@ -187,24 +187,26 @@ export const GET: RequestHandler = async ({ params, url }) => {
const clientSchedule = schedule.schedule.map((daySchedule) => ({
date: daySchedule.date,
channels: Object.fromEntries(
Object.entries(daySchedule.channels).map(([channelId, channelData]) => [
channelId,
{
channel: {
id: channelData.channel.id,
names: channelData.channel.names,
descriptions: channelData.channel.descriptions,
requiresConfirmation: channelData.channel.requiresConfirmation,
pause: channelData.channel.pause,
Object.entries(daySchedule.channels)
.filter(([, channelData]) => channelData.channel.isPublic && !channelData.channel.pause) // Only include public and non-paused channels
.map(([channelId, channelData]) => [
channelId,
{
channel: {
id: channelData.channel.id,
names: channelData.channel.names,
descriptions: channelData.channel.descriptions,
requiresConfirmation: channelData.channel.requiresConfirmation,
pause: channelData.channel.pause,
},
availableSlots: channelData.availableSlots.map((slot) => ({
from: slot.from,
to: slot.to,
duration: slot.duration,
availableAgents: slot.availableAgents,
})),
},
availableSlots: channelData.availableSlots.map((slot) => ({
from: slot.from,
to: slot.to,
duration: slot.duration,
availableAgents: slot.availableAgents,
})),
},
]),
]),
),
}));
@@ -65,6 +65,7 @@ describe("Schedule API Route", () => {
descriptions: { en: "Support Channel", de: "Support Kanal" },
pause: false,
requiresConfirmation: false,
isPublic: true,
},
appointments: [
{ id: "appointment-1", appointmentDate: "2024-01-01T10:00:00.000Z" },
@@ -1,7 +1,13 @@
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import {
AuthorizationError,
BackendError,
InternalError,
logError,
ValidationError,
} from "$lib/server/utils/errors";
import { ERRORS } from "$lib/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import { StaffService } from "$lib/server/services/staff-service";
@@ -310,6 +316,10 @@ export const PUT: RequestHandler = async ({ params, locals, request }) => {
const updateData = validation.data;
if (updateData.role === "GLOBAL_ADMIN") {
throw new AuthorizationError("Cannot assign GLOBAL_ADMIN role to a staff member");
}
const updatedUser = await StaffService.updateStaffMember(
tenantId,
staffId,
@@ -12,9 +12,36 @@ import type { RequestHandler } from "@sveltejs/kit";
import { StaffCryptoService } from "$lib/server/services/staff-crypto.service";
import { logger } from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
import { BackendError, ValidationError, InternalError, logError } from "$lib/server/utils/errors";
import {
BackendError,
ValidationError,
InternalError,
logError,
AuthorizationError,
} from "$lib/server/utils/errors";
import { z } from "zod";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { centralDb } from "$lib/server/db";
import { user } from "$lib/server/db/central-schema";
import { eq } from "drizzle-orm";
import { normalizeEmail } from "$lib/utils";
const ML_KEM_768_PUBLIC_KEY_BYTES = 1184;
const ML_KEM_768_PRIVATE_KEY_BYTES = 2400;
const passkeyIdBase64UrlPattern = /^[A-Za-z0-9_-]+$/;
const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const isBase64String = (value: string): boolean => {
return value.length > 0 && value.length % 4 === 0 && base64Pattern.test(value);
};
const hasDecodedByteLength = (value: string, expectedBytes: number): boolean => {
try {
return Buffer.from(value, "base64").length === expectedBytes;
} catch {
return false;
}
};
// Register OpenAPI documentation
registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", {
@@ -111,10 +138,26 @@ registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", {
});
const requestSchema = z.object({
passkeyId: z.string().min(1, "Passkey ID is required"),
publicKey: z.string().min(1, "Public key is required"),
privateKeyShare: z.string().min(1, "Private key share is required"),
email: z.string().email("Valid email is required").optional(), // Required for cookie validation
passkeyId: z
.string()
.min(16, "Passkey ID is required")
.max(1024, "Passkey ID is too long")
.regex(passkeyIdBase64UrlPattern, "Passkey ID must be base64url encoded"),
publicKey: z
.string()
.refine((value) => isBase64String(value), "Public key must be valid Base64")
.refine(
(value) => hasDecodedByteLength(value, ML_KEM_768_PUBLIC_KEY_BYTES),
`Public key must decode to ${ML_KEM_768_PUBLIC_KEY_BYTES} bytes (ML-KEM-768)`,
),
privateKeyShare: z
.string()
.refine((value) => isBase64String(value), "Private key share must be valid Base64")
.refine(
(value) => hasDecodedByteLength(value, ML_KEM_768_PRIVATE_KEY_BYTES),
`Private key share must decode to ${ML_KEM_768_PRIVATE_KEY_BYTES} bytes (ML-KEM-768)`,
),
email: z.email("Valid email is required").optional(), // Required for cookie validation
});
export const POST: RequestHandler = async ({ params, locals, request, cookies }) => {
@@ -140,8 +183,10 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
// Authentication: Accept either active session OR registration cookie
const isAuthenticated = locals.user && locals.user.id === staffId;
const registrationEmail = cookies.get("webauthn-registration-email");
const isRegistration = registrationEmail === email;
const registrationEmail = normalizeEmail(cookies.get("webauthn-registration-email"));
const requestEmail = normalizeEmail(email);
const isRegistration =
!!registrationEmail && !!requestEmail && registrationEmail === requestEmail;
if (!isAuthenticated && !isRegistration) {
log.warn("Unauthorized crypto key storage attempt", {
@@ -149,13 +194,29 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
staffId,
requesterId: locals.user?.id,
hasRegistrationCookie: !!registrationEmail,
emailsMatch: registrationEmail === email,
emailsMatch: registrationEmail === requestEmail,
});
throw new AuthorizationError();
}
// For authenticated users, verify tenant access
if (isAuthenticated) {
checkPermission(locals, tenantId, false);
} else {
const staffUser = await centralDb
.select({ id: user.id })
.from(user)
.where(eq(user.id, staffId))
.limit(1);
if (staffUser.length === 0) {
log.warn("Rejected registration crypto key storage for unknown staff user", {
tenantId,
staffId,
hasRegistrationCookie: !!registrationEmail,
});
throw new AuthorizationError();
}
}
log.debug("Storing staff crypto keys", { tenantId, staffId, passkeyId });
@@ -0,0 +1 @@
CREATE UNIQUE INDEX "staff_crypto_ua_idx" ON "staff_crypto" USING btree ("user_id","is_active");
+995
View File
@@ -0,0 +1,995 @@
{
"id": "3cdabf94-a461-4a66-a9a2-de9158b45984",
"prevId": "7e551bff-abaf-48b5-bb08-0ba14cb3292d",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent": {
"name": "agent",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "varchar(250000)",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_absence": {
"name": "agent_absence",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"start_date": {
"name": "start_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_date": {
"name": "end_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"absence_type": {
"name": "absence_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_absence_agent_id_agent_id_fk": {
"name": "agent_absence_agent_id_agent_id_fk",
"tableFrom": "agent_absence",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment": {
"name": "appointment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"appointment_date": {
"name": "appointment_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expiry_date": {
"name": "expiry_date",
"type": "date",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "appointment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"encrypted_data": {
"name": "encrypted_data",
"type": "text",
"primaryKey": false,
"notNull": false
},
"data_key": {
"name": "data_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"encrypted_payload": {
"name": "encrypted_payload",
"type": "text",
"primaryKey": false,
"notNull": false
},
"iv": {
"name": "iv",
"type": "text",
"primaryKey": false,
"notNull": false
},
"auth_tag": {
"name": "auth_tag",
"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()"
}
},
"indexes": {},
"foreignKeys": {
"appointment_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "appointment_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "appointment",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_channel_id_channel_id_fk": {
"name": "appointment_channel_id_channel_id_fk",
"tableFrom": "appointment",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_agent_id_agent_id_fk": {
"name": "appointment_agent_id_agent_id_fk",
"tableFrom": "appointment",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_key_share": {
"name": "appointment_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"appointment_id": {
"name": "appointment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"appointment_key_share_appointment_id_appointment_id_fk": {
"name": "appointment_key_share_appointment_id_appointment_id_fk",
"tableFrom": "appointment_key_share",
"tableTo": "appointment",
"columnsFrom": ["appointment_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.auth_challenge": {
"name": "auth_challenge",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"consumed": {
"name": "consumed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.booking_access_token": {
"name": "booking_access_token",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"client_public_key": {
"name": "client_public_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"consumed": {
"name": "consumed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel": {
"name": "channel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"names": {
"name": "names",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"paused": {
"name": "paused",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"requires_confirmation": {
"name": "requires_confirmation",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_agent": {
"name": "channel_agent",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_agent_channel_id_channel_id_fk": {
"name": "channel_agent_channel_id_channel_id_fk",
"tableFrom": "channel_agent",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_agent_agent_id_agent_id_fk": {
"name": "channel_agent_agent_id_agent_id_fk",
"tableFrom": "channel_agent",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_slot_template": {
"name": "channel_slot_template",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"slot_template_id": {
"name": "slot_template_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_slot_template_channel_id_channel_id_fk": {
"name": "channel_slot_template_channel_id_channel_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_slot_template_slot_template_id_slotTemplate_id_fk": {
"name": "channel_slot_template_slot_template_id_slotTemplate_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "slotTemplate",
"columnsFrom": ["slot_template_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_staff": {
"name": "channel_staff",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_staff_channel_id_channel_id_fk": {
"name": "channel_staff_channel_id_channel_id_fk",
"tableFrom": "channel_staff",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_appointment_tunnel": {
"name": "client_appointment_tunnel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_public_key": {
"name": "client_public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_key_share": {
"name": "client_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_appointment_tunnel_email_hash_unique": {
"name": "client_appointment_tunnel_email_hash_unique",
"nullsNotDistinct": false,
"columns": ["email_hash"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_pin_reset_token": {
"name": "client_pin_reset_token",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"token": {
"name": "token",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_pin_reset_token_token_unique": {
"name": "client_pin_reset_token_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_tunnel_staff_key_share": {
"name": "client_tunnel_staff_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_tunnel_key": {
"name": "encrypted_tunnel_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "notification_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'APPOINTMENT_CONFIRMED'"
},
"meta_data": {
"name": "meta_data",
"type": "json",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slotTemplate": {
"name": "slotTemplate",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"weekdays": {
"name": "weekdays",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"from": {
"name": "from",
"type": "time",
"primaryKey": false,
"notNull": true
},
"to": {
"name": "to",
"type": "time",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff_crypto": {
"name": "staff_crypto",
"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
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {
"staff_crypto_ua_idx": {
"name": "staff_crypto_ua_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "is_active",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]
},
"public.notification_type": {
"name": "notification_type",
"schema": "public",
"values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", "APPOINTMENT_REQUESTED"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -106,6 +106,13 @@
"when": 1775654963847,
"tag": "0014_fluffy_ezekiel",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1778225646617,
"tag": "0015_strange_warbird",
"breakpoints": true
}
]
}