feat(admin+search): user-mgmt polish, role labels, search keyword index

Admin search now matches against per-card keyword lists so typing
"client portal", "smtp", "tier ladder" lands on the System Settings card
(which hosts those flags). The same keyword list extends the topbar
global search (NAV_CATALOG) so any setting key resolves from the cmd-K
input — settings results sort to the bottom of the dropdown beneath
entity hits.

User management:
- Third action button (Power/PowerOff) enables/disables sign-in from the
  desktop list; mobile card dropdown gains the same item. Backed by the
  existing userProfiles.isActive flag — withAuth already refuses
  disabled sessions with 403.
- UserForm collects first + last name (canonical) alongside displayName,
  with admin email-change behind a confirmation modal. On confirm we
  send the OLD address an automated "your admin changed your sign-in
  email" notice (new template at admin-email-change.ts) and rewrite
  the Better Auth user row.
- Phone field swaps the bare tel input for the shared PhoneInput
  (country combobox + AsYouType formatting + E.164 storage).
- "Manage permissions" link points to /admin/roles?focusUser=… as
  a stepping stone for the future fine-tuned-permissions UI.

Role names normalize through a new ROLE_LABELS + formatRole() helper
in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the
prettifyRoleName in role-list; user-list and user-card now render
"Sales Agent" instead of "sales_agent". Custom roles pass through
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:14:12 +02:00
parent 0ab7055cf1
commit 660553c074
19 changed files with 1257 additions and 400 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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