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

@@ -264,6 +264,34 @@ export function useDeleteRole() {
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; displayName?: string; email?: string }) =>
adminFetch<UserDetail>('/users', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({

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}

View File

@@ -580,18 +580,18 @@
align-items: center;
gap: 4px;
font-size: 11px;
padding: 3px 8px;
padding: 3px 10px;
border-radius: 20px;
border: 1px dashed var(--border);
color: var(--text-muted);
background: transparent;
border: 1px dashed var(--amber);
color: var(--amber);
background: rgba(240, 180, 41, 0.08);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.addChip:hover {
background: var(--bg-hover);
color: var(--text-secondary);
background: rgba(240, 180, 41, 0.18);
color: var(--text-primary);
}
.dropdown {
@@ -842,3 +842,17 @@
outline: none;
max-width: 200px;
}
/* ─── Editable Name Input ─── */
.editNameInput {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
background: var(--bg-raised);
border: 1px solid var(--amber);
border-radius: var(--radius-sm);
padding: 2px 6px;
outline: none;
width: 100%;
max-width: 300px;
}

View File

@@ -1,7 +1,8 @@
import { useState, useMemo } from 'react';
import { useRoles, useRole, useCreateRole, useDeleteRole } from '../../../api/queries/admin/rbac';
import { useRoles, useRole, useCreateRole, useDeleteRole, useUpdateRole } from '../../../api/queries/admin/rbac';
import type { RoleDetail } from '../../../api/queries/admin/rbac';
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
import { hashColor } from './avatar-colors';
import styles from './RbacPage.module.css';
function getInitials(name: string): string {
@@ -92,7 +93,7 @@ export function RolesTab() {
<button type="button" className={styles.createFormBtnPrimary}
disabled={!newName.trim() || createRole.isPending}
onClick={() => {
createRole.mutate({ name: newName.trim(), description: newDesc || undefined, scope: newScope || undefined }, {
createRole.mutate({ name: newName.trim(), description: newDesc, scope: newScope || undefined }, {
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewDesc(''); setNewScope('custom'); setCreateError(''); },
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create role'),
});
@@ -103,13 +104,14 @@ export function RolesTab() {
<div className={styles.entityList}>
{filtered.map((role) => {
const isSelected = role.id === selectedId;
const color = hashColor(role.name);
return (
<div
key={role.id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(role.id)}
>
<div className={`${styles.avatar} ${styles.avatarRole}`}>
<div className={styles.avatar} style={{ background: color.bg, color: color.fg, borderRadius: 6 }}>
{getInitials(role.name)}
</div>
<div className={styles.entityInfo}>
@@ -152,19 +154,53 @@ export function RolesTab() {
function RoleDetailView({ role, onDeselect }: { role: RoleDetail; onDeselect: () => void }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState(role.name);
const deleteRole = useDeleteRole();
const updateRole = useUpdateRole();
const isBuiltIn = role.system;
// Reset editing state when role changes
const [prevRoleId, setPrevRoleId] = useState(role.id);
if (prevRoleId !== role.id) {
setPrevRoleId(role.id);
setEditingName(false);
setNameValue(role.name);
}
const color = hashColor(role.name);
return (
<>
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
<div className={`${styles.detailAvatar} ${styles.avatarRole}`} style={{ borderRadius: 8 }}>
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg, borderRadius: 8 }}>
{getInitials(role.name)}
</div>
<div className={styles.detailName}>
{role.name}
{role.system && <span className={styles.lockIcon}>&#128274;</span>}
</div>
{editingName ? (
<input
className={styles.editNameInput}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={() => {
if (nameValue.trim() && nameValue !== role.name) {
updateRole.mutate({ id: role.id, name: nameValue.trim() });
}
setEditingName(false);
}}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(role.name); setEditingName(false); } }}
autoFocus
/>
) : (
<div className={styles.detailName}
onClick={() => !isBuiltIn && setEditingName(true)}
style={{ cursor: isBuiltIn ? 'default' : 'pointer' }}
title={isBuiltIn ? undefined : 'Click to edit'}>
{role.name}
{role.system && <span className={styles.lockIcon}>&#128274;</span>}
</div>
)}
</div>
{!role.system && (
<button type="button" className={styles.btnDelete}

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

View File

@@ -0,0 +1,18 @@
const AVATAR_COLORS = [
{ bg: 'rgba(59, 130, 246, 0.15)', fg: '#3B82F6' }, // blue
{ bg: 'rgba(16, 185, 129, 0.15)', fg: '#10B981' }, // green
{ bg: 'rgba(240, 180, 41, 0.15)', fg: '#F0B429' }, // amber
{ bg: 'rgba(168, 85, 247, 0.15)', fg: '#A855F7' }, // purple
{ bg: 'rgba(244, 63, 94, 0.15)', fg: '#F43F5E' }, // rose
{ bg: 'rgba(34, 211, 238, 0.15)', fg: '#22D3EE' }, // cyan
{ bg: 'rgba(251, 146, 60, 0.15)', fg: '#FB923C' }, // orange
{ bg: 'rgba(132, 204, 22, 0.15)', fg: '#84CC16' }, // lime
];
export function hashColor(str: string): { bg: string; fg: string } {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}