mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
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:
parent
64f47a9a6b
commit
66653af360
12 changed files with 656 additions and 10 deletions
|
|
@ -34,6 +34,7 @@ import { registerFirmwareRoutes } from "./routes-firmware.js";
|
||||||
import { registerOsUpdateRoutes } from "./routes-os-updates.js";
|
import { registerOsUpdateRoutes } from "./routes-os-updates.js";
|
||||||
import { registerStaticRoutes } from "./routes-static.js";
|
import { registerStaticRoutes } from "./routes-static.js";
|
||||||
import { registerCloudRoutes } from "./routes-cloud.js";
|
import { registerCloudRoutes } from "./routes-cloud.js";
|
||||||
|
import { registerTenantRoutes } from "./routes-tenants.js";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -238,6 +239,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
registerFirmwareRoutes(app, deps);
|
registerFirmwareRoutes(app, deps);
|
||||||
registerOsUpdateRoutes(app, deps);
|
registerOsUpdateRoutes(app, deps);
|
||||||
registerCloudRoutes(app, deps);
|
registerCloudRoutes(app, deps);
|
||||||
|
registerTenantRoutes(app, deps);
|
||||||
|
|
||||||
// Auth-check endpoint for Angie auth_request subrequest.
|
// Auth-check endpoint for Angie auth_request subrequest.
|
||||||
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
// Returns 200 if session cookie is valid + admin role, 401 otherwise.
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,14 @@
|
||||||
* `Authorization: Bearer <bf-...>`. API-key callers get a synthetic User
|
* `Authorization: Bearer <bf-...>`. API-key callers get a synthetic User
|
||||||
* record so downstream handlers (which always read `event.context.user`)
|
* record so downstream handlers (which always read `event.context.user`)
|
||||||
* keep working unchanged.
|
* 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 { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import { type H3, getCookie, getRequestPath } from "h3";
|
import { type H3, getCookie, getRequestPath } from "h3";
|
||||||
import type { AdminDeps } from "./index.js";
|
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" {
|
declare module "h3" {
|
||||||
interface H3EventContext {
|
interface H3EventContext {
|
||||||
|
|
@ -17,6 +20,8 @@ declare module "h3" {
|
||||||
session?: Session;
|
session?: Session;
|
||||||
apiKeyPrefix?: string;
|
apiKeyPrefix?: string;
|
||||||
obs?: import("@bsb/base").Observable;
|
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 {
|
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) => {
|
app.use(async (event) => {
|
||||||
const path = getRequestPath(event);
|
const path = getRequestPath(event);
|
||||||
|
|
||||||
|
|
|
||||||
165
server/src/plugins/service-admin-http/routes-tenants.ts
Normal file
165
server/src/plugins/service-admin-http/routes-tenants.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,11 @@ export interface DbAdapter {
|
||||||
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
||||||
/** Identifies the backend. */
|
/** Identifies the backend. */
|
||||||
dialect(): "sqlite" | "postgres";
|
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. */
|
/** Release the connection / pool. */
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
import { MIGRATIONS } from "./migrations.js";
|
import { MIGRATIONS } from "./migrations.js";
|
||||||
import { Repository } from "./repository.js";
|
import { Repository } from "./repository.js";
|
||||||
|
import type { DbAdapter } from "./db-adapter.js";
|
||||||
import type { DbConfig } from "./config.js";
|
import type { DbConfig } from "./config.js";
|
||||||
|
|
||||||
interface DbLog {
|
interface DbLog {
|
||||||
|
|
@ -37,20 +38,45 @@ export async function initDb(
|
||||||
const { PgAdapter } = await import("./pg-adapter.js");
|
const { PgAdapter } = await import("./pg-adapter.js");
|
||||||
const adapter = new PgAdapter(pgUrl, config.poolMax);
|
const adapter = new PgAdapter(pgUrl, config.poolMax);
|
||||||
|
|
||||||
// Run PG migrations. Track version in schema_migrations table.
|
// Ensure schema_migrations exists (bootstrap).
|
||||||
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
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 }>(
|
const versionRow = await adapter.get<{ version: number }>(
|
||||||
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
`SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations WHERE schema_name = 'public'`,
|
||||||
).catch(() => undefined);
|
).catch(() => undefined);
|
||||||
const currentVersion = versionRow?.version ?? 0;
|
const currentVersion = versionRow?.version ?? 0;
|
||||||
if (currentVersion < TENANT_MIGRATIONS.length) {
|
if (currentVersion < TENANT_MIGRATIONS.length) {
|
||||||
log.info(`running PG migrations from ${currentVersion} to ${TENANT_MIGRATIONS.length}`);
|
log.info(`running PG tenant 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)
|
|
||||||
)`);
|
|
||||||
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
for (let i = currentVersion; i < TENANT_MIGRATIONS.length; i++) {
|
||||||
try {
|
try {
|
||||||
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
await adapter.exec(TENANT_MIGRATIONS[i]!);
|
||||||
|
|
@ -68,6 +94,18 @@ export async function initDb(
|
||||||
log.info(`PG schema up to date (version ${currentVersion})`);
|
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) => {
|
const repo = new Repository(adapter, async (table, op, id) => {
|
||||||
notify(table, op, id);
|
notify(table, op, id);
|
||||||
});
|
});
|
||||||
|
|
@ -119,3 +157,66 @@ export async function initDb(
|
||||||
|
|
||||||
return { repo, close: () => adapter.close() };
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import type {
|
||||||
StreamPolicy,
|
StreamPolicy,
|
||||||
StreamRole,
|
StreamRole,
|
||||||
StreamSelector,
|
StreamSelector,
|
||||||
|
Tenant,
|
||||||
User,
|
User,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
|
|
@ -492,3 +493,17 @@ export function rowToCameraEventSubscription(r: Row): CameraEventSubscription {
|
||||||
created_at: s(r["created_at"]),
|
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"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,16 @@ export class PgAdapter implements DbAdapter {
|
||||||
|
|
||||||
dialect(): "postgres" { return "postgres"; }
|
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> {
|
async close(): Promise<void> {
|
||||||
await this.pool.end();
|
await this.pool.end();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import type {
|
||||||
SetupState,
|
SetupState,
|
||||||
StreamPolicy,
|
StreamPolicy,
|
||||||
StreamRole,
|
StreamRole,
|
||||||
|
Tenant,
|
||||||
User,
|
User,
|
||||||
UserRole,
|
UserRole,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
|
|
@ -84,6 +85,7 @@ import {
|
||||||
rowToPairingCode,
|
rowToPairingCode,
|
||||||
rowToSession,
|
rowToSession,
|
||||||
rowToSetupState,
|
rowToSetupState,
|
||||||
|
rowToTenant,
|
||||||
rowToUser,
|
rowToUser,
|
||||||
} from "./mappers.js";
|
} from "./mappers.js";
|
||||||
import { J, isoIn, isoNow, j } from "./util.js";
|
import { J, isoIn, isoNow, j } from "./util.js";
|
||||||
|
|
@ -165,6 +167,88 @@ export class Repository {
|
||||||
return this.adapter.transaction(fn);
|
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
|
// setup_state
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,11 @@ export class SqliteAdapter implements DbAdapter {
|
||||||
|
|
||||||
dialect(): "sqlite" { return "sqlite"; }
|
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> {
|
async close(): Promise<void> {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,18 @@ export interface CameraEventSubscription {
|
||||||
created_at: string;
|
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 {
|
export interface EventQueryFilters {
|
||||||
topic?: string;
|
topic?: string;
|
||||||
kiosk_id?: string;
|
kiosk_id?: string;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import type {
|
||||||
OsUpdateRollout,
|
OsUpdateRollout,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
EventLog,
|
EventLog,
|
||||||
|
Tenant,
|
||||||
} from "../shared/types.js";
|
} from "../shared/types.js";
|
||||||
|
|
||||||
// ---- Overview ---------------------------------------------------------------
|
// ---- Overview ---------------------------------------------------------------
|
||||||
|
|
@ -4106,3 +4107,188 @@ export function KioskOsUpdatePanel(props: KioskOsUpdatePanelProps) {
|
||||||
</div>
|
</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">← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
import { css, js } from "jsx-htmx";
|
import { css, js } from "jsx-htmx";
|
||||||
import { serverVersion } from "../shared/version.js";
|
import { serverVersion } from "../shared/version.js";
|
||||||
|
import type { Tenant } from "../shared/types.js";
|
||||||
|
|
||||||
// ---- Shared types -----------------------------------------------------------
|
// ---- Shared types -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -17,6 +18,10 @@ export interface PageProps {
|
||||||
flash?: { type: "success" | "error" | "info"; message: string };
|
flash?: { type: "success" | "error" | "info"; message: string };
|
||||||
/** Active nav item key. */
|
/** Active nav item key. */
|
||||||
activeNav?: string;
|
activeNav?: string;
|
||||||
|
/** Available tenants for tenant switcher (PG multi-tenant only). */
|
||||||
|
tenants?: Tenant[];
|
||||||
|
/** Currently selected tenant slug. */
|
||||||
|
currentTenantSlug?: string;
|
||||||
children?: string | string[];
|
children?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +61,7 @@ function Sidebar(props: { activeNav?: string }) {
|
||||||
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
<NavItem href="/admin/labels" label="Labels" icon="◆" active={a === "labels"} />
|
||||||
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
<NavItem href="/admin/audit" label="Audit" icon="◎" active={a === "audit"} />
|
||||||
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
<NavItem href="/admin/backup" label="Backup" icon="☼" active={a === "backup"} />
|
||||||
|
<NavItem href="/admin/tenants" label="Tenants" icon="☷" active={a === "tenants"} />
|
||||||
<hr />
|
<hr />
|
||||||
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||||
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
||||||
|
|
@ -85,6 +91,27 @@ export function Layout(props: PageProps) {
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<span class="topbar-title">{props.title}</span>
|
<span class="topbar-title">{props.title}</span>
|
||||||
<div class="topbar-right">
|
<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>
|
<span class="topbar-user">{props.user}</span>
|
||||||
<form method="post" action="/auth/logout" style="display:inline">
|
<form method="post" action="/auth/logout" style="display:inline">
|
||||||
<button type="submit" class="btn btn-sm btn-ghost">Logout</button>
|
<button type="submit" class="btn btn-sm btn-ghost">Logout</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue