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