Files
membership-fe/src/pages/admin/AdminRoles.js
Koncept Kit 467f34b42a - - New ThemeConfigContext provider that fetches theme on app load and applies it to the DOM (title, meta description, favicon, CSS variables,
theme-color)/- - Admin Theme settings page under Settings > Theme tab/- All logo references (5 components) now pull from the theme config with fallback to default
2026-01-27 21:32:22 +07:00

606 lines
22 KiB
JavaScript

import React, { useEffect, useState } from 'react';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea';
import { Checkbox } from '../../components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../../components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../../components/ui/alert-dialog';
import { toast } from 'sonner';
import { Shield, Plus, Edit, Trash2, Lock, ChevronDown, ChevronUp } from 'lucide-react';
const AdminRoles = () => {
const [roles, setRoles] = useState([]);
const [permissions, setPermissions] = useState([]);
const [selectedRole, setSelectedRole] = useState(null);
const [rolePermissions, setRolePermissions] = useState([]);
const [selectedPermissions, setSelectedPermissions] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showPermissionsModal, setShowPermissionsModal] = useState(false);
const [expandedModules, setExpandedModules] = useState({});
const [savingPermissions, setSavingPermissions] = useState(false);
const [formData, setFormData] = useState({
code: '',
name: '',
description: '',
permissions: []
});
const formatRoleSlug = (value) => (
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/_+/g, '-')
.replace(/^_+|_+$/g, '')
);
useEffect(() => {
fetchRoles();
fetchPermissions();
}, []);
const fetchRoles = async () => {
try {
const response = await api.get('/admin/roles');
setRoles(response.data);
setLoading(false);
} catch (error) {
toast.error('Failed to fetch roles');
setLoading(false);
}
};
const fetchPermissions = async () => {
try {
const response = await api.get('/admin/permissions');
setPermissions(response.data);
// Expand all modules by default
const modules = [...new Set(response.data.map(p => p.module))];
const expanded = {};
modules.forEach(module => {
expanded[module] = true;
});
setExpandedModules(expanded);
} catch (error) {
toast.error('Failed to fetch permissions');
}
};
const fetchRolePermissions = async (roleId) => {
try {
const response = await api.get(`/admin/roles/${roleId}/permissions`);
setRolePermissions(response.data.permissions);
setSelectedPermissions(response.data.permissions.map(p => p.code));
} catch (error) {
toast.error('Failed to fetch role permissions');
}
};
const handleCreateRole = async () => {
try {
await api.post('/admin/roles', {
code: formData.code,
name: formData.name,
description: formData.description,
permission_codes: formData.permissions
});
toast.success('Role created successfully');
setShowCreateModal(false);
setFormData({ code: '', name: '', description: '', permissions: [] });
fetchRoles();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to create role');
}
};
const handleUpdateRole = async () => {
try {
await api.put(`/admin/roles/${selectedRole.id}`, {
name: formData.name,
description: formData.description
});
toast.success('Role updated successfully');
setShowEditModal(false);
fetchRoles();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to update role');
}
};
const handleDeleteRole = async () => {
try {
await api.delete(`/admin/roles/${selectedRole.id}`);
toast.success('Role deleted successfully');
setShowDeleteDialog(false);
setSelectedRole(null);
fetchRoles();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete role');
}
};
const handleSavePermissions = async () => {
setSavingPermissions(true);
try {
await api.put(`/admin/roles/${selectedRole.id}/permissions`, {
permission_codes: selectedPermissions
});
toast.success('Permissions updated successfully');
setShowPermissionsModal(false);
fetchRoles();
} catch (error) {
toast.error('Failed to update permissions');
} finally {
setSavingPermissions(false);
}
};
const togglePermission = (permissionCode) => {
setSelectedPermissions(prev => {
if (prev.includes(permissionCode)) {
return prev.filter(p => p !== permissionCode);
} else {
return [...prev, permissionCode];
}
});
};
const addPermissions = (permissionCodes) => {
setSelectedPermissions(prev => [...new Set([...prev, ...permissionCodes])]);
};
const removePermissions = (permissionCodes) => {
setSelectedPermissions(prev => prev.filter(code => !permissionCodes.includes(code)));
};
const toggleModule = (module) => {
setExpandedModules(prev => ({
...prev,
[module]: !prev[module]
}));
};
const groupPermissionsByModule = () => {
const grouped = {};
permissions.forEach(perm => {
if (!grouped[perm.module]) {
grouped[perm.module] = [];
}
grouped[perm.module].push(perm);
});
return grouped;
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Loading roles...</div>
</div>
);
}
const groupedPermissions = groupPermissionsByModule();
return (
<div className="space-y-6">
{/* Action Bar */}
<div className="flex justify-between items-center">
<p className="text-muted-foreground">
Create and manage custom roles with specific permissions
</p>
<Button className="btn-lavender" onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Role
</Button>
</div>
{/* Roles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roles.map(role => (
<Card key={role.id} className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<Shield className="w-5 h-5 text-blue-600 mr-2" />
<div>
<h3 className="font-semibold">{role.name}</h3>
<p className="text-sm text-gray-500">{role.code}</p>
</div>
</div>
{role.is_system_role && (
<Lock className="w-4 h-4 text-gray-400" title="System Role" />
)}
</div>
<p className="text-sm text-gray-600 mb-4 min-h-[40px]">
{role.description || 'No description'}
</p>
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500">
{role.permission_count} permissions
</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedRole(role);
fetchRolePermissions(role.id);
setShowPermissionsModal(true);
}}
>
Manage Permissions
</Button>
{!role.is_system_role && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedRole(role);
setFormData({
name: role.name,
description: role.description || ''
});
setShowEditModal(true);
}}
>
<Edit className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => {
setSelectedRole(role);
setShowDeleteDialog(true);
}}
>
<Trash2 className="w-3 h-3" />
</Button>
</>
)}
</div>
</Card>
))}
</div>
{/* Create Role Modal */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto scrollbar-dashboard scrollbar-dashboard">
<DialogHeader>
<DialogTitle>Create New Role</DialogTitle>
<DialogDescription>
Create a custom role with specific permissions for your team members.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Role Name *</Label>
<Input
placeholder="e.g., Content Editor, Finance Manager"
value={formData.name}
onChange={(e) => {
const nextName = e.target.value;
setFormData(prev => {
const prevAuto = formatRoleSlug(prev.name);
const isAuto = !prev.code || prev.code === prevAuto;
return {
...prev,
name: nextName,
code: isAuto ? formatRoleSlug(nextName) : prev.code
};
});
}}
/>
</div>
<div>
<Label>Role Slug *</Label>
<Input
placeholder="e.g., content_editor, finance_manager"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
/>
<p className="text-xs text-gray-500 mt-1">
Lowercase, no spaces. Used internally to identify the role.
</p>
</div>
<div>
<Label>Description</Label>
<Textarea
placeholder="Describe what this role can do..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div>
<Label>Permissions</Label>
<p className="text-sm text-gray-600 mb-3">
Select permissions for this role. You can also add permissions later.
</p>
<div className="border rounded-lg p-4 max-h-64 overflow-y-auto scrollbar-dashboard">
{Object.entries(groupedPermissions).map(([module, perms]) => {
const moduleCodes = perms.map(perm => perm.code);
const selectedCount = moduleCodes.filter(code => formData.permissions.includes(code)).length;
const hasPermissions = moduleCodes.length > 0;
const isAllSelected = hasPermissions && selectedCount === moduleCodes.length;
const isNoneSelected = selectedCount === 0;
return (
<div key={module} className="mb-4">
<div className="flex items-center justify-between mb-2">
<button
type="button"
onClick={() => toggleModule(module)}
className="flex items-center text-left font-medium hover:text-blue-600"
>
{expandedModules[module] ? (
<ChevronUp className="w-4 h-4 mr-1" />
) : (
<ChevronDown className="w-4 h-4 mr-1" />
)}
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setFormData(prev => ({
...prev,
permissions: [...new Set([...prev.permissions, ...moduleCodes])]
}));
}}
disabled={!hasPermissions || isAllSelected}
className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
Select all
</button>
<button
type="button"
onClick={() => {
setFormData(prev => ({
...prev,
permissions: prev.permissions.filter(code => !moduleCodes.includes(code))
}));
}}
disabled={!hasPermissions || isNoneSelected}
className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
Deselect all
</button>
</div>
</div>
{expandedModules[module] && (
<div className="space-y-2 ml-5">
{perms.map(perm => (
<div key={perm.code} className="flex items-center">
<Checkbox
checked={formData.permissions.includes(perm.code)}
onCheckedChange={() => {
setFormData(prev => ({
...prev,
permissions: prev.permissions.includes(perm.code)
? prev.permissions.filter(p => p !== perm.code)
: [...prev.permissions, perm.code]
}));
}}
/>
<label className="ml-2 text-sm">
<span className="font-medium">{perm.name}</span>
<span className="text-gray-500 ml-2">({perm.code})</span>
</label>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateModal(false)}>
Cancel
</Button>
<Button onClick={handleCreateRole}>
Create Role
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Role Modal */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent >
<DialogHeader>
<DialogTitle>Edit Role</DialogTitle>
<DialogDescription>
Update role name and description. Code cannot be changed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Role Name *</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<Label>Role Slug</Label>
<Input value={selectedRole?.code || ''} disabled />
</div>
<div>
<Label>Description</Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowEditModal(false)}>
Cancel
</Button>
<Button onClick={handleUpdateRole}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Manage Permissions Modal */}
<Dialog open={showPermissionsModal} onOpenChange={setShowPermissionsModal}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto scrollbar-dashboard">
<DialogHeader>
<DialogTitle>Manage Permissions: {selectedRole?.name}</DialogTitle>
<DialogDescription>
Select which permissions this role should have.
</DialogDescription>
</DialogHeader>
<div className="border rounded-lg p-4">
{Object.entries(groupedPermissions).map(([module, perms]) => {
const moduleCodes = perms.map(perm => perm.code);
const selectedCount = moduleCodes.filter(code => selectedPermissions.includes(code)).length;
const hasPermissions = moduleCodes.length > 0;
const isAllSelected = hasPermissions && selectedCount === moduleCodes.length;
const isNoneSelected = selectedCount === 0;
return (
<div key={module} className="mb-6">
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={() => toggleModule(module)}
className="flex items-center text-left font-medium text-lg hover:text-blue-600"
>
{expandedModules[module] ? (
<ChevronUp className="w-5 h-5 mr-2" />
) : (
<ChevronDown className="w-5 h-5 mr-2" />
)}
{module.charAt(0).toUpperCase() + module.slice(1)} ({perms.length})
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => addPermissions(moduleCodes)}
disabled={!hasPermissions || isAllSelected}
className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
Select all
</button>
<button
type="button"
onClick={() => removePermissions(moduleCodes)}
disabled={!hasPermissions || isNoneSelected}
className="text-xs font-medium text-gray-500 hover:text-brand-purple disabled:opacity-50 disabled:cursor-not-allowed"
>
Deselect all
</button>
</div>
</div>
{expandedModules[module] && (
<div className="space-y-3 ml-7">
{perms.map(perm => (
<div key={perm.code} className="flex items-start">
<Checkbox
checked={selectedPermissions.includes(perm.code)}
onCheckedChange={() => togglePermission(perm.code)}
/>
<div className="ml-3">
<label className="font-medium text-sm">
{perm.name}
</label>
<p className="text-xs text-gray-500">{perm.description}</p>
<span className="text-xs text-gray-400">{perm.code}</span>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsModal(false)}>
Cancel
</Button>
<Button onClick={handleSavePermissions} disabled={savingPermissions}>
{savingPermissions ? 'Saving...' : 'Save Permissions'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Role?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the role "{selectedRole?.name}"?
This action cannot be undone. Users with this role will need to be reassigned.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRole} className="bg-red-600">
Delete Role
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{savingPermissions && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-lg px-6 py-5 text-center">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-4 border-[var(--neutral-800)] border-t-transparent" />
<p className="mt-4 text-sm font-medium text-gray-700">Saving permissions...</p>
</div>
</div>
)}
</div>
);
};
export default AdminRoles;