mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(nav): hide bug-report entry in nav when admin disables the toggle
Add an `<AppSettingsProvider>` 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 `<SidebarNav>` with both flag states and assert the `/bugreport` href appears / disappears accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cc395d3937
commit
85aa15b35b
4 files changed
+203
-36
No files matched your search
@@ -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 `<ErrorDetails>` 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<AppSettings>(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 (
|
||||||
|
<AppSettingsContext.Provider value={settings}>
|
||||||
|
{children}
|
||||||
|
</AppSettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
// `<SidebarNav>` 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(
|
||||||
|
<I18nProvider initialLocale="en">
|
||||||
|
<SidebarNav />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("<SidebarNav> 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Logo } from "@/components/ui/logo";
|
import { Logo } from "@/components/ui/logo";
|
||||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||||
import { useTheme } from "@/components/providers";
|
import { useTheme } from "@/components/providers";
|
||||||
|
import { useAppSettings } from "@/components/app-settings-provider";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
import { ADMIN_SECTIONS } from "@/components/admin/admin-shell";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
@@ -215,6 +216,7 @@ export function SidebarNav() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslations();
|
const { t } = useTranslations();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { bugReportEnabled } = useAppSettings();
|
||||||
const isAdmin = user?.role === "ADMIN";
|
const isAdmin = user?.role === "ADMIN";
|
||||||
const onAdminPage = pathname.startsWith("/admin");
|
const onAdminPage = pathname.startsWith("/admin");
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
@@ -371,27 +373,29 @@ export function SidebarNav() {
|
|||||||
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
|
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
{bugReportEnabled && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Link
|
<TooltipTrigger asChild>
|
||||||
href="/bugreport"
|
<Link
|
||||||
aria-current={
|
href="/bugreport"
|
||||||
pathname === "/bugreport" ? "page" : undefined
|
aria-current={
|
||||||
}
|
pathname === "/bugreport" ? "page" : undefined
|
||||||
className={cn(
|
}
|
||||||
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
className={cn(
|
||||||
pathname === "/bugreport"
|
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||||
? "bg-primary/10 text-primary"
|
pathname === "/bugreport"
|
||||||
: "text-foreground hover:bg-accent",
|
? "bg-primary/10 text-primary"
|
||||||
)}
|
: "text-foreground hover:bg-accent",
|
||||||
>
|
)}
|
||||||
<Bug className="h-4 w-4" />
|
>
|
||||||
</Link>
|
<Bug className="h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</Link>
|
||||||
<TooltipContent side="right" sideOffset={8}>
|
</TooltipTrigger>
|
||||||
{t("nav.bugreport")}
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
</TooltipContent>
|
{t("nav.bugreport")}
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -437,19 +441,21 @@ export function SidebarNav() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
{bugReportEnabled && (
|
||||||
href="/bugreport"
|
<Link
|
||||||
aria-current={pathname === "/bugreport" ? "page" : undefined}
|
href="/bugreport"
|
||||||
className={cn(
|
aria-current={pathname === "/bugreport" ? "page" : undefined}
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
className={cn(
|
||||||
pathname === "/bugreport"
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
? "bg-primary/10 text-primary"
|
pathname === "/bugreport"
|
||||||
: "text-foreground hover:bg-accent",
|
? "bg-primary/10 text-primary"
|
||||||
)}
|
: "text-foreground hover:bg-accent",
|
||||||
>
|
)}
|
||||||
<Bug className="h-4 w-4" />
|
>
|
||||||
{t("nav.bugreport")}
|
<Bug className="h-4 w-4" />
|
||||||
</Link>
|
{t("nav.bugreport")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { I18nProvider } from "@/lib/i18n/context";
|
import { I18nProvider } from "@/lib/i18n/context";
|
||||||
import type { Locale } from "@/lib/i18n/config";
|
import type { Locale } from "@/lib/i18n/config";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { AppSettingsProvider } from "@/components/app-settings-provider";
|
||||||
|
|
||||||
// ── Theme Context ────────────────────────────────────
|
// ── Theme Context ────────────────────────────────────
|
||||||
|
|
||||||
@@ -126,8 +127,10 @@ export function Providers({
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<I18nProvider initialLocale={initialLocale}>
|
<I18nProvider initialLocale={initialLocale}>
|
||||||
{children}
|
<AppSettingsProvider>
|
||||||
<Toaster position="bottom-right" richColors />
|
{children}
|
||||||
|
<Toaster position="bottom-right" richColors />
|
||||||
|
</AppSettingsProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
Reference in new issue
Block a user