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:
@@ -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'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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user