feat: admin can change user display name

API: new updateName adminProcedure in user router
- Input: userId + name (min 1, max 200 chars)
- Argon2 not involved (name only, not password)
- Audit log: "Changed name from X to Y"

UI: "Display Name" editable section in user edit modal
- Shows current name with "Edit" link
- Click Edit: inline input with Save/Cancel + Enter/Escape
- Auto-focuses input, saves on Enter
- Invalidates user list on success

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-23 09:41:56 +01:00
parent ea9263de29
commit 840f355f4f
2 changed files with 99 additions and 0 deletions
@@ -92,6 +92,7 @@ export function UsersClient() {
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<SystemRole | "">(""); const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
const [editingName, setEditingName] = useState<{ userId: string; name: string } | null>(null);
const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null); const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null);
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@@ -187,6 +188,14 @@ export function UsersClient() {
onError: (err) => setPasswordError(err.message), onError: (err) => setPasswordError(err.message),
}); });
const updateNameMutation = trpc.user.updateName.useMutation({
onSuccess: async () => {
await utils.user.list.invalidate();
setEditingName(null);
},
onError: (err) => setActionError(err.message),
});
function openSetPassword(user: UserRow) { function openSetPassword(user: UserRow) {
setPasswordTarget({ userId: user.id, userName: user.name ?? user.email }); setPasswordTarget({ userId: user.id, userName: user.name ?? user.email });
setNewPassword(""); setNewPassword("");
@@ -756,6 +765,61 @@ export function UsersClient() {
{/* Modal Body */} {/* Modal Body */}
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-6"> <div className="overflow-y-auto flex-1 px-6 py-5 space-y-6">
{/* User Name */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Display Name
</h3>
{editingName?.userId === editState.userId ? (
<div className="flex items-center gap-2">
<input
type="text"
value={editingName.name}
onChange={(e) => setEditingName({ ...editingName, name: e.target.value })}
className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && editingName.name.trim()) {
updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() });
}
if (e.key === "Escape") setEditingName(null);
}}
/>
<button
type="button"
onClick={() => updateNameMutation.mutate({ id: editingName.userId, name: editingName.name.trim() })}
disabled={!editingName.name.trim() || updateNameMutation.isPending}
className="px-3 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
{updateNameMutation.isPending ? "..." : "Save"}
</button>
<button
type="button"
onClick={() => setEditingName(null)}
className="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-sm"
>
Cancel
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-900 dark:text-gray-100">
{(users as any)?.find((u: any) => u.id === editState.userId)?.name ?? "—"}
</span>
<button
type="button"
onClick={() => {
const user = (users as any)?.find((u: any) => u.id === editState.userId);
setEditingName({ userId: editState.userId, name: user?.name ?? "" });
}}
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
>
Edit
</button>
</div>
)}
</section>
{/* System Role */} {/* System Role */}
<section> <section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center"> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
+35
View File
@@ -196,6 +196,41 @@ export const userRouter = createTRPCRouter({
return updated; return updated;
}), }),
updateName: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1, "Name is required").max(200),
}),
)
.mutation(async ({ ctx, input }) => {
const before = await ctx.db.user.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, name: true, email: true },
});
const updated = await ctx.db.user.update({
where: { id: input.id },
data: { name: input.name },
select: { id: true, name: true, email: true },
});
void createAuditEntry({
db: ctx.db,
entityType: "User",
entityId: updated.id,
entityName: `${updated.name} (${updated.email})`,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
before: before as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
summary: `Changed name from "${before.name}" to "${updated.name}"`,
});
return updated;
}),
// ─── Resource Linking ────────────────────────────────────────────────── // ─── Resource Linking ──────────────────────────────────────────────────
linkResource: adminProcedure linkResource: adminProcedure