diff --git a/src/pages/Admin/UserManagement/UserManagement.module.css b/src/pages/Admin/UserManagement/UserManagement.module.css new file mode 100644 index 0000000..ce8b2eb --- /dev/null +++ b/src/pages/Admin/UserManagement/UserManagement.module.css @@ -0,0 +1,182 @@ +.splitPane { + display: grid; + grid-template-columns: 52fr 48fr; + gap: 1px; + background: var(--border-subtle); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + min-height: 500px; +} + +.listPane { + background: var(--bg-base); + display: flex; + flex-direction: column; + border-radius: var(--radius-md) 0 0 var(--radius-md); +} + +.detailPane { + background: var(--bg-base); + overflow-y: auto; + padding: 20px; + border-radius: 0 var(--radius-md) var(--radius-md) 0; +} + +.listHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.listHeaderSearch { + flex: 1; +} + +.entityList { + flex: 1; + overflow-y: auto; +} + +.entityItem { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.1s; + border-bottom: 1px solid var(--border-subtle); +} + +.entityItem:hover { + background: var(--bg-hover); +} + +.entityItemSelected { + background: var(--bg-raised); +} + +.entityInfo { + flex: 1; + min-width: 0; +} + +.entityName { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + font-family: var(--font-body); +} + +.entityMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-body); + margin-top: 2px; +} + +.entityTags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.detailHeader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.detailHeaderInfo { + flex: 1; + min-width: 0; +} + +.detailName { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + font-family: var(--font-body); +} + +.detailEmail { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-body); +} + +.metaGrid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 16px; + margin-bottom: 16px; + font-size: 12px; + font-family: var(--font-body); +} + +.metaLabel { + color: var(--text-muted); + font-weight: 500; +} + +.metaValue { + color: var(--text-primary); +} + +.sectionTags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + margin-bottom: 8px; +} + +.selectWrap { + margin-top: 8px; + max-width: 240px; +} + +.emptyDetail { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-faint); + font-size: 13px; + font-family: var(--font-body); +} + +.createForm { + padding: 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-raised); + display: flex; + flex-direction: column; + gap: 8px; +} + +.createFormRow { + display: flex; + gap: 8px; +} + +.createFormActions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.inheritedNote { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + font-family: var(--font-body); + margin-top: 4px; +} + +.providerBadge { + margin-left: 6px; +} diff --git a/src/pages/Admin/UserManagement/UserManagement.tsx b/src/pages/Admin/UserManagement/UserManagement.tsx new file mode 100644 index 0000000..6561b8c --- /dev/null +++ b/src/pages/Admin/UserManagement/UserManagement.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react' +import { AdminLayout } from '../Admin' +import { Tabs } from '../../../design-system/composites/Tabs/Tabs' +import { UsersTab } from './UsersTab' +import { GroupsTab } from './GroupsTab' +import { RolesTab } from './RolesTab' + +const TABS = [ + { label: 'Users', value: 'users' }, + { label: 'Groups', value: 'groups' }, + { label: 'Roles', value: 'roles' }, +] + +export function UserManagement() { + const [tab, setTab] = useState('users') + + return ( + + +
+ {tab === 'users' && } + {tab === 'groups' && } + {tab === 'roles' && } +
+
+ ) +} diff --git a/src/pages/Admin/UserManagement/UsersTab.tsx b/src/pages/Admin/UserManagement/UsersTab.tsx new file mode 100644 index 0000000..9e54061 --- /dev/null +++ b/src/pages/Admin/UserManagement/UsersTab.tsx @@ -0,0 +1,260 @@ +import { useState, useMemo } from 'react' +import { Avatar } from '../../../design-system/primitives/Avatar/Avatar' +import { Badge } from '../../../design-system/primitives/Badge/Badge' +import { Button } from '../../../design-system/primitives/Button/Button' +import { Input } from '../../../design-system/primitives/Input/Input' +import { MonoText } from '../../../design-system/primitives/MonoText/MonoText' +import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader' +import { Tag } from '../../../design-system/primitives/Tag/Tag' +import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineEdit' +import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect' +import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog' +import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks' +import styles from './UserManagement.module.css' + +export function UsersTab() { + const [users, setUsers] = useState(MOCK_USERS) + const [search, setSearch] = useState('') + const [selectedId, setSelectedId] = useState(null) + const [creating, setCreating] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + + // Create form state + const [newUsername, setNewUsername] = useState('') + const [newDisplay, setNewDisplay] = useState('') + const [newEmail, setNewEmail] = useState('') + const [newPassword, setNewPassword] = useState('') + + const filtered = useMemo(() => { + if (!search) return users + const q = search.toLowerCase() + return users.filter((u) => + u.displayName.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + u.username.toLowerCase().includes(q) + ) + }, [users, search]) + + const selected = users.find((u) => u.id === selectedId) ?? null + + function handleCreate() { + if (!newUsername.trim()) return + const newUser: MockUser = { + id: `usr-${Date.now()}`, + username: newUsername.trim(), + displayName: newDisplay.trim() || newUsername.trim(), + email: newEmail.trim(), + provider: 'local', + createdAt: new Date().toISOString(), + directRoles: [], + directGroups: [], + } + setUsers((prev) => [...prev, newUser]) + setCreating(false) + setNewUsername(''); setNewDisplay(''); setNewEmail(''); setNewPassword('') + setSelectedId(newUser.id) + } + + function handleDelete() { + if (!deleteTarget) return + setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)) + if (selectedId === deleteTarget.id) setSelectedId(null) + setDeleteTarget(null) + } + + function updateUser(id: string, patch: Partial) { + setUsers((prev) => prev.map((u) => u.id === id ? { ...u, ...patch } : u)) + } + + const effectiveRoles = selected ? getEffectiveRoles(selected) : [] + const availableGroups = MOCK_GROUPS.filter((g) => !selected?.directGroups.includes(g.id)) + .map((g) => ({ value: g.id, label: g.name })) + const availableRoles = MOCK_ROLES.filter((r) => !selected?.directRoles.includes(r.name)) + .map((r) => ({ value: r.name, label: r.name })) + + function getUserGroupPath(user: MockUser): string { + if (user.directGroups.length === 0) return 'no groups' + const group = MOCK_GROUPS.find((g) => g.id === user.directGroups[0]) + if (!group) return 'no groups' + const parent = group.parentId ? MOCK_GROUPS.find((g) => g.id === group.parentId) : null + return parent ? `${parent.name} > ${group.name}` : group.name + } + + return ( + <> +
+
+
+ setSearch(e.target.value)} + onClear={() => setSearch('')} + className={styles.listHeaderSearch} + /> + +
+ + {creating && ( +
+
+ setNewUsername(e.target.value)} /> + setNewDisplay(e.target.value)} /> +
+
+ setNewEmail(e.target.value)} /> + setNewPassword(e.target.value)} /> +
+
+ + +
+
+ )} + +
+ {filtered.map((user) => ( +
setSelectedId(user.id)} + > + +
+
+ {user.displayName} + {user.provider !== 'local' && ( + + )} +
+
+ {user.email} · {getUserGroupPath(user)} +
+
+ {user.directRoles.map((r) => )} + {user.directGroups.map((gId) => { + const g = MOCK_GROUPS.find((gr) => gr.id === gId) + return g ? : null + })} +
+
+
+ ))} +
+
+ +
+ {selected ? ( + <> +
+ +
+
+ updateUser(selected.id, { displayName: v })} + /> +
+
{selected.email}
+
+ +
+ +
+ Status + + ID + {selected.id} + Created + {new Date(selected.createdAt).toLocaleDateString()} + Provider + {selected.provider} +
+ + Group membership (direct only) +
+ {selected.directGroups.map((gId) => { + const g = MOCK_GROUPS.find((gr) => gr.id === gId) + return g ? ( + updateUser(selected.id, { + directGroups: selected.directGroups.filter((id) => id !== gId), + })} + /> + ) : null + })} + {selected.directGroups.length === 0 && ( + (no groups) + )} +
+
+ updateUser(selected.id, { + directGroups: [...selected.directGroups, ...ids], + })} + placeholder="Add groups..." + /> +
+ + Effective roles (direct + inherited) +
+ {effectiveRoles.map(({ role, source }) => ( + updateUser(selected.id, { + directRoles: selected.directRoles.filter((r) => r !== role), + }) : undefined} + /> + ))} + {effectiveRoles.length === 0 && ( + (no roles) + )} +
+ {effectiveRoles.some((r) => r.source !== 'direct') && ( + + Roles with ↑ are inherited through group membership + + )} +
+ updateUser(selected.id, { + directRoles: [...selected.directRoles, ...roles], + })} + placeholder="Add roles..." + /> +
+ + ) : ( +
Select a user to view details
+ )} +
+
+ + setDeleteTarget(null)} + onConfirm={handleDelete} + message={`Delete user "${deleteTarget?.username}"? This cannot be undone.`} + confirmText={deleteTarget?.username ?? ''} + /> + + ) +}