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
@@ -8,6 +8,7 @@ import {
Settings,
X,
Users,
UserCog,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -46,6 +47,12 @@ 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' },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
@@ -0,0 +1,202 @@
/**
* CustomerAccountPicker (#354).
*
* Multi-select autocomplete used on the event create / edit forms to
* assign customer accounts to an event. Anyone selected here gets
* dashboard access + can bypass the per-event password.
*
* Backed by GET /api/admin/customers/search (debounced 200ms).
* Selected values render as removable chips so the form can stay compact.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Search, X, UserPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { customerAdminService, type CustomerAccountSummary } from '../../services/customerAdmin.service';
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
export interface SelectedCustomer {
id: number;
email: string;
displayName: string | null;
}
interface Props {
value: SelectedCustomer[];
onChange: (next: SelectedCustomer[]) => void;
disabled?: boolean;
}
const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => {
const display = c.displayName?.trim() || c.companyName?.trim();
return display ? `${display} · ${c.email}` : c.email;
};
export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabled }) => {
const { t } = useTranslation();
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<CustomerAccountSummary[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Debounced search. Aborts in-flight requests so a fast typer doesn't
// see an old result win the race over a newer one.
useEffect(() => {
const term = query.trim();
if (!term) {
setResults([]);
setIsSearching(false);
return;
}
setIsSearching(true);
let cancelled = false;
const handle = window.setTimeout(async () => {
try {
const rows = await customerAdminService.search(term);
if (!cancelled) {
// Filter out already-selected ids on the client. Cheaper than
// round-tripping the selection state to the server.
const selectedIds = new Set(value.map((v) => v.id));
setResults(rows.filter((r) => !selectedIds.has(r.id)));
}
} catch {
if (!cancelled) setResults([]);
} finally {
if (!cancelled) setIsSearching(false);
}
}, 200);
return () => { cancelled = true; window.clearTimeout(handle); };
}, [query, value]);
// Click-outside to close. Listening on mousedown matches what the
// existing AdminHeader notification dropdown uses.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, []);
const select = (c: CustomerAccountSummary) => {
onChange([...value, { id: c.id, email: c.email, displayName: c.displayName }]);
setQuery('');
setResults([]);
setIsOpen(false);
};
const remove = (id: number) => {
onChange(value.filter((v) => v.id !== id));
};
const helpText = useMemo(
() => t(
'events.customerPicker.help',
'Customers added here can log in at /customer/login and view this gallery without entering the per-event password.'
),
[t]
);
return (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-theme mb-1">
{t('events.customerPicker.label', 'Customer accounts')}
</label>
<p className="text-xs text-muted-theme mb-2">{helpText}</p>
{/* Selected chips */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{value.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
style={{
backgroundColor: 'var(--color-elevated, #f5f5f5)',
color: 'var(--color-text)',
border: '1px solid var(--color-surface-border, #e5e5e5)',
}}
>
<span className="font-medium">{c.displayName?.trim() || c.email}</span>
{c.displayName?.trim() && c.email !== c.displayName && (
<span className="text-muted-theme">· {c.email}</span>
)}
{!disabled && (
<button
type="button"
onClick={() => remove(c.id)}
className="ml-1 -mr-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 p-0.5"
aria-label={t('events.customerPicker.removeAria', 'Remove {{name}}', { name: c.email })}
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
{/* Search input */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
disabled={disabled}
placeholder={t('events.customerPicker.placeholder', 'Search by email, name, or company')}
className="input pl-9"
/>
</div>
{/* Dropdown */}
{isOpen && query.trim() !== '' && (
<div
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto"
style={{
backgroundColor: 'var(--color-surface, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
}}
>
{isSearching ? (
<div className="px-3 py-3 text-sm text-muted-theme">
{t('events.customerPicker.searching', 'Searching…')}
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-theme">
{t('events.customerPicker.noResults', 'No matches. Invite this customer from /admin/customers first.')}
</div>
) : (
<ul role="listbox">
{results.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => select(r)}
className="w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<UserPlus className="w-4 h-4 text-muted-theme flex-shrink-0" />
<span className="flex-1 truncate">{labelFor(r)}</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
};
export default CustomerAccountPicker;