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

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