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() {
|
export function useDeleteUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { GroupDetail } from '../../../api/queries/admin/rbac';
|
import type { GroupDetail } from '../../../api/queries/admin/rbac';
|
||||||
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
||||||
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
|
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
|
||||||
|
import { hashColor } from './avatar-colors';
|
||||||
import styles from './RbacPage.module.css';
|
import styles from './RbacPage.module.css';
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
@@ -140,13 +141,14 @@ export function GroupsTab() {
|
|||||||
<div className={styles.entityList}>
|
<div className={styles.entityList}>
|
||||||
{filtered.map((group) => {
|
{filtered.map((group) => {
|
||||||
const isSelected = group.id === selectedId;
|
const isSelected = group.id === selectedId;
|
||||||
|
const color = hashColor(group.name);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||||
onClick={() => setSelectedId(group.id)}
|
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)}
|
{getInitials(group.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
@@ -211,6 +213,8 @@ function GroupDetailView({
|
|||||||
onDeselect: () => void;
|
onDeselect: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameValue, setNameValue] = useState(group.name);
|
||||||
const deleteGroup = useDeleteGroup();
|
const deleteGroup = useDeleteGroup();
|
||||||
const updateGroup = useUpdateGroup();
|
const updateGroup = useUpdateGroup();
|
||||||
const assignRole = useAssignRoleToGroup();
|
const assignRole = useAssignRoleToGroup();
|
||||||
@@ -218,6 +222,14 @@ function GroupDetailView({
|
|||||||
|
|
||||||
const isBuiltIn = group.id === ADMINS_GROUP_ID;
|
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
|
const hierarchyLabel = group.parentGroupId
|
||||||
? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}`
|
? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}`
|
||||||
: 'Top-level group';
|
: 'Top-level group';
|
||||||
@@ -254,14 +266,37 @@ function GroupDetailView({
|
|||||||
return rows;
|
return rows;
|
||||||
}, [group, groupMap]);
|
}, [group, groupMap]);
|
||||||
|
|
||||||
|
const color = hashColor(group.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.detailHeader}>
|
<div className={styles.detailHeader}>
|
||||||
<div className={styles.detailHeaderInfo}>
|
<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)}
|
{getInitials(group.name)}
|
||||||
</div>
|
</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 className={styles.detailEmail}>{hierarchyLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className={styles.btnDelete}
|
<button type="button" className={styles.btnDelete}
|
||||||
|
|||||||
@@ -580,18 +580,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 3px 8px;
|
padding: 3px 10px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--amber);
|
||||||
color: var(--text-muted);
|
color: var(--amber);
|
||||||
background: transparent;
|
background: rgba(240, 180, 41, 0.08);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s, color 0.1s;
|
transition: background 0.1s, color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addChip:hover {
|
.addChip:hover {
|
||||||
background: var(--bg-hover);
|
background: rgba(240, 180, 41, 0.18);
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
@@ -842,3 +842,17 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
max-width: 200px;
|
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 { 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 type { RoleDetail } from '../../../api/queries/admin/rbac';
|
||||||
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
||||||
|
import { hashColor } from './avatar-colors';
|
||||||
import styles from './RbacPage.module.css';
|
import styles from './RbacPage.module.css';
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
@@ -92,7 +93,7 @@ export function RolesTab() {
|
|||||||
<button type="button" className={styles.createFormBtnPrimary}
|
<button type="button" className={styles.createFormBtnPrimary}
|
||||||
disabled={!newName.trim() || createRole.isPending}
|
disabled={!newName.trim() || createRole.isPending}
|
||||||
onClick={() => {
|
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(''); },
|
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewDesc(''); setNewScope('custom'); setCreateError(''); },
|
||||||
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create role'),
|
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create role'),
|
||||||
});
|
});
|
||||||
@@ -103,13 +104,14 @@ export function RolesTab() {
|
|||||||
<div className={styles.entityList}>
|
<div className={styles.entityList}>
|
||||||
{filtered.map((role) => {
|
{filtered.map((role) => {
|
||||||
const isSelected = role.id === selectedId;
|
const isSelected = role.id === selectedId;
|
||||||
|
const color = hashColor(role.name);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={role.id}
|
key={role.id}
|
||||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||||
onClick={() => setSelectedId(role.id)}
|
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)}
|
{getInitials(role.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
@@ -152,19 +154,53 @@ export function RolesTab() {
|
|||||||
|
|
||||||
function RoleDetailView({ role, onDeselect }: { role: RoleDetail; onDeselect: () => void }) {
|
function RoleDetailView({ role, onDeselect }: { role: RoleDetail; onDeselect: () => void }) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameValue, setNameValue] = useState(role.name);
|
||||||
const deleteRole = useDeleteRole();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.detailHeader}>
|
<div className={styles.detailHeader}>
|
||||||
<div className={styles.detailHeaderInfo}>
|
<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)}
|
{getInitials(role.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailName}>
|
{editingName ? (
|
||||||
{role.name}
|
<input
|
||||||
{role.system && <span className={styles.lockIcon}>🔒</span>}
|
className={styles.editNameInput}
|
||||||
</div>
|
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>
|
</div>
|
||||||
{!role.system && (
|
{!role.system && (
|
||||||
<button type="button" className={styles.btnDelete}
|
<button type="button" className={styles.btnDelete}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useMemo } from 'react';
|
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 type { UserDetail, GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac';
|
||||||
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
|
||||||
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
|
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
|
||||||
import { useAuthStore } from '../../../auth/auth-store';
|
import { useAuthStore } from '../../../auth/auth-store';
|
||||||
|
import { hashColor } from './avatar-colors';
|
||||||
import styles from './RbacPage.module.css';
|
import styles from './RbacPage.module.css';
|
||||||
|
|
||||||
function getInitials(name: string): string {
|
function getInitials(name: string): string {
|
||||||
@@ -32,6 +33,12 @@ export function UsersTab() {
|
|||||||
const { data: allRoles } = useRoles();
|
const { data: allRoles } = useRoles();
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
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 groupMap = useMemo(() => {
|
||||||
const map = new Map<string, GroupDetail>();
|
const map = new Map<string, GroupDetail>();
|
||||||
@@ -71,6 +78,7 @@ export function UsersTab() {
|
|||||||
Manage identities, group membership and direct roles
|
Manage identities, group membership and direct roles
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add user</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.split}>
|
<div className={styles.split}>
|
||||||
<div className={styles.listPane}>
|
<div className={styles.listPane}>
|
||||||
@@ -82,17 +90,57 @@ export function UsersTab() {
|
|||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<div className={styles.entityList}>
|
||||||
{filtered.map((user) => {
|
{filtered.map((user) => {
|
||||||
const isSelected = user.userId === selected;
|
const isSelected = user.userId === selected;
|
||||||
|
const color = hashColor(user.displayName || user.userId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.userId}
|
key={user.userId}
|
||||||
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
|
||||||
onClick={() => setSelected(user.userId)}
|
onClick={() => setSelected(user.userId)}
|
||||||
>
|
>
|
||||||
<div className={`${styles.avatar} ${styles.avatarUser}`}>
|
<div className={styles.avatar} style={{ background: color.bg, color: color.fg }}>
|
||||||
{getInitials(user.displayName)}
|
{getInitials(user.displayName || user.userId)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.entityName}>
|
<div className={styles.entityName}>
|
||||||
@@ -169,7 +217,10 @@ function UserDetailView({
|
|||||||
onDeselect: () => void;
|
onDeselect: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameValue, setNameValue] = useState(user.displayName);
|
||||||
const deleteUserMut = useDeleteUser();
|
const deleteUserMut = useDeleteUser();
|
||||||
|
const updateUser = useUpdateUser();
|
||||||
const addToGroup = useAddUserToGroup();
|
const addToGroup = useAddUserToGroup();
|
||||||
const removeFromGroup = useRemoveUserFromGroup();
|
const removeFromGroup = useRemoveUserFromGroup();
|
||||||
const assignRole = useAssignRoleToUser();
|
const assignRole = useAssignRoleToUser();
|
||||||
@@ -179,6 +230,14 @@ function UserDetailView({
|
|||||||
const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null;
|
const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null;
|
||||||
const isSelf = currentUserId === user.userId;
|
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
|
// Build group tree for this user
|
||||||
const groupTree = useMemo(() => {
|
const groupTree = useMemo(() => {
|
||||||
const tree: { name: string; depth: number; annotation: string }[] = [];
|
const tree: { name: string; depth: number; annotation: string }[] = [];
|
||||||
@@ -209,19 +268,40 @@ function UserDetailView({
|
|||||||
.filter((r) => !user.directRoles.some((dr) => dr.id === r.id))
|
.filter((r) => !user.directRoles.some((dr) => dr.id === r.id))
|
||||||
.map((r) => ({ id: r.id, label: r.name }));
|
.map((r) => ({ id: r.id, label: r.name }));
|
||||||
|
|
||||||
|
const color = hashColor(user.displayName || user.userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.detailHeader}>
|
<div className={styles.detailHeader}>
|
||||||
<div className={styles.detailHeaderInfo}>
|
<div className={styles.detailHeaderInfo}>
|
||||||
<div className={`${styles.detailAvatar} ${styles.avatarUser}`}>
|
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg }}>
|
||||||
{getInitials(user.displayName)}
|
{getInitials(user.displayName || user.userId)}
|
||||||
</div>
|
|
||||||
<div className={styles.detailName}>
|
|
||||||
{user.displayName}
|
|
||||||
{user.provider !== 'local' && (
|
|
||||||
<span className={styles.oidcBadge}>{user.provider}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 className={styles.detailEmail}>{user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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