-
+ {/* 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 ? (
+
+

+
+ ) : (

-
- ) : (
-

- )}
+ );
+ })()}
{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'];