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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />}>
|
||||
|
||||
@@ -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'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 & 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's sign-in email?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'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>
|
||||
);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
93
src/lib/email/templates/admin-email-change.ts
Normal file
93
src/lib/email/templates/admin-email-change.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user