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:
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user