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] 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