diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 3708dc9..ae4b8c2 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -65,12 +65,8 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { 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); - } + 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; diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index a321cad..dd272d1 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -2219,6 +2219,21 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); + // ---- Tenant switcher fragment (htmx) ---------------------------------------- + app.get("/admin/_tenant_switcher", async (event) => { + const tenants = await deps.repo.listTenants(); + if (tenants.length <= 1) return new Response("", { headers: { "content-type": "text/html" } }); + const current = (event.context as any).tenant?.slug ?? "default"; + const options = tenants.map((t: any) => + `` + ).join(""); + const html = `
+ + +
`; + return new Response(html, { headers: { "content-type": "text/html" } }); + }); + // ---- JSON API (admin scope) — used by Node-RED bf-* nodes --------------- // // All payloads run through `stripSecrets` so credential-bearing fields diff --git a/server/src/shared/db/pg-adapter.ts b/server/src/shared/db/pg-adapter.ts index 4524ded..fdc931e 100644 --- a/server/src/shared/db/pg-adapter.ts +++ b/server/src/shared/db/pg-adapter.ts @@ -13,9 +13,9 @@ import type { DbAdapter, RunResult, Row, SqlValue } from "./db-adapter.js"; export class PgAdapter implements DbAdapter { private readonly pool: Pool; - /** Per-async-context client when inside transaction(). */ private currentTxClient: PoolClient | null = null; private txDepth = 0; + private searchPath = "public"; constructor(connectionString: string, poolMax: number = 10) { this.pool = new Pool({ @@ -70,8 +70,12 @@ export class PgAdapter implements DbAdapter { private async runner(fn: (c: PoolClient) => Promise): Promise { if (this.currentTxClient) return fn(this.currentTxClient); const client = await this.pool.connect(); - try { return await fn(client); } - finally { client.release(); } + try { + await client.query(`SET search_path TO ${this.searchPath}, public`); + return await fn(client); + } finally { + client.release(); + } } async run(sql: string, params: ReadonlyArray = []): Promise { @@ -149,13 +153,10 @@ 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`); - }); + this.searchPath = schema; } async close(): Promise { diff --git a/server/src/web-templates/layout.tsx b/server/src/web-templates/layout.tsx index a25b8a3..88d20f6 100644 --- a/server/src/web-templates/layout.tsx +++ b/server/src/web-templates/layout.tsx @@ -84,6 +84,7 @@ function Sidebar(props: { activeNav?: string }) {
+
); @@ -223,6 +224,7 @@ const baseStyles = { ".nav-group summary::-webkit-details-marker": { display: "none" }, ".nav-group-items": { paddingLeft: "1.25rem" }, ".nav-group-items .nav-item": { fontSize: "0.8rem", padding: "0.35rem 1rem" }, + ".tenant-switcher": { marginTop: "auto", borderTop: "1px solid #2a2a4e", paddingTop: "0.25rem" }, ".sidebar hr": { border: "none", borderTop: "1px solid #2a2a4e", margin: "0.5rem 0" }, ".topbar": { display: "flex",