feat(portal): route-level gate when client_portal_enabled is off

Adds isPortalDisabledGlobally() helper that returns true when every
configured per-port client_portal_enabled row is false. The (portal)
layout calls it and renders a "Portal not available" notice instead of
the login/activate/reset pages when the kill switch is flipped.

Closes the gap where flipping the admin System Settings toggle would
leave /portal/login publicly reachable as a form that rejects every
submit with a ConflictError. Now a clean notice page appears instead.

Single-port deployments get a global toggle out of this — the existing
per-port admin UI in System Settings effectively becomes the master
switch. Multi-port future will need URL-level port discrimination
(subdomain or path prefix) before the all-ports-off heuristic should
be replaced with a per-port resolution.

API routes (/api/portal/*) stay on the existing service-layer gate
(every portal-auth function checks isPortalEnabledForPort). Direct
curl gets a per-call ConflictError, which is acceptable for non-human
clients; the UI gate is what matters for accidental discovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 14:47:46 +02:00
parent d597e158fe
commit 76a57b1d6f
2 changed files with 47 additions and 0 deletions

View File

@@ -39,6 +39,33 @@ export async function isPortalEnabledForPort(portId: string): Promise<boolean> {
return row.value === true || row.value === 'true';
}
/**
* Route-level gate for the (portal) layout. Returns true when every
* configured per-port `client_portal_enabled` row evaluates to false,
* i.e. the portal is off everywhere. In single-port deployments this is
* exactly "the portal is off" (the admin toggle in System Settings).
*
* Used to render a "Portal not available" notice instead of the login
* form when the kill switch is flipped, so guessed `/portal/*` URLs
* don't surface a working-looking form that just rejects every submit.
*
* Default-OFF (returns false) when there are no setting rows — preserves
* the legacy default-on behaviour for fresh installs / ports that never
* touched the setting.
*
* For future multi-port routing (subdomain-per-port or path-prefix),
* callers should pass the resolved portId to `isPortalEnabledForPort`
* instead and not rely on the all-ports-off heuristic here.
*/
export async function isPortalDisabledGlobally(): Promise<boolean> {
const rows = await db
.select({ value: systemSettings.value })
.from(systemSettings)
.where(eq(systemSettings.key, PORTAL_ENABLED_KEY));
if (rows.length === 0) return false;
return rows.every((r) => r.value === false || r.value === 'false');
}
// ─── Admin-side: invite a client to the portal ───────────────────────────────
export async function createPortalUser(args: {