diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index d78774b..92cd405 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -34,6 +34,7 @@ import { registerFirmwareRoutes } from "./routes-firmware.js"; import { registerOsUpdateRoutes } from "./routes-os-updates.js"; import { registerStaticRoutes } from "./routes-static.js"; import { registerCloudRoutes } from "./routes-cloud.js"; +import { registerTenantRoutes } from "./routes-tenants.js"; // ---- Config ----------------------------------------------------------------- @@ -238,6 +239,7 @@ export class Plugin extends BSBService, typeof Event registerFirmwareRoutes(app, deps); registerOsUpdateRoutes(app, deps); registerCloudRoutes(app, deps); + registerTenantRoutes(app, deps); // Auth-check endpoint for Angie auth_request subrequest. // Returns 200 if session cookie is valid + admin role, 401 otherwise. diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index d9c1ebc..3708dc9 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -5,11 +5,14 @@ * `Authorization: Bearer `. API-key callers get a synthetic User * record so downstream handlers (which always read `event.context.user`) * keep working unchanged. + * + * Multi-tenant: on PG, reads `bf_tenant` cookie to set the DB search_path + * per request. Falls back to "default" tenant. */ import { createHash, timingSafeEqual } from "node:crypto"; import { type H3, getCookie, getRequestPath } from "h3"; import type { AdminDeps } from "./index.js"; -import type { User, Session } from "../../shared/types.js"; +import type { User, Session, Tenant } from "../../shared/types.js"; declare module "h3" { interface H3EventContext { @@ -17,6 +20,8 @@ declare module "h3" { session?: Session; apiKeyPrefix?: string; obs?: import("@bsb/base").Observable; + /** Current tenant (PG multi-tenant mode). Undefined for SQLite. */ + tenant?: Tenant; } } @@ -46,6 +51,35 @@ function tokenMatchesExpected(token: string, expected: string | undefined): bool } export function registerMiddleware(app: H3, deps: AdminDeps): void { + // Tenant resolution middleware — sets search_path for PG multi-tenant. + // Runs before auth so that DB queries in auth resolution use the right schema. + app.use(async (event) => { + if (deps.repo.adapter.dialect() !== "postgres") return; + + const path = getRequestPath(event); + // Skip tenant resolution for paths that don't query tenant-scoped data. + if (path.startsWith("/static/") || path === "/healthz" || path === "/readyz" || path === "/version") return; + + // Read tenant slug from cookie. + const tenantSlug = getCookie(event, "bf_tenant") || "default"; + const tenant = await deps.repo.getTenantBySlug(tenantSlug); + if (tenant && tenant.is_active) { + event.context.tenant = tenant; + // Set PG search_path to the tenant's schema. + if (tenant.schema_name !== "public") { + await deps.repo.adapter.setSearchPath(tenant.schema_name); + } + } else { + // Fall back to default tenant. + const defaultTenant = await deps.repo.getTenantBySlug("default"); + if (defaultTenant) { + event.context.tenant = defaultTenant; + } + // Reset to public if we had a bad cookie. + await deps.repo.adapter.setSearchPath("public"); + } + }); + app.use(async (event) => { const path = getRequestPath(event); diff --git a/server/src/plugins/service-admin-http/routes-tenants.ts b/server/src/plugins/service-admin-http/routes-tenants.ts new file mode 100644 index 0000000..16a241d --- /dev/null +++ b/server/src/plugins/service-admin-http/routes-tenants.ts @@ -0,0 +1,165 @@ +/** + * Tenant management routes — CRUD for tenants + tenant switching. + * PG-only feature. On SQLite these routes return 404. + */ +import { type H3, readBody, getRouterParam, getCookie } from "h3"; +import { htmlPage, redirectWithCookie } from "./html-response.js"; +import type { AdminDeps } from "./index.js"; +import { createTenantSchema } from "../../shared/db/init.js"; +import { + TenantsPage, + TenantEditPage, +} from "../../web-templates/admin-pages.js"; + +export function registerTenantRoutes(app: H3, deps: AdminDeps): void { + // Guard: multi-tenant is PG only. + const isPg = () => deps.repo.adapter.dialect() === "postgres"; + + // ---- List all tenants ----------------------------------------------------- + + app.get("/admin/tenants", async (event) => { + if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); + const user = event.context.user!; + const tenants = await deps.repo.listTenants(); + const currentTenant = event.context.tenant ?? null; + return htmlPage(TenantsPage({ + user: user.username, + tenants, + currentTenantSlug: currentTenant?.slug ?? "default", + })); + }); + + // ---- Create tenant -------------------------------------------------------- + + app.post("/admin/tenants", async (event) => { + if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); + const body = await readBody>(event); + const name = (body?.["name"] ?? "").trim(); + const slug = (body?.["slug"] ?? "").trim().toLowerCase(); + const maxKiosks = body?.["max_kiosks"] ? parseInt(body["max_kiosks"], 10) : null; + const maxCameras = body?.["max_cameras"] ? parseInt(body["max_cameras"], 10) : null; + const maxUsers = body?.["max_users"] ? parseInt(body["max_users"], 10) : null; + + if (!name || !slug || !/^[a-z0-9][a-z0-9_-]*$/.test(slug)) { + const tenants = await deps.repo.listTenants(); + return htmlPage(TenantsPage({ + user: event.context.user!.username, + tenants, + currentTenantSlug: event.context.tenant?.slug ?? "default", + error: "Name required. Slug must start with letter/digit and contain only lowercase, digits, hyphens, underscores.", + })); + } + + // Check for duplicate slug. + const existing = await deps.repo.getTenantBySlug(slug); + if (existing) { + const tenants = await deps.repo.listTenants(); + return htmlPage(TenantsPage({ + user: event.context.user!.username, + tenants, + currentTenantSlug: event.context.tenant?.slug ?? "default", + error: `Tenant with slug "${slug}" already exists.`, + })); + } + + // Create tenant record. + await deps.repo.createTenant({ + name, + slug, + max_kiosks: maxKiosks, + max_cameras: maxCameras, + max_users: maxUsers, + }); + + // Create PG schema and run tenant migrations. + await createTenantSchema( + deps.repo.adapter, + slug, + { + info: (m) => { /* swallow */ }, + warn: (m) => { /* swallow */ }, + }, + ); + + return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + }); + + // ---- Edit tenant page ----------------------------------------------------- + + app.get("/admin/tenants/:id", async (event) => { + if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); + const id = getRouterParam(event, "id") ?? ""; + const tenant = await deps.repo.getTenantById(id); + if (!tenant) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + return htmlPage(TenantEditPage({ + user: event.context.user!.username, + tenant, + })); + }); + + // ---- Update tenant -------------------------------------------------------- + + app.post("/admin/tenants/:id", async (event) => { + if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); + const id = getRouterParam(event, "id") ?? ""; + const body = await readBody>(event); + const name = (body?.["name"] ?? "").trim(); + const isActive = body?.["is_active"] === "on" || body?.["is_active"] === "true"; + const maxKiosks = body?.["max_kiosks"] ? parseInt(body["max_kiosks"], 10) : null; + const maxCameras = body?.["max_cameras"] ? parseInt(body["max_cameras"], 10) : null; + const maxUsers = body?.["max_users"] ? parseInt(body["max_users"], 10) : null; + + if (!name) { + const tenant = await deps.repo.getTenantById(id); + if (!tenant) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + return htmlPage(TenantEditPage({ + user: event.context.user!.username, + tenant, + error: "Name is required.", + })); + } + + await deps.repo.updateTenant(id, { + name, + is_active: isActive, + max_kiosks: maxKiosks, + max_cameras: maxCameras, + max_users: maxUsers, + }); + return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + }); + + // ---- Delete tenant -------------------------------------------------------- + + app.post("/admin/tenants/:id/delete", async (event) => { + if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); + const id = getRouterParam(event, "id") ?? ""; + const tenant = await deps.repo.getTenantById(id); + if (!tenant) return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + // Prevent deleting the default tenant. + if (tenant.slug === "default") { + return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + } + await deps.repo.deleteTenant(id); + // Note: does NOT drop the PG schema. That's intentional for data safety. + return new Response(null, { status: 302, headers: { location: "/admin/tenants" } }); + }); + + // ---- Switch active tenant ------------------------------------------------- + + app.post("/admin/tenants/switch", async (event) => { + if (!isPg()) return new Response("multi-tenant requires postgres", { status: 404 }); + const body = await readBody>(event); + const slug = (body?.["tenant_slug"] ?? "default").trim().toLowerCase(); + + // Validate the tenant exists and is active. + const tenant = await deps.repo.getTenantBySlug(slug); + const targetSlug = tenant?.is_active ? tenant.slug : "default"; + + // Set the bf_tenant cookie. MaxAge = 1 year (long-lived, session-like). + return redirectWithCookie( + "/admin/", + { name: "bf_tenant", value: targetSlug, maxAge: 365 * 24 * 60 * 60 }, + ); + }); +} diff --git a/server/src/shared/db/db-adapter.ts b/server/src/shared/db/db-adapter.ts index 0522453..5bc2044 100644 --- a/server/src/shared/db/db-adapter.ts +++ b/server/src/shared/db/db-adapter.ts @@ -40,6 +40,11 @@ export interface DbAdapter { transaction(fn: () => Promise): Promise; /** Identifies the backend. */ dialect(): "sqlite" | "postgres"; + /** + * Set the schema search_path for multi-tenant isolation (PG only). + * SQLite adapter implements this as a no-op. + */ + setSearchPath(schema: string): Promise; /** Release the connection / pool. */ close(): Promise; } diff --git a/server/src/shared/db/init.ts b/server/src/shared/db/init.ts index 5a87157..81585e4 100644 --- a/server/src/shared/db/init.ts +++ b/server/src/shared/db/init.ts @@ -10,6 +10,7 @@ import { mkdirSync } from "node:fs"; import { MIGRATIONS } from "./migrations.js"; import { Repository } from "./repository.js"; +import type { DbAdapter } from "./db-adapter.js"; import type { DbConfig } from "./config.js"; interface DbLog { @@ -37,20 +38,45 @@ export async function initDb( const { PgAdapter } = await import("./pg-adapter.js"); const adapter = new PgAdapter(pgUrl, config.poolMax); - // Run PG migrations. Track version in schema_migrations table. - const { TENANT_MIGRATIONS } = await import("./migrations-pg.js"); + // Ensure schema_migrations exists (bootstrap). + await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + schema_name TEXT NOT NULL, version INTEGER NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (schema_name, version) + )`); + + // 1. Run PUBLIC_MIGRATIONS first (tenants + global_admins tables). + const { PUBLIC_MIGRATIONS, TENANT_MIGRATIONS } = await import("./migrations-pg.js"); + const pubVersionRow = await adapter.get<{ version: number }>( + `SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public_global'`, + ).catch(() => undefined); + const pubCurrentVersion = pubVersionRow?.version ?? 0; + if (pubCurrentVersion < PUBLIC_MIGRATIONS.length) { + log.info(`running PUBLIC migrations from ${pubCurrentVersion} to ${PUBLIC_MIGRATIONS.length}`); + for (let i = pubCurrentVersion; i < PUBLIC_MIGRATIONS.length; i++) { + try { + await adapter.exec(PUBLIC_MIGRATIONS[i]!); + } catch (err) { + log.warn(`PUBLIC migration ${i} failed: ${(err as Error).message}`); + log.warn(`SQL: ${PUBLIC_MIGRATIONS[i]!.slice(0, 200)}`); + throw err; + } + await adapter.run( + `INSERT INTO schema_migrations (schema_name, version) VALUES ('public_global', ?)`, + [i + 1], + ); + } + } else { + log.info(`PUBLIC schema up to date (version ${pubCurrentVersion})`); + } + + // 2. Run TENANT_MIGRATIONS in the public schema (default tenant). const versionRow = await adapter.get<{ version: number }>( `SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`, ).catch(() => undefined); const currentVersion = versionRow?.version ?? 0; if (currentVersion < TENANT_MIGRATIONS.length) { - log.info(`running PG migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`); - // Ensure schema_migrations exists (bootstrap). - await adapter.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( - schema_name TEXT NOT NULL, version INTEGER NOT NULL, - applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (schema_name, version) - )`); + log.info(`running PG tenant migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`); for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) { try { await adapter.exec(TENANT_MIGRATIONS[i]!); @@ -68,6 +94,18 @@ export async function initDb( log.info(`PG schema up to date (version ${currentVersion})`); } + // 3. Ensure default tenant exists. + const defaultTenant = await adapter.get( + `SELECT id FROM public.tenants WHERE slug = 'default'`, + ); + if (!defaultTenant) { + log.info("creating default tenant"); + await adapter.run( + `INSERT INTO public.tenants (name, slug, schema_name, is_active) + VALUES ('Default', 'default', 'public', true)`, + ); + } + const repo = new Repository(adapter, async (table, op, id) => { notify(table, op, id); }); @@ -119,3 +157,66 @@ export async function initDb( return { repo, close: () => adapter.close() }; } + +/** + * Create a new tenant schema and run all TENANT_MIGRATIONS inside it. + * Called when a new tenant is created from the admin UI. + * + * @param adapter - the DB adapter (must be PG) + * @param slug - tenant slug (used to derive schema name: `tenant_`) + * @param log - logging callbacks + */ +export async function createTenantSchema( + adapter: DbAdapter, + slug: string, + log: DbLog, +): Promise { + if (adapter.dialect() !== "postgres") { + // SQLite is single-tenant — no schema creation needed. + return; + } + // Validate slug to prevent SQL injection. + if (!/^[a-z0-9][a-z0-9_-]*$/.test(slug)) { + throw new Error(`invalid tenant slug: ${slug}`); + } + const schemaName = `tenant_${slug}`; + log.info(`creating tenant schema: ${schemaName}`); + + // Create the schema. + await adapter.exec(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`); + + // Set search_path to the new schema for running tenant migrations. + await adapter.setSearchPath(schemaName); + + try { + // Run all TENANT_MIGRATIONS inside the new schema. + const { TENANT_MIGRATIONS } = await import("./migrations-pg.js"); + + // Ensure schema_migrations tracking for this schema. + // Use the public schema_migrations table (always in public). + const versionRow = await adapter.get<{ version: number }>( + `SELECT COALESCE(MAX(version), 0) AS version FROM public.schema_migrations WHERE schema_name = ?`, + [schemaName], + ); + const currentVersion = versionRow?.version ?? 0; + + if (currentVersion < TENANT_MIGRATIONS.length) { + log.info(`running tenant migrations for ${schemaName} from ${currentVersion} to ${TENANT_MIGRATIONS.length}`); + for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) { + try { + await adapter.exec(TENANT_MIGRATIONS[i]!); + } catch (err) { + log.warn(`tenant migration ${i} failed for ${schemaName}: ${(err as Error).message}`); + throw err; + } + await adapter.run( + `INSERT INTO public.schema_migrations (schema_name, version) VALUES (?, ?)`, + [schemaName, i + 1], + ); + } + } + } finally { + // Always reset search_path back to public. + await adapter.setSearchPath("public"); + } +} diff --git a/server/src/shared/db/mappers.ts b/server/src/shared/db/mappers.ts index 827ecb3..c01919e 100644 --- a/server/src/shared/db/mappers.ts +++ b/server/src/shared/db/mappers.ts @@ -54,6 +54,7 @@ import type { StreamPolicy, StreamRole, StreamSelector, + Tenant, User, UserRole, } from "../types.js"; @@ -492,3 +493,17 @@ export function rowToCameraEventSubscription(r: Row): CameraEventSubscription { created_at: s(r["created_at"]), }; } + +export function rowToTenant(r: Row): Tenant { + return { + id: s(r["id"]), + name: s(r["name"]), + slug: s(r["slug"]), + schema_name: s(r["schema_name"]), + is_active: b(r["is_active"]), + max_kiosks: nn(r["max_kiosks"]), + max_cameras: nn(r["max_cameras"]), + max_users: nn(r["max_users"]), + created_at: s(r["created_at"]), + }; +} diff --git a/server/src/shared/db/pg-adapter.ts b/server/src/shared/db/pg-adapter.ts index 1e27ba6..917c6ed 100644 --- a/server/src/shared/db/pg-adapter.ts +++ b/server/src/shared/db/pg-adapter.ts @@ -149,6 +149,16 @@ export class PgAdapter implements DbAdapter { dialect(): "postgres" { return "postgres"; } + async setSearchPath(schema: string): Promise { + // Validate schema name to prevent SQL injection (only allow alphanumeric + underscore). + if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) { + throw new Error(`invalid schema name: ${schema}`); + } + await this.runner(async (c) => { + await c.query(`SET search_path TO ${schema}, public`); + }); + } + async close(): Promise { await this.pool.end(); } diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index 0c8fed9..9bdb612 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -57,6 +57,7 @@ import type { SetupState, StreamPolicy, StreamRole, + Tenant, User, UserRole, } from "../types.js"; @@ -84,6 +85,7 @@ import { rowToPairingCode, rowToSession, rowToSetupState, + rowToTenant, rowToUser, } from "./mappers.js"; import { J, isoIn, isoNow, j } from "./util.js"; @@ -165,6 +167,88 @@ export class Repository { return this.adapter.transaction(fn); } + // =========================================================================== + // tenants (PUBLIC schema — always use public.tenants explicitly) + // =========================================================================== + + /** List all tenants. Queries public.tenants regardless of current search_path. */ + async listTenants(): Promise { + if (this.adapter.dialect() !== "postgres") return []; + const rs = await this._all( + "SELECT * FROM public.tenants ORDER BY created_at", + ); + return rs.map((r) => rowToTenant(r as Record)); + } + + /** Get tenant by UUID. */ + async getTenantById(id: string): Promise { + if (this.adapter.dialect() !== "postgres") return null; + const r = await this._get("SELECT * FROM public.tenants WHERE id = ?", [id]); + return r ? rowToTenant(r as Record) : null; + } + + /** Get tenant by slug. */ + async getTenantBySlug(slug: string): Promise { + if (this.adapter.dialect() !== "postgres") return null; + const r = await this._get("SELECT * FROM public.tenants WHERE slug = ?", [slug]); + return r ? rowToTenant(r as Record) : null; + } + + /** Create a new tenant in public.tenants. Does NOT create the PG schema — call createTenantSchema separately. */ + async createTenant(input: { + name: string; + slug: string; + max_kiosks?: number | null; + max_cameras?: number | null; + max_users?: number | null; + }): Promise { + const schemaName = input.slug === "default" ? "public" : `tenant_${input.slug}`; + await this._run( + `INSERT INTO public.tenants (name, slug, schema_name, is_active, max_kiosks, max_cameras, max_users) + VALUES (?, ?, ?, true, ?, ?, ?)`, + [ + input.name, + input.slug, + schemaName, + input.max_kiosks ?? null, + input.max_cameras ?? null, + input.max_users ?? null, + ], + ); + void this.notify("tenants", "create"); + const t = await this.getTenantBySlug(input.slug); + if (!t) throw new Error("tenant vanished after insert"); + return t; + } + + /** Update tenant metadata. */ + async updateTenant(id: string, patch: Partial>): Promise { + const sets: string[] = []; + const vals: unknown[] = []; + if ("name" in patch) { sets.push("name = ?"); vals.push(patch.name); } + if ("is_active" in patch) { sets.push("is_active = ?"); vals.push(Boolean(patch.is_active)); } + if ("max_kiosks" in patch) { sets.push("max_kiosks = ?"); vals.push(patch.max_kiosks ?? null); } + if ("max_cameras" in patch) { sets.push("max_cameras = ?"); vals.push(patch.max_cameras ?? null); } + if ("max_users" in patch) { sets.push("max_users = ?"); vals.push(patch.max_users ?? null); } + if (sets.length === 0) return; + vals.push(id); + await this._run(`UPDATE public.tenants SET ${sets.join(", ")} WHERE id = ?`, vals); + void this.notify("tenants", "update", id); + } + + /** Delete a tenant. WARNING: does not drop the PG schema — that must be done separately if desired. */ + async deleteTenant(id: string): Promise { + await this._run("DELETE FROM public.tenants WHERE id = ?", [id]); + void this.notify("tenants", "delete", id); + } + + /** Count tenants. Used to check if multi-tenant is enabled. */ + async countTenants(): Promise { + if (this.adapter.dialect() !== "postgres") return 0; + const r = await this._get<{ c: number }>("SELECT COUNT(*) AS c FROM public.tenants"); + return r?.c ?? 0; + } + // =========================================================================== // setup_state // =========================================================================== diff --git a/server/src/shared/db/sqlite-adapter.ts b/server/src/shared/db/sqlite-adapter.ts index c32f2d0..6fcee41 100644 --- a/server/src/shared/db/sqlite-adapter.ts +++ b/server/src/shared/db/sqlite-adapter.ts @@ -88,6 +88,11 @@ export class SqliteAdapter implements DbAdapter { dialect(): "sqlite" { return "sqlite"; } + /** No-op for SQLite — single-tenant only. */ + async setSearchPath(_schema: string): Promise { + // SQLite doesn't support schemas — single tenant only. + } + async close(): Promise { this.db.close(); } diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index 6d54757..e1955ce 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -426,6 +426,18 @@ export interface CameraEventSubscription { created_at: string; } +export interface Tenant { + id: string; + name: string; + slug: string; + schema_name: string; + is_active: boolean; + max_kiosks: number | null; + max_cameras: number | null; + max_users: number | null; + created_at: string; +} + export interface EventQueryFilters { topic?: string; kiosk_id?: string; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 549f4dd..8c06400 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -21,6 +21,7 @@ import type { OsUpdateRollout, PairingCode, EventLog, + Tenant, } from "../shared/types.js"; // ---- Overview --------------------------------------------------------------- @@ -4106,3 +4107,188 @@ export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) { ); } + +// ---- Tenants ---------------------------------------------------------------- + +interface TenantsPageProps { + user: string; + tenants: Tenant[]; + currentTenantSlug: string; + error?: string; +} + +export function TenantsPage(props: TenantsPageProps) { + return ( + +
+

All Tenants

+
+

+ Each tenant is an isolated data boundary with its own cameras, kiosks, layouts, + and displays. The "default" tenant uses the public schema. +

+
+
+

Create Tenant

+
+
+
+ + +
+
+ + +
Lowercase letters, digits, hyphens, underscores. Used in schema name.
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + + + + + + + + + + + + {props.tenants.length === 0 ? ( + + ) : ( + props.tenants.map((t) => ( + + + + + + + + + + )) + )} + +
NameSlugSchemaStatusLimitsActiveActions
No tenants
{t.name}{t.slug}{t.schema_name} + {t.is_active + ? active + : inactive + } + + {[ + t.max_kiosks != null ? `K:${String(t.max_kiosks)}` : null, + t.max_cameras != null ? `C:${String(t.max_cameras)}` : null, + t.max_users != null ? `U:${String(t.max_users)}` : null, + ].filter(Boolean).join(" ") || "none"} + + {t.slug === props.currentTenantSlug + ? current + : ( +
+ + +
+ ) + } +
+ Edit + {t.slug !== "default" && ( +
+ +
+ )} +
+
+
+ ); +} + +interface TenantEditPageProps { + user: string; + tenant: Tenant; + error?: string; +} + +export function TenantEditPage(props: TenantEditPageProps) { + const t = props.tenant; + return ( + +
+ ← Back to Tenants +
+

Edit Tenant

+
+
+ + +
+
+ + +
Slug cannot be changed after creation.
+
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Cancel +
+
+
+
+
+ ); +} diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index 8a71d77..1fb9550 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -4,6 +4,7 @@ */ import { css, js } from "jsx-htmx"; import { serverVersion } from "../shared/version.js"; +import type { Tenant } from "../shared/types.js"; // ---- Shared types ----------------------------------------------------------- @@ -17,6 +18,10 @@ export interface PageProps { flash?: { type: "success" | "error" | "info"; message: string }; /** Active nav item key. */ activeNav?: string; + /** Available tenants for tenant switcher (PG multi-tenant only). */ + tenants?: Tenant[]; + /** Currently selected tenant slug. */ + currentTenantSlug?: string; children?: string | string[]; } @@ -56,6 +61,7 @@ function Sidebar(props: { activeNav?: string }) { +
@@ -85,6 +91,27 @@ export function Layout(props: PageProps) {
{props.title}
+ {props.tenants && props.tenants.length > 1 ? ( +
+ + +
+ ) : props.currentTenantSlug && props.currentTenantSlug !== "default" ? ( + + tenant: {props.currentTenantSlug} + + ) : null} {props.user}