diff --git a/app/e2e/sso-gating.spec.ts b/app/e2e/sso-gating.spec.ts
new file mode 100644
index 00000000..fd209691
--- /dev/null
+++ b/app/e2e/sso-gating.spec.ts
@@ -0,0 +1,81 @@
+import { test, expect, ALICE } from "./fixtures";
+
+/**
+ * Verifies SSO gating on community edition (the default global-setup state —
+ * NEOBOARD_EDITION is not set).
+ *
+ * The inverse (enterprise mode exposing the SSO management UI) is tracked
+ * in #933 — it requires a second Next.js server with NEOBOARD_EDITION=enterprise,
+ * which is a substantial global-setup overhaul.
+ */
+test.describe("SSO gating — community edition", () => {
+ test.beforeEach(async ({ authPage, sidebarPage }) => {
+ await authPage.login(ALICE.email, ALICE.password);
+ await sidebarPage.navigateTo("Settings");
+ });
+
+ test("Authentication tab is NOT visible in settings nav", async ({
+ page,
+ }) => {
+ await expect(page.getByRole("button", { name: "Profile" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "API Keys" })).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Authentication" }),
+ ).toHaveCount(0);
+ });
+
+ test("/settings/authentication shows Enterprise-required empty state", async ({
+ page,
+ }) => {
+ await page.goto("/settings/authentication");
+ // The empty state component renders the feature title and description
+ await expect(page.getByText(/Single Sign-On/i)).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByText(/Enterprise feature/i)).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: /Learn about Enterprise/i }),
+ ).toBeVisible();
+ // Critical: the SSO management UI is NOT rendered
+ await expect(
+ page.getByRole("button", { name: /Add Provider/i }),
+ ).toHaveCount(0);
+ });
+
+ test("/api/sso-providers returns 402 ENTERPRISE_REQUIRED", async ({
+ page,
+ }) => {
+ // Use page.request so this rides the authenticated session set up in
+ // beforeEach + the page's connection pool (avoids the global request
+ // context occasionally racing with server startup → ECONNRESET on first
+ // call). The route gates on requireFeature("sso") before auth checks,
+ // so this 402 is independent of the session identity.
+ const res = await page.request.get("/api/sso-providers");
+ expect(res.status()).toBe(402);
+ const body = await res.json();
+ expect(body.error?.code).toBe("ENTERPRISE_REQUIRED");
+ });
+});
+
+test.describe("SSO gating — login page (community)", () => {
+ test("login page renders no SSO buttons", async ({ page }) => {
+ await page.goto("/login");
+ // Standard email/password form is present
+ await expect(page.getByLabel(/email/i)).toBeVisible();
+ await expect(page.getByLabel(/password/i)).toBeVisible();
+ // No "Sign in with X" SSO buttons (no providers configured + edition gates)
+ await expect(
+ page.getByRole("button", { name: /Sign in with/i }),
+ ).toHaveCount(0);
+ });
+
+ test("/api/auth/sso-providers returns empty array (no auth required)", async ({
+ request,
+ }) => {
+ const res = await request.get("/api/auth/sso-providers");
+ expect(res.status()).toBe(200);
+ const body = await res.json();
+ expect(body.data).toEqual([]);
+ expect(body.meta?.enforceSso).toBe(false);
+ });
+});
diff --git a/app/src/app/(dashboard)/settings/authentication/__tests__/page.test.tsx b/app/src/app/(dashboard)/settings/authentication/__tests__/page.test.tsx
index 434f5e8a..75b8cae4 100644
--- a/app/src/app/(dashboard)/settings/authentication/__tests__/page.test.tsx
+++ b/app/src/app/(dashboard)/settings/authentication/__tests__/page.test.tsx
@@ -25,6 +25,31 @@ vi.mock("@/hooks/use-sso-providers", () => ({
}),
}));
+// FeatureGate: default to rendering children (feature enabled). Override
+// `mockSsoEnabled = false` to test the disabled path.
+let mockSsoEnabled: boolean | undefined = true;
+vi.mock("@/components/feature-gate", () => ({
+ FeatureGate: ({
+ children,
+ fallback,
+ }: {
+ feature: string;
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+ }) => {
+ if (mockSsoEnabled === true) return <>{children}>;
+ return <>{fallback ?? null}>;
+ },
+}));
+
+vi.mock("@/components/enterprise-required-empty-state", () => ({
+ EnterpriseRequiredEmptyState: ({ feature }: { feature: string }) => (
+
+ Enterprise feature required: {feature}
+
+ ),
+}));
+
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/settings/authentication",
@@ -181,6 +206,23 @@ vi.mock("@neoboard/components", () => ({
describe("AuthenticationPage", () => {
beforeEach(() => {
vi.clearAllMocks();
+ mockSsoEnabled = true;
+ });
+
+ it("renders enterprise-required empty state on community edition", async () => {
+ mockSsoEnabled = false;
+ mockUseSsoProviders.mockReturnValue({ data: undefined, isLoading: false });
+
+ const { default: Page } = await import("../page");
+ render();
+
+ expect(screen.getByTestId("enterprise-required")).toBeInTheDocument();
+ expect(screen.getByTestId("enterprise-required")).toHaveAttribute(
+ "data-feature",
+ "sso",
+ );
+ // The community page must NOT render the SSO management UI
+ expect(screen.queryByText("Add SSO Provider")).not.toBeInTheDocument();
});
it("shows loading spinner when fetching", async () => {
diff --git a/app/src/app/(dashboard)/settings/authentication/page.tsx b/app/src/app/(dashboard)/settings/authentication/page.tsx
index 7ff08336..2b823ef6 100644
--- a/app/src/app/(dashboard)/settings/authentication/page.tsx
+++ b/app/src/app/(dashboard)/settings/authentication/page.tsx
@@ -40,6 +40,8 @@ import type {
SsoProviderListItem,
CreateSsoProviderInput,
} from "@/hooks/use-sso-providers";
+import { FeatureGate } from "@/components/feature-gate";
+import { EnterpriseRequiredEmptyState } from "@/components/enterprise-required-empty-state";
// ---------------------------------------------------------------------------
// Add Provider Dialog
@@ -388,6 +390,21 @@ function ProviderRow({
// ---------------------------------------------------------------------------
export default function AuthenticationPage() {
+ return (
+
+
+
+ }
+ >
+
+
+ );
+}
+
+function AuthenticationPageContent() {
const [createOpen, setCreateOpen] = useState(false);
const { data: providers = [], isLoading } = useSsoProviders();
const deleteMutation = useDeleteSsoProvider();
diff --git a/app/src/app/(dashboard)/settings/layout.tsx b/app/src/app/(dashboard)/settings/layout.tsx
index a2d8dd3f..6d473ac0 100644
--- a/app/src/app/(dashboard)/settings/layout.tsx
+++ b/app/src/app/(dashboard)/settings/layout.tsx
@@ -2,11 +2,25 @@
import { useRouter, usePathname } from "next/navigation";
import { User, KeyRound, Shield } from "lucide-react";
+import { useFeature, type FeatureId } from "@/hooks/use-features";
-const tabs = [
+interface Tab {
+ href: string;
+ label: string;
+ icon: typeof User;
+ /** When set, the tab is only rendered if this feature is enabled. */
+ requiresFeature?: FeatureId;
+}
+
+const tabs: Tab[] = [
{ href: "/settings/profile", label: "Profile", icon: User },
{ href: "/settings/api-keys", label: "API Keys", icon: KeyRound },
- { href: "/settings/authentication", label: "Authentication", icon: Shield },
+ {
+ href: "/settings/authentication",
+ label: "Authentication",
+ icon: Shield,
+ requiresFeature: "sso",
+ },
];
export default function SettingsLayout({
@@ -16,12 +30,23 @@ export default function SettingsLayout({
}) {
const router = useRouter();
const pathname = usePathname();
+ // Subscribe to features once at layout level; useFeature returns undefined
+ // during the initial load — we hide gated tabs in that window to avoid a
+ // flicker of enterprise UI on community installs.
+ const ssoEnabled = useFeature("sso");
+
+ const visibleTabs = tabs.filter((t) => {
+ if (!t.requiresFeature) return true;
+ if (t.requiresFeature === "sso") return ssoEnabled === true;
+ // Unknown feature gate: hide by default (safer than leak).
+ return false;
+ });
return (
}>
+ child content
+ ,
+ );
+ expect(screen.queryByText("child content")).not.toBeInTheDocument();
+ expect(screen.getByText("loading-or-disabled")).toBeInTheDocument();
+ });
+
+ it("renders nothing during initial load when hideOnLoading=false", () => {
+ mockUseFeature.mockReturnValue(undefined);
+ const { container } = render(
+ upgrade pls}
+ hideOnLoading={false}
+ >
+ child content
+ ,
+ );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("passes the feature id to useFeature", () => {
+ mockUseFeature.mockReturnValue(true);
+ render(
+
+ x
+ ,
+ );
+ expect(mockUseFeature).toHaveBeenCalledWith("custom-roles");
+ });
+});
diff --git a/app/src/components/enterprise-required-empty-state.tsx b/app/src/components/enterprise-required-empty-state.tsx
new file mode 100644
index 00000000..038cf84e
--- /dev/null
+++ b/app/src/components/enterprise-required-empty-state.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { Lock } from "lucide-react";
+import { EmptyState, Button } from "@neoboard/components";
+import type { FeatureId } from "@/hooks/use-features";
+
+interface EnterpriseRequiredEmptyStateProps {
+ readonly feature: FeatureId;
+ /** Override the auto-generated title (defaults to the feature label). */
+ readonly title?: string;
+ /** Override the auto-generated description. */
+ readonly description?: string;
+ /** Override the upgrade link target. */
+ readonly upgradeUrl?: string;
+}
+
+/**
+ * Reusable empty state shown when an admin lands on a page that's gated
+ * behind an enterprise feature. Used by FeatureGate's `fallback` prop on
+ * pages that should still be reachable on community (for upsell), as
+ * opposed to those that should be hidden entirely from navigation.
+ */
+const FEATURE_LABELS: Record<
+ FeatureId,
+ { title: string; description: string }
+> = {
+ sso: {
+ title: "Single Sign-On",
+ description:
+ "Configure OIDC providers to let your team sign in with their existing identity provider (Okta, Azure AD, Google Workspace, Keycloak, etc.).",
+ },
+ "custom-roles": {
+ title: "Custom Roles",
+ description:
+ "Define roles beyond admin/creator/reader with fine-grained permissions.",
+ },
+ "user-groups": {
+ title: "User Groups",
+ description: "Organise users into groups and assign permissions by group.",
+ },
+ "connector-labels": {
+ title: "Connector Labels",
+ description: "Tag and filter database connections with custom labels.",
+ },
+ "connector-alias": {
+ title: "Connector Alias",
+ description:
+ "Define environment-specific aliases for the same logical connector.",
+ },
+ "environment-selector": {
+ title: "Environment Selector",
+ description:
+ "Switch dashboards between staging / production data sources without rebuilding.",
+ },
+ "bulk-import": {
+ title: "Bulk Import",
+ description: "Import dashboards, users, and connections from CSV or JSON.",
+ },
+ "dashboard-sharing-links": {
+ title: "Dashboard Sharing Links",
+ description: "Generate signed, expiring share links for external viewers.",
+ },
+ impersonation: {
+ title: "User Impersonation",
+ description: "Sign in as another user for support and troubleshooting.",
+ },
+ "session-management": {
+ title: "Session Management",
+ description: "View and revoke active sessions across your tenant.",
+ },
+ "ast-completion": {
+ title: "AST-Based Query Completion",
+ description:
+ "Smarter Cypher/SQL completion powered by schema-aware AST parsing.",
+ },
+};
+
+export function EnterpriseRequiredEmptyState({
+ feature,
+ title,
+ description,
+ upgradeUrl = "https://neoboard.app/enterprise",
+}: EnterpriseRequiredEmptyStateProps) {
+ const defaults = FEATURE_LABELS[feature];
+ return (
+ }
+ title={title ?? `${defaults.title} is an Enterprise feature`}
+ description={description ?? defaults.description}
+ action={
+
+ }
+ />
+ );
+}
diff --git a/app/src/components/feature-gate.tsx b/app/src/components/feature-gate.tsx
new file mode 100644
index 00000000..3f198e84
--- /dev/null
+++ b/app/src/components/feature-gate.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useFeature, type FeatureId } from "@/hooks/use-features";
+
+interface FeatureGateProps {
+ readonly feature: FeatureId;
+ /** Rendered when the feature is enabled. */
+ readonly children: ReactNode;
+ /** Rendered when the feature is NOT enabled or still loading. Defaults to nothing. */
+ readonly fallback?: ReactNode;
+ /**
+ * When true, render the fallback during the initial load (recommended for
+ * UI that would flash an enterprise-only surface before the features list
+ * loads). Default: true.
+ */
+ readonly hideOnLoading?: boolean;
+}
+
+/**
+ * Declarative client-side enterprise feature gate.
+ *
+ * - Reads from `useFeature(feature)` (TanStack Query, 5-min cache)
+ * - Renders `children` only when the feature is enabled
+ * - Renders `fallback` (default: nothing) when disabled OR still loading
+ *
+ * For server-side gating, use `requireFeature(feature)` in API route handlers.
+ *
+ * @example
+ * }>
+ *
+ *
+ */
+export function FeatureGate({
+ feature,
+ children,
+ fallback = null,
+ hideOnLoading = true,
+}: FeatureGateProps) {
+ const enabled = useFeature(feature);
+ if (enabled === undefined) {
+ return <>{hideOnLoading ? fallback : null}>;
+ }
+ if (!enabled) {
+ return <>{fallback}>;
+ }
+ return <>{children}>;
+}
diff --git a/app/src/hooks/__tests__/use-features.test.ts b/app/src/hooks/__tests__/use-features.test.ts
new file mode 100644
index 00000000..7e8c4487
--- /dev/null
+++ b/app/src/hooks/__tests__/use-features.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// Mock React Query — we test the fetch logic, not React wiring
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: vi.fn((config: Record) => config),
+}));
+
+const { useFeatures, useFeature } = await import("../use-features");
+
+function mockResponse(body: unknown, status = 200): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(body),
+ } as Response;
+}
+
+describe("useFeatures", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("calls /api/features and unwraps the envelope", async () => {
+ const payload = {
+ edition: "enterprise",
+ features: ["sso", "custom-roles"],
+ };
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
+ mockResponse({ data: payload, error: null, meta: null }),
+ );
+ const config = useFeatures() as unknown as {
+ queryFn: () => Promise;
+ queryKey: unknown[];
+ staleTime: number;
+ };
+ const result = await config.queryFn();
+ expect(result).toEqual(payload);
+ expect(globalThis.fetch).toHaveBeenCalledWith("/api/features");
+ });
+
+ it("uses queryKey ['features'] and 5-minute staleTime", () => {
+ const config = useFeatures() as unknown as {
+ queryKey: unknown[];
+ staleTime: number;
+ };
+ expect(config.queryKey).toEqual(["features"]);
+ expect(config.staleTime).toBe(5 * 60 * 1000);
+ });
+});
+
+describe("useFeature", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns undefined while features are loading", async () => {
+ const reactQuery = await import("@tanstack/react-query");
+ vi.mocked(reactQuery.useQuery).mockReturnValueOnce({
+ data: undefined,
+ } as ReturnType);
+ expect(useFeature("sso")).toBeUndefined();
+ });
+
+ it("returns true when the feature is in the list", async () => {
+ const reactQuery = await import("@tanstack/react-query");
+ vi.mocked(reactQuery.useQuery).mockReturnValueOnce({
+ data: { edition: "enterprise", features: ["sso", "user-groups"] },
+ } as ReturnType);
+ expect(useFeature("sso")).toBe(true);
+ });
+
+ it("returns false when the feature is not in the list", async () => {
+ const reactQuery = await import("@tanstack/react-query");
+ vi.mocked(reactQuery.useQuery).mockReturnValueOnce({
+ data: { edition: "community", features: [] },
+ } as ReturnType);
+ expect(useFeature("sso")).toBe(false);
+ });
+
+ it("returns false on community edition for every gated feature", async () => {
+ const reactQuery = await import("@tanstack/react-query");
+ vi.mocked(reactQuery.useQuery).mockReturnValue({
+ data: { edition: "community", features: [] },
+ } as ReturnType);
+ expect(useFeature("sso")).toBe(false);
+ expect(useFeature("custom-roles")).toBe(false);
+ expect(useFeature("bulk-import")).toBe(false);
+ });
+});
diff --git a/app/src/hooks/use-features.ts b/app/src/hooks/use-features.ts
new file mode 100644
index 00000000..c9dbb6ca
--- /dev/null
+++ b/app/src/hooks/use-features.ts
@@ -0,0 +1,57 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { unwrapResponse } from "@/lib/api/api-client";
+
+export type Edition = "community" | "enterprise";
+
+export type FeatureId =
+ | "sso"
+ | "custom-roles"
+ | "user-groups"
+ | "connector-labels"
+ | "connector-alias"
+ | "environment-selector"
+ | "bulk-import"
+ | "dashboard-sharing-links"
+ | "impersonation"
+ | "session-management"
+ | "ast-completion";
+
+export interface FeaturesResponse {
+ edition: Edition;
+ features: FeatureId[];
+}
+
+/**
+ * Reads the current edition + enabled feature list from `/api/features`.
+ *
+ * Backed by TanStack Query with a 5-minute staleTime. Edition is an
+ * honour-based env flag (`NEOBOARD_EDITION`) — operators flip it server-
+ * side, restart, and the next request reflects the new value. A 5-minute
+ * client cache is acceptable for this cadence; if you need an immediate
+ * reaction to a flip, invalidate the `["features"]` query.
+ */
+export function useFeatures() {
+ return useQuery({
+ queryKey: ["features"],
+ queryFn: async () => {
+ const res = await fetch("/api/features");
+ return unwrapResponse(res);
+ },
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+/**
+ * Convenience: `useFeature("sso")` returns `true | false | undefined`.
+ *
+ * `undefined` means the features list hasn't loaded yet — callers
+ * should treat it the same as "feature absent" for gating UX (don't
+ * flash enterprise UI during the initial load).
+ */
+export function useFeature(id: FeatureId): boolean | undefined {
+ const { data } = useFeatures();
+ if (!data) return undefined;
+ return data.features.includes(id);
+}