),
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:
+
+ 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.
+