From 85aa15b35b1d45dd13508f6447ba5f98abc7ab30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 20:14:10 +0200 Subject: [PATCH] fix(nav): hide bug-report entry in nav when admin disables the toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an `` that fetches `bugReportEnabled` once via `/api/bugreport/status` and exposes it through `useAppSettings()`. The sidebar consumes the flag and conditionally renders the Bug Report entry — both in the collapsed icon mode and the expanded label mode. The bottom-nav and topbar do not surface bugreport today, so no changes were needed there. The provider mounts inside `Providers` (above the toaster), so any future flag-gated UI can reuse the same hook without re-fetching the status payload. Defaults to `true` while the query is loading or the user is unauthenticated so the entry doesn't flicker out under the cursor on slow connections. Tests: SSR-render `` with both flag states and assert the `/bugreport` href appears / disappears accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/app-settings-provider.tsx | 83 +++++++++++++++++++ .../layout/__tests__/sidebar-nav.test.tsx | 75 +++++++++++++++++ src/components/layout/sidebar-nav.tsx | 74 +++++++++-------- src/components/providers.tsx | 7 +- 4 files changed, 203 insertions(+), 36 deletions(-) create mode 100644 src/components/app-settings-provider.tsx create mode 100644 src/components/layout/__tests__/sidebar-nav.test.tsx diff --git a/src/components/app-settings-provider.tsx b/src/components/app-settings-provider.tsx new file mode 100644 index 000000000..e3045a91e --- /dev/null +++ b/src/components/app-settings-provider.tsx @@ -0,0 +1,83 @@ +"use client"; + +/** + * Client-side feature-flag context — exposes admin-managed app settings to + * layout components so nav surfaces can hide entries when the admin flips a + * toggle. + * + * Today this only carries the `bugReportEnabled` flag (the "Bug Report" + * sidebar entry, the bottom-nav, the topbar dropdown, and the user-facing + * "Report bug" buttons inside `` all gate on it). Add new + * flags here when more admin-managed feature toggles need to reach the + * client. + * + * Source: `GET /api/bugreport/status` already returns `{ enabled }` for any + * authenticated user. We piggy-back on that endpoint instead of standing up + * a parallel `/api/app-settings` route — the data is identical and the + * caching / rate-limit story is already solved upstream. + * + * Defaults to "all flags ON" so unauthenticated layouts (login, register) + * and the brief window before the query resolves don't flicker the entry + * out from underneath the user. The admin's "off" state only takes effect + * once the data has actually arrived. + */ + +import { createContext, useContext, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; + +export interface AppSettings { + /** Whether the admin has enabled in-app bug-report / feedback submission. */ + bugReportEnabled: boolean; +} + +const DEFAULT_SETTINGS: AppSettings = { + bugReportEnabled: true, +}; + +const AppSettingsContext = createContext(DEFAULT_SETTINGS); + +export function useAppSettings(): AppSettings { + return useContext(AppSettingsContext); +} + +interface BugReportStatusPayload { + configured: boolean; + enabled: boolean; + isAdmin: boolean; +} + +/** + * Fetches `bugReportEnabled` once per session and exposes it via context. The + * provider lives inside `Providers` so anything beneath the React tree — + * including the global nav surfaces — can observe the flag without + * each component re-fetching the same status payload. + */ +export function AppSettingsProvider({ children }: { children: ReactNode }) { + const { isAuthenticated } = useAuth(); + const { data } = useQuery({ + queryKey: queryKeys.bugreportStatus(), + enabled: isAuthenticated, + queryFn: async () => { + const res = await fetch("/api/bugreport/status"); + if (!res.ok) throw new Error("Failed to load bug-report status"); + const json = (await res.json()) as { data: BugReportStatusPayload }; + return json.data; + }, + staleTime: 5 * 60 * 1000, + }); + + // Default to ON when the query is still loading, has errored, or the user + // is unauthenticated — the nav must not flicker the entry out under the + // user's cursor on a slow network. + const settings: AppSettings = { + bugReportEnabled: data?.enabled ?? true, + }; + + return ( + + {children} + + ); +} diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx new file mode 100644 index 000000000..9e21cb051 --- /dev/null +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// `` reads the active route from `usePathname()`. Stub +// next/navigation so the SSR test render works without an App-Router +// runtime. +const mockPathnameRef = { value: "/" }; +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathnameRef.value, + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +// Auth + theme + logout — the user section is rendered inside the +// sidebar and otherwise pulls in TanStack Query + the auth endpoint. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "USER", + gravatarUrl: null, + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), + useLogout: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/components/providers", () => ({ + useTheme: () => ({ + theme: "system" as const, + resolvedTheme: "dark" as const, + setTheme: vi.fn(), + }), +})); + +// The flag-under-test. Each test mutates `mockSettingsRef.value` before +// rendering so the sidebar reads the desired state. +const mockSettingsRef = { value: { bugReportEnabled: true } }; +vi.mock("@/components/app-settings-provider", () => ({ + useAppSettings: () => mockSettingsRef.value, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SidebarNav } from "../sidebar-nav"; + +function render({ + pathname = "/", + bugReportEnabled = true, +}: { pathname?: string; bugReportEnabled?: boolean } = {}) { + mockPathnameRef.value = pathname; + mockSettingsRef.value = { bugReportEnabled }; + return renderToStaticMarkup( + + + , + ); +} + +describe(" bug-report toggle", () => { + it("renders the Bug Report entry when the admin flag is enabled", () => { + const html = render({ bugReportEnabled: true }); + expect(html).toContain('href="/bugreport"'); + // i18n string for the entry — sanity check that the link is the actual + // bug-report nav entry, not e.g. an error-page report-bug button. + expect(html).toContain("Bug Report"); + }); + + it("hides the Bug Report entry when the admin flag is disabled", () => { + const html = render({ bugReportEnabled: false }); + expect(html).not.toContain('href="/bugreport"'); + }); +}); diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index cb19b9167..0decd3fd4 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -27,6 +27,7 @@ import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; import { useTheme } from "@/components/providers"; +import { useAppSettings } from "@/components/app-settings-provider"; import { useTranslations } from "@/lib/i18n/context"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -215,6 +216,7 @@ export function SidebarNav() { const pathname = usePathname(); const { t } = useTranslations(); const { user } = useAuth(); + const { bugReportEnabled } = useAppSettings(); const isAdmin = user?.role === "ADMIN"; const onAdminPage = pathname.startsWith("/admin"); const [collapsed, setCollapsed] = useState(() => { @@ -371,27 +373,29 @@ export function SidebarNav() {
{collapsed ? ( <> - - - - - - - - {t("nav.bugreport")} - - + {bugReportEnabled && ( + + + + + + + + {t("nav.bugreport")} + + + )} {isAdmin && ( @@ -437,19 +441,21 @@ export function SidebarNav() { ) : ( <> - - - {t("nav.bugreport")} - + {bugReportEnabled && ( + + + {t("nav.bugreport")} + + )} {isAdmin && ( <> - {children} - + + {children} + +