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

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