Files
calypso/frontend/src/pages/IAM.tsx
2025-12-28 15:07:15 +00:00

2022 lines
88 KiB
TypeScript

import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ChevronRight, Search, Filter, UserPlus, History, ChevronLeft, MoreVertical, Lock, Verified, Wrench, Eye, HardDrive, Shield, ArrowRight, Network, ChevronRight as ChevronRightIcon, X, Edit, Trash2, User as UserIcon, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { iamApi, type User, type Group } from '@/api/iam'
export default function IAM() {
const [activeTab, setActiveTab] = useState('users')
const [searchQuery, setSearchQuery] = useState('')
const [showCreateUserForm, setShowCreateUserForm] = useState(false)
const [showEditUserForm, setShowEditUserForm] = useState(false)
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [openActionMenu, setOpenActionMenu] = useState<string | null>(null)
const queryClient = useQueryClient()
const { data: users, isLoading, error } = useQuery<User[]>({
queryKey: ['iam-users'],
queryFn: iamApi.listUsers,
refetchOnWindowFocus: true,
})
if (error) {
console.error('Failed to load users:', error)
}
const filteredUsers = (users || []).filter((user: User) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
(user.full_name && user.full_name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(user.email && user.email.toLowerCase().includes(searchQuery.toLowerCase())) ||
(user.roles && user.roles.some((r: string) => r.toLowerCase().includes(searchQuery.toLowerCase()))) ||
(user.groups && user.groups.some((g: string) => g.toLowerCase().includes(searchQuery.toLowerCase())))
)
const getRoleBadge = (roles: string[] | undefined) => {
if (!roles || roles.length === 0) {
return { bg: 'bg-slate-700', text: 'text-slate-300', border: 'border-slate-600', icon: Shield, Icon: Shield, label: 'No Role' }
}
// Use first role for display
const role = roles[0]
const roleConfig: Record<string, { bg: string; text: string; border: string; icon: any; label: string }> = {
'admin': { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/20', icon: Verified, label: 'Admin' },
'operator': { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/20', icon: Wrench, label: 'Operator' },
'auditor': { bg: 'bg-yellow-500/10', text: 'text-yellow-500', border: 'border-yellow-500/20', icon: Eye, label: 'Auditor' },
'storage_admin': { bg: 'bg-teal-500/10', text: 'text-teal-500', border: 'border-teal-500/20', icon: HardDrive, label: 'Storage Admin' },
'service': { bg: 'bg-slate-700', text: 'text-slate-300', border: 'border-slate-600', icon: Shield, label: 'Service' }
}
const config = roleConfig[role.toLowerCase()] || { bg: 'bg-slate-700', text: 'text-slate-300', border: 'border-slate-600', icon: Shield, label: role }
const Icon = config.icon
return { ...config, Icon }
}
const getAvatarBg = (username: string) => {
if (username.toLowerCase() === 'admin') {
return 'bg-gradient-to-br from-blue-500 to-indigo-600'
}
return 'bg-slate-700'
}
const deleteUserMutation = useMutation({
mutationFn: iamApi.deleteUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
queryClient.refetchQueries({ queryKey: ['iam-users'] })
},
onError: (error: any) => {
console.error('Failed to delete user:', error)
const errorMessage = error.response?.data?.error || error.message || 'Failed to delete user'
alert(errorMessage)
},
})
const handleDeleteUser = (userId: string) => {
deleteUserMutation.mutate(userId)
}
const formatLastLogin = (lastLoginAt: string | null) => {
if (!lastLoginAt) return 'Never'
const date = new Date(lastLoginAt)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
return date.toLocaleDateString()
}
return (
<div className="flex-1 overflow-y-auto custom-scrollbar h-full">
<div className="max-w-[1200px] mx-auto w-full p-8 flex flex-col gap-6">
{/* Page Header */}
<header className="flex flex-wrap justify-between items-end gap-4 border-b border-border-dark pb-6">
<div className="flex flex-col gap-2">
<nav className="flex items-center gap-2 text-sm text-text-secondary mb-1">
<span>System</span>
<ChevronRight size={16} />
<span className="text-white">Access Control</span>
</nav>
<h1 className="text-3xl font-black text-white leading-tight">User & Access Management</h1>
<p className="text-text-secondary text-base max-w-2xl">
Manage local accounts, define RBAC roles, and configure directory services (LDAP/AD) integration.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
className="flex items-center justify-center gap-2 px-4 py-2 bg-card-dark border border-border-dark rounded-lg text-white hover:bg-border-dark transition-colors font-semibold"
>
<History size={20} />
<span>Audit Log</span>
</Button>
</div>
</header>
{/* Content Container */}
<div className="flex flex-col gap-6">
{/* Tabs */}
<div className="flex border-b border-border-dark gap-8">
<button
onClick={() => setActiveTab('users')}
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
activeTab === 'users'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white hover:border-slate-600'
}`}
>
<Shield size={20} />
<span className="text-sm font-bold">Local Users</span>
</button>
<button
onClick={() => setActiveTab('groups')}
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
activeTab === 'groups'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white hover:border-slate-600'
}`}
>
<Network size={20} />
<span className="text-sm font-bold">Groups</span>
</button>
<button
onClick={() => setActiveTab('roles')}
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
activeTab === 'roles'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white hover:border-slate-600'
}`}
>
<Shield size={20} />
<span className="text-sm font-bold">Roles</span>
</button>
<button
onClick={() => setActiveTab('directory')}
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
activeTab === 'directory'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white hover:border-slate-600'
}`}
>
<Network size={20} />
<span className="text-sm font-bold">Directory Services</span>
</button>
<button
onClick={() => setActiveTab('auth')}
className={`flex items-center gap-2 pb-3 border-b-[3px] transition-colors ${
activeTab === 'auth'
? 'border-primary text-white'
: 'border-transparent text-text-secondary hover:text-white hover:border-slate-600'
}`}
>
<Lock size={20} />
<span className="text-sm font-bold">Authentication & SSO</span>
</button>
</div>
{/* Toolbar Area */}
{activeTab === 'users' && (
<>
<div className="flex flex-wrap gap-4 items-center justify-between">
{/* Search & Filter */}
<div className="flex flex-1 max-w-xl gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
<input
type="text"
placeholder="Search users by name, role, or group..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-card-dark border border-border-dark rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
/>
</div>
<Button
variant="outline"
className="flex items-center gap-2 px-4 py-2.5 bg-card-dark border border-border-dark rounded-lg text-text-secondary hover:text-white hover:border-slate-500 transition-colors"
>
<Filter size={20} />
<span className="text-sm font-medium">Filter</span>
</Button>
</div>
{/* Primary Action */}
<Button
onClick={() => setShowCreateUserForm(true)}
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all"
>
<UserPlus size={20} />
<span>Create User</span>
</Button>
</div>
{/* Users Table */}
<div className="rounded-xl border border-border-dark bg-[#111a22] overflow-hidden shadow-sm">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full">
<thead>
<tr className="bg-card-dark border-b border-border-dark text-left">
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider w-24">Status</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Username</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Full Name</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Role</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Groups</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Last Login</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark">
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-text-secondary">
Loading users...
</td>
</tr>
) : error ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-red-400">
Error loading users: {error instanceof Error ? error.message : 'Unknown error'}
</td>
</tr>
) : filteredUsers.length > 0 ? (
filteredUsers.map((user: User) => {
const roleBadge = getRoleBadge(user.roles)
const Icon = roleBadge.Icon
const avatarInitials = user.full_name
? user.full_name.split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase()
: user.username.substring(0, 2).toUpperCase()
return (
<tr key={user.id} className="group hover:bg-card-dark transition-colors">
<td className="px-6 py-4">
{user.is_active ? (
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-green-500/10 text-green-500 text-xs font-bold border border-green-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
Active
</div>
) : (
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-red-500/10 text-red-500 text-xs font-bold border border-red-500/20">
<Lock size={14} />
Locked
</div>
)}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full ${getAvatarBg(user.username)} flex items-center justify-center text-white text-xs font-bold`}>
{avatarInitials}
</div>
<span className="text-white font-medium">{user.username}</span>
</div>
</td>
<td className="px-6 py-4 text-text-secondary text-sm">{user.full_name || '-'}</td>
<td className="px-6 py-4">
{user.roles && user.roles.length > 0 ? (
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md ${roleBadge.bg} ${roleBadge.text} border ${roleBadge.border}`}>
<Icon size={14} />
{roleBadge.label}
</span>
) : (
<span className="text-text-secondary text-xs">No role</span>
)}
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{user.groups && user.groups.length > 0 ? user.groups.join(', ') : '-'}
</td>
<td className="px-6 py-4 text-text-secondary text-sm">{formatLastLogin(user.last_login_at)}</td>
<td className="px-6 py-4 text-right">
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation()
setOpenActionMenu(openActionMenu === user.id ? null : user.id)
}}
className="p-2 text-text-secondary hover:text-white hover:bg-border-dark rounded-lg transition-colors"
>
<MoreVertical size={20} />
</button>
{openActionMenu === user.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpenActionMenu(null)}
/>
<div className="absolute right-0 mt-1 w-48 bg-card-dark border border-border-dark rounded-lg shadow-xl z-20">
<div className="py-1">
<button
onClick={(e) => {
e.stopPropagation()
setSelectedUser(user)
setShowEditUserForm(true)
setOpenActionMenu(null)
}}
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-[#233648] flex items-center gap-2 transition-colors"
>
<Edit size={16} />
Edit User
</button>
<button
onClick={(e) => {
e.stopPropagation()
window.location.href = `/profile/${user.id}`
}}
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-[#233648] flex items-center gap-2 transition-colors"
>
<UserIcon size={16} />
View Profile
</button>
{!user.is_system && (
<button
onClick={(e) => {
e.stopPropagation()
if (confirm(`Are you sure you want to delete user "${user.username}"? This action cannot be undone.`)) {
handleDeleteUser(user.id)
}
setOpenActionMenu(null)
}}
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors"
>
<Trash2 size={16} />
Delete User
</button>
)}
</div>
</div>
</>
)}
</div>
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-text-secondary">
No users found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-border-dark flex items-center justify-between bg-card-dark">
<span className="text-sm text-text-secondary">
Showing <span className="font-bold text-white">1-{filteredUsers.length}</span> of{' '}
<span className="font-bold text-white">{filteredUsers.length}</span> users
</span>
<div className="flex gap-2">
<button className="p-2 rounded-lg text-text-secondary hover:bg-border-dark hover:text-white disabled:opacity-50 disabled:cursor-not-allowed">
<ChevronLeft size={20} />
</button>
<button className="p-2 rounded-lg text-text-secondary hover:bg-border-dark hover:text-white">
<ChevronRight size={20} />
</button>
</div>
</div>
</div>
</>
)}
{/* Create User Form Modal */}
{showCreateUserForm && (
<CreateUserForm
onClose={() => setShowCreateUserForm(false)}
onSuccess={async () => {
setShowCreateUserForm(false)
// Invalidate and refetch users list immediately
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
}}
/>
)}
{/* Edit User Form Modal */}
{showEditUserForm && selectedUser && (
<EditUserForm
user={selectedUser}
onClose={() => {
setShowEditUserForm(false)
setSelectedUser(null)
}}
onSuccess={async () => {
setShowEditUserForm(false)
setSelectedUser(null)
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
}}
/>
)}
{/* Groups Tab */}
{activeTab === 'groups' && <GroupsTab />}
{/* Roles Tab */}
{activeTab === 'roles' && <RolesTab />}
{activeTab !== 'users' && activeTab !== 'groups' && (
<div className="p-8 text-center text-text-secondary">
{activeTab === 'directory' && 'Directory Services tab coming soon'}
{activeTab === 'auth' && 'Authentication & SSO tab coming soon'}
</div>
)}
{/* Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-2">
{/* Directory Status */}
<div className="bg-[#111a22] p-5 rounded-xl border border-border-dark flex flex-col gap-4">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-800 rounded-lg text-text-secondary">
<Network size={20} />
</div>
<h3 className="text-white font-bold">Directory Service</h3>
</div>
<span className="px-2 py-1 rounded text-xs font-bold bg-slate-800 text-text-secondary border border-slate-700">
Inactive
</span>
</div>
<p className="text-sm text-text-secondary">
No LDAP or Active Directory server is currently connected. Local authentication is being used.
</p>
<div className="mt-auto pt-2">
<button className="text-primary text-sm font-bold hover:underline flex items-center gap-1">
Configure Directory
<ArrowRight size={16} />
</button>
</div>
</div>
{/* MFA Status */}
<div className="bg-[#111a22] p-5 rounded-xl border border-border-dark flex flex-col gap-4">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-500/10 rounded-lg text-orange-500">
<Shield size={20} />
</div>
<h3 className="text-white font-bold">Security Policy</h3>
</div>
<span className="px-2 py-1 rounded text-xs font-bold bg-green-500/10 text-green-500 border border-green-500/20">
Good
</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center text-sm">
<span className="text-text-secondary">Multi-Factor Auth</span>
<span className="text-green-500 font-medium">Enforced</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-text-secondary">Password Rotation</span>
<span className="text-white font-medium">90 Days</span>
</div>
</div>
<div className="mt-auto pt-2">
<button className="text-primary text-sm font-bold hover:underline flex items-center gap-1">
Manage Policies
<ArrowRight size={16} />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// Create User Form Component
interface CreateUserFormProps {
onClose: () => void
onSuccess: () => void
}
function CreateUserForm({ onClose, onSuccess }: CreateUserFormProps) {
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const createMutation = useMutation({
mutationFn: iamApi.createUser,
onSuccess: async () => {
// Wait a bit to ensure backend has processed the request
await new Promise(resolve => setTimeout(resolve, 300))
onSuccess()
},
onError: (error: any) => {
console.error('Failed to create user:', error)
const errorMessage = error.response?.data?.error || error.message || 'Failed to create user'
alert(errorMessage)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim() || !email.trim() || !password.trim()) {
alert('Username, email, and password are required')
return
}
const userData = {
username: username.trim(),
email: email.trim(),
password: password,
full_name: fullName.trim() || undefined,
}
console.log('Creating user:', { ...userData, password: '***' })
createMutation.mutate(userData)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
<div>
<h2 className="text-2xl font-bold text-white">Create User</h2>
<p className="text-sm text-text-secondary mt-1">Create a new user account</p>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
>
<X size={20} />
</button>
</div>
{/* Modal Content */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label htmlFor="user-username" className="block text-sm font-medium text-white mb-2">
Username <span className="text-red-400">*</span>
</label>
<input
id="user-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="johndoe"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
required
/>
</div>
<div>
<label htmlFor="user-email" className="block text-sm font-medium text-white mb-2">
Email <span className="text-red-400">*</span>
</label>
<input
id="user-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john.doe@example.com"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
required
/>
</div>
<div>
<label htmlFor="user-password" className="block text-sm font-medium text-white mb-2">
Password <span className="text-red-400">*</span>
</label>
<input
id="user-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
required
minLength={8}
/>
<p className="text-xs text-text-secondary mt-1">Minimum 8 characters</p>
</div>
<div>
<label htmlFor="user-fullname" className="block text-sm font-medium text-white mb-2">
Full Name <span className="text-text-secondary text-xs">(Optional)</span>
</label>
<input
id="user-fullname"
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="John Doe"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
<Button
type="button"
variant="outline"
onClick={onClose}
className="px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className="px-6 bg-primary hover:bg-blue-600"
>
{createMutation.isPending ? 'Creating...' : 'Create User'}
</Button>
</div>
</form>
</div>
</div>
)
}
// Edit User Form Component
interface EditUserFormProps {
user: User
onClose: () => void
onSuccess: () => void
}
function EditUserForm({ user, onClose, onSuccess }: EditUserFormProps) {
const [email, setEmail] = useState(user.email || '')
const [fullName, setFullName] = useState(user.full_name || '')
const [isActive, setIsActive] = useState(user.is_active)
const [userRoles, setUserRoles] = useState<string[]>(user.roles || [])
const [userGroups, setUserGroups] = useState<string[]>(user.groups || [])
const [selectedRole, setSelectedRole] = useState('')
const [selectedGroup, setSelectedGroup] = useState('')
const queryClient = useQueryClient()
// Use refs to always get the latest state values
const userRolesRef = useRef(userRoles)
const userGroupsRef = useRef(userGroups)
// Update refs whenever state changes
useEffect(() => {
userRolesRef.current = userRoles
console.log('useEffect userRoles - state updated:', userRoles)
}, [userRoles])
useEffect(() => {
userGroupsRef.current = userGroups
console.log('useEffect userGroups - state updated:', userGroups)
}, [userGroups])
// Debug: log state changes
useEffect(() => {
console.log('EditUserForm - userRoles state changed:', userRoles)
}, [userRoles])
useEffect(() => {
console.log('EditUserForm - userGroups state changed:', userGroups)
}, [userGroups])
// Fetch available roles and groups
const { data: availableRoles = [] } = useQuery({
queryKey: ['iam-roles'],
queryFn: iamApi.listRoles,
})
const { data: availableGroups = [] } = useQuery({
queryKey: ['iam-groups'],
queryFn: iamApi.listGroups,
})
// Filter out already assigned roles/groups
const unassignedRoles = availableRoles.filter(r => !userRoles.includes(r.name))
const unassignedGroups = availableGroups.filter(g => !userGroups.includes(g.name))
const updateMutation = useMutation({
mutationFn: (data: { email?: string; full_name?: string; is_active?: boolean; roles?: string[]; groups?: string[] }) =>
iamApi.updateUser(user.id, data),
onSuccess: async () => {
onSuccess()
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
await queryClient.refetchQueries({ queryKey: ['iam-user', user.id] })
},
onError: (error: any) => {
console.error('Failed to update user:', error)
const errorMessage = error.response?.data?.error || error.message || 'Failed to update user'
alert(errorMessage)
},
})
const assignRoleMutation = useMutation({
mutationFn: (roleName: string) => iamApi.assignRoleToUser(user.id, roleName),
onMutate: async (roleName: string) => {
// Optimistic update: add role to state immediately
console.log('assignRoleMutation onMutate - BEFORE update, current userRoles:', userRolesRef.current)
setUserRoles(prev => {
const newRoles = prev.includes(roleName) ? prev : [...prev, roleName]
console.log('assignRoleMutation onMutate - prev:', prev, 'roleName:', roleName, 'newRoles:', newRoles)
// Also update ref immediately
userRolesRef.current = newRoles
return newRoles
})
setSelectedRole('')
console.log('assignRoleMutation onMutate - AFTER update, ref should be:', userRolesRef.current)
},
onSuccess: async (_, roleName: string) => {
// Don't overwrite state with server data - keep optimistic update
// Just invalidate queries for other components
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
// Use functional update to get current state
setUserRoles(current => {
console.log('assignRoleMutation onSuccess - roleName:', roleName, 'current userRoles:', current)
return current
})
},
onError: (error: any, roleName: string) => {
console.error('Failed to assign role:', error, roleName)
// Rollback: remove role from state if API call failed
setUserRoles(prev => prev.filter(r => r !== roleName))
alert(error.response?.data?.error || error.message || 'Failed to assign role')
},
})
const removeRoleMutation = useMutation({
mutationFn: (roleName: string) => iamApi.removeRoleFromUser(user.id, roleName),
onMutate: async (roleName: string) => {
// Store previous state for rollback
const previousRoles = userRoles
// Optimistic update: remove role from state immediately
setUserRoles(prev => prev.filter(r => r !== roleName))
return { previousRoles }
},
onSuccess: async (_, roleName: string) => {
// Don't overwrite state with server data - keep optimistic update
// Just invalidate queries for other components
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
console.log('Role removed successfully:', roleName, 'Current userRoles:', userRoles)
},
onError: (error: any, _roleName: string, context: any) => {
console.error('Failed to remove role:', error)
// Rollback: restore previous state if API call failed
if (context?.previousRoles) {
setUserRoles(context.previousRoles)
}
alert(error.response?.data?.error || error.message || 'Failed to remove role')
},
})
const assignGroupMutation = useMutation({
mutationFn: (groupName: string) => iamApi.assignGroupToUser(user.id, groupName),
onMutate: async (groupName: string) => {
// Optimistic update: add group to state immediately
console.log('assignGroupMutation onMutate - BEFORE update, current userGroups:', userGroupsRef.current)
setUserGroups(prev => {
const newGroups = prev.includes(groupName) ? prev : [...prev, groupName]
console.log('assignGroupMutation onMutate - prev:', prev, 'groupName:', groupName, 'newGroups:', newGroups)
// Also update ref immediately
userGroupsRef.current = newGroups
return newGroups
})
setSelectedGroup('')
console.log('assignGroupMutation onMutate - AFTER update, ref should be:', userGroupsRef.current)
},
onSuccess: async (_, groupName: string) => {
// Don't overwrite state with server data - keep optimistic update
// Just invalidate queries for other components
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
// Use functional update to get current state
setUserGroups(current => {
console.log('assignGroupMutation onSuccess - groupName:', groupName, 'current userGroups:', current)
return current
})
},
onError: (error: any, groupName: string) => {
console.error('Failed to assign group:', error, groupName)
// Rollback: remove group from state if API call failed
setUserGroups(prev => prev.filter(g => g !== groupName))
alert(error.response?.data?.error || error.message || 'Failed to assign group')
},
})
const removeGroupMutation = useMutation({
mutationFn: (groupName: string) => iamApi.removeGroupFromUser(user.id, groupName),
onMutate: async (groupName: string) => {
// Store previous state for rollback
const previousGroups = userGroups
// Optimistic update: remove group from state immediately
setUserGroups(prev => prev.filter(g => g !== groupName))
return { previousGroups }
},
onSuccess: async (_, groupName: string) => {
// Don't overwrite state with server data - keep optimistic update
// Just invalidate queries for other components
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
queryClient.invalidateQueries({ queryKey: ['iam-user', user.id] })
console.log('Group removed successfully:', groupName, 'Current userGroups:', userGroups)
},
onError: (error: any, _groupName: string, context: any) => {
console.error('Failed to remove group:', error)
// Rollback: restore previous state if API call failed
if (context?.previousGroups) {
setUserGroups(context.previousGroups)
}
alert(error.response?.data?.error || error.message || 'Failed to remove group')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Use refs to get the latest state values (avoid closure issues)
const currentRoles = userRolesRef.current
const currentGroups = userGroupsRef.current
const payload = {
email: email.trim(),
full_name: fullName.trim() || undefined,
is_active: isActive,
roles: currentRoles,
groups: currentGroups,
}
console.log('EditUserForm - Submitting payload:', payload)
console.log('EditUserForm - currentRoles from ref:', currentRoles)
console.log('EditUserForm - currentGroups from ref:', currentGroups)
console.log('EditUserForm - userRoles from state:', userRoles)
console.log('EditUserForm - userGroups from state:', userGroups)
updateMutation.mutate(payload)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
<div>
<h2 className="text-2xl font-bold text-white">Edit User</h2>
<p className="text-sm text-text-secondary mt-1">Edit user account: {user.username}</p>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
>
<X size={20} />
</button>
</div>
{/* Modal Content */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label htmlFor="edit-username" className="block text-sm font-medium text-white mb-2">
Username
</label>
<input
id="edit-username"
type="text"
value={user.username}
disabled
className="w-full px-4 py-3 bg-[#0a0f14] border border-border-dark rounded-lg text-text-secondary text-sm cursor-not-allowed"
/>
<p className="text-xs text-text-secondary mt-1">Username cannot be changed</p>
</div>
<div>
<label htmlFor="edit-email" className="block text-sm font-medium text-white mb-2">
Email <span className="text-red-400">*</span>
</label>
<input
id="edit-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john.doe@example.com"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
required
/>
</div>
<div>
<label htmlFor="edit-fullname" className="block text-sm font-medium text-white mb-2">
Full Name <span className="text-text-secondary text-xs">(Optional)</span>
</label>
<input
id="edit-fullname"
type="text"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="John Doe"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
/>
</div>
{/* Roles Section */}
<div className="border-t border-border-dark pt-6">
<label className="block text-sm font-medium text-white mb-3">
Roles
</label>
<div className="flex gap-2 mb-3">
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className="flex-1 px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="">Select a role...</option>
{unassignedRoles.map((role) => (
<option key={role.name} value={role.name}>
{role.name} {role.description ? `- ${role.description}` : ''}
</option>
))}
</select>
<Button
type="button"
onClick={() => {
if (selectedRole) {
assignRoleMutation.mutate(selectedRole)
}
}}
disabled={!selectedRole || assignRoleMutation.isPending}
className="px-4 bg-primary hover:bg-blue-600"
>
<Plus size={16} />
</Button>
</div>
<div className="space-y-2">
{userRoles.length > 0 ? (
userRoles.map((role) => (
<div
key={role}
className="flex items-center justify-between px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg"
>
<span className="text-white text-sm font-medium">{role}</span>
<button
type="button"
onClick={() => removeRoleMutation.mutate(role)}
disabled={removeRoleMutation.isPending}
className="text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
))
) : (
<p className="text-text-secondary text-sm text-center py-2">No roles assigned</p>
)}
</div>
</div>
{/* Groups Section */}
<div className="border-t border-border-dark pt-6">
<label className="block text-sm font-medium text-white mb-3">
Groups
</label>
<div className="flex gap-2 mb-3">
<select
value={selectedGroup}
onChange={(e) => setSelectedGroup(e.target.value)}
className="flex-1 px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="">Select a group...</option>
{unassignedGroups.map((group) => (
<option key={group.id} value={group.name}>
{group.name} {group.description ? `- ${group.description}` : ''}
</option>
))}
</select>
<Button
type="button"
onClick={() => {
if (selectedGroup) {
assignGroupMutation.mutate(selectedGroup)
}
}}
disabled={!selectedGroup || assignGroupMutation.isPending}
className="px-4 bg-primary hover:bg-blue-600"
>
<Plus size={16} />
</Button>
</div>
<div className="space-y-2">
{userGroups.length > 0 ? (
userGroups.map((group) => (
<div
key={group}
className="flex items-center justify-between px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg"
>
<span className="text-white text-sm font-medium">{group}</span>
<button
type="button"
onClick={() => removeGroupMutation.mutate(group)}
disabled={removeGroupMutation.isPending}
className="text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
))
) : (
<p className="text-text-secondary text-sm text-center py-2">No groups assigned</p>
)}
</div>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="w-4 h-4 rounded bg-[#0f161d] border-border-dark text-primary focus:ring-2 focus:ring-primary"
/>
<span className="text-sm font-medium text-white">Active Account</span>
</label>
<p className="text-xs text-text-secondary mt-1 ml-7">
{isActive ? 'User can log in and access the system' : 'User account is disabled'}
</p>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
<Button
type="button"
variant="outline"
onClick={onClose}
className="px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={updateMutation.isPending}
className="px-6 bg-primary hover:bg-blue-600"
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
)
}
// Groups Tab Component
function GroupsTab() {
const queryClient = useQueryClient()
const [searchQuery, setSearchQuery] = useState('')
const [showCreateForm, setShowCreateForm] = useState(false)
const [showEditForm, setShowEditForm] = useState(false)
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null)
const [openActionMenu, setOpenActionMenu] = useState<string | null>(null)
const { data: groups, isLoading } = useQuery<Group[]>({
queryKey: ['iam-groups'],
queryFn: iamApi.listGroups,
})
const filteredGroups = groups?.filter(group =>
group.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(group.description && group.description.toLowerCase().includes(searchQuery.toLowerCase()))
) || []
const deleteGroupMutation = useMutation({
mutationFn: iamApi.deleteGroup,
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['iam-groups'] })
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
alert('Group deleted successfully!')
},
onError: (error: any) => {
console.error('Failed to delete group:', error)
alert(error.response?.data?.error || error.message || 'Failed to delete group')
},
})
const handleDeleteGroup = (groupId: string, groupName: string) => {
if (confirm(`Are you sure you want to delete group "${groupName}"? This action cannot be undone.`)) {
deleteGroupMutation.mutate(groupId)
}
}
return (
<>
{/* Toolbar */}
<div className="flex flex-wrap gap-4 items-center justify-between">
<div className="flex flex-1 max-w-xl gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
<input
type="text"
placeholder="Search groups by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-card-dark border border-border-dark rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
/>
</div>
<Button
variant="outline"
className="flex items-center gap-2 px-4 py-2.5 bg-card-dark border border-border-dark rounded-lg text-text-secondary hover:text-white hover:border-slate-500 transition-colors"
>
<Filter size={20} />
<span className="text-sm font-medium">Filter</span>
</Button>
</div>
<Button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all"
>
<UserPlus size={20} />
<span>Create Group</span>
</Button>
</div>
{/* Groups Table */}
<div className="rounded-xl border border-border-dark bg-[#111a22] overflow-hidden shadow-sm">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full">
<thead>
<tr className="bg-card-dark border-b border-border-dark text-left">
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Name</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Description</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Users</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Roles</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Type</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark">
{isLoading ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-text-secondary">
Loading groups...
</td>
</tr>
) : filteredGroups.length > 0 ? (
filteredGroups.map((group) => (
<tr key={group.id} className="group hover:bg-card-dark transition-colors">
<td className="px-6 py-4">
<span className="text-white font-medium">{group.name}</span>
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{group.description || '-'}
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{group.user_count}
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{group.role_count}
</td>
<td className="px-6 py-4">
{group.is_system ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-purple-500/10 text-purple-400 text-xs font-medium border border-purple-500/20">
<Shield size={12} />
System
</span>
) : (
<span className="text-text-secondary text-xs">Custom</span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation()
setOpenActionMenu(openActionMenu === group.id ? null : group.id)
}}
className="p-2 text-text-secondary hover:text-white hover:bg-border-dark rounded-lg transition-colors"
>
<MoreVertical size={20} />
</button>
{openActionMenu === group.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpenActionMenu(null)}
/>
<div className="absolute right-0 mt-1 w-48 bg-card-dark border border-border-dark rounded-lg shadow-xl z-20">
<div className="py-1">
<button
onClick={(e) => {
e.stopPropagation()
setSelectedGroup(group)
setShowEditForm(true)
setOpenActionMenu(null)
}}
disabled={group.is_system}
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-[#233648] flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit size={16} />
Edit Group
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteGroup(group.id, group.name)
setOpenActionMenu(null)
}}
disabled={group.is_system || deleteGroupMutation.isPending}
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={16} />
Delete Group
</button>
</div>
</div>
</>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-text-secondary">
No groups found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-border-dark flex items-center justify-between bg-card-dark">
<span className="text-sm text-text-secondary">
Showing <span className="font-bold text-white">1-{filteredGroups.length}</span> of{' '}
<span className="font-bold text-white">{filteredGroups.length}</span> groups
</span>
<div className="flex gap-2">
<button className="p-2 rounded-lg text-text-secondary hover:bg-border-dark hover:text-white disabled:opacity-50 disabled:cursor-not-allowed">
<ChevronLeft size={20} />
</button>
<button className="p-2 rounded-lg text-text-secondary hover:bg-border-dark hover:text-white">
<ChevronRightIcon size={20} />
</button>
</div>
</div>
</div>
{/* Create Group Form Modal */}
{showCreateForm && (
<CreateGroupForm
onClose={() => setShowCreateForm(false)}
onSuccess={async () => {
setShowCreateForm(false)
queryClient.invalidateQueries({ queryKey: ['iam-groups'] })
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
}}
/>
)}
{/* Edit Group Form Modal */}
{showEditForm && selectedGroup && (
<EditGroupForm
group={selectedGroup}
onClose={() => {
setShowEditForm(false)
setSelectedGroup(null)
}}
onSuccess={async () => {
setShowEditForm(false)
setSelectedGroup(null)
queryClient.invalidateQueries({ queryKey: ['iam-groups'] })
await queryClient.refetchQueries({ queryKey: ['iam-groups'] })
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
}}
/>
)}
</>
)
}
interface CreateGroupFormProps {
onClose: () => void
onSuccess: () => void
}
function CreateGroupForm({ onClose, onSuccess }: CreateGroupFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const createMutation = useMutation({
mutationFn: iamApi.createGroup,
onSuccess: () => {
onSuccess()
},
onError: (error: any) => {
console.error('Failed to create group:', error)
const errorMessage = error.response?.data?.error || error.message || 'Failed to create group'
alert(errorMessage)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
alert('Name is required')
return
}
const groupData = {
name: name.trim(),
description: description.trim() || '',
}
console.log('Creating group:', groupData)
createMutation.mutate(groupData)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
{/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
<div>
<h2 className="text-2xl font-bold text-white">Create Group</h2>
<p className="text-sm text-text-secondary mt-1">Create a new user group</p>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
>
<X size={20} />
</button>
</div>
{/* Modal Content */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label htmlFor="group-name" className="block text-sm font-medium text-white mb-2">
Group Name <span className="text-red-400">*</span>
</label>
<input
id="group-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="operators"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
required
/>
</div>
<div>
<label htmlFor="group-description" className="block text-sm font-medium text-white mb-2">
Description <span className="text-text-secondary text-xs">(Optional)</span>
</label>
<textarea
id="group-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Group description"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
rows={4}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
<Button
type="button"
variant="outline"
onClick={onClose}
className="px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className="px-6 bg-primary hover:bg-blue-600"
>
{createMutation.isPending ? 'Creating...' : 'Create Group'}
</Button>
</div>
</form>
</div>
</div>
)
}
// Roles Tab Component
function RolesTab() {
const queryClient = useQueryClient()
const [searchQuery, setSearchQuery] = useState('')
const [showCreateForm, setShowCreateForm] = useState(false)
const [showEditForm, setShowEditForm] = useState(false)
const [selectedRole, setSelectedRole] = useState<{ id: string; name: string; description?: string; is_system: boolean } | null>(null)
const { data: roles, isLoading } = useQuery({
queryKey: ['iam-roles'],
queryFn: iamApi.listRoles,
})
const filteredRoles = roles?.filter(role =>
role.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(role.description && role.description.toLowerCase().includes(searchQuery.toLowerCase()))
) || []
const deleteRoleMutation = useMutation({
mutationFn: iamApi.deleteRole,
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['iam-roles'] })
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
alert('Role deleted successfully!')
},
onError: (error: any) => {
console.error('Failed to delete role:', error)
alert(error.response?.data?.error || error.message || 'Failed to delete role')
},
})
const handleDeleteRole = (roleId: string) => {
if (confirm('Are you sure you want to delete this role? This action cannot be undone.')) {
deleteRoleMutation.mutate(roleId)
}
}
return (
<>
{/* Toolbar */}
<div className="flex flex-wrap gap-4 items-center justify-between">
<div className="flex flex-1 max-w-xl gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
<input
type="text"
placeholder="Search roles by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-card-dark border border-border-dark rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
/>
</div>
<Button
variant="outline"
className="flex items-center gap-2 px-4 py-2.5 bg-card-dark border border-border-dark rounded-lg text-text-secondary hover:text-white hover:border-slate-500 transition-colors"
>
<Filter size={20} />
<span className="text-sm font-medium">Filter</span>
</Button>
</div>
<Button
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 bg-primary hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-bold shadow-lg shadow-blue-500/20 transition-all"
>
<UserPlus size={20} />
<span>Create Role</span>
</Button>
</div>
{/* Roles Table */}
<div className="rounded-xl border border-border-dark bg-[#111a22] overflow-hidden shadow-sm">
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full">
<thead>
<tr className="bg-card-dark border-b border-border-dark text-left">
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Name</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Description</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Users</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider">Type</th>
<th className="px-6 py-4 text-xs font-bold text-text-secondary uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-dark">
{isLoading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-text-secondary">
Loading roles...
</td>
</tr>
) : filteredRoles.length > 0 ? (
filteredRoles.map((role) => (
<tr key={role.id} className="group hover:bg-card-dark transition-colors">
<td className="px-6 py-4">
<span className="text-white font-medium">{role.name}</span>
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{role.description || '-'}
</td>
<td className="px-6 py-4 text-text-secondary text-sm">
{role.user_count || 0}
</td>
<td className="px-6 py-4">
{role.is_system ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-purple-500/10 text-purple-400 text-xs font-medium border border-purple-500/20">
<Shield size={12} />
System
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-blue-500/10 text-blue-400 text-xs font-medium border border-blue-500/20">
Custom
</span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
setSelectedRole(role)
setShowEditForm(true)
}}
className="p-2 text-text-secondary hover:text-white hover:bg-border-dark rounded-lg transition-colors"
title="Edit role"
>
<Edit size={16} />
</button>
{!role.is_system && (
<button
onClick={() => handleDeleteRole(role.id)}
disabled={deleteRoleMutation.isPending}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
title="Delete role"
>
<Trash2 size={16} />
</button>
)}
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-text-secondary">
No roles found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Create Role Form Modal */}
{showCreateForm && (
<CreateRoleForm
onClose={() => setShowCreateForm(false)}
onSuccess={async () => {
setShowCreateForm(false)
queryClient.invalidateQueries({ queryKey: ['iam-roles'] })
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
}}
/>
)}
{/* Edit Role Form Modal */}
{showEditForm && selectedRole && (
<EditRoleForm
role={selectedRole}
onClose={() => {
setShowEditForm(false)
setSelectedRole(null)
}}
onSuccess={async () => {
setShowEditForm(false)
setSelectedRole(null)
queryClient.invalidateQueries({ queryKey: ['iam-roles'] })
await queryClient.refetchQueries({ queryKey: ['iam-roles'] })
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
}}
/>
)}
</>
)
}
// Create Role Form Component
interface CreateRoleFormProps {
onClose: () => void
onSuccess: () => void
}
function CreateRoleForm({ onClose, onSuccess }: CreateRoleFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const createMutation = useMutation({
mutationFn: (data: { name: string; description?: string }) => iamApi.createRole(data),
onSuccess: () => {
onSuccess()
},
onError: (error: any) => {
console.error('Failed to create role:', error)
const errorMessage = error.response?.data?.error || error.message || 'Failed to create role'
alert(errorMessage)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createMutation.mutate({
name: name.trim(),
description: description.trim() || undefined,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
<div>
<h2 className="text-2xl font-bold text-white">Create Role</h2>
<p className="text-sm text-text-secondary mt-1">Create a new role for access control</p>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label htmlFor="role-name" className="block text-sm font-medium text-white mb-2">
Role Name <span className="text-red-400">*</span>
</label>
<input
id="role-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., operator, auditor"
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
required
/>
</div>
<div>
<label htmlFor="role-description" className="block text-sm font-medium text-white mb-2">
Description <span className="text-text-secondary text-xs">(Optional)</span>
</label>
<textarea
id="role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the role's purpose and permissions"
rows={3}
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
<Button
type="button"
variant="outline"
onClick={onClose}
className="px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className="px-6 bg-primary hover:bg-blue-600"
>
{createMutation.isPending ? 'Creating...' : 'Create Role'}
</Button>
</div>
</form>
</div>
</div>
)
}
// Edit Role Form Component
interface EditRoleFormProps {
role: { id: string; name: string; description?: string; is_system: boolean }
onClose: () => void
onSuccess: () => void
}
function EditRoleForm({ role, onClose, onSuccess }: EditRoleFormProps) {
const [name, setName] = useState(role.name)
const [description, setDescription] = useState(role.description || '')
const [rolePermissions, setRolePermissions] = useState<string[]>([])
const [selectedPermission, setSelectedPermission] = useState('')
const queryClient = useQueryClient()
// Fetch role permissions
const { data: permissions = [] } = useQuery({
queryKey: ['iam-role-permissions', role.id],
queryFn: () => iamApi.getRolePermissions(role.id),
})
// Update rolePermissions when permissions data changes
useEffect(() => {
if (permissions) {
setRolePermissions(permissions)
}
}, [permissions])
// Fetch all available permissions
const { data: availablePermissions = [] } = useQuery({
queryKey: ['iam-permissions'],
queryFn: iamApi.listPermissions,
})
// Filter out already assigned permissions
const unassignedPermissions = availablePermissions.filter(p => !rolePermissions.includes(p.name))
const updateMutation = useMutation({
mutationFn: (data: { name?: string; description?: string }) => iamApi.updateRole(role.id, data),
onSuccess: () => {
onSuccess()
},
onError: (error: any) => {
console.error('Failed to update role:', error)
const errorMessage = error.response?.data?.error || error.message || 'Failed to update role'
alert(errorMessage)
},
})
const assignPermissionMutation = useMutation({
mutationFn: (permissionName: string) => iamApi.assignPermissionToRole(role.id, permissionName),
onSuccess: async () => {
const updatedPermissions = await iamApi.getRolePermissions(role.id)
setRolePermissions(updatedPermissions)
queryClient.invalidateQueries({ queryKey: ['iam-role-permissions', role.id] })
await queryClient.refetchQueries({ queryKey: ['iam-role-permissions', role.id] })
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
setSelectedPermission('')
},
onError: (error: any) => {
console.error('Failed to assign permission:', error)
alert(error.response?.data?.error || error.message || 'Failed to assign permission')
},
})
const removePermissionMutation = useMutation({
mutationFn: (permissionName: string) => iamApi.removePermissionFromRole(role.id, permissionName),
onSuccess: async () => {
const updatedPermissions = await iamApi.getRolePermissions(role.id)
setRolePermissions(updatedPermissions)
queryClient.invalidateQueries({ queryKey: ['iam-role-permissions', role.id] })
await queryClient.refetchQueries({ queryKey: ['iam-role-permissions', role.id] })
queryClient.invalidateQueries({ queryKey: ['iam-users'] })
await queryClient.refetchQueries({ queryKey: ['iam-users'] })
},
onError: (error: any) => {
console.error('Failed to remove permission:', error)
alert(error.response?.data?.error || error.message || 'Failed to remove permission')
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
updateMutation.mutate({
name: name.trim(),
description: description.trim() || undefined,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
<div>
<h2 className="text-2xl font-bold text-white">Edit Role: {role.name}</h2>
<p className="text-sm text-text-secondary mt-1">Modify role details</p>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label htmlFor="edit-role-name" className="block text-sm font-medium text-white mb-2">
Role Name <span className="text-red-400">*</span>
</label>
<input
id="edit-role-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={role.is_system}
className={`w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors ${
role.is_system ? 'cursor-not-allowed opacity-50' : ''
}`}
required
/>
{role.is_system && (
<p className="text-xs text-text-secondary mt-1">System roles cannot be renamed</p>
)}
</div>
<div>
<label htmlFor="edit-role-description" className="block text-sm font-medium text-white mb-2">
Description <span className="text-text-secondary text-xs">(Optional)</span>
</label>
<textarea
id="edit-role-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the role's purpose and permissions"
rows={3}
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
/>
</div>
{/* Permissions Section */}
<div className="border-t border-border-dark pt-6">
<label className="block text-sm font-medium text-white mb-3">
Permissions
</label>
<div className="flex gap-2 mb-3">
<select
value={selectedPermission}
onChange={(e) => setSelectedPermission(e.target.value)}
className="flex-1 px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="">Select a permission...</option>
{unassignedPermissions.map((perm) => (
<option key={perm.id} value={perm.name}>
{perm.name} {perm.description ? `- ${perm.description}` : `(${perm.resource}:${perm.action})`}
</option>
))}
</select>
<Button
type="button"
onClick={() => {
if (selectedPermission) {
assignPermissionMutation.mutate(selectedPermission)
}
}}
disabled={!selectedPermission || assignPermissionMutation.isPending}
className="px-4 bg-primary hover:bg-blue-600"
>
<Plus size={16} />
</Button>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto custom-scrollbar">
{rolePermissions.length > 0 ? (
rolePermissions.map((perm) => {
const permInfo = availablePermissions.find(p => p.name === perm)
return (
<div
key={perm}
className="flex items-center justify-between px-4 py-2 bg-[#0f161d] border border-border-dark rounded-lg"
>
<div className="flex flex-col">
<span className="text-white text-sm font-medium">{perm}</span>
{permInfo && (
<span className="text-text-secondary text-xs">
{permInfo.resource}:{permInfo.action}
{permInfo.description && ` - ${permInfo.description}`}
</span>
)}
</div>
<button
type="button"
onClick={() => removePermissionMutation.mutate(perm)}
disabled={removePermissionMutation.isPending}
className="text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 size={16} />
</button>
</div>
)
})
) : (
<p className="text-text-secondary text-sm text-center py-2">No permissions assigned</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
<Button
type="button"
variant="outline"
onClick={onClose}
className="px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={updateMutation.isPending || role.is_system}
className="px-6 bg-primary hover:bg-blue-600"
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
)
}
// Edit Group Form Component
interface EditGroupFormProps {
group: Group
onClose: () => void
onSuccess: () => void
}
function EditGroupForm({ group, onClose, onSuccess }: EditGroupFormProps) {
const [name, setName] = useState(group.name)
const [description, setDescription] = useState(group.description || '')
const updateMutation = useMutation({
mutationFn: (data: { name?: string; description?: string }) => iamApi.updateGroup(group.id, data),
onSuccess: () => {
onSuccess()
},
onError: (error: any) => {
console.error('Failed to update group:', error)
const errorMessage = error.response?.data?.error || error.message || 'Failed to update group'
alert(errorMessage)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
updateMutation.mutate({
name: name.trim(),
description: description.trim() || undefined,
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-card-dark border border-border-dark rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto custom-scrollbar">
<div className="flex items-center justify-between p-6 border-b border-border-dark bg-[#1e2832]">
<div>
<h2 className="text-2xl font-bold text-white">Edit Group: {group.name}</h2>
<p className="text-sm text-text-secondary mt-1">Modify group details</p>
</div>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors p-2 hover:bg-[#233648] rounded-lg"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div>
<label htmlFor="edit-group-name" className="block text-sm font-medium text-white mb-2">
Group Name <span className="text-red-400">*</span>
</label>
<input
id="edit-group-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={group.is_system}
className={`w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors ${
group.is_system ? 'cursor-not-allowed opacity-50' : ''
}`}
required
/>
{group.is_system && (
<p className="text-xs text-text-secondary mt-1">System groups cannot be renamed</p>
)}
</div>
<div>
<label htmlFor="edit-group-description" className="block text-sm font-medium text-white mb-2">
Description <span className="text-text-secondary text-xs">(Optional)</span>
</label>
<textarea
id="edit-group-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the group's purpose"
rows={3}
className="w-full px-4 py-3 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm placeholder-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-border-dark">
<Button
type="button"
variant="outline"
onClick={onClose}
className="px-6"
>
Cancel
</Button>
<Button
type="submit"
disabled={updateMutation.isPending || group.is_system}
className="px-6 bg-primary hover:bg-blue-600"
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
)
}