feat: implement multi-tenant support with PG schema isolation

Adds tenant management for PostgreSQL deployments. Each tenant gets its
own PG schema (tenant_<slug>) with a full set of BetterFrame tables.
SQLite deployments stay single-tenant with no behavior change.

Key changes:
- Run PUBLIC_MIGRATIONS (tenants + global_admins tables) during PG init
- Auto-create "default" tenant (schema=public) on first boot
- createTenantSchema() runs TENANT_MIGRATIONS in a new PG schema
- DbAdapter.setSearchPath() for per-request schema switching (PG)
- Tenant CRUD in Repository (listTenants, create, update, delete)
- Middleware resolves bf_tenant cookie and sets search_path per request
- Admin UI: /admin/tenants with CRUD + tenant switching via cookie
- Tenant dropdown in topbar (Layout) when >1 tenant exists
- Tenant nav item in sidebar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 07:22:01 +02:00
parent 64f47a9a6b
commit 66653af360
No known key found for this signature in database
12 changed files with 656 additions and 10 deletions

View file

@ -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<InstanceType<typeof Config>, 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.

View file

@ -5,11 +5,14 @@
* `Authorization: Bearer <bf-...>`. 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);

View file

@ -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<Record<string, string>>(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<Record<string, string>>(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<Record<string, string>>(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 },
);
});
}

View file

@ -40,6 +40,11 @@ export interface DbAdapter {
transaction<T>(fn: () => Promise<T>): Promise<T>;
/** 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<void>;
/** Release the connection / pool. */
close(): Promise<void>;
}

View file

@ -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");
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)
)`);
// 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 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_<slug>`)
* @param log - logging callbacks
*/
export async function createTenantSchema(
adapter: DbAdapter,
slug: string,
log: DbLog,
): Promise<void> {
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");
}
}

View file

@ -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"]),
};
}

View file

@ -149,6 +149,16 @@ export class PgAdapter implements DbAdapter {
dialect(): "postgres" { return "postgres"; }
async setSearchPath(schema: string): Promise<void> {
// 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<void> {
await this.pool.end();
}

View file

@ -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<Tenant[]> {
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<string, unknown>));
}
/** Get tenant by UUID. */
async getTenantById(id: string): Promise<Tenant | null> {
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<string, unknown>) : null;
}
/** Get tenant by slug. */
async getTenantBySlug(slug: string): Promise<Tenant | null> {
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<string, unknown>) : 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<Tenant> {
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<Pick<Tenant, "name" | "is_active" | "max_kiosks" | "max_cameras" | "max_users">>): Promise<void> {
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<void> {
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<number> {
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
// ===========================================================================

View file

@ -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<void> {
// SQLite doesn't support schemas — single tenant only.
}
async close(): Promise<void> {
this.db.close();
}

View file

@ -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;

View file

@ -21,6 +21,7 @@ import type {
OsUpdateRollout,
PairingCode,
EventLog,
Tenant,
} from "../shared/types.js";
// ---- Overview ---------------------------------------------------------------
@ -4106,3 +4107,188 @@ export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) {
</div>
);
}
// ---- Tenants ----------------------------------------------------------------
interface TenantsPageProps {
user: string;
tenants: Tenant[];
currentTenantSlug: string;
error?: string;
}
export function TenantsPage(props: TenantsPageProps) {
return (
<Layout
title="Tenants"
user={props.user}
activeNav="tenants"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div class="section-header">
<h2 class="section-title">All Tenants</h2>
</div>
<p style="color:#666; margin-bottom:1.25rem">
Each tenant is an isolated data boundary with its own cameras, kiosks, layouts,
and displays. The "default" tenant uses the public schema.
</p>
<div style="max-width:700px; margin-bottom:1.5rem">
<div class="card" style="margin-bottom:1rem">
<h3 class="card-title" style="font-size:1rem">Create Tenant</h3>
<form method="post" action="/admin/tenants">
<div class="two-col" style="gap:1rem; margin-bottom:1rem">
<div class="form-group" style="margin-bottom:0">
<label>Name</label>
<input name="name" type="text" class="form-input" placeholder="Acme Corp" required />
</div>
<div class="form-group" style="margin-bottom:0">
<label>Slug</label>
<input name="slug" type="text" class="form-input" placeholder="acme" required pattern="[a-z0-9][a-z0-9_-]*" />
<div class="form-hint">Lowercase letters, digits, hyphens, underscores. Used in schema name.</div>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:1rem; margin-bottom:1rem">
<div class="form-group" style="margin-bottom:0">
<label>Max Kiosks</label>
<input name="max_kiosks" type="number" class="form-input" placeholder="unlimited" min="1" />
</div>
<div class="form-group" style="margin-bottom:0">
<label>Max Cameras</label>
<input name="max_cameras" type="number" class="form-input" placeholder="unlimited" min="1" />
</div>
<div class="form-group" style="margin-bottom:0">
<label>Max Users</label>
<input name="max_users" type="number" class="form-input" placeholder="unlimited" min="1" />
</div>
</div>
<button type="submit" class="btn btn-primary">Create Tenant</button>
</form>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Schema</th>
<th>Status</th>
<th>Limits</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{props.tenants.length === 0 ? (
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No tenants</td></tr>
) : (
props.tenants.map((t) => (
<tr>
<td><strong>{t.name}</strong></td>
<td><code>{t.slug}</code></td>
<td><code>{t.schema_name}</code></td>
<td>
{t.is_active
? <span class="badge badge-green">active</span>
: <span class="badge badge-gray">inactive</span>
}
</td>
<td style="font-size:0.8rem; color:#666">
{[
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"}
</td>
<td>
{t.slug === props.currentTenantSlug
? <span class="badge badge-blue">current</span>
: (
<form method="post" action="/admin/tenants/switch" style="display:inline">
<input type="hidden" name="tenant_slug" value={t.slug} />
<button type="submit" class="btn btn-sm btn-ghost">Switch</button>
</form>
)
}
</td>
<td style="display:flex; gap:0.5rem">
<a href={`/admin/tenants/${t.id}`} class="btn btn-sm btn-ghost">Edit</a>
{t.slug !== "default" && (
<form method="post" action={`/admin/tenants/${t.id}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Delete this tenant? The database schema will NOT be dropped.')"}}>Delete</button>
</form>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Layout>
);
}
interface TenantEditPageProps {
user: string;
tenant: Tenant;
error?: string;
}
export function TenantEditPage(props: TenantEditPageProps) {
const t = props.tenant;
return (
<Layout
title={`Edit Tenant: ${t.name}`}
user={props.user}
activeNav="tenants"
flash={props.error ? { type: "error", message: props.error } : undefined}
>
<div style="max-width:600px">
<a href="/admin/tenants" class="btn btn-ghost btn-sm" style="margin-bottom:1rem">&larr; Back to Tenants</a>
<div class="card">
<h2 class="card-title">Edit Tenant</h2>
<form method="post" action={`/admin/tenants/${t.id}`}>
<div class="form-group">
<label>Name</label>
<input name="name" type="text" class="form-input" value={t.name} required />
</div>
<div class="form-group">
<label>Slug</label>
<input type="text" class="form-input" value={t.slug} disabled />
<div class="form-hint">Slug cannot be changed after creation.</div>
</div>
<div class="form-group">
<label>Schema</label>
<input type="text" class="form-input" value={t.schema_name} disabled />
</div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:0.5rem">
<input type="checkbox" name="is_active" checked={t.is_active} />
Active
</label>
</div>
<div class="two-col" style="gap:1rem; margin-bottom:1rem">
<div class="form-group" style="margin-bottom:0">
<label>Max Kiosks</label>
<input name="max_kiosks" type="number" class="form-input" value={t.max_kiosks != null ? String(t.max_kiosks) : ""} placeholder="unlimited" min="1" />
</div>
<div class="form-group" style="margin-bottom:0">
<label>Max Cameras</label>
<input name="max_cameras" type="number" class="form-input" value={t.max_cameras != null ? String(t.max_cameras) : ""} placeholder="unlimited" min="1" />
</div>
</div>
<div class="form-group">
<label>Max Users</label>
<input name="max_users" type="number" class="form-input" value={t.max_users != null ? String(t.max_users) : ""} placeholder="unlimited" min="1" style="max-width:200px" />
</div>
<div style="display:flex; gap:0.5rem">
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/tenants" class="btn btn-ghost">Cancel</a>
</div>
</form>
</div>
</div>
</Layout>
);
}

View file

@ -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 }) {
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<NavItem href="/admin/audit" label="Audit" icon="&#9678;" active={a === "audit"} />
<NavItem href="/admin/backup" label="Backup" icon="&#9788;" active={a === "backup"} />
<NavItem href="/admin/tenants" label="Tenants" icon="&#9783;" active={a === "tenants"} />
<hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} />
@ -85,6 +91,27 @@ export function Layout(props: PageProps) {
<header class="topbar">
<span class="topbar-title">{props.title}</span>
<div class="topbar-right">
{props.tenants && props.tenants.length > 1 ? (
<form method="post" action="/admin/tenants/switch" style="display:inline-flex; align-items:center; gap:0.35rem">
<label style="font-size:0.8rem; color:#666; white-space:nowrap">Tenant:</label>
<select
name="tenant_slug"
class="form-input"
style="width:auto; padding:0.25rem 0.5rem; font-size:0.8rem"
{...{"onchange": "this.form.submit()"}}
>
{props.tenants.map((t) => (
<option value={t.slug} selected={t.slug === props.currentTenantSlug}>
{t.name}
</option>
))}
</select>
</form>
) : props.currentTenantSlug && props.currentTenantSlug !== "default" ? (
<span class="badge badge-blue" style="font-size:0.75rem">
tenant: {props.currentTenantSlug}
</span>
) : null}
<span class="topbar-user">{props.user}</span>
<form method="post" action="/auth/logout" style="display:inline">
<button type="submit" class="btn btn-sm btn-ghost">Logout</button>