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