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

@@ -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<string, typeof User> = {
@@ -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) {