diff --git a/e2e/suites/interactions/admin/rate-limits.spec.ts b/e2e/suites/interactions/admin/rate-limits.spec.ts new file mode 100644 index 00000000..eec8a512 --- /dev/null +++ b/e2e/suites/interactions/admin/rate-limits.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '@playwright/test'; + +/** + * Admin UI for rate-limit exemption management (issue #270). + * + * The page lives at /rate-limits and lets an admin view the effective rate + * limits and add/remove exemptions for users, service accounts, and CIDR + * ranges. The backend exemption-management endpoints may not be present on + * every server, so the page is designed to degrade: the config card and the + * exemptions section both render an "unavailable" state instead of crashing. + * These tests assert the UI surface and the add/remove flow, tolerating a + * backend that has not shipped the endpoints yet. + */ +test.describe('Rate limit admin', () => { + const consoleErrors: string[] = []; + + test.beforeEach(async ({ page }) => { + consoleErrors.length = 0; + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + await page.goto('/rate-limits'); + await page.waitForLoadState('domcontentloaded'); + }); + + test('page loads with Rate Limits heading', async ({ page }) => { + await expect( + page.getByRole('heading', { name: /rate limits/i }).first() + ).toBeVisible({ timeout: 15000 }); + }); + + test('shows the current rate limits and exemptions sections', async ({ page }) => { + await expect( + page.getByText(/current rate limits/i).first() + ).toBeVisible({ timeout: 10000 }); + await expect( + page.getByText(/exemptions/i).first() + ).toBeVisible({ timeout: 10000 }); + }); + + test('Add Exemption button opens a dialog with type, value, and note fields', async ({ page }) => { + const addBtn = page.getByRole('button', { name: /add exemption/i }).first(); + await expect(addBtn).toBeVisible({ timeout: 10000 }); + await addBtn.click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Type selector (combobox) plus value and note inputs. + await expect(dialog.getByRole('combobox').first()).toBeVisible(); + await expect(dialog.locator('#exemption-value')).toBeVisible(); + await expect(dialog.locator('#exemption-note')).toBeVisible(); + + // Cancel closes the dialog without creating anything. + await dialog.getByRole('button', { name: /cancel/i }).click(); + await expect(dialog).toBeHidden({ timeout: 5000 }); + }); + + test('invalid CIDR is rejected client-side', async ({ page }) => { + await page.getByRole('button', { name: /add exemption/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Switch the type to CIDR. + await dialog.getByRole('combobox').first().click(); + const cidrOption = page.getByRole('option', { name: /cidr/i }); + await cidrOption.click(); + + await dialog.locator('#exemption-value').fill('not-a-valid-cidr'); + await dialog.getByRole('button', { name: /^add exemption$/i }).click(); + + // A validation toast appears and the dialog stays open. + await expect(page.getByText(/valid cidr/i).first()).toBeVisible({ timeout: 8000 }); + await expect(dialog).toBeVisible(); + + await dialog.getByRole('button', { name: /cancel/i }).click(); + }); + + test('add then remove a username exemption (when the backend supports it)', async ({ page }) => { + const username = `e2e-exempt-${Date.now()}`; + + await page.getByRole('button', { name: /add exemption/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Default type is username; just fill the value and submit. + await dialog.locator('#exemption-value').fill(username); + await dialog.locator('#exemption-note').fill('e2e test exemption'); + await dialog.getByRole('button', { name: /^add exemption$/i }).click(); + + // Either the backend accepts it (row appears) or it has no exemption + // endpoint (failure toast). Both are acceptable, but a success must round + // trip to a removable row. + const newRow = page.getByRole('row', { name: new RegExp(username) }); + const created = await newRow + .isVisible({ timeout: 8000 }) + .catch(() => false); + + if (!created) { + test.skip(true, 'Backend does not expose rate-limit exemption management'); + return; + } + + // Remove it and confirm in the alert dialog. + await newRow.getByRole('button', { name: new RegExp(`remove exemption ${username}`, 'i') }).click(); + const confirm = page.getByRole('alertdialog'); + await expect(confirm).toBeVisible({ timeout: 8000 }); + await confirm.getByRole('button', { name: /^remove$/i }).click(); + + await expect(newRow).toHaveCount(0, { timeout: 10000 }); + }); + + test('no uncaught console errors on load', async ({ page }) => { + await page.waitForTimeout(1500); + const fatal = consoleErrors.filter( + (e) => !/favicon|hydrat|ResizeObserver/i.test(e) + ); + expect(fatal, fatal.join('\n')).toHaveLength(0); + }); +}); diff --git a/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts b/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts new file mode 100644 index 00000000..993bf7e1 --- /dev/null +++ b/e2e/suites/interactions/admin/system-config-feature-flags.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test'; + +/** + * Feature-flag gating driven by GET /api/v1/system/config (issue #271). + * + * The web app fetches the backend's public runtime configuration once and uses + * it to decide which scanner-dependent surfaces to show and to advertise the + * upload-size limit. These tests verify the contract end to end against the + * running backend, then check that the UI reflects what the backend reports. + */ +test.describe('System config feature flags', () => { + test('public system config endpoint returns the expected shape', async ({ request }) => { + const resp = await request.get('/api/v1/system/config'); + expect(resp.ok(), `system config request failed: ${resp.status()}`).toBeTruthy(); + + const body = await resp.json(); + // Top-level fields the web app relies on. + expect(typeof body.max_upload_size_bytes).toBe('number'); + expect(typeof body.demo_mode).toBe('boolean'); + expect(typeof body.guest_access_enabled).toBe('boolean'); + expect(typeof body.search_engine).toBe('string'); + expect(typeof body.storage_backend).toBe('string'); + + // Nested scanner / auth flag groups used for navigation gating. + expect(body.scanners).toBeTruthy(); + expect(typeof body.scanners.trivy_enabled).toBe('boolean'); + expect(typeof body.scanners.openscap_enabled).toBe('boolean'); + expect(typeof body.scanners.dependency_track_enabled).toBe('boolean'); + expect(body.auth).toBeTruthy(); + expect(typeof body.auth.oidc_enabled).toBe('boolean'); + expect(typeof body.auth.sso_enabled).toBe('boolean'); + }); + + test('scanner nav entries match the backend scanner flags', async ({ page, request }) => { + const resp = await request.get('/api/v1/system/config'); + expect(resp.ok()).toBeTruthy(); + const config = await resp.json(); + + await page.goto('/'); + await page.waitForLoadState('domcontentloaded'); + + // The sidebar only renders for an authenticated admin; confirm it is there. + const nav = page.getByRole('navigation').first(); + await expect(nav).toBeVisible({ timeout: 15000 }); + + // "Scan Results" is gated on Trivy or OpenSCAP being configured. + const scanResults = nav.getByRole('link', { name: /scan results/i }); + const scannersOn = config.scanners.trivy_enabled || config.scanners.openscap_enabled; + if (scannersOn) { + await expect(scanResults).toBeVisible({ timeout: 10000 }); + } else { + await expect(scanResults).toHaveCount(0); + } + + // "DT Projects" is gated on the Dependency-Track integration. + const dtProjects = nav.getByRole('link', { name: /dt projects/i }); + if (config.scanners.dependency_track_enabled) { + await expect(dtProjects).toBeVisible({ timeout: 10000 }); + } else { + await expect(dtProjects).toHaveCount(0); + } + }); + + test('upload dialog advertises the configured max upload size', async ({ page, request }) => { + const resp = await request.get('/api/v1/system/config'); + expect(resp.ok()).toBeTruthy(); + const config = await resp.json(); + + // Only meaningful when the backend advertises a non-zero limit. + test.skip( + !config.max_upload_size_bytes || config.max_upload_size_bytes === 0, + 'Server advertises no upload size limit' + ); + + await page.goto('/repositories/e2e-maven-local'); + await page.waitForLoadState('domcontentloaded'); + + const uploadTab = page.getByRole('tab', { name: /upload/i }); + if (!(await uploadTab.isVisible({ timeout: 8000 }).catch(() => false))) { + test.skip(true, 'Upload tab not available for this repository'); + return; + } + await uploadTab.click(); + + // The dropzone helper text includes "max " derived from system config. + await expect(page.getByText(/max\s/i).first()).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/src/app/(app)/(admin)/rate-limits/page.tsx b/src/app/(app)/(admin)/rate-limits/page.tsx new file mode 100644 index 00000000..e1d17df5 --- /dev/null +++ b/src/app/(app)/(admin)/rate-limits/page.tsx @@ -0,0 +1,464 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/providers/auth-provider"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Gauge, Info, Plus, Trash2, Loader2, User, Bot, Network } from "lucide-react"; + +import { + rateLimitsApi, + validateExemption, + type ExemptionType, + type RateLimitConfig, + type RateLimitExemption, +} from "@/lib/api/rate-limits"; +import { mutationErrorToast } from "@/lib/error-utils"; + +import { PageHeader } from "@/components/common/page-header"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export const RATE_LIMITS_QUERY_KEY = ["rate-limits"] as const; +export const RATE_LIMIT_EXEMPTIONS_QUERY_KEY = ["rate-limit-exemptions"] as const; + +const TYPE_META: Record< + ExemptionType, + { label: string; icon: React.ComponentType<{ className?: string }>; placeholder: string } +> = { + username: { label: "Username", icon: User, placeholder: "ci-bot" }, + service_account: { label: "Service account", icon: Bot, placeholder: "deploy-sa" }, + cidr: { label: "CIDR range", icon: Network, placeholder: "10.0.0.0/8" }, +}; + +function rate(window: { limit: number; window_secs: number }): string { + if (window.window_secs <= 0) return `${window.limit} requests`; + return `${window.limit} requests / ${window.window_secs}s`; +} + +// -- Current configuration card -- + +function ConfigCard({ + config, + isLoading, + isError, +}: { + config: RateLimitConfig | undefined; + isLoading: boolean; + isError: boolean; +}) { + return ( + + + Current Rate Limits + + Effective per-window request limits. Configured via environment + variables and shown read-only. + + + + {isLoading && ( +
+ +
+ )} + {!isLoading && (isError || !config) && ( +

+ Rate-limit configuration is not available from this server. +

+ )} + {!isLoading && config && ( +
+
+
Authentication
+
{rate(config.auth)}
+
+
+
API
+
{rate(config.api)}
+
+
+
Search
+
{rate(config.search)}
+
+
+
+ Service accounts globally exempt +
+
+ {config.exempt_service_accounts ? "Yes" : "No"} +
+
+
+ )} +
+
+ ); +} + +// -- Add exemption dialog -- + +function AddExemptionDialog() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [type, setType] = useState("username"); + const [value, setValue] = useState(""); + const [note, setNote] = useState(""); + + const addMutation = useMutation({ + mutationFn: () => rateLimitsApi.addExemption({ type, value, note }), + onSuccess: () => { + toast.success("Exemption added"); + queryClient.invalidateQueries({ queryKey: RATE_LIMIT_EXEMPTIONS_QUERY_KEY }); + setOpen(false); + setValue(""); + setNote(""); + setType("username"); + }, + onError: mutationErrorToast("Failed to add exemption"), + }); + + function handleSubmit() { + const error = validateExemption({ type, value, note }); + if (error) { + toast.error(error); + return; + } + addMutation.mutate(); + } + + const meta = TYPE_META[type]; + + return ( + + + + + + + Add Rate-Limit Exemption + + Exempt a user, service account, or network range from rate limiting. + Use sparingly, exemptions weaken abuse protection. + + +
+
+ + +
+
+ + setValue(e.target.value)} + /> +
+
+ + setNote(e.target.value)} + /> +
+
+ + + + +
+
+ ); +} + +// -- Exemptions table -- + +function ExemptionsTable({ + exemptions, + isLoading, + isError, +}: { + exemptions: RateLimitExemption[] | undefined; + isLoading: boolean; + isError: boolean; +}) { + const queryClient = useQueryClient(); + const [pendingDelete, setPendingDelete] = useState(null); + + const removeMutation = useMutation({ + mutationFn: (id: string) => rateLimitsApi.removeExemption(id), + onSuccess: () => { + toast.success("Exemption removed"); + queryClient.invalidateQueries({ queryKey: RATE_LIMIT_EXEMPTIONS_QUERY_KEY }); + setPendingDelete(null); + }, + onError: mutationErrorToast("Failed to remove exemption"), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + Exemptions unavailable + + Unable to load rate-limit exemptions. This server may not support + managing exemptions through the UI yet. + + + ); + } + + if (!exemptions || exemptions.length === 0) { + return ( +

+ No rate-limit exemptions configured. +

+ ); + } + + return ( + <> + + + + Type + Value + Note + Source + + + + + {exemptions.map((ex) => { + const meta = TYPE_META[ex.type]; + const Icon = meta.icon; + return ( + + + + + {meta.label} + + + {ex.value} + + {ex.note || "—"} + + + {ex.source_env ? ( + Environment + ) : ( + Manual + )} + + + + + + ); + })} + +
+ + !o && setPendingDelete(null)} + > + + + Remove exemption? + + {pendingDelete && + `${TYPE_META[pendingDelete.type].label} "${pendingDelete.value}" will be subject to rate limits again.`} + + + + + Cancel + + { + e.preventDefault(); + if (pendingDelete) removeMutation.mutate(pendingDelete.id); + }} + disabled={removeMutation.isPending} + > + {removeMutation.isPending && ( + + )} + Remove + + + + + + ); +} + +// -- Page -- + +export default function RateLimitsPage() { + const { user } = useAuth(); + + const configQuery = useQuery({ + queryKey: RATE_LIMITS_QUERY_KEY, + queryFn: () => rateLimitsApi.getConfig(), + retry: false, + staleTime: 5 * 60 * 1000, + }); + + const exemptionsQuery = useQuery({ + queryKey: RATE_LIMIT_EXEMPTIONS_QUERY_KEY, + queryFn: () => rateLimitsApi.listExemptions(), + retry: false, + staleTime: 60 * 1000, + }); + + if (!user?.is_admin) { + return ( +
+ + + Access Denied + + You must be an administrator to manage rate-limit exemptions. + + +
+ ); + } + + return ( +
+ + + + } + /> + + + + About exemptions + + Exempt principals bypass rate limiting entirely. Entries marked + Environment come from server configuration and are read-only here. + Manual entries can be added and removed below. + + + + + + + +
+ Exemptions + + Users, service accounts, and CIDR ranges that bypass rate limiting. + +
+ +
+ + + +
+
+ ); +} diff --git a/src/app/(app)/repositories/_components/repo-detail-content.tsx b/src/app/(app)/repositories/_components/repo-detail-content.tsx index c5309209..d70f65da 100644 --- a/src/app/(app)/repositories/_components/repo-detail-content.tsx +++ b/src/app/(app)/repositories/_components/repo-detail-content.tsx @@ -44,6 +44,7 @@ import { QuarantineBanner } from "@/components/common/quarantine-banner"; import { RepoSettingsTab } from "./repo-settings-tab"; import { formatBytes, REPO_TYPE_COLORS } from "@/lib/utils"; import { useAuth } from "@/providers/auth-provider"; +import { useSystemConfig } from "@/providers/system-config-provider"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -95,6 +96,7 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon const searchParams = useSearchParams(); const queryClient = useQueryClient(); const { isAuthenticated, user } = useAuth(); + const { config: systemConfig } = useSystemConfig(); // artifact search / pagination const [searchQuery, setSearchQuery] = useState(""); @@ -697,6 +699,7 @@ export function RepoDetailContent({ repoKey, standalone = false }: RepoDetailCon showPathInput repositoryKey={repoKey} onChunkedComplete={handleChunkedComplete} + maxUploadSizeBytes={systemConfig.max_upload_size_bytes} /> diff --git a/src/components/common/file-upload.tsx b/src/components/common/file-upload.tsx index de825d72..0f7378cf 100644 --- a/src/components/common/file-upload.tsx +++ b/src/components/common/file-upload.tsx @@ -34,6 +34,13 @@ interface FileUploadProps { chunkedThreshold?: number; /** Called when chunked upload completes */ onChunkedComplete?: () => void; + /** + * Server-enforced maximum upload size in bytes, sourced from + * `/api/v1/system/config` (#271). When greater than 0 the limit is shown to + * the user and oversize files are rejected client-side before any request is + * sent. 0 or undefined means "no limit advertised". + */ + maxUploadSizeBytes?: number; } function formatSpeed(bytesPerSecond: number): string { @@ -102,6 +109,7 @@ export function FileUpload({ chunkSize, chunkedThreshold = 100 * 1024 * 1024, onChunkedComplete, + maxUploadSizeBytes = 0, }: FileUploadProps) { const [file, setFile] = useState(null); const [customPath, setCustomPath] = useState(""); @@ -136,18 +144,30 @@ export function FileUpload({ const handleFile = useCallback( (f: File) => { - setFile(f); setSimpleProgress(0); setShowResumePrompt(false); setError(null); + // Reject files over the server-advertised limit before selecting them, + // so the user gets immediate feedback instead of a failed upload (#271). + if (maxUploadSizeBytes > 0 && f.size > maxUploadSizeBytes) { + setFile(null); + setError( + `File is ${formatBytes(f.size)}, which exceeds the maximum upload size of ${formatBytes(maxUploadSizeBytes)}.` + ); + if (inputRef.current) inputRef.current.value = ""; + return; + } + + setFile(f); + if (repositoryKey && f.size >= chunkedThreshold) { if (chunked.hasPendingSession(f)) { setShowResumePrompt(true); } } }, - [repositoryKey, chunkedThreshold, chunked] + [repositoryKey, chunkedThreshold, chunked, maxUploadSizeBytes] ); const handleDrop = useCallback( @@ -316,6 +336,9 @@ export function FileUpload({

Upload a single artifact file + {maxUploadSizeBytes > 0 && ( + <> (max {formatBytes(maxUploadSizeBytes)}) + )}

diff --git a/src/components/layout/__tests__/app-sidebar.test.tsx b/src/components/layout/__tests__/app-sidebar.test.tsx index 43ca1841..c069f317 100644 --- a/src/components/layout/__tests__/app-sidebar.test.tsx +++ b/src/components/layout/__tests__/app-sidebar.test.tsx @@ -32,6 +32,11 @@ vi.mock("@/providers/auth-provider", () => ({ useAuth: () => mockUseAuth(), })); +const mockUseFeatureFlags = vi.fn(); +vi.mock("@/providers/system-config-provider", () => ({ + useFeatureFlags: () => mockUseFeatureFlags(), +})); + const mockUseQuery = vi.fn(); vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: any) => mockUseQuery(opts), @@ -90,9 +95,24 @@ vi.mock("lucide-react", () => { Scale: icon, FolderSearch: icon, ClipboardCheck: icon, + Gauge: icon, }; }); +// Feature flags drive scanner-dependent nav visibility (#271). Default to all +// scanners enabled so existing assertions about the Security group still hold. +const ALL_FLAGS_ON = { + scanningEnabled: true, + trivyEnabled: true, + openscapEnabled: true, + dependencyTrackEnabled: true, + ssoEnabled: false, + oidcEnabled: false, + ldapEnabled: false, + guestAccessEnabled: true, + demoMode: false, +}; + // --------------------------------------------------------------------------- // Component under test // --------------------------------------------------------------------------- @@ -126,6 +146,7 @@ describe("AppSidebar", () => { vi.clearAllMocks(); process.env.NEXT_PUBLIC_APP_VERSION = "1.1.0"; mockUseQuery.mockReturnValue({ data: undefined }); + mockUseFeatureFlags.mockReturnValue(ALL_FLAGS_ON); }); it("shows web version only when health data is not available", () => { @@ -245,4 +266,47 @@ describe("AppSidebar", () => { expect(screen.getByText("Operations")).toBeDefined(); expect(screen.getByText("Administration")).toBeDefined(); }); + + it("hides Scan Results and DT Projects when no scanner is configured (#271)", () => { + authState({ isAuthenticated: true, isAdmin: true }); + mockUseFeatureFlags.mockReturnValue({ + ...ALL_FLAGS_ON, + scanningEnabled: false, + trivyEnabled: false, + openscapEnabled: false, + dependencyTrackEnabled: false, + }); + + render(); + + // The Security group still renders (policies, permissions, quality gates), + // but the scanner-dependent entries are gone. + expect(screen.getByText("Security")).toBeDefined(); + expect(screen.queryByText("Scan Results")).toBeNull(); + expect(screen.queryByText("DT Projects")).toBeNull(); + expect(screen.getByText("Quality Gates")).toBeDefined(); + }); + + it("shows DT Projects only when Dependency-Track is enabled (#271)", () => { + authState({ isAuthenticated: true, isAdmin: true }); + mockUseFeatureFlags.mockReturnValue({ + ...ALL_FLAGS_ON, + trivyEnabled: false, + openscapEnabled: false, + dependencyTrackEnabled: true, + }); + + render(); + + expect(screen.getByText("DT Projects")).toBeDefined(); + expect(screen.queryByText("Scan Results")).toBeNull(); + }); + + it("renders the Rate Limits admin entry (#270)", () => { + authState({ isAuthenticated: true, isAdmin: true }); + + render(); + + expect(screen.getByText("Rate Limits")).toBeDefined(); + }); }); diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 12cbe8f2..7f82648e 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -35,9 +35,11 @@ import { Scale, FolderSearch, ClipboardCheck, + Gauge, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/providers/auth-provider"; +import { useFeatureFlags } from "@/providers/system-config-provider"; import { adminApi } from "@/lib/api/admin"; import { Sidebar, @@ -102,6 +104,7 @@ const adminItems: NavItem[] = [ { title: "Users", href: "/users", icon: Users }, { title: "Groups", href: "/groups", icon: UsersRound }, { title: "Service Accounts", href: "/service-accounts", icon: Bot }, + { title: "Rate Limits", href: "/rate-limits", icon: Gauge }, { title: "Backups", href: "/backups", icon: HardDrive }, { title: "SSO Providers", href: "/settings/sso", icon: KeyRound }, { title: "Settings", href: "/settings", icon: Settings }, @@ -143,6 +146,7 @@ export function AppSidebar() { const pathname = usePathname(); const { isAuthenticated, user } = useAuth(); const isAdmin = user?.is_admin ?? false; + const flags = useFeatureFlags(); const { data: health } = useQuery({ queryKey: ["health"], @@ -156,6 +160,21 @@ export function AppSidebar() { ? integrationItems : integrationItems.filter((item) => item.href !== "/migration"); + // Hide scanner-dependent security entries when the backend reports no + // scanner configured (#271). "Scan Results" needs Trivy or OpenSCAP; + // "DT Projects" needs the Dependency-Track integration. The rest of the + // Security group (policies, permissions, quality gates) is always shown + // since it doesn't depend on a scanner being wired up. + const visibleSecurityItems = securityItems.filter((item) => { + if (item.href === "/security/scans") { + return flags.trivyEnabled || flags.openscapEnabled; + } + if (item.href === "/security/dt-projects") { + return flags.dependencyTrackEnabled; + } + return true; + }); + return ( @@ -204,7 +223,7 @@ export function AppSidebar() { <> ({})); + +const mockApiFetch = vi.fn(); + +vi.mock("@/lib/api/fetch", () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), +})); + +describe("rateLimitsApi", () => { + beforeEach(() => vi.clearAllMocks()); + + it("parses an array exemptions response", async () => { + mockApiFetch.mockResolvedValue([ + { id: "1", type: "username", value: "ci-bot", source_env: true }, + { id: "2", type: "cidr", value: "10.0.0.0/8", note: "in-cluster" }, + ]); + const mod = await import("../rate-limits"); + const rows = await mod.rateLimitsApi.listExemptions(); + expect(rows).toHaveLength(2); + expect(rows[0].type).toBe("username"); + expect(rows[0].source_env).toBe(true); + expect(rows[1].note).toBe("in-cluster"); + }); + + it("parses an object-wrapped exemptions response", async () => { + mockApiFetch.mockResolvedValue({ + exemptions: [{ id: "3", type: "service_account", value: "deploy-sa" }], + }); + const mod = await import("../rate-limits"); + const rows = await mod.rateLimitsApi.listExemptions(); + expect(rows).toHaveLength(1); + expect(rows[0].value).toBe("deploy-sa"); + }); + + it("parses the rate-limit config", async () => { + mockApiFetch.mockResolvedValue({ + auth: { limit: 10, window_secs: 60 }, + api: { limit: 300, window_secs: 60 }, + search: { limit: 100, window_secs: 60 }, + exempt_service_accounts: true, + }); + const mod = await import("../rate-limits"); + const config = await mod.rateLimitsApi.getConfig(); + expect(config.api.limit).toBe(300); + expect(config.exempt_service_accounts).toBe(true); + }); + + it("posts a trimmed create payload and parses the echoed row", async () => { + mockApiFetch.mockResolvedValue({ + id: "9", + type: "username", + value: "ci-bot", + note: "build agent", + }); + const mod = await import("../rate-limits"); + const row = await mod.rateLimitsApi.addExemption({ + type: "username", + value: " ci-bot ", + note: " build agent ", + }); + expect(row.id).toBe("9"); + expect(mockApiFetch).toHaveBeenCalledWith( + "/api/v1/admin/rate-limits/exemptions", + expect.objectContaining({ method: "POST" }) + ); + const body = JSON.parse(mockApiFetch.mock.calls[0][1].body); + expect(body.value).toBe("ci-bot"); + expect(body.note).toBe("build agent"); + }); + + it("url-encodes the id when removing an exemption", async () => { + mockApiFetch.mockResolvedValue(undefined); + const mod = await import("../rate-limits"); + await mod.rateLimitsApi.removeExemption("a/b id"); + expect(mockApiFetch).toHaveBeenCalledWith( + "/api/v1/admin/rate-limits/exemptions/a%2Fb%20id", + { method: "DELETE" } + ); + }); + + it("throws on an unparseable config response", async () => { + mockApiFetch.mockResolvedValue({ auth: "bad" }); + const mod = await import("../rate-limits"); + await expect(mod.rateLimitsApi.getConfig()).rejects.toThrow(/did not match/); + }); +}); + +describe("rate-limit validation helpers", () => { + it("validates IPv4 CIDR", async () => { + const mod = await import("../rate-limits"); + expect(mod.isValidCidr("10.0.0.0/8")).toBe(true); + expect(mod.isValidCidr("192.168.1.0/24")).toBe(true); + expect(mod.isValidCidr("10.0.0.0/33")).toBe(false); + expect(mod.isValidCidr("10.0.0.256/8")).toBe(false); + expect(mod.isValidCidr("10.0.0.0")).toBe(false); + }); + + it("validates IPv6 CIDR", async () => { + const mod = await import("../rate-limits"); + expect(mod.isValidCidr("2001:db8::/32")).toBe(true); + expect(mod.isValidCidr("2001:db8::/129")).toBe(false); + }); + + it("rejects empty values and bad CIDR in validateExemption", async () => { + const mod = await import("../rate-limits"); + expect(mod.validateExemption({ type: "username", value: "" })).toMatch( + /required/ + ); + expect(mod.validateExemption({ type: "cidr", value: "not-a-cidr" })).toMatch( + /valid CIDR/ + ); + expect( + mod.validateExemption({ type: "username", value: "ci-bot" }) + ).toBeNull(); + }); +}); diff --git a/src/lib/api/__tests__/system-config.test.ts b/src/lib/api/__tests__/system-config.test.ts new file mode 100644 index 00000000..e85d0cd4 --- /dev/null +++ b/src/lib/api/__tests__/system-config.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/sdk-client", () => ({})); + +const mockApiFetch = vi.fn(); + +vi.mock("@/lib/api/fetch", () => ({ + apiFetch: (...args: unknown[]) => mockApiFetch(...args), +})); + +const VALID = { + max_upload_size_bytes: 10_737_418_240, + demo_mode: false, + guest_access_enabled: true, + scanners: { + trivy_enabled: true, + openscap_enabled: false, + dependency_track_enabled: false, + }, + search_engine: "opensearch", + storage_backend: "s3", + auth: { oidc_enabled: true, ldap_enabled: false, sso_enabled: true }, + oidc_issuer: "https://auth.example.com", + permissions: { rules_exist: true, enforcement_enabled: true }, +}; + +describe("systemConfigApi", () => { + beforeEach(() => vi.clearAllMocks()); + + it("parses a full config response", async () => { + mockApiFetch.mockResolvedValue(VALID); + const mod = await import("../system-config"); + const config = await mod.systemConfigApi.getConfig(); + expect(config.max_upload_size_bytes).toBe(10_737_418_240); + expect(config.scanners.trivy_enabled).toBe(true); + expect(config.auth.sso_enabled).toBe(true); + expect(config.oidc_issuer).toBe("https://auth.example.com"); + }); + + it("calls the public system config endpoint with GET", async () => { + mockApiFetch.mockResolvedValue(VALID); + const mod = await import("../system-config"); + await mod.systemConfigApi.getConfig(); + expect(mockApiFetch).toHaveBeenCalledWith("/api/v1/system/config", { + method: "GET", + }); + }); + + it("accepts a response without the optional oidc_issuer", async () => { + const { oidc_issuer: _omit, ...withoutIssuer } = VALID; + void _omit; + mockApiFetch.mockResolvedValue(withoutIssuer); + const mod = await import("../system-config"); + const config = await mod.systemConfigApi.getConfig(); + expect(config.oidc_issuer).toBeUndefined(); + }); + + it("ignores unknown forward-compatible fields", async () => { + mockApiFetch.mockResolvedValue({ ...VALID, future_flag: true }); + const mod = await import("../system-config"); + const config = await mod.systemConfigApi.getConfig(); + expect(config.storage_backend).toBe("s3"); + }); + + it("throws when required fields are missing or wrong type", async () => { + mockApiFetch.mockResolvedValue({ demo_mode: "nope" }); + const mod = await import("../system-config"); + await expect(mod.systemConfigApi.getConfig()).rejects.toThrow( + /did not match/ + ); + }); + + it("anyScannerEnabled reflects the scanner flags", async () => { + const mod = await import("../system-config"); + expect(mod.anyScannerEnabled(mod.parseSystemConfig(VALID))).toBe(true); + expect( + mod.anyScannerEnabled( + mod.parseSystemConfig({ + ...VALID, + scanners: { + trivy_enabled: false, + openscap_enabled: false, + dependency_track_enabled: false, + }, + }) + ) + ).toBe(false); + }); + + it("exposes permissive defaults", async () => { + const mod = await import("../system-config"); + expect(mod.DEFAULT_SYSTEM_CONFIG.guest_access_enabled).toBe(true); + expect(mod.anyScannerEnabled(mod.DEFAULT_SYSTEM_CONFIG)).toBe(false); + }); +}); diff --git a/src/lib/api/rate-limits.ts b/src/lib/api/rate-limits.ts new file mode 100644 index 00000000..981fafe4 --- /dev/null +++ b/src/lib/api/rate-limits.ts @@ -0,0 +1,204 @@ +import { z } from "zod"; +import { apiFetch } from "@/lib/api/fetch"; + +/** + * Admin client for rate-limit configuration and exemption management + * (#270, backend issue #680). + * + * Rate-limit exemptions were historically configured only through environment + * variables (`RATE_LIMIT_EXEMPT_USERNAMES`, `RATE_LIMIT_EXEMPT_SERVICE_ACCOUNTS`, + * `RATE_LIMIT_TRUSTED_CIDRS`). This module talks to the admin endpoints that let + * an operator view the effective configuration and manage exemptions from the + * UI. The endpoints are not in the generated SDK, so we use the shared + * `apiFetch` wrapper and validate responses with zod at the trust boundary. + * + * Endpoints (all under the admin-guarded router): + * GET /api/v1/admin/rate-limits -> RateLimitConfig + * GET /api/v1/admin/rate-limits/exemptions -> RateLimitExemption[] + * POST /api/v1/admin/rate-limits/exemptions -> RateLimitExemption + * DELETE /api/v1/admin/rate-limits/exemptions/{id} + */ + +export type ExemptionType = "username" | "service_account" | "cidr"; + +export interface RateLimitExemption { + id: string; + /** What the value refers to: a username, a service-account name, or a CIDR. */ + type: ExemptionType; + /** The exempted value (username, service-account name, or CIDR range). */ + value: string; + /** Optional operator note explaining why the exemption exists. */ + note?: string; + /** When the exemption was created, ISO 8601. Optional for env-sourced rows. */ + created_at?: string; + /** + * True when the exemption comes from an environment variable and therefore + * cannot be removed through the UI. Env-sourced rows are shown read-only. + */ + source_env?: boolean; +} + +export interface RateLimitWindow { + /** Requests permitted per window. */ + limit: number; + /** Window length in seconds. */ + window_secs: number; +} + +export interface RateLimitConfig { + auth: RateLimitWindow; + api: RateLimitWindow; + search: RateLimitWindow; + /** Whether all service accounts are globally exempt (env toggle). */ + exempt_service_accounts: boolean; +} + +export interface CreateExemptionRequest { + type: ExemptionType; + value: string; + note?: string; +} + +const WindowSchema = z.object({ + limit: z.number(), + window_secs: z.number(), +}); + +const RateLimitConfigSchema = z + .object({ + auth: WindowSchema, + api: WindowSchema, + search: WindowSchema, + exempt_service_accounts: z.boolean(), + }) + .passthrough(); + +const ExemptionTypeSchema = z.enum(["username", "service_account", "cidr"]); + +const ExemptionSchema = z + .object({ + id: z.string(), + type: ExemptionTypeSchema, + value: z.string(), + note: z.string().optional(), + created_at: z.string().optional(), + source_env: z.boolean().optional(), + }) + .passthrough(); + +const ExemptionListSchema = z.union([ + z.array(ExemptionSchema), + z.object({ exemptions: z.array(ExemptionSchema) }).passthrough(), +]); + +export function parseExemptions(data: unknown): RateLimitExemption[] { + const parsed = ExemptionListSchema.safeParse(data); + if (!parsed.success) { + throw new Error("Rate-limit exemptions response did not match the expected shape"); + } + const rows = Array.isArray(parsed.data) ? parsed.data : parsed.data.exemptions; + return rows.map((r) => ({ + id: r.id, + type: r.type, + value: r.value, + note: r.note, + created_at: r.created_at, + source_env: r.source_env, + })); +} + +export function parseRateLimitConfig(data: unknown): RateLimitConfig { + const parsed = RateLimitConfigSchema.safeParse(data); + if (!parsed.success) { + throw new Error("Rate-limit config response did not match the expected shape"); + } + const c = parsed.data; + return { + auth: c.auth, + api: c.api, + search: c.search, + exempt_service_accounts: c.exempt_service_accounts, + }; +} + +/** + * Basic CIDR validation for client-side feedback. Accepts IPv4 and IPv6 with a + * prefix length. The backend performs authoritative validation; this just keeps + * obviously malformed input from being submitted. + */ +export function isValidCidr(value: string): boolean { + const parts = value.trim().split("/"); + if (parts.length !== 2) return false; + const [addr, prefix] = parts; + const prefixNum = Number(prefix); + if (!Number.isInteger(prefixNum) || prefixNum < 0) return false; + const isIpv6 = addr.includes(":"); + if (isIpv6) { + return prefixNum <= 128 && /^[0-9a-fA-F:]+$/.test(addr); + } + if (prefixNum > 32) return false; + const octets = addr.split("."); + if (octets.length !== 4) return false; + return octets.every((o) => { + const n = Number(o); + return Number.isInteger(n) && n >= 0 && n <= 255; + }); +} + +/** Validate a create request, returning an error message or null if valid. */ +export function validateExemption(req: CreateExemptionRequest): string | null { + const value = req.value.trim(); + if (!value) return "Value is required"; + if (req.type === "cidr" && !isValidCidr(value)) { + return "Enter a valid CIDR range, for example 10.0.0.0/8 or 2001:db8::/32"; + } + return null; +} + +export const rateLimitsApi = { + getConfig: async (): Promise => { + const data = await apiFetch("/api/v1/admin/rate-limits", { + method: "GET", + }); + return parseRateLimitConfig(data); + }, + + listExemptions: async (): Promise => { + const data = await apiFetch( + "/api/v1/admin/rate-limits/exemptions", + { method: "GET" } + ); + return parseExemptions(data); + }, + + addExemption: async ( + req: CreateExemptionRequest + ): Promise => { + const data = await apiFetch( + "/api/v1/admin/rate-limits/exemptions", + { + method: "POST", + body: JSON.stringify({ + type: req.type, + value: req.value.trim(), + note: req.note?.trim() || undefined, + }), + } + ); + // The create response echoes the stored row; validate it the same way. + const parsed = ExemptionSchema.safeParse(data); + if (!parsed.success) { + throw new Error("Create exemption response did not match the expected shape"); + } + return parseExemptions([parsed.data])[0]; + }, + + removeExemption: async (id: string): Promise => { + await apiFetch( + `/api/v1/admin/rate-limits/exemptions/${encodeURIComponent(id)}`, + { method: "DELETE" } + ); + }, +}; + +export default rateLimitsApi; diff --git a/src/lib/api/system-config.ts b/src/lib/api/system-config.ts new file mode 100644 index 00000000..03fa2bda --- /dev/null +++ b/src/lib/api/system-config.ts @@ -0,0 +1,154 @@ +import { z } from "zod"; +import { apiFetch } from "@/lib/api/fetch"; + +/** + * Client for the public runtime configuration endpoint + * (`GET /api/v1/system/config`, backend issue #496). + * + * The endpoint requires no authentication and exposes only non-sensitive + * values that let the frontend adapt its behavior: upload limits, enabled + * integrations (scanners, auth providers), the storage and search backends, + * and feature flags. It is not modeled in the generated SDK yet, so we hit it + * through the shared `apiFetch` wrapper and validate the response with zod at + * the trust boundary. + */ + +export interface ScannersConfig { + trivy_enabled: boolean; + openscap_enabled: boolean; + dependency_track_enabled: boolean; +} + +export interface AuthProvidersConfig { + oidc_enabled: boolean; + ldap_enabled: boolean; + sso_enabled: boolean; +} + +export interface PermissionsConfig { + rules_exist: boolean; + enforcement_enabled: boolean; +} + +export interface SystemConfig { + max_upload_size_bytes: number; + demo_mode: boolean; + guest_access_enabled: boolean; + scanners: ScannersConfig; + search_engine: string; + storage_backend: string; + auth: AuthProvidersConfig; + oidc_issuer?: string; + permissions: PermissionsConfig; +} + +const ScannersSchema = z.object({ + trivy_enabled: z.boolean(), + openscap_enabled: z.boolean(), + dependency_track_enabled: z.boolean(), +}); + +const AuthSchema = z.object({ + oidc_enabled: z.boolean(), + ldap_enabled: z.boolean(), + sso_enabled: z.boolean(), +}); + +const PermissionsSchema = z.object({ + rules_exist: z.boolean(), + enforcement_enabled: z.boolean(), +}); + +// `.passthrough()` keeps the parser forward-compatible: a backend that adds new +// config fields in a later release will not fail validation here, the new +// fields are simply ignored until the web app models them. +const SystemConfigSchema = z + .object({ + max_upload_size_bytes: z.number(), + demo_mode: z.boolean(), + guest_access_enabled: z.boolean(), + scanners: ScannersSchema, + search_engine: z.string(), + storage_backend: z.string(), + auth: AuthSchema, + oidc_issuer: z.string().optional(), + permissions: PermissionsSchema, + }) + .passthrough(); + +/** + * Default config used before the real response arrives or when the endpoint is + * unavailable. Defaults are deliberately permissive (everything that affects + * navigation visibility defaults to enabled) so a transient fetch failure never + * hides a feature the operator actually configured. Scanner-gated surfaces are + * the exception: they default to disabled because showing an empty scanner tab + * is a worse experience than briefly hiding it, and the data behind those tabs + * is itself fetched with its own error handling. + */ +export const DEFAULT_SYSTEM_CONFIG: SystemConfig = { + max_upload_size_bytes: 0, + demo_mode: false, + guest_access_enabled: true, + scanners: { + trivy_enabled: false, + openscap_enabled: false, + dependency_track_enabled: false, + }, + search_engine: "database", + storage_backend: "filesystem", + auth: { + oidc_enabled: false, + ldap_enabled: false, + sso_enabled: false, + }, + permissions: { + rules_exist: false, + enforcement_enabled: false, + }, +}; + +export function parseSystemConfig(data: unknown): SystemConfig { + const parsed = SystemConfigSchema.safeParse(data); + if (!parsed.success) { + throw new Error( + "System config response did not match the expected shape" + ); + } + const c = parsed.data; + return { + max_upload_size_bytes: c.max_upload_size_bytes, + demo_mode: c.demo_mode, + guest_access_enabled: c.guest_access_enabled, + scanners: c.scanners, + search_engine: c.search_engine, + storage_backend: c.storage_backend, + auth: c.auth, + oidc_issuer: c.oidc_issuer, + permissions: c.permissions, + }; +} + +/** True when any vulnerability/compliance scanner integration is configured. */ +export function anyScannerEnabled(config: SystemConfig): boolean { + return ( + config.scanners.trivy_enabled || + config.scanners.openscap_enabled || + config.scanners.dependency_track_enabled + ); +} + +export const systemConfigApi = { + /** + * Fetch public runtime configuration. Throws on network error or an + * unparseable response so callers can decide whether to fall back to + * `DEFAULT_SYSTEM_CONFIG`. + */ + getConfig: async (): Promise => { + const data = await apiFetch("/api/v1/system/config", { + method: "GET", + }); + return parseSystemConfig(data); + }, +}; + +export default systemConfigApi; diff --git a/src/providers/__tests__/system-config-provider.test.tsx b/src/providers/__tests__/system-config-provider.test.tsx new file mode 100644 index 00000000..2e34fc19 --- /dev/null +++ b/src/providers/__tests__/system-config-provider.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("@/lib/sdk-client", () => ({})); + +const mockGetConfig = vi.fn(); +vi.mock("@/lib/api/system-config", async () => { + const actual = await vi.importActual( + "@/lib/api/system-config" + ); + return { + ...actual, + systemConfigApi: { getConfig: () => mockGetConfig() }, + }; +}); + +import { + SystemConfigProvider, + useFeatureFlags, + useSystemConfig, +} from "../system-config-provider"; + +function Consumer() { + const flags = useFeatureFlags(); + const { isError } = useSystemConfig(); + return ( +
+ {String(flags.scanningEnabled)} + {String(flags.dependencyTrackEnabled)} + {String(flags.ssoEnabled)} + {String(flags.guestAccessEnabled)} + {String(isError)} +
+ ); +} + +function renderWithProvider() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + ); +} + +describe("SystemConfigProvider", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => cleanup()); + + it("derives feature flags from a fetched config", async () => { + mockGetConfig.mockResolvedValue({ + max_upload_size_bytes: 100, + demo_mode: false, + guest_access_enabled: false, + scanners: { + trivy_enabled: true, + openscap_enabled: false, + dependency_track_enabled: true, + }, + search_engine: "opensearch", + storage_backend: "s3", + auth: { oidc_enabled: true, ldap_enabled: false, sso_enabled: false }, + permissions: { rules_exist: false, enforcement_enabled: true }, + }); + + renderWithProvider(); + + await waitFor(() => { + expect(screen.getByTestId("scanning")).toHaveTextContent("true"); + }); + expect(screen.getByTestId("dt")).toHaveTextContent("true"); + // sso flag is true when either sso_enabled or oidc_enabled is set. + expect(screen.getByTestId("sso")).toHaveTextContent("true"); + expect(screen.getByTestId("guest")).toHaveTextContent("false"); + }); + + it("falls back to permissive defaults on fetch error", async () => { + mockGetConfig.mockRejectedValue(new Error("network down")); + + renderWithProvider(); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("true"); + }); + // Defaults: scanners off, guest access on. + expect(screen.getByTestId("scanning")).toHaveTextContent("false"); + expect(screen.getByTestId("guest")).toHaveTextContent("true"); + }); + + it("throws if useSystemConfig is used outside the provider", () => { + function Bare() { + useSystemConfig(); + return null; + } + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow(/within a SystemConfigProvider/); + spy.mockRestore(); + }); +}); diff --git a/src/providers/index.tsx b/src/providers/index.tsx index be9a0c22..76f8b32a 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -5,14 +5,17 @@ import { QueryProvider } from "./query-provider"; import { ThemeProvider } from "./theme-provider"; import { AuthProvider } from "./auth-provider"; import { InstanceProvider } from "./instance-provider"; +import { SystemConfigProvider } from "./system-config-provider"; export function Providers({ children }: { children: ReactNode }) { return ( - - {children} - + + + {children} + + ); diff --git a/src/providers/system-config-provider.tsx b/src/providers/system-config-provider.tsx new file mode 100644 index 00000000..785911ad --- /dev/null +++ b/src/providers/system-config-provider.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + systemConfigApi, + anyScannerEnabled, + DEFAULT_SYSTEM_CONFIG, + type SystemConfig, +} from "@/lib/api/system-config"; + +/** + * Provides the backend's public runtime configuration + * (`GET /api/v1/system/config`) to the whole app and derives the feature flags + * the UI uses to show or hide gated surfaces (#271). + * + * The query runs once and is cached for the session; config rarely changes at + * runtime and is cheap to re-fetch on a hard reload. While loading or on error + * the context exposes `DEFAULT_SYSTEM_CONFIG` so consumers always read a + * concrete object and never have to null-check. + */ + +export interface FeatureFlags { + /** Any vulnerability or compliance scanner is configured. */ + scanningEnabled: boolean; + trivyEnabled: boolean; + openscapEnabled: boolean; + /** Dependency-Track integration is wired up and reachable. */ + dependencyTrackEnabled: boolean; + /** An SSO/OIDC provider is available for login. */ + ssoEnabled: boolean; + oidcEnabled: boolean; + ldapEnabled: boolean; + /** Anonymous browsing/download is permitted (#850). */ + guestAccessEnabled: boolean; + /** Instance is read-only (writes blocked). */ + demoMode: boolean; +} + +interface SystemConfigContextValue { + config: SystemConfig; + flags: FeatureFlags; + isLoading: boolean; + isError: boolean; +} + +function deriveFlags(config: SystemConfig): FeatureFlags { + return { + scanningEnabled: anyScannerEnabled(config), + trivyEnabled: config.scanners.trivy_enabled, + openscapEnabled: config.scanners.openscap_enabled, + dependencyTrackEnabled: config.scanners.dependency_track_enabled, + ssoEnabled: config.auth.sso_enabled || config.auth.oidc_enabled, + oidcEnabled: config.auth.oidc_enabled, + ldapEnabled: config.auth.ldap_enabled, + guestAccessEnabled: config.guest_access_enabled, + demoMode: config.demo_mode, + }; +} + +const SystemConfigContext = createContext(null); + +export const SYSTEM_CONFIG_QUERY_KEY = ["system-config"] as const; + +export function SystemConfigProvider({ children }: { children: ReactNode }) { + const { data, isLoading, isError } = useQuery({ + queryKey: SYSTEM_CONFIG_QUERY_KEY, + queryFn: () => systemConfigApi.getConfig(), + staleTime: 10 * 60 * 1000, + retry: false, + }); + + const config = data ?? DEFAULT_SYSTEM_CONFIG; + + return ( + + {children} + + ); +} + +/** Full system config plus loading/error state. */ +export function useSystemConfig(): SystemConfigContextValue { + const ctx = useContext(SystemConfigContext); + if (!ctx) { + throw new Error( + "useSystemConfig must be used within a SystemConfigProvider" + ); + } + return ctx; +} + +/** Just the derived feature flags, the common case for gating UI. */ +export function useFeatureFlags(): FeatureFlags { + return useSystemConfig().flags; +}