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 [search, setSearch] = useState("");
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 [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -187,6 +188,14 @@ export function UsersClient() {
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) {
setPasswordTarget({ userId: user.id, userName: user.name ?? user.email });
setNewPassword("");
@@ -756,6 +765,61 @@ export function UsersClient() {
{/* Modal Body */}
<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 */}
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">