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