feat(customers): customer portal (#354) on top of feature-flags reorg

Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.

* New `customerPortal` feature flag (foundation flag for the
  not-yet-built calendar/quotes/bills/messaging customer
  surfaces). Defaults FALSE on fresh installs, TRUE on existing
  installs (events > 0) via migration 095 so live customer
  accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
  event_customer_assignments, customer_password_resets, plus
  RBAC permissions customers.view / .create / .delete granted
  to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
  deactivate, reset password) + /api/customer/auth/* +
  /api/customer/* (login, dashboard, accept-invite, reset).
  Customer JWT bypass minted via
  /api/customer/events/:slug/access-token so existing gallery
  middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
  customerPortal, with login / dashboard / accept-invite /
  reset pages and a customer-side sidebar layout.
  /admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
  Customer portal card. The maintainer's Features tab stays the
  single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
  when the flag is off; backend ignores customer_account_ids in
  that case instead of erroring the whole event save.

Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Luca
2026-05-11 00:05:20 +02:00
co-authored by Claude Opus 4.6
parent f2f48f31b0
commit 087ef45942
54 changed files with 9816 additions and 54 deletions
@@ -0,0 +1,203 @@
/**
* Customer-side React auth context (#354).
*
* Sibling of AdminAuthContext / GalleryAuthContext but operates on a
* separate cookie (customer_token) and a separate API surface
* (/api/customer/auth/*). The contexts are isolated by design so that
* a single browser can hold an admin session AND a customer session
* without one clobbering the other (e.g. for the admin dogfooding the
* customer dashboard).
*
* #354 follow-up: also surfaces the effective feature set and the
* branding visibility flags so CustomerLayout can render the sidebar
* without an extra round trip on every navigation.
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { customerService, type CustomerProfile } from '../services/customer.service';
export interface CustomerFeatureFlags {
calendar: boolean;
quotes: boolean;
bills: boolean;
}
export interface CustomerBrandingFlags {
showLogo: boolean;
showCompanyName: boolean;
}
interface CustomerAuthContextType {
isAuthenticated: boolean;
customer: CustomerProfile | null;
features: CustomerFeatureFlags;
branding: CustomerBrandingFlags;
isLoading: boolean;
error: string | null;
/** Replaces the cached profile after a successful POST /login. */
setCustomer: (c: CustomerProfile) => void;
/**
* Replaces customer + features + branding atomically. Used by the
* login page so the dashboard's first paint after login shows the
* correct sidebar (without this, features default to `false` and the
* Soon menus would only appear after the next CustomerAuthProvider
* re-mount, e.g. after navigating to a gallery and back).
*/
setSession: (s: { customer: CustomerProfile; features: CustomerFeatureFlags; branding: CustomerBrandingFlags }) => void;
logout: () => Promise<void>;
}
const CustomerAuthContext = createContext<CustomerAuthContextType | undefined>(undefined);
export const useCustomerAuth = () => {
const ctx = useContext(CustomerAuthContext);
if (!ctx) {
throw new Error('useCustomerAuth must be used within a CustomerAuthProvider');
}
return ctx;
};
const STORAGE_KEY = 'customer_profile';
const FEATURES_KEY = 'customer_features';
const BRANDING_KEY = 'customer_branding';
const DEFAULT_FEATURES: CustomerFeatureFlags = { calendar: false, quotes: false, bills: false };
const DEFAULT_BRANDING: CustomerBrandingFlags = { showLogo: true, showCompanyName: true };
interface ProviderProps { children: ReactNode; }
export const CustomerAuthProvider: React.FC<ProviderProps> = ({ children }) => {
const [customer, setCustomerState] = useState<CustomerProfile | null>(null);
const [features, setFeatures] = useState<CustomerFeatureFlags>(DEFAULT_FEATURES);
const [branding, setBranding] = useState<CustomerBrandingFlags>(DEFAULT_BRANDING);
const [isLoading, setIsLoading] = useState(true);
// Reserved for future surface-level errors (login form errors are
// handled inline on the login page itself, not here).
const [error] = useState<string | null>(null);
/**
* Refetch the session from /api/customer/auth/session and update both
* React state and sessionStorage caches. Called on initial mount AND
* on window focus, so an admin who toggles a per-customer feature in
* one tab sees the change reflected in the customer tab the moment
* they switch back. Without this, the layout reads only from the
* mount-time sessionStorage cache and stays stale until a hard reload.
*/
const refreshSession = React.useCallback(async () => {
try {
const response = await customerService.session();
if (response?.customer) {
setCustomerState(response.customer);
setFeatures(response.features);
setBranding(response.branding);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
} else {
setCustomerState(null);
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
}
} catch {
setCustomerState(null);
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
}
}, []);
useEffect(() => {
// Hydrate immediately from sessionStorage so the dashboard avoids
// a flicker on hard refresh; the network call below confirms the
// cookie is still valid and overwrites stale data.
try {
const cached = sessionStorage.getItem(STORAGE_KEY);
if (cached) setCustomerState(JSON.parse(cached));
const cachedFeatures = sessionStorage.getItem(FEATURES_KEY);
if (cachedFeatures) setFeatures(JSON.parse(cachedFeatures));
const cachedBranding = sessionStorage.getItem(BRANDING_KEY);
if (cachedBranding) setBranding(JSON.parse(cachedBranding));
} catch {
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
}
let cancelled = false;
refreshSession().finally(() => {
if (!cancelled) setIsLoading(false);
});
// Refetch on tab/window focus so admin-side changes (per-customer
// feature toggles, branding visibility, deactivation) reach the
// customer browser without requiring a manual page reload.
const onFocus = () => { void refreshSession(); };
const onVisibility = () => {
if (document.visibilityState === 'visible') void refreshSession();
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVisibility);
// Periodic background refresh — covers the case where the customer
// tab stays foregrounded for a long stretch (no focus/visibility
// events fire) but admin has flipped a global toggle in another
// browser. 60 seconds matches the usePublicSettings react-query
// staleTime so branding + feature flags stay roughly in sync.
const interval = window.setInterval(() => {
if (document.visibilityState === 'visible') void refreshSession();
}, 60_000);
return () => {
cancelled = true;
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisibility);
window.clearInterval(interval);
};
}, [refreshSession]);
const setCustomer = (c: CustomerProfile) => {
setCustomerState(c);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(c));
};
const setSession = (s: { customer: CustomerProfile; features: CustomerFeatureFlags; branding: CustomerBrandingFlags }) => {
setCustomerState(s.customer);
setFeatures(s.features);
setBranding(s.branding);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s.customer));
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(s.features));
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(s.branding));
};
const logout = async () => {
await customerService.logout();
setCustomerState(null);
setFeatures(DEFAULT_FEATURES);
setBranding(DEFAULT_BRANDING);
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
// Hard navigate so any in-flight requests with the old cookie don't
// race the cleared session — same approach AdminAuthContext uses.
window.location.href = '/customer/login';
};
return (
<CustomerAuthContext.Provider
value={{
isAuthenticated: !!customer,
customer,
features,
branding,
isLoading,
error,
setCustomer,
setSession,
logout,
}}
>
{children}
</CustomerAuthContext.Provider>
);
};
@@ -19,6 +19,12 @@ export const DEFAULT_FLAGS: FeatureFlags = {
messaging: false,
analytics: true,
userManagement: true,
// 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.
customerPortal: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -50,9 +56,27 @@ 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.
return out;
}
/**
* Flags whose customer-side surface only renders when the customer
* portal is on. The FeaturesTab uses this to disable the toggle on
* child cards when customerPortal=false (with a "requires Customer
* portal" tooltip), so the admin doesn't flip something that has no
* visible effect.
*/
export const CUSTOMER_PORTAL_DEPENDENT_FLAGS: FeatureKey[] = [
'calendar', 'calendarBooking', 'quotes', 'bills', 'messaging',
];
function flagsEqual(a: FeatureFlags, b: FeatureFlags): boolean {
return (Object.keys(a) as FeatureKey[]).every((k) => a[k] === b[k]);
}