feat: SHA-based avatar colors, user create/edit, editable names, +Add visibility
- 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:
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>🔒</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}>🔒</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!role.system && (
|
||||
<button type="button" className={styles.btnDelete}
|
||||
|
||||
@@ -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
|
||||
|
||||
18
ui/src/pages/admin/rbac/avatar-colors.ts
Normal file
18
ui/src/pages/admin/rbac/avatar-colors.ts
Normal 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];
|
||||
}
|
||||
Reference in New Issue
Block a user