diff --git a/Caddyfile b/Caddyfile index 85eecb8..cad756b 100644 --- a/Caddyfile +++ b/Caddyfile @@ -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 diff --git a/migrations/0010_rich_stardust.sql b/migrations/0010_rich_stardust.sql new file mode 100644 index 0000000..39e1ebc --- /dev/null +++ b/migrations/0010_rich_stardust.sql @@ -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"); \ No newline at end of file diff --git a/migrations/meta/0010_snapshot.json b/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..9b9d470 --- /dev/null +++ b/migrations/meta/0010_snapshot.json @@ -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": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 1ee04c1..63a9e43 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1768324950524, "tag": "0009_bizarre_saracen", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1769196961847, + "tag": "0010_rich_stardust", + "breakpoints": true } ] } diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 765a8ac..80b619e 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -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" diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index b9beec9..8ae8457 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -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" diff --git a/src/lib/emails/AppointmentRejected.svelte b/src/lib/emails/AppointmentRejected.svelte index 8046107..9df32f8 100644 --- a/src/lib/emails/AppointmentRejected.svelte +++ b/src/lib/emails/AppointmentRejected.svelte @@ -32,7 +32,9 @@ }; } = $props(); - setLocale(locale); + $effect(() => { + setLocale(locale); + }); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 617670d..764765d 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -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: diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index cdfd844..7c6ef00 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -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(), }, diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index 1403f50..af5dafe 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -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(), diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index 1a3cd19..af384a9 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -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(), }; diff --git a/src/lib/server/services/__tests__/tenant-admin-service.test.ts b/src/lib/server/services/__tests__/tenant-admin-service.test.ts index dc85d73..a38e6c2 100644 --- a/src/lib/server/services/__tests__/tenant-admin-service.test.ts +++ b/src/lib/server/services/__tests__/tenant-admin-service.test.ts @@ -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", }; diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index 3ce9f51..acf1549 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -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; @@ -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 + 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) diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 9cce357..e9b4f44 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -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(), diff --git a/src/lib/types/tenant.ts b/src/lib/types/tenant.ts index c2a965d..97a3eb2 100644 --- a/src/lib/types/tenant.ts +++ b/src/lib/types/tenant.ts @@ -1,6 +1,9 @@ import type { SelectTenant } from "$lib/server/db/central-schema"; -export type TTenant = Pick & { +export type TTenant = Pick< + SelectTenant, + "id" | "shortName" | "languages" | "setupState" | "domain" +> & { logo: string | null; }; diff --git a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte index 75a1303..a76da32 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/add-tenant-form.svelte @@ -59,6 +59,18 @@ + + + {#snippet children({ props })} + {m["form.domain"]()} + + {/snippet} + + + {m["tenants.add.domain.description"]()} + + + {#snippet children({ props })} diff --git a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/schema.ts b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/schema.ts index 3238d4f..883b5c0 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/schema.ts +++ b/src/routes/(pages)/dashboard/tenants/(components)/add-tenant-form/schema.ts @@ -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("")), }); diff --git a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte index 69e75f0..74e1214 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte +++ b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/edit-tenant-form.svelte @@ -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 @@ - + {#snippet children({ props })} - {m["form.name"]()} - + {m["form.domain"]()} + {/snippet} - {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}`, })} diff --git a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/schema.ts b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/schema.ts index 5ca3608..310d612 100644 --- a/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/schema.ts +++ b/src/routes/(pages)/dashboard/tenants/(components)/edit-tenant-form/schema.ts @@ -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(), }); diff --git a/src/routes/(pages)/dashboard/tenants/+page.server.ts b/src/routes/(pages)/dashboard/tenants/+page.server.ts index c48f101..8753254 100644 --- a/src/routes/(pages)/dashboard/tenants/+page.server.ts +++ b/src/routes/(pages)/dashboard/tenants/+page.server.ts @@ -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) { diff --git a/src/routes/(pages)/dashboard/tenants/+page.svelte b/src/routes/(pages)/dashboard/tenants/+page.svelte index ce5521b..163d152 100644 --- a/src/routes/(pages)/dashboard/tenants/+page.svelte +++ b/src/routes/(pages)/dashboard/tenants/+page.svelte @@ -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 @@ - 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, diff --git a/src/routes/api/public/+server.ts b/src/routes/api/public/+server.ts index fc6d61d..2f38a52 100644 --- a/src/routes/api/public/+server.ts +++ b/src/routes/api/public/+server.ts @@ -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(); diff --git a/src/routes/api/public/channels/+server.ts b/src/routes/api/public/channels/+server.ts index 7e217ad..ab81e9b 100644 --- a/src/routes/api/public/channels/+server.ts +++ b/src/routes/api/public/channels/+server.ts @@ -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(); diff --git a/src/routes/api/public/channels/[id]/agents/+server.ts b/src/routes/api/public/channels/[id]/agents/+server.ts index ee3f764..8c48ba7 100644 --- a/src/routes/api/public/channels/[id]/agents/+server.ts +++ b/src/routes/api/public/channels/[id]/agents/+server.ts @@ -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(); } diff --git a/src/routes/api/public/domains/+server.ts b/src/routes/api/public/domains/+server.ts new file mode 100644 index 0000000..73c6197 --- /dev/null +++ b/src/routes/api/public/domains/+server.ts @@ -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(); + } +}; diff --git a/src/routes/api/public/utils.ts b/src/routes/api/public/utils.ts index 7ee773d..471b1a1 100644 --- a/src/routes/api/public/utils.ts +++ b/src/routes/api/public/utils.ts @@ -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 => { - if (dev) { +export const getTenantIdByDomain = async ( + domain: string, + chooseFirstTenantLocally?: boolean, +): Promise => { + 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 { 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,