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:
@@ -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