Merge pull request #280 from open-reception/273-bug-tenantdomain-is-interpreted-inconsistently-confirm-invite-urls-are-malformed

Unify confirmation emails, fixing issue #273
This commit is contained in:
Karl Ludwig Weise
2026-06-01 11:11:09 +02:00
committed by GitHub
24 changed files with 2133 additions and 656 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "user" DROP COLUMN "token";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "token_valid_until";
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "user_invite" ALTER COLUMN "tenant_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "user_invite" ALTER COLUMN "invited_by" DROP NOT NULL;
+853
View File
@@ -0,0 +1,853 @@
{
"id": "f0ff401a-6299-471e-9812-80c678b9d47e",
"prevId": "4ab7df30-6490-4189-93b7-e1b25b0c8e90",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.challenge_throttle": {
"name": "challenge_throttle",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"failed_attempts": {
"name": "failed_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_attempt_at": {
"name": "last_attempt_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"reset_at": {
"name": "reset_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"challenge_throttle_tenant_id_tenant_id_fk": {
"name": "challenge_throttle_tenant_id_tenant_id_fk",
"tableFrom": "challenge_throttle",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant": {
"name": "tenant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"short_name": {
"name": "short_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"long_name": {
"name": "long_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"languages": {
"name": "languages",
"type": "json",
"primaryKey": false,
"notNull": true
},
"defaultLanguage": {
"name": "defaultLanguage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'en'"
},
"logo": {
"name": "logo",
"type": "varchar(100000)",
"primaryKey": false,
"notNull": false
},
"database_url": {
"name": "database_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"setup_state": {
"name": "setup_state",
"type": "setup_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'SETTINGS'"
},
"links": {
"name": "links",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'::json"
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_database_url_idx": {
"name": "tenant_database_url_idx",
"columns": [
{
"expression": "database_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": ["short_name"]
},
"tenant_domain_unique": {
"name": "tenant_domain_unique",
"nullsNotDistinct": false,
"columns": ["domain"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant_config": {
"name": "tenant_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "config_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_config_tenant_name_idx": {
"name": "tenant_config_tenant_name_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tenant_config_tenant_id_tenant_id_fk": {
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'STAFF'"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"confirmation_state": {
"name": "confirmation_state",
"type": "confirmation_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'INVITED'"
},
"passphrase_hash": {
"name": "passphrase_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"recovery_passphrase": {
"name": "recovery_passphrase",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_tenant_id_tenant_id_fk": {
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_invite": {
"name": "user_invite",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invite_code": {
"name": "invite_code",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+853
View File
@@ -0,0 +1,853 @@
{
"id": "8da6b050-6e61-46c0-8480-8b0c2ac62e55",
"prevId": "f0ff401a-6299-471e-9812-80c678b9d47e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.challenge_throttle": {
"name": "challenge_throttle",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"failed_attempts": {
"name": "failed_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_attempt_at": {
"name": "last_attempt_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"reset_at": {
"name": "reset_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"challenge_throttle_tenant_id_tenant_id_fk": {
"name": "challenge_throttle_tenant_id_tenant_id_fk",
"tableFrom": "challenge_throttle",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant": {
"name": "tenant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"short_name": {
"name": "short_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"long_name": {
"name": "long_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"languages": {
"name": "languages",
"type": "json",
"primaryKey": false,
"notNull": true
},
"defaultLanguage": {
"name": "defaultLanguage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'en'"
},
"logo": {
"name": "logo",
"type": "varchar(100000)",
"primaryKey": false,
"notNull": false
},
"database_url": {
"name": "database_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"setup_state": {
"name": "setup_state",
"type": "setup_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'SETTINGS'"
},
"links": {
"name": "links",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'::json"
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_database_url_idx": {
"name": "tenant_database_url_idx",
"columns": [
{
"expression": "database_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": ["short_name"]
},
"tenant_domain_unique": {
"name": "tenant_domain_unique",
"nullsNotDistinct": false,
"columns": ["domain"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant_config": {
"name": "tenant_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "config_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_config_tenant_name_idx": {
"name": "tenant_config_tenant_name_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tenant_config_tenant_id_tenant_id_fk": {
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'STAFF'"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"confirmation_state": {
"name": "confirmation_state",
"type": "confirmation_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'INVITED'"
},
"passphrase_hash": {
"name": "passphrase_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"recovery_passphrase": {
"name": "recovery_passphrase",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_tenant_id_tenant_id_fk": {
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_invite": {
"name": "user_invite",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invite_code": {
"name": "invite_code",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+14
View File
@@ -85,6 +85,20 @@
"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
}
]
}
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "open-reception",
"private": true,
"version": "1.0.9",
"version": "1.1.0-rc-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",
-7
View File
@@ -1045,13 +1045,6 @@
"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": {
+5 -12
View File
@@ -1043,24 +1043,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."
},
"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."
},
"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."
},
"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."
"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."
}
},
"notifications": {
-44
View File
@@ -1,44 +0,0 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import { type SelectTenant } from "$lib/server/db/central-schema";
import type { SelectUserEmail } from "$lib/server/email/email-service";
import EmailButton from "./components/EmailButton.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
let {
locale,
user,
tenant,
confirmUrl,
expirationMinutes,
}: {
locale: SupportedLocale;
user: SelectUserEmail;
tenant: SelectTenant;
confirmUrl: string;
expirationMinutes: number;
} = $props();
$effect(() => {
setLocale(locale);
});
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.name }, { locale })}</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 })}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.userInvite.reason"]({}, { locale })}
</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",
+2 -8
View File
@@ -122,8 +122,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) */
@@ -206,13 +204,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 */
@@ -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");
});
});
+7 -65
View File
@@ -22,7 +22,6 @@ 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";
@@ -148,7 +147,7 @@ export async function sendPinResetEmail(
locale,
user,
tenant,
loginUrl: generateBaseUrl(requestUrl, tenant) ?? "http://localhost:5173",
loginUrl: generateBaseUrl(requestUrl),
},
});
const html = renderOutputToHtml(emailRender);
@@ -400,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}`;
}
@@ -435,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
@@ -460,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
@@ -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,
+1 -1
View File
@@ -33,7 +33,7 @@ export class InviteService {
tenantId,
invitedBy,
language,
expiresAt: sql`timezone('utc', now()) + interval '30 minutes'`,
expiresAt: sql`timezone('utc', now()) + interval '10 minutes'`,
used: false,
};
+89 -81
View File
@@ -22,6 +22,7 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
import { AppointmentService } from "./appointment-service";
export type InsertUser = InferInsertModel<typeof centralSchema.user>;
export type InsertUserInvite = InferInsertModel<typeof centralSchema.userInvite>;
export type InsertUserPasskey = InferInsertModel<typeof centralSchema.userPasskey>;
export type UserTransaction = PgTransaction<
PostgresJsQueryResultHKT,
@@ -45,10 +46,8 @@ const userCreationSchema = z.object({
name: z.string().min(5),
email: z.email(),
role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(),
tenantId: z.string().uuid().optional(),
tenantId: z.uuid().optional(),
passphrase: z.string().min(12).optional(),
token: z.uuidv7().optional(),
tokenValidUntil: z.date().optional(),
language: z.enum(["de", "en"]).optional().default("de"),
confirmationState: z.enum(["INVITED", "CONFIRMED", "ACCESS_GRANTED"]).optional(),
// Note: passphraseHash and recoveryPassphrase are handled internally, not via user input
@@ -99,7 +98,7 @@ export class UserService {
/**
* Create a new user
*/
static async createUser(userData: UserCreation, requestUrl?: URL) {
static async createUser(userData: UserCreation, requestUrl: URL) {
const log = logger.setContext("UserService");
log.debug("Creating new user account", {
email: userData.email,
@@ -122,8 +121,14 @@ export class UserService {
throw new ValidationError("Passphrase must be at least 12 characters long");
}
userData.token = uuidv7();
userData.tokenValidUntil = addMinutes(new Date(), 10);
const userInviteForDb: InsertUserInvite = {
email: userData.email,
name: userData.name,
role: userData.role!,
tenantId: userData.tenantId!,
expiresAt: addMinutes(new Date(), 10),
inviteCode: uuidv7(),
};
// Prepare user data for database
const userDataForDb: InsertUser = {
@@ -131,8 +136,6 @@ export class UserService {
email: userData.email,
role: userData.role,
tenantId: userData.tenantId,
token: userData.token,
tokenValidUntil: userData.tokenValidUntil,
language: userData.language || "de",
confirmationState: userData.confirmationState || "INVITED",
isActive: false,
@@ -162,43 +165,49 @@ export class UserService {
}
try {
const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
const [inviteResult] = await centralDb
.insert(centralSchema.userInvite)
.values(userInviteForDb)
.returning();
const [insertedUser] = await centralDb
.insert(centralSchema.user)
.values(userDataForDb)
.returning();
log.debug("User account created successfully", {
userId: result[0].id,
email: result[0].email,
tokenValidUntil: result[0].tokenValidUntil,
hasPassphrase: !!result[0].passphraseHash,
hasRecoveryPassphrase: !!result[0].recoveryPassphrase,
userId: insertedUser.id,
email: insertedUser.email,
hasPassphrase: !!insertedUser.passphraseHash,
hasRecoveryPassphrase: !!insertedUser.recoveryPassphrase,
});
// Send confirmation email to user (token is used as confirmation code)
try {
if (result[0].email && result[0].token) {
const tenant = await getTenantForUser(result[0]);
if (insertedUser?.email && inviteResult?.inviteCode) {
const tenant = await getTenantForUser(insertedUser);
await sendConfirmationEmail(
result[0],
insertedUser,
tenant,
result[0].token,
inviteResult.inviteCode,
10, // 10 minutes expiration to match tokenValidUntil
requestUrl,
);
log.debug("Confirmation email sent successfully", {
userId: result[0].id,
email: result[0].email,
tenantId: result[0].tenantId,
userId: insertedUser.id,
email: insertedUser.email,
tenantId: insertedUser.tenantId,
});
}
} catch (emailError) {
log.warn("Failed to send confirmation email", {
userId: result[0].id,
email: result[0].email,
userId: insertedUser?.id,
email: insertedUser?.email,
error: String(emailError),
});
// Don't throw - user creation succeeded, email is just a bonus
}
return result[0];
return insertedUser;
} catch (error) {
log.error("Failed to create user account", { email: userData.email, error: String(error) });
throw error;
@@ -208,9 +217,9 @@ export class UserService {
/**
* Resend the confirmation email for a user
* @param email - Email of the user to confirm
* @param requestUrl - Optional request URL for generating correct baseUrl
* @param requestUrl - request URL for generating correct baseUrl
*/
static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise<void> {
static async resendConfirmationEmail(email: string, requestUrl: URL): Promise<void> {
const log = logger.setContext("UserService");
log.debug("Resending confirmation email", { email });
@@ -219,9 +228,9 @@ export class UserService {
try {
const result = await centralDb
.update(centralSchema.user)
.set({ token, tokenValidUntil })
.where(eq(centralSchema.user.email, email))
.update(centralSchema.userInvite)
.set({ inviteCode: token, expiresAt: tokenValidUntil })
.where(eq(centralSchema.userInvite.email, email))
.returning();
if (result.length !== 1) {
@@ -294,7 +303,32 @@ export class UserService {
}
| undefined = undefined;
const userData = await centralDb
const [matchingInvite] = await centralDb
.select({
id: centralSchema.userInvite.id,
email: centralSchema.userInvite.email,
tenantId: centralSchema.userInvite.tenantId,
role: centralSchema.userInvite.role,
name: centralSchema.userInvite.name,
language: centralSchema.userInvite.language,
})
.from(centralSchema.userInvite)
.where(
and(
eq(centralSchema.userInvite.inviteCode, linkToken),
gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`),
),
)
.limit(1);
if (!matchingInvite) {
log.warn("User confirmation failed: Invalid or expired invite code", {
token: linkToken.substring(0, 8) + "...",
});
throw new NotFoundError("Invalid or expired invite code");
}
const [userData] = await centralDb
.select({
id: centralSchema.user.id,
recoveryPassphrase: centralSchema.user.recoveryPassphrase,
@@ -305,64 +339,38 @@ export class UserService {
.from(centralSchema.user)
.where(
and(
eq(centralSchema.user.token, linkToken),
gt(centralSchema.user.tokenValidUntil, sql`timezone('utc', now())`),
eq(centralSchema.user.email, matchingInvite.email),
matchingInvite.tenantId
? eq(centralSchema.user.tenantId, matchingInvite.tenantId)
: undefined,
),
)
.limit(1);
if (userData.length === 0) {
const inviteData = await centralDb
.select({
id: centralSchema.userInvite.id,
tenantId: centralSchema.userInvite.tenantId,
role: centralSchema.userInvite.role,
email: centralSchema.userInvite.email,
name: centralSchema.userInvite.name,
language: centralSchema.userInvite.language,
})
.from(centralSchema.userInvite)
.where(
and(
eq(centralSchema.userInvite.inviteCode, linkToken),
gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`),
),
)
.limit(1);
if (!userData) {
resultData = { ...matchingInvite, recoveryPassphrase: null };
const userDataForDb: InsertUser = {
name: resultData.name!,
email: resultData.email,
role: resultData.role,
tenantId: resultData.tenantId,
language: resultData.language || "de",
confirmationState: "CONFIRMED",
isActive: true,
};
const retVal = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
resultData.id = retVal[0].id;
if (inviteData.length === 0) {
log.warn("User confirmation failed: Invalid or expired token", {
token: linkToken.substring(0, 8) + "...",
});
throw new NotFoundError("Invalid or timed-out token");
} else {
resultData = { ...inviteData[0], recoveryPassphrase: null };
const userDataForDb: InsertUser = {
name: resultData.name!,
email: resultData.email,
role: resultData.role,
tenantId: resultData.tenantId,
language: resultData.language || "de",
confirmationState: "CONFIRMED",
isActive: true,
};
const retVal = await centralDb
.insert(centralSchema.user)
.values(userDataForDb)
.returning();
resultData.id = retVal[0].id;
await InviteService.markInviteAsUsed(linkToken, resultData.id);
log.debug("Invitation marked as used", {
inviteCode: linkToken,
userId: resultData.id,
});
await InviteService.markInviteAsUsed(linkToken, resultData.id);
log.debug("Invitation marked as used", {
inviteCode: linkToken,
userId: resultData.id,
});
const adminService = await TenantAdminService.getTenantById(resultData.tenantId!);
adminService.validateSetupState();
}
const adminService = await TenantAdminService.getTenantById(resultData.tenantId!);
adminService.validateSetupState();
} else {
resultData = userData[0];
resultData = userData;
}
// Check if this is the first tenant admin for the tenant
@@ -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",
},
});
};
+4 -4
View File
@@ -198,12 +198,12 @@
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Form.Description>
<Text style="xs" color="medium" class="mt-1 ml-1 leading-none">
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
{m["login.usePasskey"]()}
</Button>
</Form.Description>
</Text>
</Form.Field>
{/if}
{#if $formData.type === "passkey"}
@@ -240,7 +240,7 @@
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
<Text style="xs" color="medium" class="mt-1 ml-1 leading-none">
{m["login.or"]()}
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
{m["login.usePassphrase"]()}
</Button>.
</Text>
@@ -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>
+10 -7
View File
@@ -1,6 +1,6 @@
import { dev } from "$app/environment";
import { UniversalLogger } from "$lib/logger";
import { sendUserInviteEmail } from "$lib/server/email/email-service";
import { sendConfirmationEmail } from "$lib/server/email/email-service";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { InviteService } from "$lib/server/services/invite-service";
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
@@ -128,7 +128,7 @@ registerOpenAPIRoute("/auth/invite", "POST", {
},
});
export const POST: RequestHandler = async ({ request, locals }) => {
export const POST: RequestHandler = async ({ request, locals, url }) => {
try {
// Verify user is authenticated
if (!locals.user) {
@@ -200,17 +200,20 @@ export const POST: RequestHandler = async ({ request, locals }) => {
language,
);
// Generate registration URL with secure invite code
const baseUrl = dev ? "http://localhost:5173" : `https://${tenant.domain}`;
const registrationUrl = `${baseUrl}/confirm/${invitation.inviteCode}`;
// Send invitation email
await sendUserInviteEmail(email, name, tenant, role, registrationUrl, language);
await sendConfirmationEmail(
{ id: invitation.id, email, language, name },
tenant,
invitation.inviteCode,
10, // 10 minutes expiration to match tokenValidUntil
dev ? url : new URL(`https://${tenant.domain}`),
);
logger.debug("User invitation sent successfully", {
invitedEmail: email,
tenantId,
role,
language,
});
return json({
@@ -17,7 +17,7 @@ vi.mock("$lib/server/services/invite-service", () => ({
}));
vi.mock("$lib/server/email/email-service", () => ({
sendUserInviteEmail: vi.fn(),
sendConfirmationEmail: vi.fn(),
}));
vi.mock("$env/dynamic/private", () => ({
@@ -28,7 +28,7 @@ vi.mock("$env/dynamic/private", () => ({
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
import { InviteService } from "$lib/server/services/invite-service";
import { sendUserInviteEmail } from "$lib/server/email/email-service";
import { sendConfirmationEmail } from "$lib/server/email/email-service";
import { mockCookies } from "$lib/tests/const";
describe("POST /api/auth/invite", () => {
@@ -88,7 +88,7 @@ describe("POST /api/auth/invite", () => {
vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any);
vi.mocked(InviteService.hasPendingInvite).mockResolvedValue(false);
vi.mocked(InviteService.createInvite).mockResolvedValue(mockInvitation as any);
vi.mocked(sendUserInviteEmail).mockResolvedValue();
vi.mocked(sendConfirmationEmail).mockResolvedValue();
});
it("should reject unauthenticated requests", async () => {
@@ -142,7 +142,7 @@ describe("POST /api/auth/invite", () => {
"admin-id",
"en",
);
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).toHaveBeenCalled();
});
it("should allow tenant admin to invite to their own tenant", async () => {
@@ -176,13 +176,22 @@ describe("POST /api/auth/invite", () => {
"admin-id",
"en",
);
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith(
"user@example.com",
"Test User",
mockTenant,
"STAFF",
expect.stringContaining("invite-code-123"),
"en",
expect(vi.mocked(sendConfirmationEmail)).toHaveBeenCalledWith(
{ email: "user@example.com", id: "invite-123", language: "en", name: "Test User" },
{
createdAt: expect.any(Date),
databaseUrl: "postgresql://test",
description: "A test corporation",
id: "12345678-1234-4234-8234-123456789012",
longName: "Test Corporation GmbH",
logo: null,
setupState: "NEW",
shortName: "testcorp",
updatedAt: expect.any(Date),
},
"invite-code-123",
10,
expect.any(URL),
);
});
@@ -209,7 +218,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should reject staff members from inviting", async () => {
@@ -235,7 +244,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should validate request data", async () => {
@@ -261,7 +270,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid request data");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should reject if user already has pending invitation", async () => {
@@ -294,6 +303,6 @@ describe("POST /api/auth/invite", () => {
"12345678-1234-4234-8234-123456789012",
);
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
});
@@ -1,11 +1,142 @@
import { json } from "@sveltejs/kit";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import {
BackendError,
InternalError,
logError,
NotFoundError,
ValidationError,
} from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
summary: "Get appointment by ID",
description:
"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: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID",
},
],
responses: {
"200": {
description: "Appointment retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
appointment: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
tunnelId: { type: "string", format: "uuid", description: "Client tunnel ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time",
},
expiryDate: {
type: "string",
format: "date",
description: "Data expiry date (nullable)",
},
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
description: "Appointment status",
},
encryptedPayload: {
type: "string",
description: "Encrypted appointment data (nullable)",
},
iv: {
type: "string",
description: "Initialization vector for encryption (nullable)",
},
authTag: {
type: "string",
description: "Authentication tag for encryption (nullable)",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp (nullable)",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp (nullable)",
},
},
required: ["id", "tunnelId", "channelId", "appointmentDate", "status"],
},
},
required: ["appointment"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
summary: "Delete appointment",
@@ -85,6 +216,58 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const appointmentId = params.appointmentId;
if (!tenantId || !appointmentId) {
throw new ValidationError("Tenant ID and appointment ID are required");
}
checkPermission(locals, tenantId, true);
log.debug("Getting appointment by ID", {
tenantId,
appointmentId,
requestedBy: locals.user?.id,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const appointment = await appointmentService.getAppointmentById(appointmentId);
if (!appointment) {
throw new NotFoundError("Appointment not found");
}
log.debug("Appointment retrieved successfully", {
tenantId,
appointmentId,
requestedBy: locals.user?.id,
});
// Stripping data, because we only us this for notification previews
return json({
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);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
@@ -381,7 +381,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) {