Added feature flags for tenants

This commit is contained in:
Karl Ludwig Weise
2026-09-01 09:58:47 +02:00
parent 45c9449285
commit cd66614aea
16 changed files with 1000 additions and 35 deletions
+4
View File
@@ -0,0 +1,4 @@
ALTER TABLE "challenge_throttle" DROP CONSTRAINT "challenge_throttle_tenant_id_tenant_id_fk";
--> statement-breakpoint
ALTER TABLE "tenant" ADD COLUMN "features" json NOT NULL DEFAULT '[]';--> statement-breakpoint
ALTER TABLE "challenge_throttle" DROP COLUMN "tenant_id";
+843
View File
@@ -0,0 +1,843 @@
{
"id": "4a67faf9-fa46-4310-a127-1c5b8f12e3c0",
"prevId": "8da6b050-6e61-46c0-8480-8b0c2ac62e55",
"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"
},
"features": {
"name": "features",
"type": "json",
"primaryKey": false,
"notNull": true
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_database_url_idx": {
"name": "tenant_database_url_idx",
"columns": [
{
"expression": "database_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": ["short_name"]
},
"tenant_domain_unique": {
"name": "tenant_domain_unique",
"nullsNotDistinct": false,
"columns": ["domain"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant_config": {
"name": "tenant_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "config_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"tenant_config_tenant_name_idx": {
"name": "tenant_config_tenant_name_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tenant_config_tenant_id_tenant_id_fk": {
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'STAFF'"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"confirmation_state": {
"name": "confirmation_state",
"type": "confirmation_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'INVITED'"
},
"passphrase_hash": {
"name": "passphrase_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"recovery_passphrase": {
"name": "recovery_passphrase",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_tenant_id_tenant_id_fk": {
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_invite": {
"name": "user_invite",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invite_code": {
"name": "invite_code",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -99,6 +99,13 @@
"when": 1780252077820,
"tag": "0013_abnormal_vengeance",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1788247574429,
"tag": "0014_green_moonstone",
"breakpoints": true
}
]
}
+4
View File
@@ -190,6 +190,10 @@
"startEndDash": "Darf nicht mit einem Bindestrich beginnen oder enden"
}
},
"features": {
"title": "Feature Flags",
"placeholder": "Funktionen auswählen"
},
"domain": {
"description": "Muss einmalig auf diesem Server sein.",
"errors": {
+4
View File
@@ -198,6 +198,10 @@
"startEndDash": "Must not start or end with a dash"
}
},
"features": {
"title": "Feature Flags",
"placeholder": "Select Features"
},
"domain": {
"description": "Must be unique on this server.",
"errors": {
+4
View File
@@ -1 +1,5 @@
export const SETUP_STATES_LIST = ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"];
export const TENANT_FEATURE_FLAGS = [
// List of supported features flags
];
+2
View File
@@ -69,6 +69,8 @@ export const tenant = pgTable(
.$type<{ imprint?: string; privacyStatement?: string; website?: string }>()
.notNull()
.default({}),
/** Active features for this tenant (array of feature codes) */
features: json("features").$type<string[]>().notNull(),
/** Domain for the tenant */
domain: text("domain").notNull().unique(),
createdAt: timestamp("created_at").defaultNow(),
@@ -22,6 +22,7 @@ type UpdateTenantBody = Partial<
| "domain"
| "links"
| "defaultLanguage"
| "features"
>
>;
@@ -41,6 +42,7 @@ const tenantCreationSchema = z.object({
.max(253)
.toLowerCase()
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)*[a-z0-9]+(-[a-z0-9]+)*$/, "Invalid domain format"),
features: z.array(z.string().min(1).max(25)).optional(),
});
export type TenantCreationRequest = z.infer<typeof tenantCreationSchema>;
@@ -120,6 +122,7 @@ export class TenantAdminService {
privacyStatement: "",
},
domain: request.domain,
features: request.features || [],
};
newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName;
@@ -243,6 +246,7 @@ export class TenantAdminService {
logo: updateData.logo,
links: updateData.links,
domain: updateData.domain,
features: updateData.features,
};
const log = logger.setContext("TenantAdminService");
@@ -298,6 +302,7 @@ export class TenantAdminService {
longName: centralSchema.tenant.longName,
descriptions: centralSchema.tenant.descriptions || {},
domain: centralSchema.tenant.domain,
features: centralSchema.tenant.features,
logo: centralSchema.tenant.logo,
setupState: centralSchema.tenant.setupState,
links: centralSchema.tenant.links,
+1 -1
View File
@@ -2,7 +2,7 @@ import type { SelectTenant } from "$lib/server/db/central-schema";
export type TTenant = Pick<
SelectTenant,
"id" | "shortName" | "languages" | "setupState" | "domain"
"id" | "shortName" | "languages" | "setupState" | "domain" | "features"
> & {
logo: string | null;
};
@@ -3,38 +3,48 @@
import CheckboxWithLabel from "$lib/components/ui/checkbox-with-label/checkbox-with-label.svelte";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { toast } from "svelte-sonner";
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema, type FormSchema } from ".";
import { ERRORS } from "$lib/errors";
import * as Select from "$lib/components/ui/select";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { page } from "$app/state";
let { data, done }: { done: () => void; data: { form: SuperValidated<Infer<FormSchema>> } } =
$props();
let { done }: { done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm($state.snapshot(data.form), {
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["tenants.add.success"]());
done();
} else if (event.result.type === "failure") {
switch (event.result.data?.error) {
case ERRORS.USERS.EMAIL_EXISTS:
toast.error(m["tenants.add.errors.emailTaken"]());
break;
case ERRORS.TENANTS.NAME_EXISTS:
toast.error(m["tenants.add.errors.nameTaken"]());
break;
default:
toast.error(m["tenants.add.errors.unknown"]());
}
}
isSubmitting = false;
const form = superForm(
{
shortName: "",
domain: "",
features: [] as string[],
inviteAdmin: false,
email: "",
},
onSubmit: () => (isSubmitting = true),
});
{
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
toast.success(m["tenants.add.success"]());
done();
} else if (event.result.type === "failure") {
switch (event.result.data?.error) {
case ERRORS.USERS.EMAIL_EXISTS:
toast.error(m["tenants.add.errors.emailTaken"]());
break;
case ERRORS.TENANTS.NAME_EXISTS:
toast.error(m["tenants.add.errors.nameTaken"]());
break;
default:
toast.error(m["tenants.add.errors.unknown"]());
}
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
@@ -59,6 +69,35 @@
</Form.Description>
<Form.FieldErrors />
</Form.Field>
{#if page.data.streamed.hasFeatureFlags}
<Form.Field {form} name="features">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["tenants.add.features.title"]()}</Form.Label>
<Select.Root
type="multiple"
bind:value={$formData.features}
name={props.name}
onValueChange={(v) => ($formData.features = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.features.length > 0
? $formData.features
.map((id) => TENANT_FEATURE_FLAGS.find((x) => x === id))
.join(", ")
: m["tenants.add.features.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each TENANT_FEATURE_FLAGS as feature (feature)}
<Select.Item value={feature}>{feature}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
{/if}
<Form.Field {form} name="domain">
<Form.Control>
{#snippet children({ props })}
@@ -15,8 +15,9 @@ export const formSchema = z.object({
.max(253)
.toLowerCase()
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/, m["tenants.add.domain.errors.urlFormat"]()),
features: z.array(z.string()).optional(),
inviteAdmin: z.boolean(),
email: z.string().email(m["form.errors.email"]()).optional().or(z.literal("")),
email: z.email(m["form.errors.email"]()).optional().or(z.literal("")),
});
export type FormSchema = typeof formSchema;
@@ -7,12 +7,15 @@
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import * as Select from "$lib/components/ui/select";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { page } from "$app/state";
let { entity, done }: { entity: TTenant; done: () => void } = $props();
// svelte-ignore state_referenced_locally
const form = superForm(
{ id: entity.id, domain: entity.domain },
{ id: entity.id, domain: entity.domain, features: entity.features ?? ([] as string[]) },
{
validators: zodClient(formSchema),
onResult: async (event) => {
@@ -49,6 +52,35 @@
</Form.Description>
<Form.FieldErrors />
</Form.Field>
{#if page.data.streamed.hasFeatureFlags}
<Form.Field {form} name="features">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["tenants.add.features.title"]()}</Form.Label>
<Select.Root
type="multiple"
bind:value={$formData.features}
name={props.name}
onValueChange={(v) => ($formData.features = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.features.length > 0
? $formData.features
.map((id) => TENANT_FEATURE_FLAGS.find((x) => x === id))
.join(", ")
: m["tenants.add.features.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each TENANT_FEATURE_FLAGS as feature (feature)}
<Select.Item value={feature}>{feature}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
{/if}
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
@@ -2,13 +2,14 @@ import { m } from "$i18n/messages";
import { z } from "zod";
export const formSchema = z.object({
id: z.string(),
domain: z
.string()
.min(1)
.max(253)
.toLowerCase()
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/, m["tenants.add.domain.errors.urlFormat"]()),
id: z.string(),
features: z.array(z.string()).optional(),
});
export type FormSchema = typeof formSchema;
@@ -7,8 +7,10 @@ import { formSchema as addFormSchema } from "./(components)/add-tenant-form";
import { formSchema as editFormSchema } from "./(components)/edit-tenant-form";
import { formSchema as deleteFormSchema } from "./(components)/delete-tenant-form";
import type { TTenant } from "$lib/types/tenant";
import { env } from "$env/dynamic/private";
const log = logger.setContext(import.meta.filename);
const hasFeatureFlags = env.ALLOW_FEATURE_FLAGS === "true";
export const load = async (event) => {
const list = event
@@ -34,9 +36,9 @@ export const load = async (event) => {
});
return {
form: await superValidate(zod(addFormSchema)),
streamed: {
list,
hasFeatureFlags,
},
};
};
@@ -63,6 +65,7 @@ export const actions: Actions = {
body: JSON.stringify({
shortName: form.data.shortName,
domain: form.data.domain,
...(hasFeatureFlags ? { features: form.data.features } : {}),
}),
});
@@ -129,7 +132,10 @@ export const actions: Actions = {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({ domain: form.data.domain }),
body: JSON.stringify({
domain: form.data.domain,
...(hasFeatureFlags ? { features: form.data.features } : {}),
}),
});
if (resp.status < 400) {
@@ -59,7 +59,6 @@
<PlusIcon /> {m["tenants.add.title"]()}
{/snippet}
<AddTenantForm
{data}
done={() => {
invalidate(ROUTES.DASHBOARD.TENANTS);
tenantsStore.reload();
+14
View File
@@ -35,6 +35,11 @@ registerOpenAPIRoute("/tenants", "POST", {
description: "Domain for the tenant",
example: "https://acme-corp.example.com",
},
features: {
type: "string",
description: "Feature flags for the tenant",
example: "feature1,feature2",
},
},
required: ["shortName", "domain"],
},
@@ -56,6 +61,7 @@ registerOpenAPIRoute("/tenants", "POST", {
id: { type: "string", description: "Generated tenant ID" },
shortName: { type: "string", description: "Tenant short name" },
domain: { type: "string", format: "uri", description: "Tenant domain" },
features: { type: "string", description: "Feature flags for the tenant" },
},
required: ["id", "shortName", "domain"],
},
@@ -67,6 +73,8 @@ registerOpenAPIRoute("/tenants", "POST", {
tenant: {
id: "01234567-89ab-cdef-0123-456789abcdef",
shortName: "acme-corp",
domain: "https://acme-corp.example.com",
features: "feature1,feature2",
},
},
},
@@ -117,6 +125,7 @@ registerOpenAPIRoute("/tenants", "GET", {
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" },
features: { type: "string", description: "Feature flags for the tenant" },
setupState: {
type: "string",
enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"],
@@ -135,6 +144,8 @@ registerOpenAPIRoute("/tenants", "GET", {
id: "01234567-89ab-cdef-0123-456789abcdef",
shortName: "acme-corp",
longName: "ACME Corporation",
domain: "https://acme-corp.example.com",
features: "feature1,feature2",
setupState: "FIRST_CHANNEL_CREATED",
},
],
@@ -176,6 +187,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
const tenantService = await TenantAdminService.createTenant({
shortName: body.shortName,
domain: body.domain,
features: body.features,
});
log.debug("Tenant created successfully", {
@@ -225,6 +237,7 @@ export const GET: RequestHandler = async ({ locals }) => {
.select({
id: tenant.id,
domain: tenant.domain,
features: tenant.features,
shortName: tenant.shortName,
languages: tenant.languages,
setupState: tenant.setupState,
@@ -246,6 +259,7 @@ export const GET: RequestHandler = async ({ locals }) => {
.select({
id: tenant.id,
domain: tenant.domain,
features: tenant.features,
shortName: tenant.shortName,
languages: tenant.languages,
setupState: tenant.setupState,