feat(admin+search): user-mgmt polish, role labels, search keyword index

Admin search now matches against per-card keyword lists so typing
"client portal", "smtp", "tier ladder" lands on the System Settings card
(which hosts those flags). The same keyword list extends the topbar
global search (NAV_CATALOG) so any setting key resolves from the cmd-K
input — settings results sort to the bottom of the dropdown beneath
entity hits.

User management:
- Third action button (Power/PowerOff) enables/disables sign-in from the
  desktop list; mobile card dropdown gains the same item. Backed by the
  existing userProfiles.isActive flag — withAuth already refuses
  disabled sessions with 403.
- UserForm collects first + last name (canonical) alongside displayName,
  with admin email-change behind a confirmation modal. On confirm we
  send the OLD address an automated "your admin changed your sign-in
  email" notice (new template at admin-email-change.ts) and rewrite
  the Better Auth user row.
- Phone field swaps the bare tel input for the shared PhoneInput
  (country combobox + AsYouType formatting + E.164 storage).
- "Manage permissions" link points to /admin/roles?focusUser=… as
  a stepping stone for the future fine-tuned-permissions UI.

Role names normalize through a new ROLE_LABELS + formatRole() helper
in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the
prettifyRoleName in role-list; user-list and user-card now render
"Sales Agent" instead of "sales_agent". Custom roles pass through
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:14:12 +02:00
parent 0ab7055cf1
commit 660553c074
19 changed files with 1257 additions and 400 deletions

View File

@@ -2,7 +2,31 @@
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { Search, X } from 'lucide-react';
import {
Bell,
BookOpen,
Briefcase,
Database,
FileText,
Globe,
HardDrive,
Inbox,
Key,
LayoutDashboard,
Mail,
Palette,
ScrollText,
Search,
Settings,
Shield,
Sliders,
Tag,
Upload,
Users,
UsersRound,
Webhook,
X,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -10,22 +34,309 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface AdminSection {
interface AdminSection {
href: string;
label: string;
description: string;
icon: LucideIcon;
/**
* Free-text aliases the search input also matches against. Use this to
* surface the inner setting keys exposed *inside* a section card so
* typing "client portal" finds the System Settings card that hosts
* `client_portal_enabled`, not just labels that literally contain the
* phrase.
*/
keywords?: string[];
}
export interface AdminGroup {
interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
// Catalog lives inside the client component so React Server Components don't
// need to serialize the lucide icon factories (functions / $$typeof refs)
// across the RSC boundary — that crash was the error the rep hit when the
// catalog lived in the server-component `page.tsx`. Adding a new admin
// surface? Append it to the matching group below.
const GROUPS: AdminGroup[] = [
{
title: 'Access',
description: 'Who can sign in and what they can do once they do.',
sections: [
{
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
keywords: ['accounts', 'staff', 'team', 'disable user', 'reset password', 'residential access'],
},
{
href: 'invitations',
label: 'Invitations',
description: 'Send invitations, track pending invites, and resend or revoke them.',
icon: Mail,
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
],
},
{
title: 'Configuration',
description: 'Branding, integrations, and per-port settings.',
sections: [
{
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'EOI signing service',
description:
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileText,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: Bell,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Palette,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
icon: Settings,
// Mirrors KNOWN_SETTINGS in settings-manager.tsx so a search for any
// individual flag jumps straight to this card. Keep in sync when
// adding new keys there.
keywords: [
'client portal',
'client portal enabled',
'ai',
'ai interest scoring',
'ai email drafts',
'invoice net10 discount',
'net-10',
'pipeline weights',
'pipeline stage weights',
'forecast',
'berth rules',
'berth status rules',
'inquiry contact email',
'inquiry notification recipients',
'residential notification recipients',
'eoi signers',
'developer',
'approver',
'countersign',
'recommender max oversize',
'recommender top n',
'recommender default count',
'fallthrough policy',
'fallthrough cooldown',
'heat weight recency',
'heat weight furthest stage',
'heat weight interest count',
'heat weight eoi count',
'tier ladder',
'hide late stage',
'documents show expired tab',
'expired tab',
'berths default currency',
'default currency',
],
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
],
},
{
title: 'Content',
description: 'Forms, templates, and labels that users see.',
sections: [
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'email-templates',
label: 'Email Templates',
description: 'Customize subject lines for transactional emails (portal, inquiry, invite).',
icon: Mail,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'vocabularies',
label: 'Vocabularies',
description:
'Per-port pick lists used across the CRM: interest temperatures, status reasons, tenure types, expense categories, document types.',
icon: BookOpen,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
],
},
{
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
{
href: 'inquiries',
label: 'Inquiry Inbox',
description:
'Submissions captured from the public marketing site (berth, residence, contact).',
icon: Inbox,
},
{
href: 'sends',
label: 'Send Log',
description: 'Brochure and per-berth PDF sends, with delivery failures surfaced for retry.',
icon: Mail,
},
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: UsersRound,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
},
{
title: 'Operations',
description: 'Health checks and disaster recovery.',
sections: [
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Backup posture + retention policy (read-only).',
icon: HardDrive,
},
{
href: 'storage',
label: 'Storage Backend',
description:
'Choose between S3-compatible object store or local filesystem; migrate between them.',
icon: HardDrive,
},
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding checklist',
description: 'Setup checklist for fresh ports (read-only references).',
icon: LayoutDashboard,
},
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{
href: 'ai',
label: 'AI configuration',
description:
'Master switch + provider credentials shared by every AI surface (OCR, berth-PDF parser, future recommender embeddings).',
icon: ScrollText,
keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'],
},
{
href: 'ocr',
label: 'Receipt OCR (per-feature)',
description: 'Provider, model, and confidence thresholds for the receipt scanner.',
icon: ScrollText,
keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'],
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
},
{
href: 'residential-stages',
label: 'Residential pipeline stages',
description:
'Configure stages residential interests flow through. Removing a stage with active interests prompts for reassignment.',
icon: ScrollText,
keywords: ['stages', 'pipeline', 'residential funnel', 'reassign'],
},
],
},
];
interface AdminSectionsBrowserProps {
portSlug: string;
groups: AdminGroup[];
}
/**
@@ -35,26 +346,25 @@ interface AdminSectionsBrowserProps {
*
* Match is substring against label + description + group title so a search
* for "tax" finds Document Templates (description mentions tax-id mergefield)
* as well as ID fields, without needing perfect spelling of the label.
* as well as the literal "Tax ID" field.
*/
export function AdminSectionsBrowser({ portSlug, groups }: AdminSectionsBrowserProps) {
export function AdminSectionsBrowser({ portSlug }: AdminSectionsBrowserProps) {
const [query, setQuery] = useState('');
const q = query.trim().toLowerCase();
// Flatten + filter when there's an active query; otherwise let the grouped
// view render. The grouped view is also memoised because the section count
// is large (30+) and the JSX otherwise rebuilds on every keystroke.
const filteredMatches = useMemo(() => {
if (!q) return null;
const matches: Array<AdminSection & { groupTitle: string }> = [];
for (const g of groups) {
for (const g of GROUPS) {
for (const s of g.sections) {
const hay = `${s.label} ${s.description} ${g.title}`.toLowerCase();
const hay = [s.label, s.description, g.title, ...(s.keywords ?? [])]
.join(' ')
.toLowerCase();
if (hay.includes(q)) matches.push({ ...s, groupTitle: g.title });
}
}
return matches;
}, [q, groups]);
}, [q]);
return (
<div className="space-y-6">
@@ -106,7 +416,7 @@ export function AdminSectionsBrowser({ portSlug, groups }: AdminSectionsBrowserP
</div>
)
) : (
groups.map((group) => (
GROUPS.map((group) => (
<section key={group.title} className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
@@ -133,7 +443,6 @@ function SectionCard({
}: {
portSlug: string;
section: AdminSection;
/** Optional "from group X" tag for search-result mode. */
groupTitle?: string;
}) {
const Icon = section.icon;