diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index cd223e5..70c60a9 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d87eac2..dd1f6c7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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: diff --git a/docs/staff-crypto-worker.md b/docs/staff-crypto-worker.md index db7da8f..ac8d1b4 100644 --- a/docs/staff-crypto-worker.md +++ b/docs/staff-crypto-worker.md @@ -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)", ); } diff --git a/migrations/0011_nebulous_stephen_strange.sql b/migrations/0011_nebulous_stephen_strange.sql new file mode 100644 index 0000000..4ff597e --- /dev/null +++ b/migrations/0011_nebulous_stephen_strange.sql @@ -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; \ No newline at end of file diff --git a/migrations/0012_polite_impossible_man.sql b/migrations/0012_polite_impossible_man.sql new file mode 100644 index 0000000..cdc5224 --- /dev/null +++ b/migrations/0012_polite_impossible_man.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" DROP COLUMN "token";--> statement-breakpoint +ALTER TABLE "user" DROP COLUMN "token_valid_until"; \ No newline at end of file diff --git a/migrations/0013_abnormal_vengeance.sql b/migrations/0013_abnormal_vengeance.sql new file mode 100644 index 0000000..7082cd0 --- /dev/null +++ b/migrations/0013_abnormal_vengeance.sql @@ -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; \ No newline at end of file diff --git a/migrations/meta/0011_snapshot.json b/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..4042964 --- /dev/null +++ b/migrations/meta/0011_snapshot.json @@ -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": {} + } +} diff --git a/migrations/meta/0012_snapshot.json b/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..71f68e3 --- /dev/null +++ b/migrations/meta/0012_snapshot.json @@ -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": {} + } +} diff --git a/migrations/meta/0013_snapshot.json b/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..7daf77c --- /dev/null +++ b/migrations/meta/0013_snapshot.json @@ -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": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 63a9e43..e2fbc68 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -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 } ] } diff --git a/package-lock.json b/package-lock.json index e096886..24e939f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 005e909..a8d3373 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 5361931..a0456a6 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -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": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 5a2755a..17a5c07 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -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": "You’ve 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": { diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 546e3b9..9b1323d 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -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 { // 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, }), }); diff --git a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte index 7e48087..472faeb 100644 --- a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte @@ -70,7 +70,7 @@ {activeTenant.shortName} - {activeTenant.shortName}.{window.location.hostname} + {activeTenant.domain} {:else} diff --git a/src/lib/components/ui/appointment-details/appointment-details.svelte b/src/lib/components/ui/appointment-details/appointment-details.svelte new file mode 100644 index 0000000..34dda01 --- /dev/null +++ b/src/lib/components/ui/appointment-details/appointment-details.svelte @@ -0,0 +1,68 @@ + + +{#if items.length > 0} +
+ {#each items as item (item.type)} + {#if item.value} +
+ {#if item.type === "agent"} + + + {item.value} + + {:else if item.type === "client-name"} + + + {item.value} + + + {:else if item.type === "client-locale"} + {@const locale = + languageSwitchLocales[item.value as keyof typeof languageSwitchLocales]} + {#if locale} + + + {locale.label} + + {/if} + {:else if item.type === "client-email"} + + + + {item.value} + + + + {:else if item.type === "client-phone"} + + + + {item.value} + + + + {:else if item.type === "date"} + + + {toDisplayDateTime(item.value)} + + + {/if} +
+ {/if} + {/each} +
+{/if} diff --git a/src/lib/components/ui/appointment-details/copy-button.svelte b/src/lib/components/ui/appointment-details/copy-button.svelte new file mode 100644 index 0000000..d6c869f --- /dev/null +++ b/src/lib/components/ui/appointment-details/copy-button.svelte @@ -0,0 +1,41 @@ + + +{#if value} + +{/if} diff --git a/src/lib/components/ui/appointment-details/index.ts b/src/lib/components/ui/appointment-details/index.ts new file mode 100644 index 0000000..0e9589f --- /dev/null +++ b/src/lib/components/ui/appointment-details/index.ts @@ -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; + }; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index a442c51..66da5a5 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -43,6 +43,10 @@ -{#if href} +{#if derivedHref} & { 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)); } }); diff --git a/src/lib/emails/UserInvite.svelte b/src/lib/emails/Notification.svelte similarity index 57% rename from src/lib/emails/UserInvite.svelte rename to src/lib/emails/Notification.svelte index 307aed1..ad4061a 100644 --- a/src/lib/emails/UserInvite.svelte +++ b/src/lib/emails/Notification.svelte @@ -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 @@ - {m["emails.greeting"]({ name: user.name }, { locale })} + {m["emails.greeting"]({ name: user.name })} - {m["emails.userInvite.introduction"]({ tenant: tenant.longName }, { locale })} - - - {m["emails.userInvite.action"]({}, { locale })} - - - {m["emails.userInvite.hint"]({ expirationMinutes }, { locale })} + {m["emails.notification.introduction"]()} + {m["emails.notification.action"]()} - {m["emails.userInvite.reason"]({}, { locale })} + {m["emails.notification.reason"]()} diff --git a/src/lib/server/auth/__tests__/jwt-utils.test.ts b/src/lib/server/auth/__tests__/jwt-utils.test.ts index d774da9..99d63a7 100644 --- a/src/lib/server/auth/__tests__/jwt-utils.test.ts +++ b/src/lib/server/auth/__tests__/jwt-utils.test.ts @@ -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", diff --git a/src/lib/server/auth/__tests__/registration-bootstrap.test.ts b/src/lib/server/auth/__tests__/registration-bootstrap.test.ts new file mode 100644 index 0000000..f29467a --- /dev/null +++ b/src/lib/server/auth/__tests__/registration-bootstrap.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/server/auth/registration-bootstrap.ts b/src/lib/server/auth/registration-bootstrap.ts new file mode 100644 index 0000000..814aa9f --- /dev/null +++ b/src/lib/server/auth/registration-bootstrap.ts @@ -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 { + 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; + + 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; + } +} diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index bb6bc1f..c62ccb8 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -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 */ diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index c67cf5e..7254d71 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -9,6 +9,7 @@ import { text, time, timestamp, + uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -287,24 +288,28 @@ export type SelectNotification = InferSelectModel; * 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 diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index 1f7b0e3..d5d2fac 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -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(); }); diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index a34d581..869a452 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -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"); }); }); diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 6011768..69e6d13 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -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 { // 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} - */ -export async function sendUserInviteEmail( - userEmail: string, - userName: string, - tenant: SelectTenant, - role: "TENANT_ADMIN" | "STAFF", - registrationUrl: string, - language: Language = "en", -): Promise { - 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} + */ +export async function sendNotificationEmail( + user: SelectUser, + tenant: { domain: string; longName: string }, +): Promise { + // 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); +} diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index b4c1def..19f8596 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -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", ); }); diff --git a/src/lib/server/services/__tests__/tenant-admin-service.test.ts b/src/lib/server/services/__tests__/tenant-admin-service.test.ts index a38e6c2..bdfb895 100644 --- a/src/lib/server/services/__tests__/tenant-admin-service.test.ts +++ b/src/lib/server/services/__tests__/tenant-admin-service.test.ts @@ -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", }; diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index 70aee52..7518dbe 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -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, diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index d790325..0b58372 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -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(); diff --git a/src/lib/server/services/bootstrap-challenge.ts b/src/lib/server/services/bootstrap-challenge.ts index ba95aeb..ae88a79 100644 --- a/src/lib/server/services/bootstrap-challenge.ts +++ b/src/lib/server/services/bootstrap-challenge.ts @@ -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; diff --git a/src/lib/server/services/challenge-throttle.ts b/src/lib/server/services/challenge-throttle.ts index 6b7710a..130bd7f 100644 --- a/src/lib/server/services/challenge-throttle.ts +++ b/src/lib/server/services/challenge-throttle.ts @@ -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 { + async checkThrottle( + identifier: string, + type: ThrottleType, + tenantId?: string, + ): Promise { 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 { + async recordFailedAttempt( + identifier: string, + type: ThrottleType, + tenantId?: string, + ): Promise { 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 { - await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier)); + async clearThrottle(identifier: string, type: ThrottleType, tenantId?: string): Promise { + 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), diff --git a/src/lib/server/services/invite-service.ts b/src/lib/server/services/invite-service.ts index 909d062..efd12ce 100644 --- a/src/lib/server/services/invite-service.ts +++ b/src/lib/server/services/invite-service.ts @@ -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, }; diff --git a/src/lib/server/services/notification-service.ts b/src/lib/server/services/notification-service.ts index 02c1901..6f49a24 100644 --- a/src/lib/server/services/notification-service.ts +++ b/src/lib/server/services/notification-service.ts @@ -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, diff --git a/src/lib/server/services/staff-service.ts b/src/lib/server/services/staff-service.ts index 07d2b27..f2c5a7e 100644 --- a/src/lib/server/services/staff-service.ts +++ b/src/lib/server/services/staff-service.ts @@ -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", { diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index 5a77360..f170f3e 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -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]; diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index b9ade13..2f34b1f 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -22,6 +22,7 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; import { AppointmentService } from "./appointment-service"; export type InsertUser = InferInsertModel; +export type InsertUserInvite = InferInsertModel; export type InsertUserPasskey = InferInsertModel; 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 { + static async resendConfirmationEmail(email: string, requestUrl: URL): Promise { 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 */ diff --git a/src/lib/server/utils/url.ts b/src/lib/server/utils/url.ts index 66c4a79..995cea9 100644 --- a/src/lib/server/utils/url.ts +++ b/src/lib/server/utils/url.ts @@ -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 + ); +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ac0b00a..c4c7a25 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -11,3 +11,13 @@ export type WithoutChild = T extends { child?: any } ? Omit : T; export type WithoutChildren = T extends { children?: any } ? Omit : T; export type WithoutChildrenOrChild = WithoutChildren>; export type WithElementRef = 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(); +} diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 5222e8f..c5677c2 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -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 => { + 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 => { - 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 => { // 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, diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts deleted file mode 100644 index bc111c6..0000000 --- a/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts +++ /dev/null @@ -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", - }, - }); -}; diff --git a/src/routes/(pages)/(clients)/clients/login/+page.svelte b/src/routes/(pages)/(clients)/clients/login/+page.svelte index a97af2d..091ffbc 100644 --- a/src/routes/(pages)/(clients)/clients/login/+page.svelte +++ b/src/routes/(pages)/(clients)/clients/login/+page.svelte @@ -47,6 +47,8 @@ > {m["login.action"]()} - + diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index b9122ff..c142615 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -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 = 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, + }; + } + }, }; diff --git a/src/routes/(pages)/confirm/[token]/+page.svelte b/src/routes/(pages)/confirm/[token]/+page.svelte index 688b3d5..7534567 100644 --- a/src/routes/(pages)/confirm/[token]/+page.svelte +++ b/src/routes/(pages)/confirm/[token]/+page.svelte @@ -1,17 +1,18 @@ @@ -21,68 +22,91 @@ - {#await data.streaming.confirmation} - - {:then confirmation} - {#if confirmation.success} - {#if confirmation.isSetup} - - {:else} - - {/if} + {#if !confirmation} + + {:else if confirmation.success} + {#if confirmation.isSetup} + {:else} {/if} - {/await} + {:else} + + {/if} - {#await data.streaming.confirmation} - - - - {:then confirmation} - {#if confirmation.success} - {#if confirmation.isSetup} - - {m["setup.confirm.success.hint"]()} - - - {:else} - + + {:else if confirmation.success} + {#if confirmation.isSetup} + + {m["setup.confirm.success.hint"]()} + + + {:else} + - {/if} - {:else} - {/if} - {/await} + {:else} + + {/if} diff --git a/src/routes/(pages)/confirm/[token]/types.ts b/src/routes/(pages)/confirm/[token]/types.ts new file mode 100644 index 0000000..e9d9132 --- /dev/null +++ b/src/routes/(pages)/confirm/[token]/types.ts @@ -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; +}; diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index 58fadcb..f00b48a 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -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"]()); }) diff --git a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte index d5ddf81..58e613a 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte @@ -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; @@ -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} @@ -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} diff --git a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte index cab647b..3bf4d5b 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/edit-absence-form.svelte @@ -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; @@ -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} @@ -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} diff --git a/src/routes/(pages)/dashboard/absences/+page.svelte b/src/routes/(pages)/dashboard/absences/+page.svelte index 2eebdc9..fc139d8 100644 --- a/src/routes/(pages)/dashboard/absences/+page.svelte +++ b/src/routes/(pages)/dashboard/absences/+page.svelte @@ -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}`; + } }; diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte similarity index 75% rename from src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte rename to src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte index 71df577..3b8522b 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte @@ -1,10 +1,10 @@ - + {#if step === "email"} {:else if step === "agent" && item.availableAgents} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte index ba1cefe..faf9d11 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/Summary.svelte @@ -1,94 +1,49 @@ -
-
- - - {toDisplayDateTime(newAppointment.dateTime)} - -
- {#if newAppointment.name} -
- - - {newAppointment.name} - -
- {/if} - {#if newAppointment.email} -
- - - - {@const locale = newAppointment.locale - ? languageSwitchLocales[newAppointment.locale as keyof typeof languageSwitchLocales] - : undefined} - {locale ? locale.label : "Select language"} - - - {#each languages as language (language)} - - {languageSwitchLocales[language as keyof typeof languageSwitchLocales].label} - - {/each} - - -
- - {/if} - {#if newAppointment.phone} - - {/if} - {#if agent} -
- - - {agent.name} - -
- {/if} -
+
{#if step !== "summary"} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/client-data-form/client-data-form.svelte b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/client-data-form/client-data-form.svelte index 58df4f6..45e5f40 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/client-data-form/client-data-form.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/add-appointment/client-data-form/client-data-form.svelte @@ -1,12 +1,17 @@ + + + {#snippet children({ props })} + {m["form.locale"]()} + + + {@const locale = $formData.locale + ? languageSwitchLocales[$formData.locale as keyof typeof languageSwitchLocales] + : undefined} + {locale ? locale.label : m["form.localePlaceholder"]()} + + + {#each languages as language (language)} + + {languageSwitchLocales[language as keyof typeof languageSwitchLocales].label} + + {/each} + + + {/snippet} + + + {#snippet children({ props })} diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index bfe0464..1e540ee 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -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} > - + {/if} diff --git a/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts b/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts index 10419de..58458f9 100644 --- a/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts +++ b/src/routes/(pages)/dashboard/settings/(components)/edit-settings-form/schema.ts @@ -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({ diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 5d28a28..7dd9d53 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -198,12 +198,12 @@ {/snippet} - + {m["login.or"]()} - - + {/if} {#if $formData.type === "passkey"} @@ -240,7 +240,7 @@ {m["login.or"]()} - . diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts deleted file mode 100644 index 4686081..0000000 --- a/src/routes/(pages)/logout/+page.server.ts +++ /dev/null @@ -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 = 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 } }; -}; diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte index af94c03..e39f254 100644 --- a/src/routes/(pages)/logout/+page.svelte +++ b/src/routes/(pages)/logout/+page.svelte @@ -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(); + }); + }; @@ -29,24 +55,42 @@ {/snippet} - {#await data.streaming.success} + {#if success === undefined} - {:then} + {:else if success === true} - {/await} + {:else} + + {/if} - {#await data.streaming.success} + {#if success === undefined} - {:then} + {:else} + {#if success === false} + + {/if} - {/await} + {/if} diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 5eb2cad..0d851e7 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -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 } diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index fba9cdb..98eb554 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -174,13 +174,13 @@ {/snippet} - + {m["form.passphraseRequirements"]()} {m["login.or"]()} - . - + {/if} {#if $formData.type === "passkey"} @@ -211,9 +211,9 @@ - + {m["login.or"]()} - . diff --git a/src/routes/api/auth/challenge/+server.ts b/src/routes/api/auth/challenge/+server.ts index 36b9c77..4415b62 100644 --- a/src/routes/api/auth/challenge/+server.ts +++ b/src/routes/api/auth/challenge/+server.ts @@ -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 { + 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) + "...", }); } diff --git a/src/routes/api/auth/challenge/__tests__/challenge-bootstrap.test.ts b/src/routes/api/auth/challenge/__tests__/challenge-bootstrap.test.ts new file mode 100644 index 0000000..491ca39 --- /dev/null +++ b/src/routes/api/auth/challenge/__tests__/challenge-bootstrap.test.ts @@ -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 = {}): 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); + }); + }); +}); diff --git a/src/routes/api/auth/confirm/+server.ts b/src/routes/api/auth/confirm/+server.ts index cdd5a8f..7922189 100644 --- a/src/routes/api/auth/confirm/+server.ts +++ b/src/routes/api/auth/confirm/+server.ts @@ -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; diff --git a/src/routes/api/auth/invite/+server.ts b/src/routes/api/auth/invite/+server.ts index fb018c3..54cc190 100644 --- a/src/routes/api/auth/invite/+server.ts +++ b/src/routes/api/auth/invite/+server.ts @@ -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({ diff --git a/src/routes/api/auth/invite/__tests__/invite.test.ts b/src/routes/api/auth/invite/__tests__/invite.test.ts index 6ecfd0a..1cd3748 100644 --- a/src/routes/api/auth/invite/__tests__/invite.test.ts +++ b/src/routes/api/auth/invite/__tests__/invite.test.ts @@ -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(); }); }); diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 2a8016e..ccb5a56 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -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 }); } diff --git a/src/routes/api/auth/passkeys/+server.ts b/src/routes/api/auth/passkeys/+server.ts index 16acd3b..ca52cae 100644 --- a/src/routes/api/auth/passkeys/+server.ts +++ b/src/routes/api/auth/passkeys/+server.ts @@ -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 }); } }; diff --git a/src/routes/api/auth/register/[id]/+server.ts b/src/routes/api/auth/register/[id]/+server.ts index e51fbda..14f35e7 100644 --- a/src/routes/api/auth/register/[id]/+server.ts +++ b/src/routes/api/auth/register/[id]/+server.ts @@ -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) + "...", diff --git a/src/routes/api/auth/register/__tests__/register-bootstrap.test.ts b/src/routes/api/auth/register/__tests__/register-bootstrap.test.ts new file mode 100644 index 0000000..783fc67 --- /dev/null +++ b/src/routes/api/auth/register/__tests__/register-bootstrap.test.ts @@ -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 = {}, + 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); + }); + }); +}); diff --git a/src/routes/api/log/+server.ts b/src/routes/api/log/+server.ts index 3bb4457..0d419e4 100644 --- a/src/routes/api/log/+server.ts +++ b/src/routes/api/log/+server.ts @@ -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 }); diff --git a/src/routes/api/public/utils.ts b/src/routes/api/public/utils.ts index bf9ee9b..eef24b3 100644 --- a/src/routes/api/public/utils.ts +++ b/src/routes/api/public/utils.ts @@ -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) { diff --git a/src/routes/api/tenants/[id]/+server.ts b/src/routes/api/tenants/[id]/+server.ts index 6aa0942..7e36927 100644 --- a/src/routes/api/tenants/[id]/+server.ts +++ b/src/routes/api/tenants/[id]/+server.ts @@ -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); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts index 0c293e0..09a9df7 100644 --- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts @@ -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); diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts index ffd0dd5..a2151b0 100644 --- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts @@ -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; + +async function requireBootstrapBookingAccessToken( + request: Request, + tenantId: string, +): Promise>>> { + 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>>, + 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(); } diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts index 898dc60..7891ce9 100644 --- a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts @@ -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( { diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts index 78399fe..472003f 100644 --- a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts @@ -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(); }); diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts index 1c237f1..6e5285b 100644 --- a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts @@ -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, diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts index 51b07f0..1fdf287 100644 --- a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts @@ -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; } } diff --git a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts index 7ec1eab..134b196 100644 --- a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts @@ -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", { diff --git a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts index 9e42657..e141130 100644 --- a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts @@ -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, diff --git a/src/routes/api/tenants/[id]/calendar/+server.ts b/src/routes/api/tenants/[id]/calendar/+server.ts index 6bb9169..c748967 100644 --- a/src/routes/api/tenants/[id]/calendar/+server.ts +++ b/src/routes/api/tenants/[id]/calendar/+server.ts @@ -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", diff --git a/src/routes/api/tenants/[id]/schedule/+server.ts b/src/routes/api/tenants/[id]/schedule/+server.ts index 1cb1564..0cedacf 100644 --- a/src/routes/api/tenants/[id]/schedule/+server.ts +++ b/src/routes/api/tenants/[id]/schedule/+server.ts @@ -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, - })), - }, - ]), + ]), ), })); diff --git a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts index 2fd3686..a43763c 100644 --- a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts +++ b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts @@ -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" }, diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts index 62de6eb..c642947 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/+server.ts @@ -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, diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts index 11edfef..4da8eb0 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts @@ -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 }); diff --git a/tenant-migrations/0015_strange_warbird.sql b/tenant-migrations/0015_strange_warbird.sql new file mode 100644 index 0000000..75b879e --- /dev/null +++ b/tenant-migrations/0015_strange_warbird.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "staff_crypto_ua_idx" ON "staff_crypto" USING btree ("user_id","is_active"); \ No newline at end of file diff --git a/tenant-migrations/meta/0015_snapshot.json b/tenant-migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..4e1ceff --- /dev/null +++ b/tenant-migrations/meta/0015_snapshot.json @@ -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": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index fa1c97d..902fb44 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1775654963847, "tag": "0014_fluffy_ezekiel", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1778225646617, + "tag": "0015_strange_warbird", + "breakpoints": true } ] }