393 lines
18 KiB
JavaScript
393 lines
18 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import DataTable from '../components/DataTable';
|
|
import { rolesAPI, listPermissions } from '../services/api';
|
|
import { Plus, Trash2, Search, Pencil } from 'lucide-react';
|
|
import { ToastContainer, toast } from 'react-toastify';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import { getErrorMessage, getSuccessMessage } from '../utils/errorHandler';
|
|
|
|
const Roles = () => {
|
|
const [roles, setRoles] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [name, setName] = useState('');
|
|
const [permissionsInput, setPermissionsInput] = useState('');
|
|
const [nameFilter, setNameFilter] = useState('');
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
const [editingRole, setEditingRole] = useState(null);
|
|
const [selectedPermissions, setSelectedPermissions] = useState([]);
|
|
|
|
const [permissionOptions, setPermissionOptions] = useState([]);
|
|
|
|
const fetchRoles = async (q = '') => {
|
|
try {
|
|
setLoading(true);
|
|
const list = await rolesAPI.list(q);
|
|
setRoles(list);
|
|
} catch (e) {
|
|
const errorMsg = getErrorMessage(e);
|
|
setError(errorMsg);
|
|
toast.error(errorMsg);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchRoles();
|
|
(async () => {
|
|
try {
|
|
const perms = await listPermissions();
|
|
setPermissionOptions(Array.isArray(perms) ? perms : []);
|
|
} catch (_) {
|
|
setPermissionOptions([]);
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const onAddRole = async (e) => {
|
|
e.preventDefault();
|
|
if (!name.trim()) return;
|
|
const perms = selectedPermissions.length > 0
|
|
? selectedPermissions
|
|
: permissionsInput.split(',').map(p => p.trim()).filter(Boolean);
|
|
try {
|
|
await rolesAPI.create({ name: name.trim(), permissions: perms });
|
|
setName('');
|
|
setPermissionsInput('');
|
|
setSelectedPermissions([]);
|
|
await fetchRoles(nameFilter);
|
|
setIsModalOpen(false);
|
|
toast.success('Role created successfully');
|
|
} catch (err) {
|
|
const errorMsg = getErrorMessage(err);
|
|
setError(errorMsg);
|
|
toast.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const onDelete = async (id) => {
|
|
try {
|
|
await rolesAPI.remove(id);
|
|
await fetchRoles(nameFilter);
|
|
toast.success('Role deleted successfully');
|
|
} catch (err) {
|
|
toast.error(getErrorMessage(err));
|
|
}
|
|
};
|
|
|
|
const openEdit = (role) => {
|
|
setEditingRole(role);
|
|
setName(role.name || '');
|
|
setSelectedPermissions(Array.isArray(role.permissions) ? role.permissions : []);
|
|
setPermissionsInput('');
|
|
setIsEditModalOpen(true);
|
|
};
|
|
|
|
const onUpdateRole = async (e) => {
|
|
e.preventDefault();
|
|
if (!editingRole) return;
|
|
const perms = selectedPermissions.length > 0
|
|
? selectedPermissions
|
|
: permissionsInput.split(',').map(p => p.trim()).filter(Boolean);
|
|
try {
|
|
await rolesAPI.update(editingRole.id, { name: name.trim(), permissions: perms });
|
|
await fetchRoles(nameFilter);
|
|
setIsEditModalOpen(false);
|
|
setEditingRole(null);
|
|
setName('');
|
|
setPermissionsInput('');
|
|
setSelectedPermissions([]);
|
|
toast.success('Role updated successfully');
|
|
} catch (err) {
|
|
const errorMsg = getErrorMessage(err);
|
|
setError(errorMsg);
|
|
toast.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const columns = useMemo(() => [
|
|
{ key: 'name', header: 'Name' },
|
|
{ key: 'permissions', header: 'Permissions', render: (val) => Array.isArray(val) ? val.join(', ') : '' },
|
|
{ key: 'userType', header: 'User Type' },
|
|
{ key: 'actions', header: 'Actions', render: (_val, row) => (
|
|
<div className="flex items-center gap-x-2 ">
|
|
<button
|
|
onClick={() => openEdit(row)}
|
|
className="inline-flex items-center px-3 py-1 text-sm rounded-md bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 hover:opacity-90"
|
|
>
|
|
<Pencil className="h-4 w-4 mr-1" /> Edit
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(row.id)}
|
|
className="inline-flex items-center px-3 py-1 text-sm rounded-md bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 hover:opacity-90"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" /> Delete
|
|
</button>
|
|
</div>
|
|
) },
|
|
], []);
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<ToastContainer position="top-right" autoClose={3000} />
|
|
<div className="mb-6 flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Roles</h1>
|
|
<p className="text-gray-600 dark:text-gray-400">Manage roles: add, filter by name, and delete</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsModalOpen(true)}
|
|
className="btn-primary inline-flex items-center"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" /> Add Role
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add Role Modal */}
|
|
{isModalOpen && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 bg-black bg-opacity-50 z-40"
|
|
onClick={() => setIsModalOpen(false)}
|
|
/>
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="w-full max-w-2xl card p-6 relative">
|
|
<div className="mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Add Role</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">Create a new role with permissions</p>
|
|
</div>
|
|
<form className="space-y-4" onSubmit={onAddRole}>
|
|
<div>
|
|
<label className="block text-sm mb-1 text-gray-700 dark:text-gray-300">Role Name</label>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
placeholder="e.g., Admin"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm mb-2 text-gray-700 dark:text-gray-300">Permissions</label>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedPermissions(permissionOptions.map(p => typeof p === 'string' ? p : p?.name).filter(Boolean))}
|
|
className="text-xs border px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-primary-50 dark:hover:bg-gray-600 transition"
|
|
>
|
|
Select All
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedPermissions([])}
|
|
className="text-xs border px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-red-50 dark:hover:bg-gray-600 transition"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 max-h-48 overflow-y-auto border rounded-lg p-3 bg-gray-50 dark:bg-gray-800">
|
|
{permissionOptions.map((perm) => {
|
|
const permName = typeof perm === 'string' ? perm : perm?.name;
|
|
const permDesc = typeof perm === 'object' ? perm?.description : undefined;
|
|
const checked = selectedPermissions.includes(permName);
|
|
return (
|
|
<label key={permName}
|
|
className="flex items-start gap-x-2 cursor-pointer border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 px-2 py-2 shadow-sm hover:border-primary-400 dark:hover:border-primary-600 relative transition"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="mt-1 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
|
checked={checked}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedPermissions((prev) => [...prev, permName]);
|
|
} else {
|
|
setSelectedPermissions((prev) => prev.filter(p => p !== permName));
|
|
}
|
|
}}
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-200 flex flex-col">
|
|
<span
|
|
title={permDesc || ''}
|
|
className={permDesc ? 'underline decoration-dotted decoration-gray-400 cursor-help' : ''}
|
|
>
|
|
{permName}
|
|
</span>
|
|
{permDesc && (
|
|
<span className="text-xs text-gray-400 dark:text-gray-400 mt-1 ">{permDesc}</span>
|
|
)}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="mt-4 p-3 rounded bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
|
|
<label className="block text-xs mb-1 text-gray-500 dark:text-gray-400">Custom permissions (comma separated)</label>
|
|
<input
|
|
value={permissionsInput}
|
|
onChange={(e) => setPermissionsInput(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
placeholder="Administrator, RoleManagement"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-x-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsModalOpen(false)}
|
|
className="inline-flex items-center px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="btn-primary inline-flex items-center">
|
|
<Plus className="h-4 w-4 mr-2" /> Create Role
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Edit Role Modal */}
|
|
{isEditModalOpen && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 bg-black bg-opacity-50 z-40"
|
|
onClick={() => setIsEditModalOpen(false)}
|
|
/>
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="w-full max-w-2xl card p-6 relative">
|
|
<div className="mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Edit Role</h3>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">Update role name and permissions</p>
|
|
</div>
|
|
<form className="space-y-4" onSubmit={onUpdateRole}>
|
|
<div>
|
|
<label className="block text-sm mb-1 text-gray-700 dark:text-gray-300">Role Name</label>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
placeholder="e.g., Admin"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm mb-2 text-gray-700 dark:text-gray-300">Permissions</label>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedPermissions(permissionOptions.map(p => typeof p === 'string' ? p : p?.name).filter(Boolean))}
|
|
className="text-xs border px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-primary-50 dark:hover:bg-gray-600 transition"
|
|
>
|
|
Select All
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedPermissions([])}
|
|
className="text-xs border px-2 py-1 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-red-50 dark:hover:bg-gray-600 transition"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 max-h-48 overflow-y-auto border rounded-lg p-3 bg-gray-50 dark:bg-gray-800">
|
|
{permissionOptions.map((perm) => {
|
|
const permName = typeof perm === 'string' ? perm : perm?.name;
|
|
const permDesc = typeof perm === 'object' ? perm?.description : undefined;
|
|
const checked = selectedPermissions.includes(permName);
|
|
return (
|
|
<label key={permName}
|
|
className="flex items-start gap-x-2 cursor-pointer border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-900 px-2 py-2 shadow-sm hover:border-primary-400 dark:hover:border-primary-600 relative transition"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="mt-1 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
|
checked={checked}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedPermissions((prev) => [...prev, permName]);
|
|
} else {
|
|
setSelectedPermissions((prev) => prev.filter(p => p !== permName));
|
|
}
|
|
}}
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-200 flex flex-col">
|
|
<span
|
|
title={permDesc || ''}
|
|
className={permDesc ? 'underline decoration-dotted decoration-gray-400 cursor-help' : ''}
|
|
>
|
|
{permName}
|
|
</span>
|
|
{permDesc && (
|
|
<span className="text-xs text-gray-400 dark:text-gray-400 mt-1 ">{permDesc}</span>
|
|
)}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="mt-4 p-3 rounded bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600">
|
|
<label className="block text-xs mb-1 text-gray-500 dark:text-gray-400">Custom permissions (comma separated)</label>
|
|
<input
|
|
value={permissionsInput}
|
|
onChange={(e) => setPermissionsInput(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
placeholder="Administrator, RoleManagement"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-x-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsEditModalOpen(false)}
|
|
className="inline-flex items-center px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="btn-primary inline-flex items-center">
|
|
<Pencil className="h-4 w-4 mr-2" /> Update Role
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Filter by name */}
|
|
<div className="card p-4 mb-4">
|
|
<div className="relative max-w-md">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<input
|
|
value={nameFilter}
|
|
onChange={async (e) => {
|
|
const v = e.target.value;
|
|
setNameFilter(v);
|
|
await fetchRoles(v);
|
|
}}
|
|
placeholder="Filter by role name"
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
data={roles}
|
|
columns={columns}
|
|
loading={loading}
|
|
searchable={false}
|
|
className=""
|
|
/>
|
|
|
|
{error && (
|
|
<div className="mt-4 text-sm text-red-600 dark:text-red-400">{error}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Roles;
|
|
|
|
|