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

@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import { getPortalSession } from '@/lib/portal/auth';
import { getPortalDashboard } from '@/lib/services/portal.service';
import { isPortalDisabledGlobally } from '@/lib/services/portal-auth.service';
import { PortalHeader } from '@/components/portal/portal-header';
import { PortalNav } from '@/components/portal/portal-nav';
@@ -13,6 +14,25 @@ export const metadata: Metadata = {
};
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
// Route-level kill switch. When every port has client_portal_enabled=false,
// surface a clean "Portal not available" notice instead of letting the
// login form render (it would just reject every submit with a confusing
// ConflictError). Single-port deployments effectively get a global toggle
// out of the admin System Settings UI.
if (await isPortalDisabledGlobally()) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md text-center space-y-3">
<h1 className="text-2xl font-semibold text-gray-900">Client portal unavailable</h1>
<p className="text-sm text-gray-600">
The client portal isn&apos;t currently enabled for this site. If you were expecting to
sign in here, please contact your account manager.
</p>
</div>
</div>
);
}
// This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down - login/verify pages handle their own

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: {