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