From 840f355f4f85597ada58c330252660251e6f9c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Mon, 23 Mar 2026 09:41:56 +0100 Subject: [PATCH] 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 --- apps/web/src/components/admin/UsersClient.tsx | 64 +++++++++++++++++++ packages/api/src/router/user.ts | 35 ++++++++++ 2 files changed, 99 insertions(+) diff --git a/apps/web/src/components/admin/UsersClient.tsx b/apps/web/src/components/admin/UsersClient.tsx index fc21dba..6126605 100644 --- a/apps/web/src/components/admin/UsersClient.tsx +++ b/apps/web/src/components/admin/UsersClient.tsx @@ -92,6 +92,7 @@ export function UsersClient() { const [actionError, setActionError] = useState(null); const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState(""); + 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 */}
+ {/* User Name */} +
+

+ Display Name +

+ {editingName?.userId === editState.userId ? ( +
+ 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); + }} + /> + + +
+ ) : ( +
+ + {(users as any)?.find((u: any) => u.id === editState.userId)?.name ?? "—"} + + +
+ )} +
+ {/* System Role */}

diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 57e5091..f5f20cb 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -196,6 +196,41 @@ export const userRouter = createTRPCRouter({ 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, + after: updated as unknown as Record, + source: "ui", + summary: `Changed name from "${before.name}" to "${updated.name}"`, + }); + + return updated; + }), + // ─── Resource Linking ────────────────────────────────────────────────── linkResource: adminProcedure