From 660553c0747047c932b23909affec9d4ccd6657b Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 16:14:12 +0200 Subject: [PATCH] feat(admin+search): user-mgmt polish, role labels, search keyword index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(dashboard)/[portSlug]/admin/page.tsx | 259 +------------- .../(dashboard)/[portSlug]/dashboard/page.tsx | 44 ++- .../admin/admin-sections-browser.tsx | 337 +++++++++++++++++- src/components/admin/roles/role-list.tsx | 20 +- src/components/admin/users/user-card.tsx | 53 ++- src/components/admin/users/user-form.tsx | 238 ++++++++++--- src/components/admin/users/user-list.tsx | 66 +++- src/components/dashboard/dashboard-shell.tsx | 32 +- .../dashboard/timezone-drift-banner.tsx | 3 +- src/components/layout/sidebar.tsx | 15 +- src/components/search/command-search.tsx | 48 +-- src/hooks/use-dashboard-widgets.ts | 15 +- src/lib/constants.ts | 33 ++ src/lib/email/templates/admin-email-change.ts | 93 +++++ src/lib/services/alert-rules.ts | 3 +- src/lib/services/interests.service.ts | 42 ++- src/lib/services/search-nav-catalog.ts | 247 +++++++++++++ src/lib/services/users.service.ts | 97 ++++- src/lib/validators/users.ts | 12 + 19 files changed, 1257 insertions(+), 400 deletions(-) create mode 100644 src/lib/email/templates/admin-email-change.ts diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx index ae1db070..8dc55171 100644 --- a/src/app/(dashboard)/[portSlug]/admin/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx @@ -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." /> - + ); } diff --git a/src/app/(dashboard)/[portSlug]/dashboard/page.tsx b/src/app/(dashboard)/[portSlug]/dashboard/page.tsx index 9dac256e..27bd915d 100644 --- a/src/app/(dashboard)/[portSlug]/dashboard/page.tsx +++ b/src/app/(dashboard)/[portSlug]/dashboard/page.tsx @@ -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 ; +/** + * 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 ; + } + + 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 ( + + ); } diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx index 7755dbe8..aaf76eb5 100644 --- a/src/components/admin/admin-sections-browser.tsx +++ b/src/components/admin/admin-sections-browser.tsx @@ -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 = []; - 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 (
@@ -106,7 +416,7 @@ export function AdminSectionsBrowser({ portSlug, groups }: AdminSectionsBrowserP
) ) : ( - groups.map((group) => ( + GROUPS.map((group) => (

@@ -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; diff --git a/src/components/admin/roles/role-list.tsx b/src/components/admin/roles/role-list.tsx index db092323..7df4c433 100644 --- a/src/components/admin/roles/role-list.tsx +++ b/src/components/admin/roles/role-list.tsx @@ -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. */} - {prettifyRoleName(row.original.name)} + {formatRole(row.original.name)} {row.original.isSystem && ( @@ -219,7 +205,7 @@ export function RoleList() { - Permissions — {viewingPermissions ? prettifyRoleName(viewingPermissions.name) : ''} + Permissions — {viewingPermissions ? formatRole(viewingPermissions.name) : ''} Granted vs total per resource. Click Edit to change. diff --git a/src/components/admin/users/user-card.tsx b/src/components/admin/users/user-card.tsx index fc0114e5..dfc8a0bb 100644 --- a/src/components/admin/users/user-card.tsx +++ b/src/components/admin/users/user-card.tsx @@ -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) Edit + e.preventDefault()} disabled={isToggling}> + {user.isActive ? ( + <> + + Disable sign-in + + ) : ( + <> + + Enable sign-in + + )} + + } + 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} + /> e.preventDefault()}> @@ -118,7 +163,9 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps) {/* Role + last login meta */}
- }>{user.role.name} + }> + {formatRole(user.role.name)} + {user.lastLoginAt ? ( }> diff --git a/src/components/admin/users/user-form.tsx b/src/components/admin/users/user-form.tsx index 4ae464bc..b739049d 100644 --- a/src/components/admin/users/user-form.tsx +++ b/src/components/admin/users/user-form.tsx @@ -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([]); - 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(null); const [roleId, setRoleId] = useState(''); const [isActive, setIsActive] = useState(true); const [residentialAccess, setResidentialAccess] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(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)
- {!isEdit && ( - <> -
- - setEmail(e.target.value)} - placeholder="user@example.com" - required - /> -
-
- - setPassword(e.target.value)} - placeholder="Min 12 characters" - minLength={12} - required - /> -
- - )} +
+
+ + setFirstName(e.target.value)} + placeholder="Jane" + required + /> +
+
+ + setLastName(e.target.value)} + placeholder="Doe" + required + /> +
+
- + setDisplayName(e.target.value)} - placeholder="John Smith" + placeholder={fullName || 'Jane Doe'} required /> +

+ How this user appears across the app — usually their full name, but they can pick a + nickname. +

- + setEmail(e.target.value)} + placeholder="user@example.com" + required + /> + {isEdit && email.trim().toLowerCase() !== originalEmail.toLowerCase() ? ( +

+ You'll be asked to confirm — the original address will receive an automated + notice that you, the admin, changed their sign-in email. +

+ ) : isEdit ? ( +

+ Changing this address is an admin-only override; the user will be notified at the + old address. +

+ ) : null} +
+ + {!isEdit && ( +
+ + setPassword(e.target.value)} + placeholder="Min 12 characters" + minLength={12} + required + /> +
+ )} + +
+ + setPhone(e.target.value)} - placeholder="+1 555-0123" + value={phoneValue} + onChange={setPhoneValue} + placeholder="Phone number" />
@@ -190,7 +288,7 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {roles.map((r) => ( - {r.name} + {formatRole(r.name)} ))} @@ -215,13 +313,30 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) {isEdit && (
- -

Disabled users cannot sign in

+ +

Disabled users cannot sign in.

)} + {isEdit && portSlug && ( +
+

Fine-tuned permissions

+

+ The selected role grants a baseline. To add or remove a specific permission for + this user only, open the role & permissions page. +

+ + Manage permissions → + +
+ )} + {error &&

{error}

} @@ -234,10 +349,37 @@ export function UserForm({ open, onOpenChange, user, onSuccess }: UserFormProps) Cancel
+ + + + + Change this user's sign-in email? + + You're about to change {originalEmail} to{' '} + {email}. 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. + + + + Cancel + { + e.preventDefault(); + setEmailConfirmOpen(false); + void persist(); + }} + disabled={loading} + > + Confirm change + + + + ); diff --git a/src/components/admin/users/user-list.tsx b/src/components/admin/users/user-list.tsx index 34e9361a..808d52d4 100644 --- a/src/components/admin/users/user-list.tsx +++ b/src/components/admin/users/user-list.tsx @@ -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(null); const [deletingId, setDeletingId] = useState(null); + const [togglingId, setTogglingId] = useState(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[] = [ { accessorKey: 'displayName', @@ -81,7 +96,7 @@ export function UserList() { { accessorKey: 'role', header: 'Role', - cell: ({ row }) => {row.original.role.name}, + cell: ({ row }) => {formatRole(row.original.role.name)}, }, { accessorKey: 'isActive', @@ -113,7 +128,12 @@ export function UserList() { cell: ({ row }) => (
- @@ -124,6 +144,42 @@ export function UserList() { + } + 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} + /> + + + @@ -140,7 +196,7 @@ export function UserList() {
), 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={ diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index ef156fc0..31b84d3c 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -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 | null; +} + +export function DashboardShell({ + initialFirstName, + initialWidgetVisibility, +}: DashboardShellProps = {}) { const [range, setRange] = useState('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({ queryKey: ['me'], queryFn: ({ signal }) => apiFetch('/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); }, []); diff --git a/src/components/dashboard/timezone-drift-banner.tsx b/src/components/dashboard/timezone-drift-banner.tsx index 03d4bb3e..d57ce282 100644 --- a/src/components/dashboard/timezone-drift-banner.tsx +++ b/src/components/dashboard/timezone-drift-banner.tsx @@ -60,8 +60,7 @@ export function TimezoneDriftBanner() { } void apiFetch('/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)) diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index b139aef8..4f6111b1 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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)} {currentPortName && (

{currentPortName}

diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index 4318cd1c..45676856 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -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 = { @@ -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) { diff --git a/src/hooks/use-dashboard-widgets.ts b/src/hooks/use-dashboard-widgets.ts index c216e1d7..c8e9d3af 100644 --- a/src/hooks/use-dashboard-widgets.ts +++ b/src/hooks/use-dashboard-widgets.ts @@ -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 | 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('/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` diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 57f51ef4..da8693b4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 = { + 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; diff --git a/src/lib/email/templates/admin-email-change.ts b/src/lib/email/templates/admin-email-change.ts new file mode 100644 index 00000000..736a5d41 --- /dev/null +++ b/src/lib/email/templates/admin-email-change.ts @@ -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 = ` +

+ Your sign-in email was changed +

+

${greeting}

+

+ ${adminLine} just updated the email address linked to your ${escapeHtml( + portName, + )} account. From now on, please sign in with the new address below: +

+

+ ${escapeHtml(data.newEmail)} +

+ ${ + data.loginUrl + ? `

+ + Sign in + +

` + : '' + } +

+ 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. +

+

+ Thanks,
+ ${escapeHtml(portName)} +

`; + + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/lib/services/alert-rules.ts b/src/lib/services/alert-rules.ts index 34bed327..f37525aa 100644 --- a/src/lib/services/alert-rules.ts +++ b/src/lib/services/alert-rules.ts @@ -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 { 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, diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 174a8280..978be43d 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -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!; } diff --git a/src/lib/services/search-nav-catalog.ts b/src/lib/services/search-nav-catalog.ts index a3ef060f..8af75992 100644 --- a/src/lib/services/search-nav-catalog.ts +++ b/src/lib/services/search-nav-catalog.ts @@ -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. */ diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts index c2653795..198c0740 100644 --- a/src/lib/services/users.service.ts +++ b/src/lib/services/users.service.ts @@ -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 = { 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 = { 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 = {}; 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 { + 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)', + ); + } +} diff --git a/src/lib/validators/users.ts b/src/lib/validators/users.ts index b4879091..de8d1a66 100644 --- a/src/lib/validators/users.ts +++ b/src/lib/validators/users.ts @@ -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; 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(),