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:
@@ -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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user