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

@@ -18,23 +18,9 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
import { RoleForm } from './role-form';
/**
* Display-normalize a stored role name. Roles are stored with whatever
* key the admin entered (snake_case, kebab-case, free text). For UI
* display we titlecase + space-separate so "super_admin" reads as
* "Super Admin" and "Some role!" reads as "Some Role!" Display-only —
* code paths that compare role names use the stored verbatim value.
*/
function prettifyRoleName(name: string): string {
return name
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
interface Role {
id: string;
name: string;
@@ -109,7 +95,7 @@ export function RoleList() {
created roles with arbitrary keys still read cleanly.
The underlying name is stored verbatim and is what code
checks against — display is purely cosmetic. */}
<span className="font-medium">{prettifyRoleName(row.original.name)}</span>
<span className="font-medium">{formatRole(row.original.name)}</span>
{row.original.isSystem && (
<Badge variant="outline" className="text-xs">
<Lock className="mr-1 h-3 w-3" />
@@ -219,7 +205,7 @@ export function RoleList() {
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Permissions {viewingPermissions ? prettifyRoleName(viewingPermissions.name) : ''}
Permissions {viewingPermissions ? formatRole(viewingPermissions.name) : ''}
</DialogTitle>
<DialogDescription>
Granted vs total per resource. Click Edit to change.