mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-27 01:46:35 +00:00
fix: per-connection search_path + sidebar tenant dropdown
PG adapter: setSearchPath now stores schema name, runner applies SET search_path on every connection checkout. Eliminates cross-request schema bleed (previous: setSearchPath mutated shared connection state). Middleware: always set search_path (removed 'public' skip condition). Sidebar: tenant switcher dropdown at bottom, loaded via htmx from /admin/_tenant_switcher. Hidden when only one tenant. Auto-submits on change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1dbb56752c
commit
10f5cf7fac
4 changed files with 26 additions and 12 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
`<option value="${t.slug as string}"${t.slug === current ? " selected" : ""}>${t.name as string}</option>`
|
||||
).join("");
|
||||
const html = `<form method="post" action="/admin/tenants/switch" style="padding:0.5rem 1rem">
|
||||
<label style="font-size:0.75rem; color:#888; display:block; margin-bottom:0.25rem">Tenant</label>
|
||||
<select name="tenant_slug" style="width:100%; font-size:0.8rem; padding:0.25rem" onchange="this.form.submit()">${options}</select>
|
||||
</form>`;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<T>(fn: (c: PoolClient) => Promise<T>): Promise<T> {
|
||||
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<SqlValue> = []): Promise<RunResult> {
|
||||
|
|
@ -149,13 +153,10 @@ 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`);
|
||||
});
|
||||
this.searchPath = schema;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ function Sidebar(props: { activeNav?: string }) {
|
|||
<hr />
|
||||
<NavItem href="/admin/account" label="Account" icon="●" active={a === "account"} />
|
||||
<NavItem href="/admin/nodered" label="Node-RED" icon="→" active={a === "nodered"} />
|
||||
<div class="tenant-switcher" {...{"hx-get": "/admin/_tenant_switcher", "hx-trigger": "load", "hx-swap": "innerHTML"}}></div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue