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:
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { CornerDownRight, Lock, AlertTriangle } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Switch } from './Switch';
|
||||
import { StatusBadge, type FeatureStatus } from './StatusBadge';
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
status: FeatureStatus;
|
||||
statusLabel: string;
|
||||
sidebarLabel?: string;
|
||||
sidebarHidden?: boolean;
|
||||
sidebarHiddenLabel?: string;
|
||||
enabled: boolean;
|
||||
onToggle: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
lockedReason?: string;
|
||||
warning?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusLabel,
|
||||
sidebarLabel,
|
||||
sidebarHidden,
|
||||
sidebarHiddenLabel,
|
||||
enabled,
|
||||
onToggle,
|
||||
disabled = false,
|
||||
lockedReason,
|
||||
warning,
|
||||
children,
|
||||
}) => (
|
||||
<li
|
||||
className={clsx(
|
||||
'rounded-xl border bg-white dark:bg-neutral-900 shadow-soft transition-colors',
|
||||
'border-neutral-200 dark:border-neutral-700',
|
||||
!disabled && 'hover:border-neutral-300 dark:hover:border-neutral-600',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4 p-5">
|
||||
{/* Icon tile */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
enabled
|
||||
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300'
|
||||
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{title}</h4>
|
||||
<StatusBadge status={status} label={statusLabel} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">{description}</p>
|
||||
|
||||
{/* Sidebar callout */}
|
||||
<div className="mt-3 flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<CornerDownRight className="w-3.5 h-3.5" />
|
||||
{sidebarHidden ? (
|
||||
<span className="italic">{sidebarHiddenLabel}</span>
|
||||
) : sidebarLabel ? (
|
||||
<>
|
||||
<span>Sidebar:</span>
|
||||
<span className="font-medium text-neutral-700 dark:text-neutral-300">{sidebarLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Locked-reason hint */}
|
||||
{lockedReason && (
|
||||
<div className="mt-3 flex items-start gap-2 px-3 py-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<Lock className="w-3.5 h-3.5 mt-0.5 text-amber-700 dark:text-amber-400 flex-shrink-0" />
|
||||
<span className="text-xs text-amber-800 dark:text-amber-300">{lockedReason}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning shown only when the user is about to disable (i.e. enabled=true).
|
||||
Wording assumes "you're disabling X — here's the consequence". */}
|
||||
{warning && enabled && (
|
||||
<div className="mt-3 flex items-start gap-2 px-3 py-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 text-amber-700 dark:text-amber-400 flex-shrink-0" />
|
||||
<span className="text-xs text-amber-800 dark:text-amber-300">{warning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-controls visible when enabled (e.g. Calendar mode radio) */}
|
||||
{enabled && children && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-100 dark:border-neutral-800">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={disabled}
|
||||
onChange={onToggle}
|
||||
ariaLabel={title}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
Archive,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Card } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
|
||||
interface PreviewItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
featureDriven: boolean;
|
||||
}
|
||||
|
||||
interface SidebarPreviewProps {
|
||||
staged: FeatureFlags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the live shape of the main admin sidebar based on the user's
|
||||
* staged (unsaved) feature flags. Items in green (primary tint) are
|
||||
* controlled by toggles above; greyscale items are unconditional.
|
||||
*/
|
||||
export const SidebarPreview: React.FC<SidebarPreviewProps> = ({ staged }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = useMemo<PreviewItem[]>(() => {
|
||||
const all: Array<PreviewItem & { gate?: keyof FeatureFlags }> = [
|
||||
{ key: 'dashboard', label: t('navigation.dashboard'), icon: LayoutDashboard, featureDriven: false },
|
||||
{ key: 'events', label: t('navigation.events'), icon: Calendar, featureDriven: false },
|
||||
{ key: 'archives', label: t('navigation.archives'), icon: Archive, featureDriven: false },
|
||||
{ key: 'analytics', label: t('admin.analytics', 'Analytics'), icon: BarChart3, featureDriven: true, gate: 'analytics' },
|
||||
{ key: 'settings', label: t('navigation.settings'), icon: Settings, featureDriven: false },
|
||||
{ key: 'users', label: t('navigation.users'), icon: Users, featureDriven: true, gate: 'userManagement' },
|
||||
];
|
||||
return all.filter((it) => !it.gate || staged[it.gate]);
|
||||
}, [staged, t]);
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.features.preview.title', 'Sidebar preview')}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.features.preview.note', 'Reflects unsaved changes')}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs font-medium border',
|
||||
item.featureDriven
|
||||
? 'border-primary-200 bg-primary-50 text-primary-800 dark:border-primary-800 dark:bg-primary-900/30 dark:text-primary-200'
|
||||
: 'border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-3.5 h-3.5" />
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t(
|
||||
'settings.features.preview.legend',
|
||||
'Green tinted items are controlled by toggles above.',
|
||||
)}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export type FeatureStatus = 'stable' | 'beta' | 'new' | 'experimental' | 'roadmap';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: FeatureStatus;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<FeatureStatus, string> = {
|
||||
stable: 'bg-neutral-100 text-neutral-600 border-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-700',
|
||||
beta: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-800',
|
||||
new: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800',
|
||||
experimental: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-800',
|
||||
roadmap: 'bg-white text-neutral-500 border-neutral-300 dark:bg-neutral-900 dark:text-neutral-400 dark:border-neutral-600',
|
||||
};
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, label }) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center px-2 py-0.5 rounded-full border text-[10px] font-semibold uppercase tracking-wide',
|
||||
STATUS_STYLES[status],
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible toggle switch (role=switch, aria-checked, keyboard-flippable).
|
||||
* Used by FeatureCard. Sized to match the spec (44×24px) — see also the
|
||||
* larger toggles in EventsTab.tsx for comparison; those are checkboxes.
|
||||
*/
|
||||
export const Switch: React.FC<SwitchProps> = ({ checked, onChange, disabled = false, ariaLabel }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={clsx(
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 items-center rounded-full transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
|
||||
checked ? 'bg-primary-600 dark:bg-primary-500' : 'bg-neutral-300 dark:bg-neutral-600',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-6' : 'translate-x-1',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
Reference in New Issue
Block a user