mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Merge pull request #120 from open-reception/feat/custom-domains
Support Custom Domains
This commit is contained in:
@@ -1,7 +1,44 @@
|
||||
# Production Caddyfile for appointment booking application
|
||||
# Replace your-domain.com with your actual domain
|
||||
{
|
||||
on_demand_tls {
|
||||
ask http://app:3000/api/public/domains
|
||||
burst 5
|
||||
interval 60s
|
||||
}
|
||||
}
|
||||
|
||||
your-domain.com {
|
||||
:443 {
|
||||
reverse_proxy app:3000
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
-Server
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'"
|
||||
}
|
||||
|
||||
encode gzip
|
||||
|
||||
log {
|
||||
output file /data/access.log {
|
||||
roll_size 100mb
|
||||
roll_keep 10
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
tls {
|
||||
on_demand
|
||||
protocols tls1.2 tls1.3
|
||||
}
|
||||
}
|
||||
|
||||
your-admin-domain.com {
|
||||
reverse_proxy app:3000
|
||||
|
||||
# Security headers
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "tenant" ADD COLUMN "domain" text;--> statement-breakpoint
|
||||
UPDATE "tenant" SET "domain" = CONCAT("short_name", '.open-reception.example.com');
|
||||
ALTER TABLE "tenant" ALTER COLUMN "domain" SET NOT NULL;
|
||||
ALTER TABLE "tenant" ADD CONSTRAINT "tenant_domain_unique" UNIQUE("domain");
|
||||
@@ -0,0 +1,849 @@
|
||||
{
|
||||
"id": "ef7f86cc-6f1f-45cc-833b-0645ede95732",
|
||||
"prevId": "945e895d-77bd-4acb-a4d3-20f652924edf",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.challenge_throttle": {
|
||||
"name": "challenge_throttle",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"failed_attempts": {
|
||||
"name": "failed_attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"last_attempt_at": {
|
||||
"name": "last_attempt_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"reset_at": {
|
||||
"name": "reset_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tenant": {
|
||||
"name": "tenant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"short_name": {
|
||||
"name": "short_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"long_name": {
|
||||
"name": "long_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"descriptions": {
|
||||
"name": "descriptions",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"languages": {
|
||||
"name": "languages",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"defaultLanguage": {
|
||||
"name": "defaultLanguage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'en'"
|
||||
},
|
||||
"logo": {
|
||||
"name": "logo",
|
||||
"type": "varchar(100000)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"database_url": {
|
||||
"name": "database_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"setup_state": {
|
||||
"name": "setup_state",
|
||||
"type": "setup_state",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'SETTINGS'"
|
||||
},
|
||||
"links": {
|
||||
"name": "links",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::json"
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1768324950524,
|
||||
"tag": "0009_bizarre_saracen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1769196961847,
|
||||
"tag": "0010_rich_stardust",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"passkey": "Passkey",
|
||||
"salutation": "Anrede",
|
||||
"name": "Name",
|
||||
"domain": "Domain",
|
||||
"phone": "Telefonnummer",
|
||||
"pin": "PIN",
|
||||
"pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager."
|
||||
@@ -164,12 +165,17 @@
|
||||
"title": "Mandant hinzufügen",
|
||||
"description": "Erstelle eine Seite für jemanden, der Termine sammeln möchte.",
|
||||
"name": {
|
||||
"description": "Muss einmalig auf diesem Server sein. Kann niemals geändert werden. Wird als URL verwendet {domain}",
|
||||
"description": "Muss einmalig auf diesem Server sein. Kann niemals geändert werden.",
|
||||
"errors": {
|
||||
"urlFormat": "Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt",
|
||||
"startEndDash": "Darf nicht mit einem Bindestrich beginnen oder enden"
|
||||
}
|
||||
},
|
||||
"domain": {
|
||||
"description": "Muss einmalig auf diesem Server sein.",
|
||||
"errors": {
|
||||
"urlFormat": "Format der Domain ist ungültig. Bitte kein https:// einfügen"
|
||||
}
|
||||
},
|
||||
"invite": {
|
||||
"title": "Tenant-Admin einladen"
|
||||
},
|
||||
@@ -210,7 +216,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Mandant bearbeiten",
|
||||
"description": "Aktualisiere den Namen des Mandanten.",
|
||||
"description": "Aktualisiere die Domain des Mandanten.",
|
||||
"success": "Mandant aktualisiert",
|
||||
"error": "Mandant konnte nicht aktualisiert werden.",
|
||||
"action": "Name ändern"
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"passkey": "Passkey",
|
||||
"salutation": "Salutation",
|
||||
"name": "Name",
|
||||
"domain": "Domain",
|
||||
"phone": "Phone Number",
|
||||
"pin": "PIN",
|
||||
"pinHint": "Save your PIN in a secure place, like a password-manager."
|
||||
@@ -172,12 +173,17 @@
|
||||
"title": "Add Tenant",
|
||||
"description": "Create a site for someone to start collecting appointments.",
|
||||
"name": {
|
||||
"description": "Must be unique on this server. Cannot be changed. Used as a subdomain {domain}",
|
||||
"description": "Must be unique on this server. Cannot be changed.",
|
||||
"errors": {
|
||||
"urlFormat": "Only letters, numbers, and hyphens are allowed",
|
||||
"startEndDash": "Must not start or end with a dash"
|
||||
}
|
||||
},
|
||||
"domain": {
|
||||
"description": "Must be unique on this server.",
|
||||
"errors": {
|
||||
"urlFormat": "Not a valid domain format. Note: Do not include https://"
|
||||
}
|
||||
},
|
||||
"invite": {
|
||||
"title": "Invite a tenant admin"
|
||||
},
|
||||
@@ -218,7 +224,7 @@
|
||||
"failedToSwitch": "Failed to switch tenant",
|
||||
"edit": {
|
||||
"title": "Edit Tenant",
|
||||
"description": "Update the tenant's short name.",
|
||||
"description": "Update the tenant's domain.",
|
||||
"success": "Tenant updated",
|
||||
"error": "Could not update tenant.",
|
||||
"action": "Edit Tenant"
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
};
|
||||
} = $props();
|
||||
|
||||
setLocale(locale);
|
||||
$effect(() => {
|
||||
setLocale(locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
|
||||
@@ -26,6 +26,10 @@ export const ERRORS = {
|
||||
MISSING_TENANT_OR_AGENT_ID: "Missing tenant or agent ID",
|
||||
NONE_FOUND: "No Tenant found on this instance",
|
||||
},
|
||||
DOMAINS: {
|
||||
QUERY_MISSING: "Domain query missing",
|
||||
NOT_FOUND: "Domain not found",
|
||||
},
|
||||
CHANNELS: {
|
||||
NOT_FOUND: "Channel not found",
|
||||
OPEN_APPOINTMENTS_CONFLICT:
|
||||
|
||||
@@ -69,6 +69,8 @@ export const tenant = pgTable(
|
||||
.$type<{ imprint?: string; privacyStatement?: string; website?: string }>()
|
||||
.notNull()
|
||||
.default({}),
|
||||
/** Domain for the tenant */
|
||||
domain: text("domain").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
},
|
||||
|
||||
@@ -113,6 +113,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -146,6 +147,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -179,6 +181,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -212,6 +215,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -270,6 +274,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -336,6 +341,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -378,6 +384,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -414,6 +421,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -450,6 +458,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -488,6 +497,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
@@ -517,6 +527,7 @@ describe("Email System", () => {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "postgresql://test",
|
||||
setupState: "STAFF" as const,
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -38,6 +38,7 @@ describe("generateBaseUrl", () => {
|
||||
databaseUrl: "",
|
||||
setupState: "SETTINGS",
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
};
|
||||
|
||||
const result = generateBaseUrl(requestUrl, tenant);
|
||||
@@ -64,6 +65,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -85,6 +87,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -106,6 +109,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -147,6 +151,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -168,6 +173,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -189,6 +195,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -210,6 +217,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -231,6 +239,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -252,6 +261,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -273,6 +283,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -297,6 +308,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -319,6 +331,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -341,6 +354,7 @@ describe("generateBaseUrl", () => {
|
||||
setupState: "SETTINGS",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ describe("TenantAdminService", () => {
|
||||
defaultLanguage: "en",
|
||||
description: "A test clinic",
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
};
|
||||
|
||||
const mockCreatedTenant = {
|
||||
@@ -142,6 +143,7 @@ describe("TenantAdminService", () => {
|
||||
it("should rollback tenant creation if database initialization fails", async () => {
|
||||
const newTenant = {
|
||||
shortName: "test-clinic",
|
||||
domain: "tenant.example.com",
|
||||
descriptions: "A test clinic",
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import { TenantConfig } from "../db/tenant-config";
|
||||
import { TenantMigrationService } from "./tenant-migration-service";
|
||||
|
||||
import { env } from "$env/dynamic/private";
|
||||
import { eq, and, not, count, or } from "drizzle-orm";
|
||||
import logger from "$lib/logger";
|
||||
import { and, count, eq, ne, not, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
|
||||
import { ConflictError, NotFoundError, ValidationError } from "../utils/errors";
|
||||
import { redactDbUrl } from "../utils/url";
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
|
||||
@@ -19,6 +19,12 @@ const tenantCreationSchema = z.object({
|
||||
.min(4)
|
||||
.max(15)
|
||||
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
|
||||
domain: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(253)
|
||||
.toLowerCase()
|
||||
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)*[a-z0-9]+(-[a-z0-9]+)*$/, "Invalid domain format"),
|
||||
});
|
||||
|
||||
export type TenantCreationRequest = z.infer<typeof tenantCreationSchema>;
|
||||
@@ -61,16 +67,23 @@ export class TenantAdminService {
|
||||
|
||||
log.debug("Creating new tenant", {
|
||||
shortName: request.shortName,
|
||||
domain: request.domain,
|
||||
});
|
||||
|
||||
// Check if tenant can be created without any duplications. Do not create tenant if:
|
||||
// - short name already exists
|
||||
// - domain already exists
|
||||
const tenantExists = await centralDb
|
||||
.select()
|
||||
.from(centralSchema.tenant)
|
||||
.where(eq(centralSchema.tenant.shortName, request.shortName));
|
||||
.where(
|
||||
or(
|
||||
eq(centralSchema.tenant.shortName, request.shortName),
|
||||
eq(centralSchema.tenant.domain, request.domain),
|
||||
),
|
||||
);
|
||||
if (tenantExists.length > 0) {
|
||||
throw new ConflictError("Tenant with shortname already exists");
|
||||
throw new ConflictError("Tenant with shortname or domain already exists");
|
||||
}
|
||||
|
||||
const configuration = TenantAdminService.getConfigDefaults();
|
||||
@@ -90,6 +103,7 @@ export class TenantAdminService {
|
||||
imprint: "",
|
||||
privacyStatement: "",
|
||||
},
|
||||
domain: request.domain,
|
||||
};
|
||||
newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName;
|
||||
|
||||
@@ -199,7 +213,10 @@ export class TenantAdminService {
|
||||
*/
|
||||
async updateTenantData(
|
||||
updateData: Partial<
|
||||
Pick<InsertTenant, "longName" | "shortName" | "descriptions" | "languages" | "logo">
|
||||
Pick<
|
||||
InsertTenant,
|
||||
"longName" | "shortName" | "descriptions" | "languages" | "logo" | "domain"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
const log = logger.setContext("TenantAdminService");
|
||||
@@ -212,6 +229,22 @@ export class TenantAdminService {
|
||||
throw new ValidationError("Shortname cannot be changed");
|
||||
}
|
||||
|
||||
// Check if domain is already in use
|
||||
if (updateData.domain) {
|
||||
const domainExists = await centralDb
|
||||
.select()
|
||||
.from(centralSchema.tenant)
|
||||
.where(
|
||||
and(
|
||||
eq(centralSchema.tenant.domain, updateData.domain),
|
||||
ne(centralSchema.tenant.id, this.tenantId),
|
||||
),
|
||||
);
|
||||
if (domainExists.length > 0) {
|
||||
throw new ConflictError("Tenant with shortname or domain already exists");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await centralDb
|
||||
.update(centralSchema.tenant)
|
||||
|
||||
@@ -69,6 +69,7 @@ function createSystemTenant(): SelectTenant {
|
||||
defaultLanguage: "en",
|
||||
logo: null,
|
||||
links: { website: "", imprint: "", privacyStatement: "" },
|
||||
domain: "tenant.example.com",
|
||||
databaseUrl: "",
|
||||
setupState: "SETTINGS",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { SelectTenant } from "$lib/server/db/central-schema";
|
||||
|
||||
export type TTenant = Pick<SelectTenant, "id" | "shortName" | "languages" | "setupState"> & {
|
||||
export type TTenant = Pick<
|
||||
SelectTenant,
|
||||
"id" | "shortName" | "languages" | "setupState" | "domain"
|
||||
> & {
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
|
||||
+12
@@ -59,6 +59,18 @@
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="domain">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.domain"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.domain} type="text" autocomplete="off" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
{m["tenants.add.domain.description"]()}
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="inviteAdmin">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
|
||||
@@ -6,12 +6,15 @@ export const formSchema = z.object({
|
||||
.string()
|
||||
.min(4, m["form.errors.shortname"]())
|
||||
.max(15, m["form.errors.shortname"]())
|
||||
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, {
|
||||
message: m["tenants.add.name.errors.urlFormat"](),
|
||||
})
|
||||
.refine((val) => !val.startsWith("-") && !val.endsWith("-"), {
|
||||
message: m["tenants.add.name.errors.startEndDash"](),
|
||||
}),
|
||||
domain: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(253)
|
||||
.toLowerCase()
|
||||
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/, m["tenants.add.domain.errors.urlFormat"]()),
|
||||
inviteAdmin: z.boolean(),
|
||||
email: z.string().email(m["form.errors.email"]()).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
+6
-8
@@ -12,7 +12,7 @@
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const form = superForm(
|
||||
{ id: entity.id, shortName: entity.shortName },
|
||||
{ id: entity.id, domain: entity.domain },
|
||||
{
|
||||
validators: zodClient(formSchema),
|
||||
onResult: async (event) => {
|
||||
@@ -34,19 +34,17 @@
|
||||
</script>
|
||||
|
||||
<Form.Root {enhance} action="?/edit">
|
||||
<Form.Field {form} name="shortName">
|
||||
<Form.Field {form} name="domain">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.name"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.shortName} type="text" autocomplete="off" />
|
||||
<Form.Label>{m["form.domain"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.domain} type="text" autocomplete="off" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.Description>
|
||||
{m["tenants.add.name.description"]({
|
||||
{m["tenants.add.domain.description"]({
|
||||
domain:
|
||||
$formData.shortName.length < 2
|
||||
? ""
|
||||
: `${$formData.shortName}.${window.location.hostname}`,
|
||||
$formData.domain.length < 2 ? "" : `${$formData.domain}.${window.location.hostname}`,
|
||||
})}
|
||||
</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
|
||||
@@ -2,16 +2,12 @@ import { m } from "$i18n/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
shortName: z
|
||||
domain: z
|
||||
.string()
|
||||
.min(4, m["form.errors.shortname"]())
|
||||
.max(15, m["form.errors.shortname"]())
|
||||
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, {
|
||||
message: m["tenants.add.name.errors.urlFormat"](),
|
||||
})
|
||||
.refine((val) => !val.startsWith("-") && !val.endsWith("-"), {
|
||||
message: m["tenants.add.name.errors.startEndDash"](),
|
||||
}),
|
||||
.min(1)
|
||||
.max(253)
|
||||
.toLowerCase()
|
||||
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/, m["tenants.add.domain.errors.urlFormat"]()),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export const actions: Actions = {
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
shortName: form.data.shortName,
|
||||
domain: form.data.domain,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -128,7 +129,7 @@ export const actions: Actions = {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ shortName: form.data.shortName }),
|
||||
body: JSON.stringify({ domain: form.data.domain }),
|
||||
});
|
||||
|
||||
if (resp.status < 400) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import { tenants as tenantsStore } from "$lib/stores/tenants";
|
||||
import { type TTenant } from "$lib/types/tenant";
|
||||
// import EditIcon from "@lucide/svelte/icons/pencil";
|
||||
import EditIcon from "@lucide/svelte/icons/pencil";
|
||||
import SelectIcon from "@lucide/svelte/icons/plug-zap";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import UnknownItemIcon from "@lucide/svelte/icons/landmark";
|
||||
@@ -74,24 +74,19 @@
|
||||
<ListItem
|
||||
image={item.logo || UnknownItemIcon}
|
||||
title={item.shortName}
|
||||
description={`${item.shortName}.${window.location.hostname}`}
|
||||
description={item.domain}
|
||||
descriptionOnClick={() =>
|
||||
window.open(
|
||||
`https://${item.shortName}.${window.location.hostname}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)}
|
||||
window.open(`https://${item.domain}`, "_blank", "noopener,noreferrer")}
|
||||
actions={[
|
||||
// Editiing will we re-introduced, when we support custom domains
|
||||
// {
|
||||
// type: "action",
|
||||
// icon: EditIcon,
|
||||
// label: m["edit"](),
|
||||
// onClick: () => {
|
||||
// curItem = item;
|
||||
// openDialog("edit");
|
||||
// },
|
||||
// },
|
||||
{
|
||||
type: "action",
|
||||
icon: EditIcon,
|
||||
label: m["edit"](),
|
||||
onClick: () => {
|
||||
curItem = item;
|
||||
openDialog("edit");
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
icon: SelectIcon,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const log = logger.setContext("API");
|
||||
|
||||
try {
|
||||
const tenantId = await getTenantIdByDomain(url.hostname);
|
||||
const tenantId = await getTenantIdByDomain(url.hostname, true);
|
||||
|
||||
if (!tenantId) {
|
||||
return new NotFoundError(ERRORS.TENANTS.NONE_FOUND).toJson();
|
||||
|
||||
@@ -64,7 +64,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const log = logger.setContext("API");
|
||||
|
||||
try {
|
||||
const tenantId = await getTenantIdByDomain(url.hostname);
|
||||
const tenantId = await getTenantIdByDomain(url.hostname, true);
|
||||
|
||||
if (!tenantId) {
|
||||
return new NotFoundError(ERRORS.TENANTS.NONE_FOUND).toJson();
|
||||
|
||||
@@ -55,7 +55,7 @@ export const GET: RequestHandler = async ({ locals, url, params }) => {
|
||||
const log = logger.setContext("API");
|
||||
|
||||
try {
|
||||
const tenantId = await getTenantIdByDomain(url.hostname);
|
||||
const tenantId = await getTenantIdByDomain(url.hostname, true);
|
||||
if (!tenantId) {
|
||||
return new NotFoundError(ERRORS.TENANTS.NOT_FOUND).toJson();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ERRORS } from "$lib/errors";
|
||||
import logger from "$lib/logger";
|
||||
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import { InternalError, logError, NotFoundError } from "$lib/server/utils/errors";
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
import { json } from "@sveltejs/kit";
|
||||
import { getTenantIdByDomain } from "../utils";
|
||||
|
||||
// Register OpenAPI documentation
|
||||
registerOpenAPIRoute("/public/domains", "GET", {
|
||||
summary: "Get public tenant data",
|
||||
description: "Checks if a domain belongs to this server",
|
||||
tags: ["Tenants"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Retrieved data successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tbd: { type: "string", description: "TBD" },
|
||||
},
|
||||
required: ["tbd"],
|
||||
},
|
||||
example: {
|
||||
tbd: "tbd",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "The request does not contain the proper format",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Malformatted request" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Domain not found on this instance",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Domain not found on this instance" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"500": {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
example: { error: "Internal server error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const log = logger.setContext("API");
|
||||
|
||||
const domain = url.searchParams.get("domain");
|
||||
|
||||
if (!domain) {
|
||||
return new NotFoundError(ERRORS.DOMAINS.QUERY_MISSING).toJson();
|
||||
}
|
||||
|
||||
try {
|
||||
const tenantId = await getTenantIdByDomain(domain);
|
||||
|
||||
if (!tenantId) {
|
||||
return new NotFoundError(ERRORS.DOMAINS.NOT_FOUND).toJson();
|
||||
}
|
||||
|
||||
return json({});
|
||||
} catch (error) {
|
||||
logError(log)(`Error verifying domain ${domain}`, error);
|
||||
return new InternalError().toJson();
|
||||
}
|
||||
};
|
||||
@@ -3,8 +3,11 @@ import { db } from "$lib/server/db";
|
||||
import { tenant } from "$lib/server/db/central-schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const getTenantIdByDomain = async (domain: string): Promise<string | null> => {
|
||||
if (dev) {
|
||||
export const getTenantIdByDomain = async (
|
||||
domain: string,
|
||||
chooseFirstTenantLocally?: boolean,
|
||||
): Promise<string | null> => {
|
||||
if (dev && chooseFirstTenantLocally) {
|
||||
// Get the first tenant ID in development
|
||||
const tenants = await db
|
||||
.select({
|
||||
@@ -17,13 +20,18 @@ export const getTenantIdByDomain = async (domain: string): Promise<string | null
|
||||
} else {
|
||||
// Get tenant ID by domain in production
|
||||
const shortName = domain.replace("https://", "").split(".")[0];
|
||||
const tenants = await db
|
||||
.select({
|
||||
id: tenant.id,
|
||||
})
|
||||
.from(tenant)
|
||||
.where(eq(tenant.shortName, shortName))
|
||||
.limit(1);
|
||||
return tenants[0].id || null;
|
||||
try {
|
||||
const tenants = await db
|
||||
.select({
|
||||
id: tenant.id,
|
||||
})
|
||||
.from(tenant)
|
||||
.where(eq(tenant.shortName, shortName))
|
||||
.limit(1);
|
||||
return tenants[0]?.id || null;
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,8 +28,14 @@ registerOpenAPIRoute("/tenants", "POST", {
|
||||
description: "Short name for the tenant (4-15 characters)",
|
||||
example: "acme-corp",
|
||||
},
|
||||
domain: {
|
||||
type: "string",
|
||||
format: "uri",
|
||||
description: "Domain for the tenant",
|
||||
example: "https://acme-corp.example.com",
|
||||
},
|
||||
},
|
||||
required: ["shortName"],
|
||||
required: ["shortName", "domain"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -48,8 +54,9 @@ registerOpenAPIRoute("/tenants", "POST", {
|
||||
properties: {
|
||||
id: { type: "string", description: "Generated tenant ID" },
|
||||
shortName: { type: "string", description: "Tenant short name" },
|
||||
domain: { type: "string", format: "uri", description: "Tenant domain" },
|
||||
},
|
||||
required: ["id", "shortName"],
|
||||
required: ["id", "shortName", "domain"],
|
||||
},
|
||||
},
|
||||
required: ["message", "tenant"],
|
||||
@@ -107,6 +114,7 @@ registerOpenAPIRoute("/tenants", "GET", {
|
||||
shortName: { type: "string", description: "Tenant short name" },
|
||||
longName: { type: "string", description: "Tenant long name" },
|
||||
logo: { type: "string", format: "uri", description: "URL of the tenant logo" },
|
||||
domain: { type: "string", format: "uri", description: "Tenant domain" },
|
||||
setupState: {
|
||||
type: "string",
|
||||
enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"],
|
||||
@@ -165,6 +173,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
|
||||
const tenantService = await TenantAdminService.createTenant({
|
||||
shortName: body.shortName,
|
||||
domain: body.domain,
|
||||
});
|
||||
|
||||
log.debug("Tenant created successfully", {
|
||||
@@ -216,6 +225,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
const tenants = await db
|
||||
.select({
|
||||
id: tenant.id,
|
||||
domain: tenant.domain,
|
||||
shortName: tenant.shortName,
|
||||
languages: tenant.languages,
|
||||
setupState: tenant.setupState,
|
||||
|
||||
Reference in New Issue
Block a user