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:
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user