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,88 @@
|
||||
/**
|
||||
* Migration 088: Feature flags table.
|
||||
*
|
||||
* Backs the Features tab on the admin Settings page. Flags gate which
|
||||
* product surfaces appear in the main sidebar and (in future PRs) which
|
||||
* background jobs run.
|
||||
*
|
||||
* Existing-vs-fresh detection:
|
||||
* The Features tab introduces a curated set of "default ON" flags
|
||||
* (galleries, reminderEmails, analytics, userManagement) and "default
|
||||
* OFF" flags for surfaces that aren't built yet (calendar, quotes,
|
||||
* bills, messaging). For a brand-new install those defaults are right
|
||||
* out of the box. For an existing install, we want every flag ON so
|
||||
* nothing in the admin's UI silently disappears the moment they
|
||||
* upgrade — they can opt out later via the Features tab.
|
||||
*
|
||||
* Detection rule: if the `events` table has any rows at migration
|
||||
* time, treat this as an existing install. Empty events = fresh.
|
||||
* This is single-shot (the migration only runs once) and atomic
|
||||
* (no race window). It picks up the rare edge case where an admin
|
||||
* upgrades immediately after running setup but before creating an
|
||||
* event — they'll get fresh-install defaults, which is acceptable
|
||||
* (they can flip flags on the Features page).
|
||||
*
|
||||
* Schema:
|
||||
* - key (PK): the flag identifier (matches FeatureKey on the frontend)
|
||||
* - value: the boolean state
|
||||
* - updated_at: last-changed timestamp
|
||||
* - updated_by: admin id of the last person who flipped it (nullable
|
||||
* for the migration-seeded rows)
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 088_add_feature_flags');
|
||||
|
||||
const exists = await knex.schema.hasTable('feature_flags');
|
||||
if (!exists) {
|
||||
await knex.schema.createTable('feature_flags', (table) => {
|
||||
table.string('key', 64).primary();
|
||||
table.boolean('value').notNullable();
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
table.integer('updated_by').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
});
|
||||
console.log(' created feature_flags table');
|
||||
} else {
|
||||
console.log(' feature_flags table already exists, skipping create');
|
||||
}
|
||||
|
||||
// Detect install age. Use events table — it's user-created content,
|
||||
// unlike admin_users which is seeded by migration 001.
|
||||
const eventCountRow = await knex('events').count({ count: '*' }).first();
|
||||
const eventCount = parseInt(eventCountRow?.count || 0, 10);
|
||||
const isExistingInstall = eventCount > 0;
|
||||
console.log(` detected ${isExistingInstall ? 'EXISTING' : 'FRESH'} install (events count: ${eventCount})`);
|
||||
|
||||
// Spec defaults (frontend/src/contexts/FeatureFlagsContext.tsx).
|
||||
// For an existing install every flag becomes TRUE so nothing
|
||||
// disappears from the admin UI on upgrade.
|
||||
const FLAGS_FRESH = {
|
||||
galleries: true, // always-on, locked
|
||||
reminderEmails: true, // existing cron, locked-on for now
|
||||
calendar: false, // surface not built yet
|
||||
calendarBooking: false, // ditto
|
||||
quotes: false, // surface not built yet
|
||||
bills: false, // surface not built yet (depends on quotes)
|
||||
messaging: false, // surface not built yet
|
||||
analytics: true, // existing surface
|
||||
userManagement: true, // existing surface
|
||||
};
|
||||
const flagsToSeed = isExistingInstall
|
||||
? Object.fromEntries(Object.keys(FLAGS_FRESH).map((k) => [k, true]))
|
||||
: FLAGS_FRESH;
|
||||
|
||||
for (const [key, value] of Object.entries(flagsToSeed)) {
|
||||
const existingRow = await knex('feature_flags').where({ key }).first();
|
||||
if (!existingRow) {
|
||||
await knex('feature_flags').insert({ key, value });
|
||||
}
|
||||
}
|
||||
console.log(` seeded ${Object.keys(flagsToSeed).length} flags`);
|
||||
|
||||
console.log('Migration 088_add_feature_flags completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 088_add_feature_flags');
|
||||
await knex.schema.dropTableIfExists('feature_flags');
|
||||
};
|
||||
Reference in New Issue
Block a user