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..5048b4a1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -569,18 +569,29 @@ 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')); +// in TWO places: +// 1. Frontend: RequireFeature guards + AdminSidebar visibility +// (handles navigation cleanly when an admin is using the app). +// 2. Backend: the requireCustomerPortalEnabled middleware below. +// Belt-and-braces — a stale tab, a saved bookmark, or any +// third-party API client trying to hit /api/customer/* or +// /api/admin/customers/* gets a 410 Gone the moment the toggle +// is flipped off. Includes /api/customer/auth/login: flag off +// = nobody can log in until the admin re-enables, including +// already-issued customers (their sessions still have valid +// JWTs but every API call returns 410 → frontend boots them +// out). PR #458 deliberate departure from the prior design +// that left login alive when the rest of the surface was off. +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/adminEvents.js b/backend/src/routes/adminEvents.js index e441dab3..ae1443dc 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -1262,6 +1262,13 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne } delete updates.regenerate_client_token; + // customer_account_ids (#354) is a body-only field consumed + // separately below by customerAccountsService.setAssignmentsForEvent + // — it isn't a column on the events table, so spreading it into + // the UPDATE statement throws "column does not exist" and crashes + // the entire edit with 500 Failed to update event. + delete updates.customer_account_ids; + // Log the update request for debugging logger.debug('Update event request', { id, 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/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 624d2ac1..5a65cfb3 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -303,6 +303,16 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_mode, hide_powered_by, force_color_mode, + // Login-page-only branding (#354 follow-up). Both toggles apply + // exclusively to /admin/login and /customer/login — the gallery + // and admin chrome use their own logo_size / logo_max_height. + // - login_logo_frame_enabled: true (default) renders the tinted + // square behind the logo; false drops it. + // - login_logo_size: 'small' | 'medium' | 'large' | 'xlarge' + // matches the gallery logo_size token set but applies only to + // the two login screens. + login_logo_frame_enabled, + login_logo_size, // Footer overhaul (#441 + #440). Socials are URL strings (empty // hides the icon). Promo content is markdown (rendered via // marked → DOMPurify on the frontend, no raw HTML accepted). @@ -330,6 +340,13 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re ? 'below_footer' : 'above_footer'; + // Normalize login_logo_size to the same token set as logo_size. + // Anything else falls back to 'medium' on the next render. + const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge']; + const normalizedLoginLogoSize = allowedLoginLogoSizes.includes(login_logo_size) + ? login_logo_size + : undefined; + const brandingSettings = { company_name, company_tagline, @@ -350,6 +367,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re logo_display_mode, hide_powered_by, force_color_mode: normalizedForceColorMode, + // Login-only knobs (only persist when the request actually + // included the key, so a partial PUT from another tab doesn't + // accidentally clear them). + ...(login_logo_frame_enabled !== undefined && { login_logo_frame_enabled }), + ...(normalizedLoginLogoSize !== undefined && { login_logo_size: normalizedLoginLogoSize }), // Footer overhaul (#441 + #440). String fields normalize empty/ // undefined → '' so the column is always a known type. Only persist // when the request actually included the key (partial PUTs). diff --git a/backend/src/routes/customerAuth.js b/backend/src/routes/customerAuth.js index 2f459177..87eb7b4b 100644 --- a/backend/src/routes/customerAuth.js +++ b/backend/src/routes/customerAuth.js @@ -48,12 +48,15 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens // ---- login ------------------------------------------------------------- -// The customerPortal feature flag deliberately does NOT gate this route. -// Flipping the master toggle off in Settings → Features hides the admin -// UI surface (sidebar entry, /admin/customers page) but does not revoke -// access for customers who already accepted an invitation. To lock out -// existing customers, deactivate their accounts individually -// (customer_accounts.is_active = false) — which IS enforced below. +// Flag-gate note: this route IS now gated by the customerPortal feature +// flag via the requireCustomerPortalEnabled middleware mounted in +// server.js (`app.use('/api/customer/auth', requireCustomerPortalEnabled, …)`). +// When the admin flips the toggle off in Settings → Features, every +// customer-side endpoint — including login — returns 410. The previous +// design left login reachable while the rest of the surface was gated; +// that was confusing and asymmetric. Single source of truth wins. +// To lock out a specific customer without disabling the feature for +// everyone, deactivate the account (customer_accounts.is_active = false). router.post('/login', [ body('email').isEmail().normalizeEmail().withMessage('Valid email is required'), body('password').isString().notEmpty(), diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index c468e2fe..3dce3721 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -85,6 +85,15 @@ router.get('/', async (req, res) => { : settingsObject.branding_force_color_mode === 'light' ? 'light' : null, + // Login-page-only branding (#354 follow-up). Applies exclusively + // to /admin/login and /customer/login — the rest of the app keeps + // using branding_logo_size / branding_logo_max_height. Default + // true / 'medium' preserves the visual state shipped before the + // toggles existed. + branding_login_logo_frame_enabled: settingsObject.branding_login_logo_frame_enabled !== false, + branding_login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(settingsObject.branding_login_logo_size) + ? settingsObject.branding_login_logo_size + : 'medium', theme_config: settingsObject.theme_config || null, default_language: settingsObject.general_default_language || 'en', enable_analytics: settingsObject.general_enable_analytics !== false, 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 navigate(e.target.value)} + className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500" + > + {enabledItems.map((item) => ( + + ))} + + + + {/* Desktop: sticky left rail */} + + +
+ +
+ + + ); +}; diff --git a/frontend/src/components/admin/CustomerAccountPicker.tsx b/frontend/src/components/admin/CustomerAccountPicker.tsx index 4fdb2a27..9dea5bce 100644 --- a/frontend/src/components/admin/CustomerAccountPicker.tsx +++ b/frontend/src/components/admin/CustomerAccountPicker.tsx @@ -33,14 +33,17 @@ const labelFor = (c: { email: string; displayName?: string | null; companyName?: export const CustomerAccountPicker: React.FC = ({ value, onChange, disabled }) => { const { t } = useTranslation(); + // Rules of Hooks: the feature-flag gate (early-return) is moved to + // the very end of this hook list (see end of function). The previous + // shape did `if (!customerPortalEnabled) return null` BEFORE the + // useState/useRef/useEffect calls below, which caused the hook count + // to differ between renders the moment the React Query for + // /admin/feature-flags resolved (first render: enabled=false from + // DEFAULT_FLAGS → return null; second render: enabled=true → hooks + // run → "Rendered more hooks than during the previous render" + // crash). That tanked the entire /admin/events/new page through + // the global error boundary. PR #458 reviewer flag. const customerPortalEnabled = useFeatureEnabled('customerPortal'); - - // Gate the entire picker on the customerPortal feature flag. When off, - // the backend returns 410 on /admin/customers/search anyway, but hiding - // the UI here keeps the event form clean and removes the dangling - // "Customer accounts" label that would otherwise appear above an - // empty/error placeholder. - if (!customerPortalEnabled) return null; const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [isOpen, setIsOpen] = useState(false); @@ -107,6 +110,14 @@ export const CustomerAccountPicker: React.FC = ({ value, onChange, disabl [t] ); + // Feature-flag gate (deliberately placed AFTER all hooks — see the + // long comment at the top of this component for why). When the + // customerPortal flag is off the backend returns 410 on + // /admin/customers/search anyway, but hiding the UI here keeps the + // event form clean and removes the dangling "Customer accounts" + // label that would otherwise appear above an empty placeholder. + if (!customerPortalEnabled) return null; + return (
) : results.length === 0 ? (
- {t('events.customerPicker.noResults', 'No matches. Invite this customer from /admin/customers first.')} + {t('events.customerPicker.noResults', 'No matches. Invite this customer from Clients → Accounts first.')}
) : (
    diff --git a/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx b/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx index 27a8c92d..a461104c 100644 --- a/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx +++ b/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx @@ -120,7 +120,7 @@ export const CustomerDashboardBrandingCard: React.FC = () => { return (
    -
    +
    diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index da78d965..4b20a73c 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -19,11 +19,14 @@ export const DEFAULT_FLAGS: FeatureFlags = { messaging: false, analytics: true, userManagement: true, - // Customer portal (#354) defaults OFF on a fresh install — picpeak + // Top-level Clients section (#354 follow-up). Migration 097 mirrors + // the install's current customerPortal value so admins who already + // had the portal enabled keep seeing the section after upgrade. + clients: false, + // Customer portal (#354). Defaults OFF on a fresh install — picpeak // ships as a focused gallery delivery tool, recurring-customer - // logins are opt-in. Migration 094 flips this to TRUE on existing - // installs (events>0) so the customer-portal foundation isn't - // silently disabled mid-deployment. + // logins are opt-in. Migration 095 flips this to TRUE on existing + // installs (events>0). customerPortal: false, }; @@ -56,13 +59,15 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags { 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 - // Customer-portal-dependent flags: if the customer portal is OFF the - // customer-side dashboard never renders, so the calendar/quotes/bills/ - // messaging customer-side surfaces have nowhere to live. Their server - // toggles can stay at whatever the admin set (so re-enabling the - // portal restores the previous state), but the dependency is - // documented here for the FeaturesTab UI to disable child cards - // visually when customerPortal is off. + // Clients parent flag is DERIVED from its children. Admins don't + // toggle it directly — enabling any CRM-area sub-feature + // (Accounts today; future Calendar / Quotes / Bills / Messaging) + // lights up the Clients sidebar section automatically, and + // disabling all of them hides it again. + out.clients = Boolean( + out.customerPortal + // future siblings: || out.calendar || out.quotes || out.bills || out.messaging + ); return out; } 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) { diff --git a/frontend/src/features/settings/components/FeatureCard.tsx b/frontend/src/features/settings/components/FeatureCard.tsx index 196a20ce..8bc2161b 100644 --- a/frontend/src/features/settings/components/FeatureCard.tsx +++ b/frontend/src/features/settings/components/FeatureCard.tsx @@ -46,12 +46,15 @@ export const FeatureCard: React.FC = ({ )} >
    - {/* Icon tile */} + {/* Icon tile — enabled state uses the admin's CI accent (via + .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 b012e724..d9f9ee00 100644 --- a/frontend/src/features/settings/components/SidebarPreview.tsx +++ b/frontend/src/features/settings/components/SidebarPreview.tsx @@ -60,8 +60,12 @@ export const SidebarPreview: React.FC = ({ staged }) => { key={item.key} 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, with + // .text-on-accent-soft as the legible foreground (the + // accent token itself washes out on its own tint). 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-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', )} > @@ -73,7 +77,7 @@ export const SidebarPreview: React.FC = ({ staged }) => {

    {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/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index 62ac2e7e..ef41785a 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -12,6 +12,7 @@ import { BarChart3, Users, UserCog, + Briefcase, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Card } from '../../../components/common'; @@ -69,7 +70,7 @@ export const FeaturesTab: React.FC = () => { {/* Header */}
    -
    +
    @@ -108,24 +109,30 @@ export const FeaturesTab: React.FC = () => { /> - {/* Customer accounts (#354) — recurring customer logins. The - calendar / quotes / bills / messaging cards below are - customer-side surfaces; they only render in the customer - dashboard when this is on. */} -
    + {/* Clients (#354 follow-up). Visual grouping for the CRM-area + sub-features. The "Clients" sidebar section itself is gated + by a derived `clients` flag (computed from whether any + child below is on), so there's no explicit parent toggle — + admins just enable the specific feature they want and the + section appears automatically. */} +
    setFlag('customerPortal', next)} /> + {/* Future sub-features (Calendar / Quotes / Bills / Messaging) + slot in here as FeatureCard entries when they ship. No + placeholder cards today — the Clients section just shows + what's actually built. */}
    {/* Communication */} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 74eec578..2f3f3cda 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -168,7 +168,7 @@ "cmsPages": "CMS-Seiten", "users": "Benutzer", "calendar": "Kalender", - "customers": "Kunden" + "clients": "Kunden" }, "eventTypes": { "title": "Veranstaltungsarten", @@ -1449,7 +1449,8 @@ "scheduling": "Terminplanung", "sales": "Vertrieb", "insights": "Auswertungen & Zugriff", - "customers": "Kunden" + "customers": "Kunden", + "clients": "Kunden" }, "status": { "stable": "stabil", @@ -1497,8 +1498,12 @@ "warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren." }, "customerPortal": { - "title": "Kundenportal", - "description": "Persistente Kunden-Logins. Wiederkehrende Kunden sehen alle zugeordneten Galerien an einem Ort — keine Passwörter pro Event. Grundlage für Kalender / Angebote / Rechnungen (die nur im Kundendashboard erscheinen, wenn diese Option aktiviert ist)." + "title": "Konten", + "description": "Persistente Kunden-Logins. Wiederkehrende Kunden sehen alle zugewiesenen Galerien an einem Ort — keine Passwörter pro Event. Kunden melden sich unter /customer/login an, verwaltet werden sie unter Kunden → Konten." + }, + "clients": { + "title": "Kunden", + "description": "Hauptschalter für den Kunden-Bereich in der Seitenleiste. Aus blendet den Bereich und alle Unterfunktionen aus, unabhängig von deren individuellen Schaltern – beim erneuten Einschalten wird der vorherige Zustand wiederhergestellt." } }, "customerSurface": { @@ -1771,6 +1776,17 @@ "straight": "Gerade", "angle": "Winkel", "curve": "Kurve" + }, + "loginLogo": { + "title": "Logo auf Login-Seiten", + "subtitle": "Wirkt nur auf /admin/login und /customer/login. Galerie und Admin-Chrome verwenden die Logo-Einstellungen oben.", + "frame": "Hellen Rahmen hinter dem Logo anzeigen", + "frameHelp": "Wenn aus, sitzt das Logo direkt auf dem Seitenhintergrund. Nützlich für Logos, die bereits einen eigenen Hintergrund haben.", + "size": "Logogröße auf den Login-Seiten", + "sizeSmall": "Klein", + "sizeMedium": "Mittel (Standard)", + "sizeLarge": "Groß", + "sizeXLarge": "Sehr groß" } }, "admin": { @@ -2843,5 +2859,17 @@ "success": "Kunde gelöscht", "error": "Kunde konnte nicht gelöscht werden" } + }, + "clients": { + "title": "Kunden", + "subtitle": "Kundenkonten, Termine, Angebote und Rechnungen für wiederkehrende Kunden.", + "navAriaLabel": "Kunden-Navigation", + "empty": { + "title": "Keine Kunden-Funktionen aktiviert", + "body": "Aktiviere „Konten\" (oder eine andere Kunden-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen." + }, + "subnav": { + "accounts": "Konten" + } } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2cfc2da2..079b5644 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -168,7 +168,7 @@ "cmsPages": "CMS Pages", "users": "Users", "calendar": "Calendar", - "customers": "Customers" + "clients": "Clients" }, "archives": { "title": "Archives", @@ -1088,7 +1088,8 @@ "scheduling": "Scheduling", "sales": "Sales", "insights": "Insights & Access", - "customers": "Customers" + "customers": "Customers", + "clients": "Clients" }, "status": { "stable": "stable", @@ -1136,8 +1137,12 @@ "warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this." }, "customerPortal": { - "title": "Customer portal", - "description": "Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on)." + "title": "Accounts", + "description": "Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Customers log in at /customer/login and you manage them under Clients → Accounts." + }, + "clients": { + "title": "Clients", + "description": "Master switch for the Clients sidebar section. Off hides the section and every sub-feature below regardless of their individual toggles — re-enable to restore them to whatever you set last." } }, "customerSurface": { @@ -1441,6 +1446,17 @@ "angle": "Angle", "curve": "Curve", "none": "None" + }, + "loginLogo": { + "title": "Login pages logo", + "subtitle": "Controls only /admin/login and /customer/login. Gallery and admin chrome use the logo settings above.", + "frame": "Show tinted frame behind the logo", + "frameHelp": "When off, the logo sits directly on the page background. Useful for logos that already include their own backdrop.", + "size": "Logo size on login pages", + "sizeSmall": "Small", + "sizeMedium": "Medium (default)", + "sizeLarge": "Large", + "sizeXLarge": "Extra large" } }, "admin": { @@ -2843,5 +2859,17 @@ "success": "Customer erased", "error": "Could not erase customer" } + }, + "clients": { + "title": "Clients", + "subtitle": "Customer accounts, scheduling, quotes and billing for recurring clients.", + "navAriaLabel": "Clients navigation", + "empty": { + "title": "No client features enabled", + "body": "Enable Accounts (or another Clients sub-feature) under Settings → Features to get started." + }, + "subnav": { + "accounts": "Accounts" + } } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index efff4521..fe92cd25 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -169,7 +169,7 @@ "backup": "Sauvegarde et Restauration", "cmsPages": "Pages CMS", "users": "Utilisateurs", - "customers": "Clients" + "clients": "Clients" }, "archives": { "title": "Archives", @@ -1071,11 +1071,16 @@ "navAriaLabel": "Navigation des paramètres", "features": { "sections": { - "customers": "Clients" + "customers": "Clients", + "clients": "Clients" }, "customerPortal": { - "title": "Portail client", - "description": "Connexions client persistantes. Les clients récurrents voient toutes leurs galeries assignées au même endroit — plus de mots de passe par événement. Base pour Calendrier / Devis / Factures (qui n'apparaissent dans le tableau de bord client que lorsque cette option est activée)." + "title": "Comptes", + "description": "Connexions client persistantes. Les clients récurrents voient toutes leurs galeries assignées au même endroit — pas de mots de passe par événement. Les clients se connectent sur /customer/login et vous les gérez sous Clients → Comptes." + }, + "clients": { + "title": "Clients", + "description": "Interrupteur principal pour la section Clients de la barre latérale. Désactivé, masque la section et toutes les sous-fonctionnalités quelles que soient leurs bascules individuelles — réactiver les restaure dans leur état précédent." } }, "customerSurface": { @@ -1379,6 +1384,17 @@ "straight": "Droit", "angle": "Angle", "curve": "Courbe" + }, + "loginLogo": { + "title": "Logo des pages de connexion", + "subtitle": "S'applique uniquement à /admin/login et /customer/login. La galerie et l'interface admin utilisent les paramètres de logo ci-dessus.", + "frame": "Afficher le cadre teinté derrière le logo", + "frameHelp": "Lorsque désactivé, le logo se pose directement sur l'arrière-plan de la page. Utile pour les logos qui ont déjà leur propre fond.", + "size": "Taille du logo sur les pages de connexion", + "sizeSmall": "Petite", + "sizeMedium": "Moyenne (par défaut)", + "sizeLarge": "Grande", + "sizeXLarge": "Très grande" } }, "admin": { @@ -2797,5 +2813,17 @@ "success": "Client effacé", "error": "Impossible d'effacer le client" } + }, + "clients": { + "title": "Clients", + "subtitle": "Comptes clients, planification, devis et facturation pour les clients récurrents.", + "navAriaLabel": "Navigation Clients", + "empty": { + "title": "Aucune fonctionnalité client activée", + "body": "Activez Comptes (ou une autre sous-fonctionnalité Clients) dans Paramètres → Fonctionnalités pour commencer." + }, + "subnav": { + "accounts": "Comptes" + } } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 7cdf19cd..01af8d6b 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -168,7 +168,7 @@ "cmsPages": "CMS-pagina's", "users": "Gebruikers", "calendar": "Agenda", - "customers": "Klanten" + "clients": "Klanten" }, "archives": { "title": "Archieven", @@ -1088,7 +1088,8 @@ "scheduling": "Planning", "sales": "Verkoop", "insights": "Inzichten & Toegang", - "customers": "Klanten" + "customers": "Klanten", + "clients": "Klanten" }, "status": { "stable": "stabiel", @@ -1136,8 +1137,12 @@ "warning": "Bestaande gebruikersaccounts blijven geldig; de admin-interface om ze te beheren wordt verborgen totdat u dit weer inschakelt." }, "customerPortal": { - "title": "Klantenportaal", - "description": "Permanente klantlogins. Terugkerende klanten zien al hun toegewezen galerijen op één plek — geen wachtwoorden per evenement. Basis voor Agenda / Offertes / Facturen (die alleen in het klantdashboard verschijnen als dit aan staat)." + "title": "Accounts", + "description": "Permanente klantlogins. Terugkerende klanten zien al hun toegewezen galerijen op één plek — geen wachtwoorden per evenement. Klanten loggen in op /customer/login en je beheert ze onder Klanten → Accounts." + }, + "clients": { + "title": "Klanten", + "description": "Hoofdschakelaar voor het Klanten-onderdeel in de zijbalk. Uitgeschakeld verbergt het onderdeel en alle subfuncties ongeacht hun individuele schakelaars — opnieuw inschakelen herstelt de vorige status." } }, "customerSurface": { @@ -1441,6 +1446,17 @@ "straight": "Recht", "angle": "Hoek", "curve": "Boog" + }, + "loginLogo": { + "title": "Logo op loginpagina", + "subtitle": "Geldt alleen voor /admin/login en /customer/login. Galerij en beheer gebruiken de logo-instellingen hierboven.", + "frame": "Getint kader achter het logo tonen", + "frameHelp": "Wanneer uitgeschakeld staat het logo direct op de pagina-achtergrond. Handig voor logo's met een eigen achtergrond.", + "size": "Logogrootte op loginpagina's", + "sizeSmall": "Klein", + "sizeMedium": "Middel (standaard)", + "sizeLarge": "Groot", + "sizeXLarge": "Extra groot" } }, "admin": { @@ -2843,5 +2859,17 @@ "success": "Klant gewist", "error": "Klant kon niet worden gewist" } + }, + "clients": { + "title": "Klanten", + "subtitle": "Klantaccounts, planning, offertes en facturering voor terugkerende klanten.", + "navAriaLabel": "Klanten-navigatie", + "empty": { + "title": "Geen klantfuncties ingeschakeld", + "body": "Schakel Accounts (of een andere Klanten-subfunctie) in onder Instellingen → Functies om te beginnen." + }, + "subnav": { + "accounts": "Accounts" + } } } diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index d2c23d79..80268c69 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -171,7 +171,7 @@ "cmsPages": "Páginas CMS", "users": "Utilizadores", "calendar": "Calendário", - "customers": "Clientes" + "clients": "Clientes" }, "archives": { "title": "Arquivos", @@ -1105,7 +1105,8 @@ "scheduling": "Agendamento", "sales": "Vendas", "insights": "Análises & Acesso", - "customers": "Clientes" + "customers": "Clientes", + "clients": "Clientes" }, "status": { "stable": "estável", @@ -1153,8 +1154,12 @@ "warning": "Contas de usuário existentes permanecem válidas; a interface administrativa para gerenciá-las ficará oculta até você reativar isto." }, "customerPortal": { - "title": "Portal do cliente", - "description": "Logins persistentes para clientes. Clientes recorrentes veem todas as galerias atribuídas em um só lugar — sem senhas por evento. Base para Calendário / Orçamentos / Faturas (que só aparecem no painel do cliente quando isto está ativo)." + "title": "Contas", + "description": "Logins persistentes para clientes. Clientes recorrentes veem todas as galerias atribuídas em um só lugar — sem senhas por evento. Os clientes entram em /customer/login e você os gerencia em Clientes → Contas." + }, + "clients": { + "title": "Clientes", + "description": "Chave principal da seção Clientes na barra lateral. Desligado oculta a seção e todas as sub-funcionalidades independentemente dos seus controles individuais — reativar restaura o estado anterior." } }, "customerSurface": { @@ -1458,6 +1463,17 @@ "straight": "Reto", "angle": "Ângulo", "curve": "Curva" + }, + "loginLogo": { + "title": "Logo nas páginas de login", + "subtitle": "Aplica-se apenas a /admin/login e /customer/login. A galeria e a interface admin usam as configurações de logo acima.", + "frame": "Mostrar moldura tonalizada atrás do logo", + "frameHelp": "Quando desativado, o logo fica diretamente sobre o fundo da página. Útil para logos que já incluem seu próprio fundo.", + "size": "Tamanho do logo nas páginas de login", + "sizeSmall": "Pequeno", + "sizeMedium": "Médio (padrão)", + "sizeLarge": "Grande", + "sizeXLarge": "Extra grande" } }, "admin": { @@ -2876,5 +2892,17 @@ "success": "Cliente apagado", "error": "Não foi possível apagar o cliente" } + }, + "clients": { + "title": "Clientes", + "subtitle": "Contas de clientes, agendamento, orçamentos e faturamento para clientes recorrentes.", + "navAriaLabel": "Navegação de Clientes", + "empty": { + "title": "Nenhuma funcionalidade de cliente ativada", + "body": "Ative Contas (ou outra sub-funcionalidade de Clientes) em Configurações → Recursos para começar." + }, + "subnav": { + "accounts": "Contas" + } } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index d9e5fb18..6a157dad 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -174,7 +174,7 @@ "cmsPages": "CMS-страницы", "users": "Пользователи", "calendar": "Календарь", - "customers": "Клиенты" + "clients": "Клиенты" }, "archives": { "title": "Архивы", @@ -1122,7 +1122,8 @@ "scheduling": "Планирование", "sales": "Продажи", "insights": "Аналитика и доступ", - "customers": "Клиенты" + "customers": "Клиенты", + "clients": "Клиенты" }, "status": { "stable": "стабильно", @@ -1170,8 +1171,12 @@ "warning": "Существующие учётные записи остаются действительными; интерфейс управления ими будет скрыт до повторного включения." }, "customerPortal": { - "title": "Клиентский портал", - "description": "Постоянные логины клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Основа для Календаря / Смет / Счетов (которые отображаются в клиентской панели только при включённой опции)." + "title": "Учётные записи", + "description": "Постоянные учётные записи клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Клиенты входят на /customer/login, а вы управляете ими в разделе Клиенты → Учётные записи." + }, + "clients": { + "title": "Клиенты", + "description": "Главный переключатель раздела «Клиенты» в боковой панели. Выключено — раздел и все подфункции скрыты независимо от их отдельных переключателей. При повторном включении состояние подфункций восстанавливается." } }, "customerSurface": { @@ -1475,6 +1480,17 @@ "straight": "Прямой", "angle": "Угол", "curve": "Кривая" + }, + "loginLogo": { + "title": "Логотип на страницах входа", + "subtitle": "Применяется только к /admin/login и /customer/login. Галерея и админ-панель используют настройки логотипа выше.", + "frame": "Показывать тонированный фон за логотипом", + "frameHelp": "Если выключено, логотип будет на фоне страницы. Полезно для логотипов с собственным фоном.", + "size": "Размер логотипа на страницах входа", + "sizeSmall": "Маленький", + "sizeMedium": "Средний (по умолчанию)", + "sizeLarge": "Большой", + "sizeXLarge": "Очень большой" } }, "admin": { @@ -2909,5 +2925,17 @@ "success": "Данные клиента стёрты", "error": "Не удалось стереть данные клиента" } + }, + "clients": { + "title": "Клиенты", + "subtitle": "Учётные записи клиентов, расписание, сметы и счета для постоянных клиентов.", + "navAriaLabel": "Навигация по клиентам", + "empty": { + "title": "Нет включённых клиентских функций", + "body": "Включите «Учётные записи» (или другую подфункцию «Клиенты») в Настройки → Функции, чтобы начать." + }, + "subnav": { + "accounts": "Учётные записи" + } } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 51d209eb..37a47798 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -300,6 +300,59 @@ 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. + * + * 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) 18%, transparent); + } + .dark .bg-accent-soft { + background-color: var(--color-accent-dark); + 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) 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; + } + /* * Selected-tile state for picker grids (Gallery Layout, Filter Bar Style, * Header Style, Hero Divider, Theme Presets). Used in place of the dim diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 842584fc..d9ece291 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -8,6 +8,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common'; import { useAdminAuth } from '../../contexts'; import { authService } from '../../services/auth.service'; import { usePublicSettings } from '../../hooks/usePublicSettings'; +import { resolveLoginLogoClasses } from '../../utils/loginLogoSize'; import { api } from '../../config/api'; export const AdminLoginPage: React.FC = () => { @@ -122,18 +123,32 @@ export const AdminLoginPage: React.FC = () => { return (
    - {/* Logo/Header */} + {/* Logo/Header — frame visibility and size are admin-controllable + via Branding → "Login pages logo" settings. Both knobs apply + to /admin/login and /customer/login exclusively. */}
    -
    - {companyName} -
    + {(() => { + const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size); + const showFrame = settingsData?.branding_login_logo_frame_enabled !== false; + return showFrame ? ( +
    + {companyName} +
    + ) : ( + {companyName} + ); + })()}

    {t('adminLogin.title')}

    {t('adminLogin.subtitle')}

    diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 43c00710..df0e98f9 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -34,6 +34,8 @@ export const BrandingPage: React.FC = () => { logo_display_mode: 'logo_and_text', hide_powered_by: false, force_color_mode: null, + login_logo_frame_enabled: true, + login_logo_size: 'medium', facebook_url: '', instagram_url: '', whatsapp_url: '', @@ -670,6 +672,58 @@ export const BrandingPage: React.FC = () => {
    + {/* Login-page-only logo controls (#354 follow-up). These do + NOT affect gallery/admin headers — those keep their own + logo_size knob above. */} +
    +

    + {t('branding.loginLogo.title', 'Login pages logo')} +

    +

    + {t('branding.loginLogo.subtitle', 'Controls only /admin/login and /customer/login. Gallery and admin chrome use the logo settings above.')} +

    + +
    + {/* Frame toggle */} + + + {/* Size selector */} +
    + + +
    +
    +
    + {/* White Label Settings */}

    {t('branding.whiteLabel', 'White Label')}

    diff --git a/frontend/src/pages/admin/CustomerDetailPage.tsx b/frontend/src/pages/admin/CustomerDetailPage.tsx index 3debacb5..b5a630cc 100644 --- a/frontend/src/pages/admin/CustomerDetailPage.tsx +++ b/frontend/src/pages/admin/CustomerDetailPage.tsx @@ -125,7 +125,7 @@ export const CustomerDetailPage: React.FC = () => { queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); toast.success(t('customers.deactivate.success', 'Customer deactivated')); - navigate('/admin/customers'); + navigate('/admin/clients/accounts'); }, onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')), }); @@ -152,7 +152,7 @@ export const CustomerDetailPage: React.FC = () => { queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); toast.success(t('customers.erase.success', 'Customer erased')); - navigate('/admin/customers'); + navigate('/admin/clients/accounts'); }, onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')), }); @@ -176,7 +176,7 @@ export const CustomerDetailPage: React.FC = () => {
    diff --git a/frontend/src/pages/admin/CustomerManagementPage.tsx b/frontend/src/pages/admin/CustomerManagementPage.tsx index 090cb930..10061659 100644 --- a/frontend/src/pages/admin/CustomerManagementPage.tsx +++ b/frontend/src/pages/admin/CustomerManagementPage.tsx @@ -444,7 +444,7 @@ export const CustomerManagementPage: React.FC = () => { {filteredCustomers.map((c) => ( - + {renderCustomerName(c)} 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. */} +

    {activeItem.label}

    diff --git a/frontend/src/pages/customer/CustomerLayout.tsx b/frontend/src/pages/customer/CustomerLayout.tsx index 3f26f5d9..3391371b 100644 --- a/frontend/src/pages/customer/CustomerLayout.tsx +++ b/frontend/src/pages/customer/CustomerLayout.tsx @@ -175,24 +175,32 @@ export const CustomerLayout: React.FC = () => { key={item.to} to={item.to} onClick={() => setSidebarOpen(false)} - className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2" - style={isActive ? { - backgroundColor: 'color-mix(in srgb, var(--color-accent) 12%, transparent)', - color: 'var(--color-accent)', - } : undefined} + className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${ + isActive + ? 'bg-accent-dark text-white' + : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100' + }`} > - - - {t(item.labelKey, item.fallback)} - - {/* Coming-soon pill for the gated entries. The pages - themselves are still placeholders even when the - admin has enabled access — the badge keeps that - promise honest. */} + {/* Mirrors AdminSidebar's active state exactly: solid + accent-dark pill, white icon and label, no extra + flex grow on the label so the pill width matches + what the admin chrome renders. */} + + {t(item.labelKey, item.fallback)} + {/* Coming-soon pill for the gated entries. Pushed to + the right of the label with ml-auto so it doesn't + add the flex-1 stretching that was throwing off + the active-pill visual. */} {item.feature && ( { const { t } = useTranslation(); @@ -113,30 +114,34 @@ export const CustomerLoginPage: React.FC = () => { style={{ backgroundColor: 'var(--color-background, #fafafa)' }} >
    - {/* 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). */}
    - {settingsData?.branding_login_logo_frame_enabled !== false ? ( -
    + {(() => { + const cls = resolveLoginLogoClasses(settingsData?.branding_login_logo_size); + const showFrame = settingsData?.branding_login_logo_frame_enabled !== false; + return showFrame ? ( +
    + {companyName} +
    + ) : ( {companyName} -
    - ) : ( - {companyName} - )} + ); + })()}

    {t('customer.login.title', 'Customer login')}

    diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts index 5aa692f7..118888dd 100644 --- a/frontend/src/services/featureFlags.service.ts +++ b/frontend/src/services/featureFlags.service.ts @@ -10,12 +10,15 @@ export type FeatureKey = | 'messaging' | 'analytics' | 'userManagement' - // Foundation flag for the customer-side surface (#354). Gates the - // /customer/* routes (login, dashboard, profile, accept-invite, - // reset-password) and the admin Customers management page. The - // calendar / calendarBooking / quotes / bills / messaging flags - // above hang off this — they only appear in the customer dashboard - // when customerPortal is also ON. + // 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 + // (login, dashboard, profile, accept-invite, reset-password) and + // the Accounts sub-page under Clients in the admin UI. | 'customerPortal'; export type FeatureFlags = Record; diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index b3640cde..0ee322cd 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -25,6 +25,12 @@ export interface PublicSettings { * AdminDarkModeContext + ThemeContext both honor this. */ branding_force_color_mode?: 'dark' | 'light' | null; + /** + * Login-page-only branding (#354 follow-up). Applies exclusively to + * /admin/login and /customer/login. Defaults: frame on, size 'medium'. + */ + branding_login_logo_frame_enabled?: boolean; + branding_login_logo_size?: 'small' | 'medium' | 'large' | 'xlarge'; // Footer overhaul (#441 + #440). Empty strings mean "hide". branding_facebook_url?: string; branding_instagram_url?: string; diff --git a/frontend/src/services/settings.service.ts b/frontend/src/services/settings.service.ts index 270461cd..40276c24 100644 --- a/frontend/src/services/settings.service.ts +++ b/frontend/src/services/settings.service.ts @@ -25,6 +25,13 @@ export interface BrandingSettings { * `colorMode` override is ignored. `null` means no force (default behavior). */ force_color_mode?: 'dark' | 'light' | null; + /** + * Login-page-only branding (#354 follow-up). Applies exclusively to + * /admin/login and /customer/login. Frame default is true; size + * default is 'medium' (matches the visual state before the toggle). + */ + login_logo_frame_enabled?: boolean; + login_logo_size?: 'small' | 'medium' | 'large' | 'xlarge'; // Footer overhaul (#441 + #440). Empty strings hide each social // icon individually; promo_markdown empty hides the slot for events // in 'inherit' mode. Position controls global default placement. @@ -309,7 +316,14 @@ export const settingsService = { ? 'dark' : rawSettings.branding_force_color_mode === 'light' ? 'light' - : null + : null, + // Login-only knobs (#354). Default to frame ON, size 'medium' so + // installs that haven't toggled them keep the visual state shipped + // before the controls existed. + login_logo_frame_enabled: this._parseBoolean(rawSettings.branding_login_logo_frame_enabled, true), + login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(rawSettings.branding_login_logo_size) + ? rawSettings.branding_login_logo_size + : 'medium' }; }, 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 - } }; diff --git a/frontend/src/utils/loginLogoSize.ts b/frontend/src/utils/loginLogoSize.ts new file mode 100644 index 00000000..020fdbf6 --- /dev/null +++ b/frontend/src/utils/loginLogoSize.ts @@ -0,0 +1,51 @@ +/** + * Login-page logo sizing (#354 follow-up). + * + * Used ONLY on /admin/login and /customer/login. The rest of the app + * (gallery headers, admin chrome) keeps its own `branding_logo_size` / + * `branding_logo_max_height` knobs — kept separate so admins can have a + * compact logo in the in-app header but a hero-sized one on the login + * splash. + * + * Returns Tailwind class strings for both modes: + * - `frame`: dimensions of the tinted square frame + the inner image. + * - `bare`: height/width of the standalone image when the frame is off. + */ +export type LoginLogoSize = 'small' | 'medium' | 'large' | 'xlarge'; + +interface LoginLogoClasses { + frameOuter: string; // tinted square dimensions + frameInner: string; // logo image inside the frame + bare: string; // logo image when frame is off +} + +const SIZE_MAP: Record = { + small: { + frameOuter: 'w-[140px] h-[105px]', + frameInner: 'w-[120px] h-[90px]', + bare: 'h-16 w-auto', + }, + medium: { + // Matches the visual state shipped before the size toggle existed. + frameOuter: 'w-[200px] h-[150px]', + frameInner: 'w-[180px] h-[130px]', + bare: 'h-24 w-auto', + }, + large: { + frameOuter: 'w-[260px] h-[195px]', + frameInner: 'w-[240px] h-[175px]', + bare: 'h-32 w-auto', + }, + xlarge: { + frameOuter: 'w-[320px] h-[240px]', + frameInner: 'w-[300px] h-[220px]', + bare: 'h-40 w-auto', + }, +}; + +export function resolveLoginLogoClasses(size: LoginLogoSize | string | undefined | null): LoginLogoClasses { + if (size && size in SIZE_MAP) return SIZE_MAP[size as LoginLogoSize]; + return SIZE_MAP.medium; +} + +export const LOGIN_LOGO_SIZES: LoginLogoSize[] = ['small', 'medium', 'large', 'xlarge'];