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

@@ -1,260 +1,5 @@
import {
Bell,
BookOpen,
Briefcase,
Database,
FileText,
HardDrive,
Inbox,
Key,
LayoutDashboard,
Mail,
Palette,
ScrollText,
Settings,
Shield,
Sliders,
Tag,
Upload,
Users,
UsersRound,
Webhook,
Globe,
} from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { AdminSectionsBrowser, type AdminGroup } from '@/components/admin/admin-sections-browser';
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,
},
{
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,
},
{
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,
},
{
href: 'ocr',
label: 'Receipt OCR (per-feature)',
description: 'Provider, model, and confidence thresholds for the receipt scanner.',
icon: ScrollText,
},
{
href: 'website-analytics',
label: 'Website analytics (Umami)',
description: 'Per-port Umami URL, API token, and Website ID.',
icon: Globe,
},
{
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,
},
],
},
];
import { AdminSectionsBrowser } from '@/components/admin/admin-sections-browser';
export default async function AdminLandingPage({
params,
@@ -268,7 +13,7 @@ export default async function AdminLandingPage({
title="Administration"
description="Per-port configuration and system administration. Use the search to jump to a setting, or browse the grouped index below."
/>
<AdminSectionsBrowser portSlug={portSlug} groups={GROUPS} />
<AdminSectionsBrowser portSlug={portSlug} />
</div>
);
}

View File

@@ -1,5 +1,45 @@
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { userProfiles, type UserPreferences } from '@/lib/db/schema/users';
import { DashboardShell } from '@/components/dashboard/dashboard-shell';
export default function DashboardPage() {
return <DashboardShell />;
/**
* Prefetch the user's first name + dashboard widget visibility server-side so
* the dashboard renders its first paint with the rep's name and saved layout
* already populated. Without this prefetch the page flickered three times on
* cold cache: SSR fallback → /me arrives (firstName lands) → /preferences
* arrives (widget layout reflows). All three caches are seeded synchronously
* from the same DB row so the post-mount useQuery resolves instantly.
*/
export default async function DashboardPage() {
// Resolve the signed-in user from the session cookie. The (dashboard)
// layout already gates unauthenticated access; this is the second-pass
// lookup that gives us the profile row.
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
// The outer layout will redirect; bail with the un-prefetched shell so
// we don't crash if this server component is invoked in a non-auth
// context (e.g. a future preview / RSC sub-route).
return <DashboardShell />;
}
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, session.user.id),
columns: {
firstName: true,
preferences: true,
},
});
const prefs = (profile?.preferences ?? {}) as UserPreferences;
return (
<DashboardShell
initialFirstName={profile?.firstName ?? null}
initialWidgetVisibility={prefs.dashboardWidgets ?? null}
/>
);
}

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;

View File

@@ -18,23 +18,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
import { RoleForm } from './role-form';
/**
* Display-normalize a stored role name. Roles are stored with whatever
* key the admin entered (snake_case, kebab-case, free text). For UI
* display we titlecase + space-separate so "super_admin" reads as
* "Super Admin" and "Some role!" reads as "Some Role!" Display-only —
* code paths that compare role names use the stored verbatim value.
*/
function prettifyRoleName(name: string): string {
return name
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
interface Role {
id: string;
name: string;
@@ -109,7 +95,7 @@ export function RoleList() {
created roles with arbitrary keys still read cleanly.
The underlying name is stored verbatim and is what code
checks against — display is purely cosmetic. */}
<span className="font-medium">{prettifyRoleName(row.original.name)}</span>
<span className="font-medium">{formatRole(row.original.name)}</span>
{row.original.isSystem && (
<Badge variant="outline" className="text-xs">
<Lock className="mr-1 h-3 w-3" />
@@ -219,7 +205,7 @@ export function RoleList() {
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Permissions {viewingPermissions ? prettifyRoleName(viewingPermissions.name) : ''}
Permissions {viewingPermissions ? formatRole(viewingPermissions.name) : ''}
</DialogTitle>
<DialogDescription>
Granted vs total per resource. Click Edit to change.

View File

@@ -1,8 +1,18 @@
'use client';
import { Clock, Mail, MoreHorizontal, Pencil, Shield, Trash2 } from 'lucide-react';
import {
Clock,
Mail,
MoreHorizontal,
Pencil,
Power,
PowerOff,
Shield,
Trash2,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { formatRole } from '@/lib/constants';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -35,10 +45,19 @@ interface UserCardProps {
user: UserRow;
onEdit: (user: UserRow) => void;
onRemove: (userId: string) => void;
onToggleActive: (user: UserRow) => void;
isRemoving: boolean;
isToggling: boolean;
}
export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps) {
export function UserCard({
user,
onEdit,
onRemove,
onToggleActive,
isRemoving,
isToggling,
}: UserCardProps) {
const initials = deriveInitials(user.displayName || user.email);
const accentClass = user.isSuperAdmin
@@ -75,6 +94,32 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<ConfirmationDialog
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()} disabled={isToggling}>
{user.isActive ? (
<>
<PowerOff className="mr-2 h-3.5 w-3.5" />
Disable sign-in
</>
) : (
<>
<Power className="mr-2 h-3.5 w-3.5 text-emerald-600" />
Enable sign-in
</>
)}
</DropdownMenuItem>
}
title={user.isActive ? 'Disable user' : 'Enable user'}
description={
user.isActive
? `Disable sign-in for "${user.displayName}"? Their account stays intact; they just can't log in until you re-enable.`
: `Re-enable sign-in for "${user.displayName}"?`
}
confirmLabel={user.isActive ? 'Disable' : 'Enable'}
onConfirm={() => onToggleActive(user)}
loading={isToggling}
/>
<ConfirmationDialog
trigger={
<DropdownMenuItem className="text-destructive" onSelect={(e) => e.preventDefault()}>
@@ -118,7 +163,9 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
{/* Role + last login meta */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
<ListCardMeta icon={<Shield className="h-3 w-3" />}>{user.role.name}</ListCardMeta>
<ListCardMeta icon={<Shield className="h-3 w-3" />}>
{formatRole(user.role.name)}
</ListCardMeta>
{user.lastLoginAt ? (
<ListCardMeta icon={<Clock className="h-3 w-3" />}>

View File

@@ -2,6 +2,7 @@
import { formatErrorBanner } from '@/lib/api/toast-error';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,7 +15,20 @@ import {
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useUIStore } from '@/stores/ui-store';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
interface Role {
id: string;
@@ -27,6 +41,9 @@ interface UserFormProps {
user?: {
userId: string;
displayName: string;
fullName?: string | null;
firstName?: string | null;
lastName?: string | null;
email: string;
phone: string | null;
isActive: boolean;
@@ -38,18 +55,23 @@ interface UserFormProps {
export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {
const [roles, setRoles] = useState<Role[]>([]);
const [name, setName] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [originalEmail, setOriginalEmail] = useState('');
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(null);
const [roleId, setRoleId] = useState('');
const [isActive, setIsActive] = useState(true);
const [residentialAccess, setResidentialAccess] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const portSlug = useUIStore((s) => s.currentPortSlug);
const isEdit = !!user;
const fullName = `${firstName} ${lastName}`.trim();
useEffect(() => {
if (open) {
@@ -60,19 +82,38 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
useEffect(() => {
if (open) {
if (user) {
setName(user.displayName);
// Prefer canonical first/last from the API; fall back to a best-
// effort split of displayName for older records that pre-date the
// first_name/last_name columns.
const first = user.firstName ?? '';
const last = user.lastName ?? '';
if (first || last) {
setFirstName(first);
setLastName(last);
} else if (user.fullName) {
const parts = user.fullName.split(/\s+/);
setFirstName(parts[0] ?? '');
setLastName(parts.slice(1).join(' '));
} else {
const parts = user.displayName.split(/\s+/);
setFirstName(parts[0] ?? '');
setLastName(parts.slice(1).join(' '));
}
setEmail(user.email);
setOriginalEmail(user.email);
setDisplayName(user.displayName);
setPhone(user.phone ?? '');
setPhoneValue(user.phone ? { e164: user.phone, country: 'US' } : null);
setRoleId(user.role.id);
setIsActive(user.isActive);
setResidentialAccess(user.residentialAccess ?? false);
setPassword('');
} else {
setName('');
setFirstName('');
setLastName('');
setEmail('');
setOriginalEmail('');
setDisplayName('');
setPhone('');
setPhoneValue(null);
setRoleId('');
setIsActive(true);
setResidentialAccess(false);
@@ -82,32 +123,53 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
}
}, [open, user]);
async function handleSubmit(e: React.FormEvent) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Admin email change for an existing user goes through a confirmation
// dialog because it locks the original sign-in identity out — the
// submit path runs after the admin acknowledges. New-user creation
// and same-email saves go straight through.
if (isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase()) {
setEmailConfirmOpen(true);
return;
}
void persist();
}
async function persist() {
setError(null);
setLoading(true);
const phoneE164 = phoneValue?.e164 ?? null;
try {
if (isEdit) {
const emailChanged = email.trim().toLowerCase() !== originalEmail.toLowerCase();
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: {
firstName: firstName || null,
lastName: lastName || null,
fullName: fullName || displayName,
displayName,
phone: phone || null,
email: emailChanged ? email.trim() : undefined,
phone: phoneE164,
roleId,
isActive,
residentialAccess,
notifyEmailChange: emailChanged ? true : undefined,
},
});
} else {
await apiFetch('/api/v1/admin/users', {
method: 'POST',
body: {
name: name || displayName,
name: fullName || displayName,
firstName: firstName || null,
lastName: lastName || null,
email,
password,
displayName,
phone: phone || undefined,
phone: phoneE164 ?? undefined,
roleId,
residentialAccess,
},
@@ -131,53 +193,89 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
{!isEdit && (
<>
<div className="space-y-2">
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
</>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="user-first-name">First name</Label>
<Input
id="user-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="user-last-name">Last name</Label>
<Input
id="user-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="user-display-name">Display Name</Label>
<Label htmlFor="user-display-name">Display name</Label>
<Input
id="user-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="John Smith"
placeholder={fullName || 'Jane Doe'}
required
/>
<p className="text-xs text-muted-foreground">
How this user appears across the app usually their full name, but they can pick a
nickname.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<Label htmlFor="user-email">Email</Label>
<Input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
/>
{isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? (
<p className="text-xs text-amber-600">
You&apos;ll be asked to confirm the original address will receive an automated
notice that you, the admin, changed their sign-in email.
</p>
) : isEdit ? (
<p className="text-xs text-muted-foreground">
Changing this address is an admin-only override; the user will be notified at the
old address.
</p>
) : null}
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min 12 characters"
minLength={12}
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
<PhoneInput
id="user-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 555-0123"
value={phoneValue}
onChange={setPhoneValue}
placeholder="Phone number"
/>
</div>
@@ -190,7 +288,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
{formatRole(r.name)}
</SelectItem>
))}
</SelectContent>
@@ -215,13 +313,30 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
{isEdit && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-active">Account Active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in</p>
<Label htmlFor="user-active">Account active</Label>
<p className="text-xs text-muted-foreground">Disabled users cannot sign in.</p>
</div>
<Switch id="user-active" checked={isActive} onCheckedChange={setIsActive} />
</div>
)}
{isEdit && portSlug && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm font-medium">Fine-tuned permissions</p>
<p className="text-xs text-muted-foreground">
The selected role grants a baseline. To add or remove a specific permission for
this user only, open the role &amp; permissions page.
</p>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/admin/roles?focusUser=${user.userId}` as any}
className="mt-2 inline-block text-xs font-medium text-primary hover:underline"
>
Manage permissions
</Link>
</div>
)}
{error && <p className="whitespace-pre-line text-sm text-destructive">{error}</p>}
<SheetFooter>
@@ -234,10 +349,37 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps)
Cancel
</Button>
<Button type="submit" disabled={loading || !displayName.trim() || !roleId}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create User'}
{loading ? 'Saving...' : isEdit ? 'Save changes' : 'Create user'}
</Button>
</SheetFooter>
</form>
<AlertDialog open={emailConfirmOpen} onOpenChange={setEmailConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Change this user&apos;s sign-in email?</AlertDialogTitle>
<AlertDialogDescription>
You&apos;re about to change <span className="font-medium">{originalEmail}</span> to{' '}
<span className="font-medium">{email}</span>. From now on, they must sign in with
the new address. The original address will receive an automated notification
explaining that an administrator made the change.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
setEmailConfirmOpen(false);
void persist();
}}
disabled={loading}
>
Confirm change
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SheetContent>
</Sheet>
);

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff } from 'lucide-react';
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff, Power, PowerOff } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
@@ -11,6 +11,7 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
import { UserCard } from './user-card';
import { UserForm } from './user-form';
@@ -32,6 +33,7 @@ export function UserList() {
const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserRow | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
@@ -67,6 +69,19 @@ export function UserList() {
}
}
async function handleToggleActive(user: UserRow) {
setTogglingId(user.userId);
try {
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: { isActive: !user.isActive },
});
await fetchUsers();
} finally {
setTogglingId(null);
}
}
const columns: ColumnDef<UserRow, unknown>[] = [
{
accessorKey: 'displayName',
@@ -81,7 +96,7 @@ export function UserList() {
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => <Badge variant="secondary">{row.original.role.name}</Badge>,
cell: ({ row }) => <Badge variant="secondary">{formatRole(row.original.role.name)}</Badge>,
},
{
accessorKey: 'isActive',
@@ -113,7 +128,12 @@ export function UserList() {
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<PermissionGate resource="admin" action="manage_users">
<Button variant="ghost" size="sm" onClick={() => handleEditUser(row.original)}>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(row.original)}
title="Edit user"
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
@@ -124,6 +144,42 @@ export function UserList() {
<Button
variant="ghost"
size="sm"
title={row.original.isActive ? 'Disable sign-in' : 'Enable sign-in'}
disabled={togglingId === row.original.userId}
className={
row.original.isActive
? 'text-muted-foreground hover:text-foreground'
: 'text-emerald-600 hover:text-emerald-700'
}
>
{row.original.isActive ? (
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
<span className="sr-only">
{row.original.isActive ? 'Disable' : 'Enable'}
</span>
</Button>
}
title={row.original.isActive ? 'Disable user' : 'Enable user'}
description={
row.original.isActive
? `Disable sign-in for "${row.original.displayName}"? Their account stays intact; they just can't log in until you re-enable.`
: `Re-enable sign-in for "${row.original.displayName}"?`
}
confirmLabel={row.original.isActive ? 'Disable' : 'Enable'}
onConfirm={() => handleToggleActive(row.original)}
loading={togglingId === row.original.userId}
/>
</PermissionGate>
<PermissionGate resource="admin" action="manage_users">
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
title="Remove from port"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
@@ -140,7 +196,7 @@ export function UserList() {
</div>
),
enableSorting: false,
size: 80,
size: 120,
},
];
@@ -167,7 +223,9 @@ export function UserList() {
user={row.original}
onEdit={handleEditUser}
onRemove={handleRemoveUser}
onToggleActive={handleToggleActive}
isRemoving={deletingId === row.original.userId}
isToggling={togglingId === row.original.userId}
/>
)}
emptyState={

View File

@@ -59,10 +59,24 @@ function timeOfDayGreeting(): string {
return 'Good evening';
}
export function DashboardShell() {
interface DashboardShellProps {
/** SSR-prefetched first name. When provided, the greeting renders with it
* on first paint instead of flickering "Welcome back" → "Hello, Matt". */
initialFirstName?: string | null;
/** SSR-prefetched widget visibility map. Seeds the preferences cache so the
* layout doesn't reflow once the client-side fetch resolves. */
initialWidgetVisibility?: Record<string, boolean> | null;
}
export function DashboardShell({
initialFirstName,
initialWidgetVisibility,
}: DashboardShellProps = {}) {
const [range, setRange] = useState<DateRange>('30d');
const { visibleWidgets } = useDashboardWidgets();
const { visibleWidgets } = useDashboardWidgets({
initialVisibility: initialWidgetVisibility ?? null,
});
// Bucket once so the JSX stays readable. Registry order is preserved
// inside each bucket, so reordering the registry reorders the render.
@@ -72,12 +86,17 @@ export function DashboardShell() {
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
// useTablePreferences elsewhere — usually a cache hit, so no extra
// request. Falls back to a generic greeting if the profile isn't
// available yet so we never block the dashboard render.
// request. When the page server-prefetches the first name we seed it
// here via `initialData` so the cache is warm before the post-mount
// fetch resolves, eliminating the "Welcome back → Hello, Matt" flash.
const me = useQuery<MeData>({
queryKey: ['me'],
queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }),
staleTime: 5 * 60_000,
initialData:
initialFirstName !== undefined
? ({ data: { profile: { firstName: initialFirstName } } } as MeData)
: undefined,
});
const firstName = me.data?.data?.profile?.firstName?.trim();
@@ -91,10 +110,7 @@ export function DashboardShell() {
setClientGreeting(timeOfDayGreeting());
// Re-evaluate hourly so a rep who leaves the dashboard open through a
// boundary (5am, noon, 6pm) doesn't keep stale text on screen.
const interval = window.setInterval(
() => setClientGreeting(timeOfDayGreeting()),
60 * 60_000,
);
const interval = window.setInterval(() => setClientGreeting(timeOfDayGreeting()), 60 * 60_000);
return () => window.clearInterval(interval);
}, []);

View File

@@ -60,8 +60,7 @@ export function TimezoneDriftBanner() {
}
void apiFetch<MeResponse>('/api/v1/me')
.then((res) => {
const tz =
res.data.profile?.preferences?.timezone ?? res.data.profile?.timezone ?? null;
const tz = res.data.profile?.preferences?.timezone ?? res.data.profile?.timezone ?? null;
setStored(tz);
})
.catch(() => setStored(null))

View File

@@ -26,6 +26,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatRole } from '@/lib/constants';
import { useUIStore } from '@/stores/ui-store';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
@@ -49,18 +50,6 @@ interface SidebarProps {
ports?: Port[];
}
/**
* Turn a snake_cased DB role identifier (e.g. "super_admin") into a human
* label ("Super Admin"). Empty/missing → "Staff" fallback.
*/
function humanizeRole(roleName: string | null | undefined): string {
if (!roleName) return 'Staff';
return roleName
.split('_')
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
.join(' ');
}
interface NavItem {
href: string;
label: string;
@@ -423,7 +412,7 @@ function SidebarContent({
variant="outline"
className="text-[10px] px-1.5 py-0 text-slate-500 border-slate-300 mt-0.5"
>
{isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
{isSuperAdmin ? 'Super Admin' : formatRole(portRoles[0]?.role?.name)}
</Badge>
{currentPortName && (
<p className="mt-1 text-[10px] text-slate-400 truncate">{currentPortName}</p>

View File

@@ -71,9 +71,12 @@ const BUCKETS: BucketConfig[] = [
{ type: 'reminders', label: 'Reminders', icon: Bell },
{ type: 'brochures', label: 'Brochures', icon: Camera },
{ type: 'tags', label: 'Tags', icon: TagIcon },
{ type: 'navigation', label: 'Settings', icon: SettingsIcon },
// Notes always last — broad content search is noisy.
// Notes are noisy content search.
{ type: 'notes', label: 'Notes', icon: MessageSquare },
// Navigation (settings pages + admin sub-cards) lives at the very bottom —
// users open the search to find entity records first; pages/settings are
// the long-tail jump targets.
{ type: 'navigation', label: 'Settings', icon: SettingsIcon },
];
const NAV_ICON: Record<string, typeof User> = {
@@ -1099,25 +1102,9 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
});
}
}
if (include('navigation')) {
for (const n of results.navigation) {
const Icon = NAV_ICON[n.category] ?? SettingsIcon;
rows.push({
kind: 'result',
key: `navigation:${n.id}`,
bucket: 'navigation',
icon: Icon,
label: n.label,
sub: n.category,
// Catalog hrefs already have :portSlug substituted server-side.
href: n.href,
});
}
}
// Notes go LAST — content matches inside notes are noisy by nature
// (free-text search across thousands of rows), so the user sees
// them only after the entity-specific buckets above have surfaced
// their tighter matches.
// Notes — content matches inside free-text notes are noisy by nature, so
// the user sees them after the entity-specific buckets above have
// surfaced their tighter matches.
if (include('notes')) {
for (const n of results.notes) {
const sourceCollection =
@@ -1139,6 +1126,25 @@ export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
});
}
}
// Navigation (settings pages + admin section cards) goes LAST — these
// are jump targets, not the primary thing a user opens the search to
// find. Surfacing them after entity matches keeps the top of the
// dropdown focused on records.
if (include('navigation')) {
for (const n of results.navigation) {
const Icon = NAV_ICON[n.category] ?? SettingsIcon;
rows.push({
kind: 'result',
key: `navigation:${n.id}`,
bucket: 'navigation',
icon: Icon,
label: n.label,
sub: n.category,
// Catalog hrefs already have :portSlug substituted server-side.
href: n.href,
});
}
}
if (results.otherPorts && activeBucket === 'all') {
for (const op of results.otherPorts) {

View File

@@ -15,6 +15,15 @@ interface PreferencesResponse {
};
}
interface UseDashboardWidgetsOptions {
/** SSR-prefetched visibility map. When provided, seeds the react-query
* cache so the first render uses the rep's saved layout — no reflow when
* the client fetch resolves. Pass `null` (not `undefined`) when the
* caller has confirmed there is no stored preference so we don't keep
* showing the loading layout. */
initialVisibility?: Record<string, boolean> | null;
}
/**
* Returns the dashboard widget list filtered by the user's visibility
* preferences and exposes a toggle. Single source of truth for "what's
@@ -25,7 +34,7 @@ interface PreferencesResponse {
* Missing keys fall back to the registry's `defaultVisible`, so a newly
* added widget surfaces for everyone without a migration.
*/
export function useDashboardWidgets() {
export function useDashboardWidgets(options: UseDashboardWidgetsOptions = {}) {
const queryClient = useQueryClient();
const integrations = useDashboardIntegrations();
@@ -33,6 +42,10 @@ export function useDashboardWidgets() {
queryKey: ['me', 'preferences', 'dashboard-widgets'],
queryFn: () => apiFetch<PreferencesResponse>('/api/v1/users/me/preferences'),
staleTime: 60_000,
initialData:
options.initialVisibility !== undefined
? ({ data: { dashboardWidgets: options.initialVisibility ?? {} } } as PreferencesResponse)
: undefined,
});
// The registry is the universe of declared widgets. `availableWidgets`

View File

@@ -235,6 +235,39 @@ export function formatSource(source: string | null | undefined): string | null {
return source.charAt(0).toUpperCase() + source.slice(1);
}
// ─── Role names ──────────────────────────────────────────────────────────────
// Roles are stored verbatim in the `roles` table as the seeded snake_case
// identifier (super_admin, sales_agent, …) so every comparison + permission
// lookup keeps using the stable name. UI surfaces should render through
// `formatRole()` so customers see "Sales Agent" instead of "sales_agent".
// Custom roles created by admins keep their typed name; we only Title-Case
// snake_case identifiers, so a hand-typed role like "Marina Lead" comes
// through untouched.
export const ROLE_LABELS: Record<string, string> = {
super_admin: 'Super Admin',
director: 'Director',
sales_manager: 'Sales Manager',
sales_agent: 'Sales Agent',
finance_manager: 'Finance Manager',
viewer: 'Viewer',
residential_partner: 'Residential Partner',
};
/** Returns the human label for a stored role name. Falls back to a
* Title-Case rendering for legacy / custom roles. */
export function formatRole(role: string | null | undefined): string {
if (!role) return 'Staff';
if (role in ROLE_LABELS) return ROLE_LABELS[role]!;
// Title-Case any snake_case input (covers custom roles that happen to be
// entered in lowercase_with_underscores). Free-text role names that
// already contain spaces pass through unchanged.
return role
.split('_')
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
.join(' ');
}
// ─── Document Types ──────────────────────────────────────────────────────────
export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement', 'other'] as const;

View File

@@ -0,0 +1,93 @@
import { brandingPrimaryColor, renderShell, type BrandingShell } from '@/lib/email/shell';
interface AdminEmailChangeData {
recipientName?: string;
/** New address the user should sign in with from now on. */
newEmail: string;
/** Display name of the admin who initiated the change — surfaced so the
* recipient knows who to follow up with. */
changedByDisplayName?: string;
/** Optional URL for the sign-in page. */
loginUrl?: string;
portName?: string;
}
interface RenderOpts {
branding?: BrandingShell | null;
}
export function adminEmailChangeEmail(
data: AdminEmailChangeData,
overrides?: RenderOpts,
): { subject: string; html: string; text: string } {
const portName = data.portName ?? 'Port Nimara';
const subject = `An administrator updated your ${portName} sign-in email`;
const greeting = data.recipientName ? `Hello ${escapeHtml(data.recipientName)},` : 'Hello,';
const accent = brandingPrimaryColor(overrides?.branding);
const adminLine = data.changedByDisplayName
? `${escapeHtml(data.changedByDisplayName)} (an administrator)`
: 'an administrator';
const body = `
<p style="margin-bottom:10px; font-size:18px; font-weight:bold; color:${accent};">
Your sign-in email was changed
</p>
<p style="margin-bottom:10px; font-size:16px; line-height:1.5;">${greeting}</p>
<p style="margin-bottom:20px; font-size:16px; line-height:1.5;">
${adminLine} just updated the email address linked to your ${escapeHtml(
portName,
)} account. From now on, please sign in with the new address below:
</p>
<p style="margin:20px 0; text-align:center; font-size:16px;">
<strong>${escapeHtml(data.newEmail)}</strong>
</p>
${
data.loginUrl
? `<p style="text-align:center; margin:30px 0;">
<a href="${data.loginUrl}" style="display:inline-block; background-color:${accent}; color:#ffffff; text-decoration:none; padding:14px 35px; border-radius:5px; font-weight:bold; font-size:16px;">
Sign in
</a>
</p>`
: ''
}
<p style="font-size:14px; color:#666; line-height:1.5; padding:15px 0; border-top:1px solid #eee; margin-top:20px;">
If you weren't expecting this change, contact your administrator immediately.
Your old address (the one this message was sent to) can no longer be used to
sign in.
</p>
<p style="font-size:16px; margin-top:30px;">
Thanks,<br />
<strong>${escapeHtml(portName)}</strong>
</p>`;
const text = [
`Your sign-in email was changed`,
'',
`${data.changedByDisplayName ?? 'An administrator'} updated the email linked to your ${portName} account.`,
`From now on, sign in with: ${data.newEmail}`,
'',
data.loginUrl ? `Sign in: ${data.loginUrl}` : '',
'',
`If you weren't expecting this change, contact your administrator immediately.`,
]
.filter(Boolean)
.join('\n');
const html = renderShell({
branding: overrides?.branding ?? null,
title: subject,
body,
});
return { subject, html, text };
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -21,6 +21,7 @@ import { documents, documentSigners } from '@/lib/db/schema/documents';
import { expenses } from '@/lib/db/schema/financial';
import { alerts as alertsTable } from '@/lib/db/schema/insights';
import { ALERT_RULES, type AlertRuleId } from '@/lib/db/schema/insights';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
import type { AlertCandidate } from './alerts.service';
@@ -101,7 +102,7 @@ async function interestStale(portId: string): Promise<AlertCandidate[]> {
ruleId: 'interest.stale',
severity: 'info',
title: `Stale interest: ${r.clientName}`,
body: `In '${r.stage}' with no contact for 14+ days.`,
body: `In '${STAGE_LABELS[r.stage as PipelineStage] ?? r.stage.replace(/_/g, ' ')}' with no contact for 14+ days.`,
link: `/[port]/interests/${r.id}`,
entityType: 'interest',
entityId: r.id,

View File

@@ -26,7 +26,12 @@ import {
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore, withTransaction } from '@/lib/db/utils';
import { PIPELINE_STAGES, canTransitionStage, type PipelineStage } from '@/lib/constants';
import {
PIPELINE_STAGES,
STAGE_LABELS,
canTransitionStage,
type PipelineStage,
} from '@/lib/constants';
import type {
CreateInterestInput,
UpdateInterestInput,
@@ -824,21 +829,42 @@ export async function changeInterestStage(
}),
);
// Fire-and-forget notification to the acting user
void import('@/lib/services/notifications.service').then(({ createNotification }) =>
createNotification({
// Fire-and-forget notification to the acting user. Resolve a friendly
// label (client full name → primary mooring number → "this interest") so
// the inbox doesn't surface a raw UUID; stage names go through the
// canonical STAGE_LABELS dictionary so "deposit_10pct" reads as
// "10% Deposit" everywhere.
void (async () => {
const [{ createNotification }, clientRow, primaryBerth] = await Promise.all([
import('@/lib/services/notifications.service'),
db.query.clients.findFirst({
where: eq(clients.id, existing.clientId),
columns: { fullName: true },
}),
getPrimaryBerth(id).catch(() => null),
]);
const subject =
clientRow?.fullName ??
(primaryBerth ? `Berth ${primaryBerth.mooringNumber}` : 'this interest');
const fromLabel = oldStage
? (STAGE_LABELS[oldStage as PipelineStage] ?? oldStage.replace(/_/g, ' '))
: 'unknown';
const toLabel =
STAGE_LABELS[data.pipelineStage as PipelineStage] ??
data.pipelineStage.replace(/_/g, ' ');
await createNotification({
portId,
userId: meta.userId,
type: 'interest_stage_changed',
title: `Interest moved to ${data.pipelineStage}`,
description: `Interest ${id} stage changed from ${oldStage ?? 'unknown'} to ${data.pipelineStage}`,
title: `${subject} moved to ${toLabel}`,
description: `Stage changed from ${fromLabel} to ${toLabel}.`,
link: `/interests/${id}`,
entityType: 'interest',
entityId: id,
dedupeKey: `interest:${id}:stage:${data.pipelineStage}`,
cooldownMs: 300_000,
}),
);
});
})();
return updated!;
}

View File

@@ -114,6 +114,34 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
category: 'settings',
keywords: ['alerts', 'email digest', 'in-app', 'push'],
},
{
href: '/:portSlug/user-settings',
label: 'My profile & preferences',
category: 'settings',
keywords: [
'profile',
'avatar',
'display name',
'full name',
'phone',
'timezone',
'locale',
'country',
'dark mode',
'theme',
'password',
'change email',
'security',
'account',
'me',
],
},
{
href: '/:portSlug/dashboard?customize=1',
label: 'Customize dashboard widgets',
category: 'settings',
keywords: ['widgets', 'tiles', 'dashboard layout', 'kpi', 'reorder widgets'],
},
// ─── Admin ──────────────────────────────────────────────────────────────
{
@@ -150,6 +178,225 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
keywords: ['errors', 'exceptions', 'incidents', 'failures'],
superAdminOnly: true,
},
// ─── Admin → granular section cards (the AdminSectionsBrowser groups) ────
// These deep-link to specific admin sub-pages. Each one's `keywords`
// mirrors the corresponding entry in src/components/admin/
// admin-sections-browser.tsx — so typing a setting key in the topbar
// global search finds the same card the in-admin search would.
{
href: '/:portSlug/admin/settings',
label: 'System Settings',
category: 'admin',
keywords: [
'client portal',
'client portal enabled',
'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',
],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/branding',
label: 'Branding',
category: 'admin',
keywords: ['logo', 'app name', 'theme', 'colors', 'email header', 'white-label'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/email',
label: 'Email settings',
category: 'admin',
keywords: ['smtp', 'imap', 'mail', 'from address', 'signature', 'mail server'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/documenso',
label: 'EOI signing service',
category: 'admin',
keywords: ['documenso', 'signing', 'eoi', 'api credentials', 'webhook secret'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/reminders',
label: 'Reminder settings',
category: 'admin',
keywords: ['reminders', 'daily digest', 'delivery window'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/webhooks',
label: 'Webhooks',
category: 'admin',
keywords: ['webhook', 'outgoing', 'callback', 'delivery log'],
requires: 'admin.manage_webhooks',
},
{
href: '/:portSlug/admin/forms',
label: 'Forms',
category: 'admin',
keywords: ['form templates', 'inquiry', 'intake', 'public form'],
requires: 'admin.manage_forms',
},
{
href: '/:portSlug/admin/templates',
label: 'Document templates',
category: 'admin',
keywords: ['pdf templates', 'email templates', 'merge fields', 'eoi template'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/email-templates',
label: 'Email templates',
category: 'admin',
keywords: ['transactional emails', 'subject lines', 'portal email', 'inquiry email'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/tags',
label: 'Tags',
category: 'admin',
keywords: ['labels', 'color-coded', 'classification'],
requires: 'admin.manage_tags',
},
{
href: '/:portSlug/admin/vocabularies',
label: 'Vocabularies',
category: 'admin',
keywords: [
'pick lists',
'interest temperatures',
'status reasons',
'tenure types',
'expense categories',
'document types',
],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/custom-fields',
label: 'Custom fields',
category: 'admin',
keywords: ['custom fields', 'tenant fields', 'extra fields'],
requires: 'admin.manage_custom_fields',
},
{
href: '/:portSlug/admin/duplicates',
label: 'Duplicates queue',
category: 'admin',
keywords: ['dedup', 'duplicate clients', 'merge', 'review queue'],
},
{
href: '/:portSlug/admin/import',
label: 'Bulk import',
category: 'admin',
keywords: ['csv', 'import', 'bulk upload'],
},
{
href: '/:portSlug/admin/sends',
label: 'Send log',
category: 'admin',
keywords: ['email sends', 'brochures', 'send failures', 'retries'],
},
{
href: '/:portSlug/admin/monitoring',
label: 'Queue monitoring',
category: 'admin',
keywords: ['bullmq', 'queue', 'jobs', 'throughput', 'retries'],
requires: 'admin.system_backup',
},
{
href: '/:portSlug/admin/backup',
label: 'Backup & restore',
category: 'admin',
keywords: ['backup', 'restore', 'retention', 'disaster recovery'],
requires: 'admin.system_backup',
},
{
href: '/:portSlug/admin/storage',
label: 'Storage backend',
category: 'admin',
keywords: ['s3', 'minio', 'filesystem', 'storage backend', 'object store'],
requires: 'admin.system_backup',
},
{
href: '/:portSlug/admin/ports',
label: 'Ports',
category: 'admin',
keywords: ['marinas', 'tenancy', 'port management', 'multi-port'],
superAdminOnly: true,
},
{
href: '/:portSlug/admin/ai',
label: 'AI configuration',
category: 'admin',
keywords: ['openai', 'anthropic', 'gpt', 'claude', 'llm', 'api key', 'embeddings'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/ocr',
label: 'Receipt OCR',
category: 'admin',
keywords: ['receipt', 'scan', 'tesseract', 'expense scanner', 'confidence'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/website-analytics',
label: 'Website analytics (Umami)',
category: 'admin',
keywords: ['umami', 'analytics', 'traffic', 'visitors', 'marketing', 'pageviews'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/residential-stages',
label: 'Residential pipeline stages',
category: 'admin',
keywords: ['residential stages', 'pipeline', 'residential funnel'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/admin/roles',
label: 'Roles & permissions',
category: 'admin',
keywords: ['roles', 'permissions', 'access control', 'rbac'],
requires: 'admin.manage_users',
},
{
href: '/:portSlug/admin/invitations',
label: 'Invitations',
category: 'admin',
keywords: ['invite', 'pending invites', 'onboarding'],
requires: 'admin.manage_users',
},
];
/** Substitute `:portSlug` placeholder for the current port. */

View File

@@ -1,11 +1,16 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles, userPortRoles, roles } from '@/lib/db/schema';
import { user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { sendEmail } from '@/lib/email';
import { adminEmailChangeEmail } from '@/lib/email/templates/admin-email-change';
import { getBrandingShell } from '@/lib/email/branding-resolver';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
export async function listUsers(portId: string) {
@@ -19,6 +24,9 @@ export async function listUsers(portId: string) {
.select({
userId: userPortRoles.userId,
displayName: userProfiles.displayName,
firstName: userProfiles.firstName,
lastName: userProfiles.lastName,
fullName: user.name,
email: user.email,
phone: userProfiles.phone,
isActive: userProfiles.isActive,
@@ -38,6 +46,9 @@ export async function listUsers(portId: string) {
.select({
userId: userProfiles.userId,
displayName: userProfiles.displayName,
firstName: userProfiles.firstName,
lastName: userProfiles.lastName,
fullName: user.name,
email: user.email,
phone: userProfiles.phone,
isActive: userProfiles.isActive,
@@ -56,6 +67,9 @@ export async function listUsers(portId: string) {
...portRoleRows.map((row) => ({
userId: row.userId,
displayName: row.displayName,
firstName: row.firstName,
lastName: row.lastName,
fullName: row.fullName,
email: row.email,
phone: row.phone,
isActive: row.isActive,
@@ -69,6 +83,9 @@ export async function listUsers(portId: string) {
.map((row) => ({
userId: row.userId,
displayName: row.displayName,
firstName: row.firstName,
lastName: row.lastName,
fullName: row.fullName,
email: row.email,
phone: row.phone,
isActive: row.isActive,
@@ -105,6 +122,9 @@ export async function getUser(userId: string, portId: string) {
return {
userId: profile.userId,
displayName: profile.displayName,
firstName: profile.firstName,
lastName: profile.lastName,
fullName: authUser?.name ?? null,
email: authUser?.email ?? '',
phone: profile.phone,
isActive: profile.isActive,
@@ -148,6 +168,8 @@ export async function createUser(portId: string, data: CreateUserInput, meta: Au
await db.insert(userProfiles).values({
userId: newUserId,
displayName: data.displayName,
firstName: data.firstName ?? null,
lastName: data.lastName ?? null,
phone: data.phone ?? null,
});
@@ -199,6 +221,8 @@ export async function updateUser(
// Update profile fields
const profileUpdates: Record<string, unknown> = { updatedAt: new Date() };
if (data.displayName !== undefined) profileUpdates.displayName = data.displayName;
if (data.firstName !== undefined) profileUpdates.firstName = data.firstName;
if (data.lastName !== undefined) profileUpdates.lastName = data.lastName;
if (data.phone !== undefined) profileUpdates.phone = data.phone;
if (data.isActive !== undefined) profileUpdates.isActive = data.isActive;
@@ -206,6 +230,37 @@ export async function updateUser(
await db.update(userProfiles).set(profileUpdates).where(eq(userProfiles.userId, userId));
}
// Auth-table updates: full name + email. Both go through Better Auth's
// `user` table; the email change is admin-initiated and forces the
// user to sign in with the new address (we notify the prior one).
const authUserRow = await db.query.user.findFirst({ where: eq(user.id, userId) });
const previousEmail = authUserRow?.email ?? null;
const wantsEmailChange =
typeof data.email === 'string' &&
previousEmail !== null &&
data.email.toLowerCase() !== previousEmail.toLowerCase();
if (data.fullName !== undefined || wantsEmailChange) {
const userUpdates: Record<string, unknown> = { updatedAt: new Date() };
if (data.fullName !== undefined) userUpdates.name = data.fullName;
if (wantsEmailChange) userUpdates.email = data.email!.toLowerCase();
await db.update(user).set(userUpdates).where(eq(user.id, userId));
}
if (wantsEmailChange && previousEmail) {
// Best-effort notification — failure to send doesn't roll back the
// change because Better Auth's primary identity has already moved.
// The user still gets in with the new address; this is just an
// outbound courtesy.
void notifyAdminEmailChange({
previousEmail,
newEmail: data.email!.toLowerCase(),
displayName: data.displayName ?? profile.displayName,
changedByUserId: meta.userId,
portId,
});
}
// Update role assignment + per-user toggles
const portRoleUpdates: Record<string, unknown> = {};
if (data.roleId && data.roleId !== portRole.roleId) {
@@ -290,3 +345,43 @@ export async function removeUserFromPort(userId: string, portId: string, meta: A
severity: 'info',
});
}
/**
* Sends the "your admin changed your sign-in email" courtesy notice to
* the prior address. Best-effort — failures are logged but don't roll
* back the change; Better Auth has already pointed the account at the
* new address by the time this fires.
*/
async function notifyAdminEmailChange(args: {
previousEmail: string;
newEmail: string;
displayName: string;
changedByUserId: string;
portId: string;
}): Promise<void> {
try {
const [admin, port, branding] = await Promise.all([
db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, args.changedByUserId) }),
db.query.ports.findFirst({ where: eq(ports.id, args.portId) }),
getBrandingShell(args.portId).catch(() => null),
]);
const { subject, html, text } = adminEmailChangeEmail(
{
recipientName: args.displayName,
newEmail: args.newEmail,
changedByDisplayName: admin?.displayName,
portName: port?.name,
loginUrl: env.APP_URL ? `${env.APP_URL}/login` : undefined,
},
{ branding },
);
await sendEmail(args.previousEmail, subject, html, undefined, text, args.portId);
} catch (err) {
logger.warn(
{ err, previousEmail: args.previousEmail, newEmail: args.newEmail },
'admin email-change notification failed (non-fatal)',
);
}
}

View File

@@ -5,6 +5,8 @@ export const createUserSchema = z.object({
name: z.string().min(1).max(200),
password: z.string().min(12),
displayName: z.string().min(1).max(200),
firstName: z.string().min(1).max(200).nullable().optional(),
lastName: z.string().min(1).max(200).nullable().optional(),
phone: z.string().optional(),
roleId: z.string().uuid(),
residentialAccess: z.boolean().optional().default(false),
@@ -14,6 +16,16 @@ export type CreateUserInput = z.infer<typeof createUserSchema>;
export const updateUserSchema = z.object({
displayName: z.string().min(1).max(200).optional(),
firstName: z.string().min(1).max(200).nullable().optional(),
lastName: z.string().min(1).max(200).nullable().optional(),
fullName: z.string().min(1).max(400).optional(),
/** Admin-initiated email change. When changed, the original address
* receives an automated heads-up email (see notifyEmailChange). */
email: z.string().email().optional(),
/** Set true alongside `email` to send the "your admin changed your
* sign-in email" notification to the prior address. UI sets this when
* the admin confirms the warning dialog. */
notifyEmailChange: z.boolean().optional(),
phone: z.string().nullable().optional(),
isActive: z.boolean().optional(),
roleId: z.string().uuid().optional(),