- {/* Logo / header — matches AdminLoginPage's tinted square frame
- so the brand presentation is identical across admin and
- customer entry points. The frame itself is admin-controllable
- via Branding → "Show tinted frame behind login logo" — same
- toggle drives both login pages. */}
+ {/* Logo / header — matches AdminLoginPage. The frame and size
+ are admin-controllable via Branding → "Login pages logo"
+ settings; both toggles apply to /admin/login and
+ /customer/login exclusively (the rest of the app uses its
+ own logo_size). */}
{t(
'settings.features.preview.legend',
- 'Green tinted items are controlled by toggles above.',
+ 'Accent-tinted items are controlled by toggles above.',
)}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 51d209eb..d18f03ee 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -300,6 +300,35 @@
color: var(--color-accent);
}
+ .text-accent-dark {
+ color: var(--color-accent-dark);
+ }
+
+ /*
+ * Low-opacity accent fills used as "icon tile" / "tinted chip"
+ * backgrounds (Features tab feature icons, Sidebar Preview pills,
+ * Customer dashboard branding card header). Picks up the admin's CI
+ * accent automatically — previously these places used Tailwind's
+ * hardcoded `primary-50` palette which stayed green regardless of
+ * branding settings.
+ *
+ * color-mix is supported on every browser we ship (Chromium 111+,
+ * Safari 16.2+, Firefox 113+ — see baseline). Older browsers fall
+ * back to the var(--color-accent-dark) below.
+ */
+ .bg-accent-soft {
+ background-color: var(--color-accent-dark);
+ background-color: color-mix(in srgb, var(--color-accent-dark) 12%, transparent);
+ }
+ .dark .bg-accent-soft {
+ background-color: var(--color-accent-dark);
+ background-color: color-mix(in srgb, var(--color-accent-dark) 28%, transparent);
+ }
+ .border-accent-soft {
+ border-color: var(--color-accent-dark);
+ border-color: color-mix(in srgb, var(--color-accent-dark) 30%, transparent);
+ }
+
/*
* Selected-tile state for picker grids (Gallery Layout, Filter Bar Style,
* Header Style, Hero Divider, Theme Presets). Used in place of the dim
From 2f00bbdd90ef38feca886e834e753d4c4b60c11d Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Mon, 11 May 2026 11:11:23 +0200
Subject: [PATCH 04/12] fix(settings): neutralize sidebar icons for a
consistent palette
---
frontend/src/pages/admin/SettingsPage.tsx | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx
index e9057b2e..ffb4b66e 100644
--- a/frontend/src/pages/admin/SettingsPage.tsx
+++ b/frontend/src/pages/admin/SettingsPage.tsx
@@ -296,10 +296,14 @@ export const SettingsPage: React.FC = () => {
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
+ {/* Active-state icon paints white to sit on the
+ accent-dark pill (matches the label colour
+ and avoids the accent-on-accent low-contrast
+ that the prior `text-accent` produced). */}
@@ -318,7 +322,11 @@ export const SettingsPage: React.FC = () => {
{showSectionHeading && (
-
+ {/* Section heading icon stays neutral so the Settings
+ chrome reads as one consistent palette — no stray
+ accent flecks. The active sidebar pill is the only
+ place that uses the accent fill. */}
+
{/* Icon tile — enabled state uses the admin's CI accent (via
- .bg-accent-soft / .text-accent-dark from index.css) so the
- tile colour follows the configured brand palette. Previously
- hard-coded to Tailwind's primary-50 green. */}
+ .bg-accent-soft + .text-on-accent-soft) so it follows the
+ configured brand palette. The foreground token resolves to
+ a high-contrast colour in both light and dark mode. */}
diff --git a/frontend/src/features/settings/components/SidebarPreview.tsx b/frontend/src/features/settings/components/SidebarPreview.tsx
index 16487e0b..d9f9ee00 100644
--- a/frontend/src/features/settings/components/SidebarPreview.tsx
+++ b/frontend/src/features/settings/components/SidebarPreview.tsx
@@ -61,10 +61,11 @@ export const SidebarPreview: React.FC = ({ staged }) => {
className={clsx(
'inline-flex items-center gap-2 px-2.5 py-1.5 rounded-md text-xs font-medium border',
// Feature-driven pills pick up the admin's CI accent via
- // .bg-accent-soft / .border-accent-soft / .text-accent-dark
- // — previously hard-coded to Tailwind primary-50 green.
+ // .bg-accent-soft / .border-accent-soft, with
+ // .text-on-accent-soft as the legible foreground (the
+ // accent token itself washes out on its own tint).
item.featureDriven
- ? 'border-accent-soft bg-accent-soft text-accent-dark'
+ ? 'border-accent-soft bg-accent-soft text-on-accent-soft'
: 'border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
)}
>
diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx
index 03983d7d..45388210 100644
--- a/frontend/src/features/settings/tabs/FeaturesTab.tsx
+++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx
@@ -69,7 +69,7 @@ export const FeaturesTab: React.FC = () => {
{/* Header */}
-
+
diff --git a/frontend/src/index.css b/frontend/src/index.css
index d18f03ee..37a47798 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -312,21 +312,45 @@
* hardcoded `primary-50` palette which stayed green regardless of
* branding settings.
*
+ * Mix weights bumped from 12% / 28% to 18% / 55% so the tint reads
+ * as "tinted" rather than "barely there" on dark backgrounds. The
+ * pill foreground intentionally falls back to a high-contrast theme
+ * colour rather than the accent itself (.text-accent-dark on an
+ * accent-soft bg disappears because both come from the same
+ * source).
+ *
* color-mix is supported on every browser we ship (Chromium 111+,
* Safari 16.2+, Firefox 113+ — see baseline). Older browsers fall
* back to the var(--color-accent-dark) below.
*/
.bg-accent-soft {
background-color: var(--color-accent-dark);
- background-color: color-mix(in srgb, var(--color-accent-dark) 12%, transparent);
+ background-color: color-mix(in srgb, var(--color-accent-dark) 18%, transparent);
}
.dark .bg-accent-soft {
background-color: var(--color-accent-dark);
- background-color: color-mix(in srgb, var(--color-accent-dark) 28%, transparent);
+ background-color: color-mix(in srgb, var(--color-accent-dark) 55%, transparent);
}
.border-accent-soft {
border-color: var(--color-accent-dark);
- border-color: color-mix(in srgb, var(--color-accent-dark) 30%, transparent);
+ border-color: color-mix(in srgb, var(--color-accent-dark) 45%, transparent);
+ }
+ .dark .border-accent-soft {
+ border-color: color-mix(in srgb, var(--color-accent-dark) 70%, transparent);
+ }
+
+ /*
+ * Foreground for content sitting on .bg-accent-soft. Uses the
+ * theme's text token (already high-contrast against both light
+ * backgrounds and dark surfaces) instead of the accent itself —
+ * accent-on-accent-soft was the reason the Sidebar Preview pills
+ * read as "barely visible".
+ */
+ .text-on-accent-soft {
+ color: var(--color-accent-dark);
+ }
+ .dark .text-on-accent-soft {
+ color: #ffffff;
}
/*
From 7ac1d1473860796ea0925dd77454218f2b1f0020 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Mon, 11 May 2026 11:32:59 +0200
Subject: [PATCH 06/12] fix(customer): preserve slug-scoped gallery tokens on
auth provider mount
---
frontend/src/utils/cleanupGalleryAuth.ts | 67 +++++++++++-------------
1 file changed, 31 insertions(+), 36 deletions(-)
diff --git a/frontend/src/utils/cleanupGalleryAuth.ts b/frontend/src/utils/cleanupGalleryAuth.ts
index ff5751c1..ae049a52 100644
--- a/frontend/src/utils/cleanupGalleryAuth.ts
+++ b/frontend/src/utils/cleanupGalleryAuth.ts
@@ -1,42 +1,37 @@
-// Cleanup function to remove old gallery authentication data
+/**
+ * Cleanup helper for legacy gallery-auth artefacts.
+ *
+ * Removes pre-multi-gallery storage:
+ * - global `gallery_token` / `gallery_event` keys in localStorage AND
+ * sessionStorage (the old single-gallery shape).
+ * - the bare `gallery_token` cookie (now replaced by slug-scoped
+ * `gallery_token_` cookies).
+ *
+ * Does NOT wipe slug-scoped sessionStorage entries any more — the
+ * previous version did, which broke the customer-dashboard → gallery
+ * handoff. CustomerDashboardPage stores
+ * `sessionStorage.gallery_token_` immediately before navigating
+ * to /gallery/; GalleryAuthProvider then mounts and ran this
+ * cleanup as its first effect, wiping the just-set entry and forcing
+ * the user back to the per-event password prompt even though their
+ * customer JWT had just been exchanged for a valid gallery JWT.
+ *
+ * Slug-scoped storage is owned by GalleryAuthProvider itself (cleared
+ * on logout, token invalidation, archived event) — this helper has
+ * no business sweeping it.
+ */
export const cleanupOldGalleryAuth = () => {
- // Remove old global gallery authentication
+ // Legacy global keys (pre-multi-gallery shape).
localStorage.removeItem('gallery_event');
- localStorage.removeItem('gallery_token'); // Remove old global token format
-
- // Remove any corrupted or old gallery tokens from localStorage
- const keysToRemove: string[] = [];
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- if (key && (key.startsWith('gallery_token') || key.startsWith('gallery_event'))) {
- keysToRemove.push(key);
- }
- }
-
- keysToRemove.forEach(key => {
- localStorage.removeItem(key);
- });
-
- // Remove old gallery token from cookies if it exists
- document.cookie = 'gallery_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
-
- // Also clear session storage
+ localStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token');
+
+ // Legacy bare cookie (path=/, no slug suffix). Slug-scoped
+ // `gallery_token_` cookies are kept — they're how the customer
+ // dashboard hands off auth to /gallery/.
+ document.cookie = 'gallery_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+
+ // gallery_active_slug is a UI hint, not auth. Safe to drop.
sessionStorage.removeItem('gallery_active_slug');
-
- // Remove slug-specific session storage entries as well
- try {
- const sessionKeysToRemove: string[] = [];
- for (let i = 0; i < sessionStorage.length; i += 1) {
- const key = sessionStorage.key(i);
- if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
- sessionKeysToRemove.push(key);
- }
- }
-
- sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
- } catch {
- // Session storage may be unavailable; ignore cleanup failures
- }
};
From 35f5b86d0f6a56627aac2abfe5228b5935709b08 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Mon, 11 May 2026 11:50:09 +0200
Subject: [PATCH 07/12] fix(theme): 'Same as body' heading font no longer
inherits stale value
---
frontend/src/contexts/ThemeContext.tsx | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx
index 3b9ddcdc..af058d9d 100644
--- a/frontend/src/contexts/ThemeContext.tsx
+++ b/frontend/src/contexts/ThemeContext.tsx
@@ -186,9 +186,18 @@ export const ThemeProvider: React.FC = ({
void loadFontForFamily(themeConfig.fontFamily);
}
- if (themeConfig.headingFontFamily) {
- root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily);
- void loadFontForFamily(themeConfig.headingFontFamily);
+ // "Same as body" is stored as headingFontFamily='' — in that case
+ // mirror the body family so a stale --heading-font-family (e.g.
+ // from a previously-visited gallery with a serif heading theme)
+ // doesn't bleed into pages that picked the matched-fonts option.
+ // Without this fall-through the CSS variable retained the last
+ // explicit value across theme switches, which is why the customer
+ // profile and admin login rendered serif headings even though the
+ // active theme had "Same as body" selected.
+ const effectiveHeadingFont = themeConfig.headingFontFamily || themeConfig.fontFamily;
+ if (effectiveHeadingFont) {
+ root.style.setProperty('--heading-font-family', effectiveHeadingFont);
+ void loadFontForFamily(effectiveHeadingFont);
}
if (themeConfig.borderRadius) {
From 9091ed4012a85400f216ebfb40d5720c2c86a826 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Mon, 11 May 2026 16:17:14 +0200
Subject: [PATCH 08/12] feat(clients): scaffold top-level Clients section with
sub-nav around Accounts
---
.../core/097_add_clients_feature_flag.js | 38 ++++
backend/server.js | 26 +--
.../src/middleware/requireCustomerPortal.js | 62 +++++++
backend/src/routes/adminFeatureFlags.js | 27 ++-
frontend/src/App.tsx | 49 +++++-
.../src/components/admin/AdminSidebar.tsx | 48 ++++-
.../src/components/admin/ClientsLayout.tsx | 164 ++++++++++++++++++
frontend/src/contexts/FeatureFlagsContext.tsx | 27 +--
.../features/settings/tabs/FeaturesTab.tsx | 23 ++-
frontend/src/i18n/locales/de.json | 26 ++-
frontend/src/i18n/locales/en.json | 26 ++-
frontend/src/i18n/locales/fr.json | 26 ++-
frontend/src/i18n/locales/nl.json | 26 ++-
frontend/src/i18n/locales/pt.json | 26 ++-
frontend/src/i18n/locales/ru.json | 26 ++-
.../src/pages/admin/CustomerDetailPage.tsx | 6 +-
.../pages/admin/CustomerManagementPage.tsx | 2 +-
frontend/src/services/featureFlags.service.ts | 15 +-
18 files changed, 562 insertions(+), 81 deletions(-)
create mode 100644 backend/migrations/core/097_add_clients_feature_flag.js
create mode 100644 backend/src/middleware/requireCustomerPortal.js
create mode 100644 frontend/src/components/admin/ClientsLayout.tsx
diff --git a/backend/migrations/core/097_add_clients_feature_flag.js b/backend/migrations/core/097_add_clients_feature_flag.js
new file mode 100644
index 00000000..86e384d4
--- /dev/null
+++ b/backend/migrations/core/097_add_clients_feature_flag.js
@@ -0,0 +1,38 @@
+/**
+ * Migration: Add the `clients` top-level feature flag.
+ *
+ * Introduces a parent flag for the "Clients" sidebar section, which
+ * groups customer accounts today and will host calendar / quotes /
+ * bills / messaging in future PRs. The existing `customerPortal` flag
+ * is unchanged and continues to gate the /customer/* surface plus the
+ * Accounts sub-page; it now lives logically beneath `clients` in the
+ * Features tab.
+ *
+ * Initial value: mirrors the install's current `customerPortal` value
+ * so an admin who had the customer portal enabled keeps seeing the
+ * Clients sidebar entry after upgrade, and an admin who had it off
+ * doesn't suddenly see a new sidebar entry.
+ *
+ * Idempotent: re-runs are no-ops.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+
+ const existing = await knex('feature_flags').where({ key: 'clients' }).first();
+ if (existing) return;
+
+ const portalRow = await knex('feature_flags').where({ key: 'customerPortal' }).first();
+ let initialValue = false;
+ if (portalRow) {
+ const raw = portalRow.value;
+ initialValue = raw === true || raw === 1 || raw === '1' || raw === 'true';
+ }
+
+ await knex('feature_flags').insert({ key: 'clients', value: initialValue });
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+ await knex('feature_flags').where({ key: 'clients' }).del();
+};
diff --git a/backend/server.js b/backend/server.js
index 942d5725..14c4cac9 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -568,19 +568,23 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
-// Customer portal (#354). The customerPortal feature flag is enforced
-// on the frontend via route
-// guards (App.tsx) and AdminSidebar visibility — when the flag is off,
-// users never reach these endpoints. Defence in depth is provided by
-// customerAccountsService.isCustomerPortalEnabled() in the few backend
-// paths that matter (e.g. adminEvents customer_account_ids handling).
-// Admin routes are protected by adminAuth; customer routes by
-// customerAuth — so no additional route-level gate is needed.
-app.use('/api/admin/customers', require('./src/routes/adminCustomers'));
+// Customer portal (#354). When the `customerPortal` feature flag is
+// OFF, both the admin-facing /api/admin/customers/* surface AND the
+// customer-facing /api/customer/* surface return 410 Gone — turning
+// the toggle off in Settings → Features cleanly kills the feature
+// everywhere, not just in the UI. The frontend RequireFeature guard
+// + AdminSidebar visibility still apply for navigation, but a stale
+// tab or third-party API client can't bypass the gate.
+const {
+ requireCustomerPortalEnabled,
+ requireCustomerPortalEnabledAdmin,
+} = require('./src/middleware/requireCustomerPortal');
+
+app.use('/api/admin/customers', requireCustomerPortalEnabledAdmin, require('./src/routes/adminCustomers'));
// Customer-side surface (#354). Strictly separate from /api/admin/* —
// distinct token type, distinct cookie, distinct middleware.
-app.use('/api/customer/auth', require('./src/routes/customerAuth'));
-app.use('/api/customer', require('./src/routes/customer'));
+app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
+app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
diff --git a/backend/src/middleware/requireCustomerPortal.js b/backend/src/middleware/requireCustomerPortal.js
new file mode 100644
index 00000000..5b307e71
--- /dev/null
+++ b/backend/src/middleware/requireCustomerPortal.js
@@ -0,0 +1,62 @@
+/**
+ * Customer portal feature-flag gate.
+ *
+ * Blocks every /api/customer/* and /api/admin/customers/* endpoint
+ * when the `customerPortal` flag is off. Returns 410 Gone so the
+ * frontend can distinguish "feature has been disabled" from "you
+ * don't have access" (which would be 403) — useful for the customer
+ * dashboard's auto-redirect on a soft-kill scenario.
+ *
+ * Reads the flag via customerAccountsService.isCustomerPortalEnabled
+ * (which itself reads from the maintainer's feature_flags table),
+ * so a single source of truth.
+ */
+
+const customerAccountsService = require('../services/customerAccountsService');
+const logger = require('../utils/logger');
+
+async function isEnabled() {
+ try {
+ return await customerAccountsService.isCustomerPortalEnabled();
+ } catch (err) {
+ // Defensive: if the lookup throws (DB unavailable, table missing
+ // mid-migration), fail closed so an enabled-by-default fallback
+ // can't accidentally expose customer surfaces during boot.
+ logger.warn('requireCustomerPortal: feature flag lookup failed, treating as off', {
+ error: err?.message,
+ });
+ return false;
+ }
+}
+
+/**
+ * Customer-facing endpoints. Returns 410 with a code the frontend
+ * can interpret to clear stale session storage + redirect to
+ * /admin/login.
+ */
+async function requireCustomerPortalEnabled(req, res, next) {
+ if (await isEnabled()) return next();
+ return res.status(410).json({
+ error: 'Customer portal is disabled',
+ code: 'CUSTOMER_PORTAL_DISABLED',
+ });
+}
+
+/**
+ * Admin-facing /api/admin/customers/* endpoints. Same gate, same
+ * status code — keeps the contract consistent across both halves of
+ * the customer-portal surface. The sidebar UI already hides the
+ * entry, but a stale tab or direct API call must also be blocked.
+ */
+async function requireCustomerPortalEnabledAdmin(req, res, next) {
+ if (await isEnabled()) return next();
+ return res.status(410).json({
+ error: 'Customer portal is disabled',
+ code: 'CUSTOMER_PORTAL_DISABLED',
+ });
+}
+
+module.exports = {
+ requireCustomerPortalEnabled,
+ requireCustomerPortalEnabledAdmin,
+};
diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js
index 21ea3d06..6202b32c 100644
--- a/backend/src/routes/adminFeatureFlags.js
+++ b/backend/src/routes/adminFeatureFlags.js
@@ -32,8 +32,14 @@ const KNOWN_FLAGS = [
'messaging',
'analytics',
'userManagement',
- // Foundation flag for the customer-side surface (#354). See migration
- // 094 for the seeding rule.
+ // Top-level "Clients" section (#354 follow-up). Parent flag that
+ // gates the /admin/clients/* sidebar entry. customerPortal,
+ // calendar, quotes, bills and messaging are conceptually its
+ // children — when `clients` is off none of them surface in the
+ // admin UI even if their individual flags are on.
+ 'clients',
+ // Customer-side portal surface (#354). Gates /customer/* routes
+ // and the Accounts sub-page under Clients. See migration 095.
'customerPortal',
];
@@ -49,6 +55,7 @@ const DEFAULT_FLAGS = {
messaging: false,
analytics: true,
userManagement: true,
+ clients: false,
};
async function readAllFlags() {
@@ -69,13 +76,27 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = false;
if (out.calendar === false) out.calendarBooking = false;
+ // Clients parent flag is DERIVED from its children. Admins don't
+ // toggle it directly in the Features tab — they enable a specific
+ // sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
+ // later) and the Clients sidebar section lights up automatically.
+ // Computing the value here (rather than only on writes) means GET
+ // /admin/feature-flags also returns a consistent state if the DB
+ // ever drifts (e.g. partial migration run).
+ out.clients = Boolean(
+ out.customerPortal
+ // future siblings (out.calendar || out.quotes || out.bills || out.messaging) go here
+ );
return out;
}
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const flags = await readAllFlags();
- res.json(flags);
+ // Always run the rules so derived flags (e.g. `clients`) and
+ // hard invariants (galleries always on) are consistent even if
+ // the DB row is stale or missing.
+ res.json(applyDependencyRules(flags));
} catch (error) {
logger.error('Failed to read feature flags', { error: error.message });
res.status(500).json({ error: 'Failed to read feature flags' });
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index da045454..8b96d4b1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
-import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
+import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
@@ -40,6 +40,7 @@ import {
} from './pages/customer';
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
+import { ClientsLayout } from './components/admin/ClientsLayout';
import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -90,6 +91,17 @@ function AnalyticsBootstrap() {
return null;
}
+/**
+ * Backward-compat redirect for /admin/customers/:id → /admin/clients/accounts/:id.
+ * Needed because can't interpolate route params and we
+ * want stale bookmarks / email links to keep working after the Clients
+ * section reorg.
+ */
+function RedirectCustomerDetail() {
+ const { id } = useParams();
+ return ;
+}
+
function App() {
// Track dark mode for toast theming
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
@@ -146,14 +158,37 @@ function App() {
}>
} />
- {/* Customer accounts (#354) — admin-side management.
- Hidden from sidebar + redirected away when the
- customerPortal flag is off. */}
- }>
- } />
- } />
+ {/* Clients section (#354 follow-up). Parent route
+ gated by the top-level `clients` flag — when off
+ the sidebar entry is hidden and every /admin/clients/*
+ URL redirects to /admin/dashboard. Inside, the
+ ClientsLayout renders a Settings-style sub-nav
+ and the active sub-feature's page through an
+ Outlet. Each sub-route is feature-flagged
+ independently. */}
+ }>
+ }>
+ }>
+ } />
+ } />
+
+ {/* Default: send /admin/clients (no sub-path) to
+ the first available sub-feature. Today that's
+ always accounts; when calendar/quotes ship they
+ get their own routes here and the empty-state
+ in ClientsLayout handles the rare "parent on,
+ all children off" case. */}
+ } />
+
+ {/* Old /admin/customers paths now live under
+ /admin/clients/accounts. Kept indefinitely as
+ redirects so existing bookmarks and email links
+ don't 404. */}
+ } />
+ } />
+
} />
} />
diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx
index 2ae1cd8f..ffff92df 100644
--- a/frontend/src/components/admin/AdminSidebar.tsx
+++ b/frontend/src/components/admin/AdminSidebar.tsx
@@ -8,7 +8,7 @@ import {
Settings,
X,
Users,
- UserCog,
+ Briefcase,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -27,7 +27,15 @@ interface NavItem {
href: string;
icon: React.ComponentType<{ className?: string }>;
permission?: string | false;
+ /** Single required flag — entry hidden when this is false. */
featureFlag?: FeatureKey;
+ /**
+ * "At least one of these must be on" — used by the Clients section
+ * to hide the sidebar entry when the parent flag is on but no
+ * child sub-feature is enabled. Empty arrays are treated as no
+ * constraint.
+ */
+ featureFlagsAny?: FeatureKey[];
}
// Sidebar shape after the Settings reorg (#feature-flags-settings-reorg).
@@ -47,12 +55,30 @@ const navigation: NavItem[] = [
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
- // Customer accounts (#354) — separate from admin users (#users.view)
- // by design: customers log in at /customer/login with their own
- // cookie + token type. Hidden when the customerPortal feature flag
- // is OFF (Settings → Features). The corresponding /customer/* routes
- // also redirect away in that case (see RequireFeature in App.tsx).
- { nameKey: 'navigation.customers', href: '/admin/customers', icon: UserCog, permission: 'customers.view', featureFlag: 'customerPortal' },
+ // Clients section (#354 follow-up) — admin-side surface for the
+ // CRM-area sub-features. Today this entry leads to /admin/clients
+ // which renders a Settings-style sub-nav with one item (Accounts).
+ // When calendar / quotes / bills / messaging ship they slot in as
+ // additional sub-nav items inside ClientsLayout without needing
+ // their own top-level sidebar entry.
+ //
+ // Gate uses the parent `clients` flag (master). The Accounts page
+ // itself is independently gated by `customerPortal` inside the
+ // route tree — that nested check is invisible from here.
+ //
+ // `permission: 'customers.view'` is the only Clients-area
+ // permission today; future sub-features (booking, billing) get
+ // their own permission keys and the gate here grows into an OR.
+ {
+ nameKey: 'navigation.clients', href: '/admin/clients', icon: Briefcase,
+ permission: 'customers.view',
+ featureFlag: 'clients',
+ // Hide the entry when the parent is on but no sub-feature is —
+ // there's nothing inside ClientsLayout to link to. Add future
+ // sub-flags (calendar, quotes, bills, messaging) here as they
+ // ship; the entry reappears the moment any of them is enabled.
+ featureFlagsAny: ['customerPortal'],
+ },
];
export const AdminSidebar: React.FC = ({ isOpen, onClose }) => {
@@ -64,6 +90,14 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose }) =
const filteredNavigation = navigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false;
if (item.featureFlag && !flags[item.featureFlag]) return false;
+ // featureFlagsAny: entry is hidden when none of the listed
+ // sub-flags are on, even if the parent flag IS on. Used by
+ // the Clients section so the sidebar entry only appears when
+ // there's at least one sub-feature it can link to.
+ if (item.featureFlagsAny && item.featureFlagsAny.length > 0
+ && !item.featureFlagsAny.some((k) => flags[k])) {
+ return false;
+ }
return true;
});
diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx
new file mode 100644
index 00000000..22e38b02
--- /dev/null
+++ b/frontend/src/components/admin/ClientsLayout.tsx
@@ -0,0 +1,164 @@
+/**
+ * Clients section layout (#354 follow-up).
+ *
+ * Wraps /admin/clients/* routes with a Settings-style left sub-nav.
+ * Today the only sub-nav entry is "Accounts" — when calendar / quotes
+ * / bills / messaging ship they get added to `navItems` below and
+ * mounted as nested routes in App.tsx. No placeholder UI; absent
+ * entries simply don't render.
+ *
+ * Visual pattern intentionally mirrors SettingsPage: 220px left rail
+ * on desktop, native