feat: SHA-based avatar colors, user create/edit, editable names, +Add visibility
All checks were successful
CI / build (push) Successful in 1m11s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 56s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s

- Add hashColor utility for unique avatar colors derived from entity names
- Add user creation form with username/displayName/email fields
- Add useCreateUser and useUpdateUser mutation hooks
- Make display names editable on all detail panes (click to edit)
- Protect built-in entities: Admins group and system roles not editable
- Make +Add chip more visible with amber border and background
- Send empty string instead of null for role description on create
- Add .editNameInput CSS for inline name editing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 18:52:07 +01:00
parent f42e6279e6
commit 9d08e74913
6 changed files with 239 additions and 28 deletions

View File

@@ -1,9 +1,10 @@
import { useState, useMemo } from 'react';
import { useUsers, useGroups, useRoles, useDeleteUser, useAddUserToGroup, useRemoveUserFromGroup, useAssignRoleToUser, useRemoveRoleFromUser } from '../../../api/queries/admin/rbac';
import { useUsers, useGroups, useRoles, useDeleteUser, useCreateUser, useUpdateUser, useAddUserToGroup, useRemoveUserFromGroup, useAssignRoleToUser, useRemoveRoleFromUser } from '../../../api/queries/admin/rbac';
import type { UserDetail, GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac';
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
import { useAuthStore } from '../../../auth/auth-store';
import { hashColor } from './avatar-colors';
import styles from './RbacPage.module.css';
function getInitials(name: string): string {
@@ -32,6 +33,12 @@ export function UsersTab() {
const { data: allRoles } = useRoles();
const [selected, setSelected] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newDisplayName, setNewDisplayName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [createError, setCreateError] = useState('');
const createUser = useCreateUser();
const groupMap = useMemo(() => {
const map = new Map<string, GroupDetail>();
@@ -71,6 +78,7 @@ export function UsersTab() {
Manage identities, group membership and direct roles
</div>
</div>
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add user</button>
</div>
<div className={styles.split}>
<div className={styles.listPane}>
@@ -82,17 +90,57 @@ export function UsersTab() {
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{showCreateForm && (
<div className={styles.createForm}>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Username</label>
<input className={styles.createFormInput} value={newUsername}
onChange={e => { setNewUsername(e.target.value); setCreateError(''); }}
placeholder="Username (required)" autoFocus />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Display</label>
<input className={styles.createFormInput} value={newDisplayName}
onChange={e => setNewDisplayName(e.target.value)}
placeholder="Display name (optional)" />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Email</label>
<input className={styles.createFormInput} value={newEmail}
onChange={e => setNewEmail(e.target.value)}
placeholder="Email (optional)" />
</div>
{createError && <div className={styles.createFormError}>{createError}</div>}
<div className={styles.createFormActions}>
<button type="button" className={styles.createFormBtn}
onClick={() => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setCreateError(''); }}>Cancel</button>
<button type="button" className={styles.createFormBtnPrimary}
disabled={!newUsername.trim() || createUser.isPending}
onClick={() => {
createUser.mutate({
username: newUsername.trim(),
displayName: newDisplayName.trim() || undefined,
email: newEmail.trim() || undefined,
}, {
onSuccess: () => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setCreateError(''); },
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create user'),
});
}}>Create</button>
</div>
</div>
)}
<div className={styles.entityList}>
{filtered.map((user) => {
const isSelected = user.userId === selected;
const color = hashColor(user.displayName || user.userId);
return (
<div
key={user.userId}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelected(user.userId)}
>
<div className={`${styles.avatar} ${styles.avatarUser}`}>
{getInitials(user.displayName)}
<div className={styles.avatar} style={{ background: color.bg, color: color.fg }}>
{getInitials(user.displayName || user.userId)}
</div>
<div className={styles.entityInfo}>
<div className={styles.entityName}>
@@ -169,7 +217,10 @@ function UserDetailView({
onDeselect: () => void;
}) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState(user.displayName);
const deleteUserMut = useDeleteUser();
const updateUser = useUpdateUser();
const addToGroup = useAddUserToGroup();
const removeFromGroup = useRemoveUserFromGroup();
const assignRole = useAssignRoleToUser();
@@ -179,6 +230,14 @@ function UserDetailView({
const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null;
const isSelf = currentUserId === user.userId;
// Reset editing state when user changes
const [prevUserId, setPrevUserId] = useState(user.userId);
if (prevUserId !== user.userId) {
setPrevUserId(user.userId);
setEditingName(false);
setNameValue(user.displayName);
}
// Build group tree for this user
const groupTree = useMemo(() => {
const tree: { name: string; depth: number; annotation: string }[] = [];
@@ -209,19 +268,40 @@ function UserDetailView({
.filter((r) => !user.directRoles.some((dr) => dr.id === r.id))
.map((r) => ({ id: r.id, label: r.name }));
const color = hashColor(user.displayName || user.userId);
return (
<>
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
<div className={`${styles.detailAvatar} ${styles.avatarUser}`}>
{getInitials(user.displayName)}
</div>
<div className={styles.detailName}>
{user.displayName}
{user.provider !== 'local' && (
<span className={styles.oidcBadge}>{user.provider}</span>
)}
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg }}>
{getInitials(user.displayName || user.userId)}
</div>
{editingName ? (
<input
className={styles.editNameInput}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={() => {
if (nameValue.trim() && nameValue !== user.displayName) {
updateUser.mutate({ userId: user.userId, displayName: nameValue.trim() });
}
setEditingName(false);
}}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(user.displayName); setEditingName(false); } }}
autoFocus
/>
) : (
<div className={styles.detailName}
onClick={() => setEditingName(true)}
style={{ cursor: 'pointer' }}
title="Click to edit">
{user.displayName}
{user.provider !== 'local' && (
<span className={styles.oidcBadge}>{user.provider}</span>
)}
</div>
)}
<div className={styles.detailEmail}>{user.email}</div>
</div>
<button