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