diff --git a/src/app/(portal)/layout.tsx b/src/app/(portal)/layout.tsx index 91bf70b4..56b1078c 100644 --- a/src/app/(portal)/layout.tsx +++ b/src/app/(portal)/layout.tsx @@ -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 ( +
+
+

Client portal unavailable

+

+ The client portal isn't currently enabled for this site. If you were expecting to + sign in here, please contact your account manager. +

+
+
+ ); + } + // 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 diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index df497f41..07a56f6f 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -39,6 +39,33 @@ export async function isPortalEnabledForPort(portId: string): Promise { 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 { + 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: {