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

@@ -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)',
);
}
}