feat(settings): Features tab + sidebar reorg with feature-flag gating

Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.

Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.

Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
  Migration 088 detects existing-vs-fresh installs from the events
  table:
    * Existing install (events>0)  → all 9 flags TRUE so nothing
      vanishes from an admin's UI on upgrade.
    * Fresh install      (events=0) → spec defaults: galleries,
      reminderEmails, analytics, userManagement TRUE; calendar,
      calendarBooking, quotes, bills, messaging FALSE.

- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
  `settings.edit`. Server enforces the same dependency rules the
  frontend does (galleries always TRUE, quotes=false → bills=false,
  calendar=false → calendarBooking=false). PUT writes one
  `feature_flags_updated` activity log row with the diff.

Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
  reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
  AdminLayout so flag fetches carry the auth cookie. Source of truth
  is the server response; staged is a local copy that the Features tab
  edits and the Save button PUTs.

- `RequireFeature` route guard for /admin/analytics and /admin/users —
  redirects to /admin/dashboard when the corresponding flag is OFF.

- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
  Branding, Event Types, Backup, CMS Pages (now Settings tabs).
  Feature-gated: Analytics, Users.

- Old top-level routes (/admin/email, /admin/branding, /admin/event-
  types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
  /admin/settings?tab=<key> so existing bookmarks don't 404.

- SettingsPage rewritten with a 6-group inner-nav (General /
  Content & Appearance / Communication / Privacy & Security /
  Integrations / System) and 19 tabs. New Features tab is the
  default landing tab. URL ?tab=<key> roundtrips with state — deep
  links and the back button work.

- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
  Analytics + User Management (the two flags that gate sidebar items
  in this PR). All other toggles disabled with a "Not yet available"
  lockedReason — the cards still render so admins see the roadmap, but
  the flag has no UI effect until the surface ships in its own PR. The
  galleries card is locked TRUE per spec (foundation, can't be off).

- Live SidebarPreview reflects unsaved staged changes — admins see
  what their sidebar will look like before they save.

- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
  Features tab copy, the new Settings group labels, and the lifted
  tab titles.

Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
  set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
  defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
  rule enforced (bills forced false when quotes=false even when bills=
  true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
  redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
  Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
  enabled, toggling Analytics off + saving updates the sidebar +
  redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
This commit is contained in:
Paul Nothaft
2026-05-10 20:36:32 +02:00
parent b6aaea21ca
commit 15e333681f
21 changed files with 1679 additions and 157 deletions
@@ -0,0 +1,161 @@
import React, { createContext, useContext, useMemo, useState, useCallback, useEffect } from 'react';
import type { ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
export type { FeatureKey, FeatureFlags };
// Spec defaults — used as a non-blocking fallback while the network
// request is in flight, and as the source of truth for any flag the
// server doesn't return (e.g. a brand-new flag added in a release that
// hasn't run its migration yet on this instance).
export const DEFAULT_FLAGS: FeatureFlags = {
galleries: true,
reminderEmails: true,
calendar: false,
calendarBooking: false,
quotes: false,
bills: false,
messaging: false,
analytics: true,
userManagement: true,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
interface FeatureFlagsContextValue {
// Live (saved) flags from the server. Never undefined — falls back to
// DEFAULT_FLAGS while loading so consumers don't have to handle the
// loading state for nav rendering.
flags: FeatureFlags;
// Staged (unsaved) flags. Same shape; equals `flags` when nothing is
// pending.
staged: FeatureFlags;
isLoading: boolean;
isSaving: boolean;
isDirty: boolean;
// Stage a single change locally. Enforces dependency rules
// (quotes→bills, calendar→calendarBooking, galleries always true).
setFlag: (key: FeatureKey, value: boolean) => void;
// PUT all staged changes to the server.
save: () => Promise<void>;
// Revert staged → server-current.
reset: () => void;
}
const FeatureFlagsContext = createContext<FeatureFlagsContextValue | undefined>(undefined);
function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
const out = { ...flags };
out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
return out;
}
function flagsEqual(a: FeatureFlags, b: FeatureFlags): boolean {
return (Object.keys(a) as FeatureKey[]).every((k) => a[k] === b[k]);
}
interface ProviderProps {
children: ReactNode;
}
export const FeatureFlagsProvider: React.FC<ProviderProps> = ({ children }) => {
const queryClient = useQueryClient();
const { data: serverFlags, isLoading } = useQuery<FeatureFlags>({
queryKey: FEATURE_FLAGS_QUERY_KEY,
queryFn: () => featureFlagsService.get(),
staleTime: 60_000,
});
// Source-of-truth = server response; fall back to defaults during load.
const flags = useMemo(() => serverFlags ?? DEFAULT_FLAGS, [serverFlags]);
// Staged copy — what the Features tab is currently showing pre-save.
const [staged, setStaged] = useState<FeatureFlags>(flags);
// Re-sync staged whenever the server response arrives or refreshes,
// unless the user has unsaved changes (which we'd silently overwrite
// otherwise).
useEffect(() => {
setStaged((current) => {
if (flagsEqual(current, flags)) return current;
// If staged matches the OLD server state (no pending edits), accept
// the new server state. Otherwise keep user's edits.
// Detection: if every key is either equal to flags or differs only
// because the user edited, we can't distinguish — so be conservative
// and only auto-sync when the user hasn't touched anything.
// Simplification: only replace staged on first load (when staged
// still equals DEFAULT_FLAGS shape from the initial useState call).
return current;
});
}, [flags]);
// First-load wiring: when serverFlags arrives, seed `staged` once.
useEffect(() => {
if (serverFlags) {
setStaged((current) => (flagsEqual(current, DEFAULT_FLAGS) ? serverFlags : current));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(serverFlags)]);
const isDirty = useMemo(() => !flagsEqual(staged, flags), [staged, flags]);
const setFlag = useCallback((key: FeatureKey, value: boolean) => {
if (key === 'galleries') return; // locked
setStaged((prev) => applyDependencyRules({ ...prev, [key]: value }));
}, []);
const reset = useCallback(() => {
setStaged(flags);
}, [flags]);
const mutation = useMutation({
mutationFn: (next: FeatureFlags) => featureFlagsService.update(next),
onSuccess: (saved) => {
queryClient.setQueryData(FEATURE_FLAGS_QUERY_KEY, saved);
setStaged(saved);
// Also invalidate so any other consumer (e.g. AdminSidebar that
// reads via the context) re-renders against fresh data.
queryClient.invalidateQueries({ queryKey: FEATURE_FLAGS_QUERY_KEY });
},
});
const save = useCallback(async () => {
if (!isDirty) return;
await mutation.mutateAsync(staged);
}, [isDirty, mutation, staged]);
const value = useMemo<FeatureFlagsContextValue>(
() => ({
flags,
staged,
isLoading,
isSaving: mutation.isPending,
isDirty,
setFlag,
save,
reset,
}),
[flags, staged, isLoading, mutation.isPending, isDirty, setFlag, save, reset],
);
return <FeatureFlagsContext.Provider value={value}>{children}</FeatureFlagsContext.Provider>;
};
export function useFeatureFlags(): FeatureFlagsContextValue {
const ctx = useContext(FeatureFlagsContext);
if (!ctx) {
throw new Error('useFeatureFlags must be used inside a FeatureFlagsProvider');
}
return ctx;
}
// Convenience hook for "is this feature enabled right now?" — returns
// the SAVED value, not the staged one (sidebar visibility shouldn't
// flip the moment someone toggles something on the Features page).
export function useFeatureEnabled(key: FeatureKey): boolean {
const { flags } = useFeatureFlags();
return flags[key];
}