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

@@ -12,6 +12,7 @@ import {
import type { GroupDetail } from '../../../api/queries/admin/rbac';
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
import { hashColor } from './avatar-colors';
import styles from './RbacPage.module.css';
function getInitials(name: string): string {
@@ -140,13 +141,14 @@ export function GroupsTab() {
<div className={styles.entityList}>
{filtered.map((group) => {
const isSelected = group.id === selectedId;
const color = hashColor(group.name);
return (
<div
key={group.id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(group.id)}
>
<div className={`${styles.avatar} ${styles.avatarGroup}`}>
<div className={styles.avatar} style={{ background: color.bg, color: color.fg, borderRadius: 8 }}>
{getInitials(group.name)}
</div>
<div className={styles.entityInfo}>
@@ -211,6 +213,8 @@ function GroupDetailView({
onDeselect: () => void;
}) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState(group.name);
const deleteGroup = useDeleteGroup();
const updateGroup = useUpdateGroup();
const assignRole = useAssignRoleToGroup();
@@ -218,6 +222,14 @@ function GroupDetailView({
const isBuiltIn = group.id === ADMINS_GROUP_ID;
// Reset editing state when group changes
const [prevGroupId, setPrevGroupId] = useState(group.id);
if (prevGroupId !== group.id) {
setPrevGroupId(group.id);
setEditingName(false);
setNameValue(group.name);
}
const hierarchyLabel = group.parentGroupId
? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}`
: 'Top-level group';
@@ -254,14 +266,37 @@ function GroupDetailView({
return rows;
}, [group, groupMap]);
const color = hashColor(group.name);
return (
<>
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
<div className={`${styles.detailAvatar} ${styles.avatarGroup}`} style={{ borderRadius: 10 }}>
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg, borderRadius: 10 }}>
{getInitials(group.name)}
</div>
<div className={styles.detailName}>{group.name}</div>
{editingName ? (
<input
className={styles.editNameInput}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={() => {
if (nameValue.trim() && nameValue !== group.name) {
updateGroup.mutate({ id: group.id, name: nameValue.trim() });
}
setEditingName(false);
}}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(group.name); setEditingName(false); } }}
autoFocus
/>
) : (
<div className={styles.detailName}
onClick={() => !isBuiltIn && setEditingName(true)}
style={{ cursor: isBuiltIn ? 'default' : 'pointer' }}
title={isBuiltIn ? undefined : 'Click to edit'}>
{group.name}
</div>
)}
<div className={styles.detailEmail}>{hierarchyLabel}</div>
</div>
<button type="button" className={styles.btnDelete}