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