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); +}