237 lines
14 KiB
TypeScript
237 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
|
import { Head, router } from '@inertiajs/react';
|
|
import { PageProps } from '@/types';
|
|
import { Can } from '@/Components/Can';
|
|
import { Portal } from '@/Components/Portal';
|
|
import { swal } from '@/lib/swal';
|
|
|
|
interface RoleData {
|
|
id: number;
|
|
name: string;
|
|
guard_name: string;
|
|
permissions: string[];
|
|
users_count: number;
|
|
}
|
|
|
|
interface PermissionData {
|
|
id: number;
|
|
name: string;
|
|
group: string;
|
|
}
|
|
|
|
interface RolesPageProps extends PageProps {
|
|
roles: RoleData[];
|
|
permissions: PermissionData[];
|
|
}
|
|
|
|
export default function RolesIndex({ roles, permissions }: RolesPageProps) {
|
|
const groups = [...new Set(permissions.map(p => p.group))];
|
|
const [localMatrix, setLocalMatrix] = useState<Record<number, string[]>>(() => {
|
|
const m: Record<number, string[]> = {};
|
|
roles.forEach(r => { m[r.id] = [...r.permissions]; });
|
|
return m;
|
|
});
|
|
const [dirty, setDirty] = useState<Record<number, boolean>>({});
|
|
const [saving, setSaving] = useState<Record<number, boolean>>({});
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [newRoleName, setNewRoleName] = useState('');
|
|
|
|
const togglePermission = (roleId: number, permName: string) => {
|
|
setLocalMatrix(prev => {
|
|
const current = prev[roleId] || [];
|
|
const updated = current.includes(permName)
|
|
? current.filter(p => p !== permName)
|
|
: [...current, permName];
|
|
return { ...prev, [roleId]: updated };
|
|
});
|
|
setDirty(prev => ({ ...prev, [roleId]: true }));
|
|
};
|
|
|
|
const handleSaveRole = async (role: RoleData) => {
|
|
setSaving(prev => ({ ...prev, [role.id]: true }));
|
|
router.patch(`/roles/${role.id}/permissions`, {
|
|
permissions: localMatrix[role.id] || [],
|
|
}, {
|
|
preserveScroll: true,
|
|
onSuccess: () => {
|
|
setDirty(prev => ({ ...prev, [role.id]: false }));
|
|
setSaving(prev => ({ ...prev, [role.id]: false }));
|
|
swal.success('Saved', `Permissions updated for "${role.name}"`);
|
|
},
|
|
onError: () => {
|
|
setSaving(prev => ({ ...prev, [role.id]: false }));
|
|
swal.error('Error', 'Failed to update permissions.');
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleCreateRole = (e: React.SyntheticEvent) => {
|
|
e.preventDefault();
|
|
if (!newRoleName.trim()) return;
|
|
router.post('/roles', { name: newRoleName.trim() }, {
|
|
onSuccess: () => {
|
|
setShowCreateModal(false);
|
|
setNewRoleName('');
|
|
swal.success('Created', 'New role created successfully.');
|
|
},
|
|
onError: () => swal.error('Error', 'Failed to create role.'),
|
|
});
|
|
};
|
|
|
|
const handleDeleteRole = async (role: RoleData) => {
|
|
const result = await swal.confirmDelete(role.name);
|
|
if (!result.isConfirmed) return;
|
|
router.delete(`/roles/${role.id}`, {
|
|
onSuccess: () => swal.success('Deleted', `Role "${role.name}" has been deleted.`),
|
|
onError: () => swal.error('Error', 'Failed to delete role.'),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<AuthenticatedLayout>
|
|
<Head title="Roles & Permissions" />
|
|
|
|
<div className="flex items-center justify-between mb-8 anim-down">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-[#3D4E4B] tracking-tight leading-none">Access Control</h1>
|
|
<p className="text-sm font-semibold text-gray-400 tracking-tight mt-2">Configure hierarchical roles and granular permissions</p>
|
|
</div>
|
|
<Can ability="role.manage">
|
|
<button onClick={() => setShowCreateModal(true)}
|
|
className="h-10 px-6 rounded-xl bg-[#D4A017] text-white text-sm font-bold tracking-tight hover:bg-[#B88B14] transition-all shadow-lg shadow-[#D4A017]/20">
|
|
New Role
|
|
</button>
|
|
</Can>
|
|
</div>
|
|
|
|
{/* Role Summary Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
{roles.map((role, idx) => {
|
|
const count = (localMatrix[role.id] || []).length;
|
|
const isSuperAdmin = role.name === 'super-admin';
|
|
return (
|
|
<div key={role.id} className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm anim-up" style={{ animationDelay: `${idx * 0.05}s` }}>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<span className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg border ${isSuperAdmin ? 'bg-[#3D4E4B] text-white border-[#3D4E4B]' : 'bg-white text-gray-500 border-gray-100'}`}>
|
|
{role.name}
|
|
</span>
|
|
{!isSuperAdmin && (
|
|
<Can ability="role.manage">
|
|
<button onClick={() => handleDeleteRole(role)} className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-300 hover:text-red-500 hover:bg-red-50 transition-all">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
|
</button>
|
|
</Can>
|
|
)}
|
|
</div>
|
|
<div className="text-3xl font-black text-[#3D4E4B] tracking-tighter">
|
|
{count}
|
|
<span className="text-sm text-gray-200 font-bold ml-1 tracking-normal">/ {permissions.length} perms</span>
|
|
</div>
|
|
<div className="text-[10px] font-black text-gray-300 uppercase tracking-widest mt-2">{role.users_count} Total Active Users</div>
|
|
|
|
<div className="mt-6 h-1.5 bg-gray-50 rounded-full overflow-hidden">
|
|
<div className="h-full bg-[#D4A017] transition-all duration-700" style={{ width: `${(count / permissions.length) * 100}%` }} />
|
|
</div>
|
|
|
|
{dirty[role.id] && (
|
|
<button onClick={() => handleSaveRole(role)} disabled={saving[role.id]}
|
|
className="mt-6 w-full h-10 bg-[#3D4E4B] text-white text-xs font-black uppercase tracking-widest rounded-xl anim-fade shadow-lg shadow-[#3D4E4B]/20 disabled:opacity-60">
|
|
{saving[role.id] ? 'Saving…' : 'Apply Changes'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Permissions Matrix */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden mb-20 anim-up" style={{ animationDelay: '0.2s' }}>
|
|
<div className="px-8 py-6 border-b border-gray-50 bg-gray-50/20">
|
|
<h3 className="text-sm font-black text-[#3D4E4B] uppercase tracking-widest">Permissions Matrix</h3>
|
|
</div>
|
|
<div className="overflow-x-auto custom-scrollbar">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="bg-white">
|
|
<th className="px-8 py-5 text-[10px] font-black text-gray-400 uppercase tracking-widest w-72 sticky left-0 bg-white z-10 border-b border-gray-50">Functional Permission</th>
|
|
{roles.map(role => (
|
|
<th key={role.id} className="px-8 py-5 text-center text-[10px] font-black text-[#3D4E4B] uppercase tracking-widest border-b border-gray-50 bg-gray-50/30">{role.name}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-50">
|
|
{groups.map(group => (
|
|
<React.Fragment key={group}>
|
|
<tr className="bg-gray-50/10">
|
|
<td colSpan={roles.length + 1} className="px-8 py-3">
|
|
<span className="text-[10px] font-black text-[#D4A017] uppercase tracking-[0.2em]">{group} Module</span>
|
|
</td>
|
|
</tr>
|
|
{permissions.filter(p => p.group === group).map((perm) => (
|
|
<tr key={perm.id} className="hover:bg-gray-50/30 transition-colors">
|
|
<td className="px-8 py-4 sticky left-0 bg-white group border-r border-gray-50/50">
|
|
<div className="text-sm font-bold text-[#3D4E4B] tracking-tight capitalize">{perm.name.replace('.', ' ')}</div>
|
|
<div className="text-[9px] text-gray-300 font-bold uppercase tracking-widest mt-1">{perm.name}</div>
|
|
</td>
|
|
{roles.map(role => {
|
|
const isChecked = (localMatrix[role.id] || []).includes(perm.name);
|
|
const isSuperAdmin = role.name === 'super-admin';
|
|
return (
|
|
<td key={`${role.id}-${perm.id}`} className="px-8 py-4 text-center">
|
|
<button
|
|
onClick={() => !isSuperAdmin && togglePermission(role.id, perm.name)}
|
|
disabled={isSuperAdmin}
|
|
className={`w-8 h-8 rounded-xl transition-all flex items-center justify-center mx-auto border-2 ${
|
|
isSuperAdmin
|
|
? 'bg-gray-50 text-[#3D4E4B] border-gray-100 opacity-40'
|
|
: isChecked
|
|
? 'bg-[#3D4E4B] text-white shadow-lg shadow-[#3D4E4B]/20 border-[#3D4E4B]'
|
|
: 'bg-white text-gray-100 hover:text-[#D4A017] border-gray-100 hover:border-[#D4A017]'
|
|
}`}
|
|
>
|
|
{isChecked ? (
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={4}><path d="M5 13l4 4L19 7" /></svg>
|
|
) : (
|
|
<div className="w-1.5 h-1.5 rounded-full bg-current" />
|
|
)}
|
|
</button>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create Role Modal */}
|
|
{showCreateModal && (
|
|
<Portal>
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#3D4E4B]/60 backdrop-blur-md anim-fade">
|
|
<div className="relative bg-white rounded-3xl shadow-2xl w-full max-w-sm overflow-hidden anim-zoom border border-gray-100">
|
|
<div className="px-8 py-6 border-b border-gray-50">
|
|
<h3 className="text-base font-black text-[#3D4E4B] tracking-tight">Provision New Role</h3>
|
|
</div>
|
|
<form onSubmit={handleCreateRole} className="p-8 space-y-8">
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-black text-gray-400 uppercase tracking-widest ml-1">Internal Role Name</label>
|
|
<input type="text" value={newRoleName} onChange={e => setNewRoleName(e.target.value)} placeholder="e.g. auditor"
|
|
className="input-field" required autoFocus />
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<button type="button" onClick={() => setShowCreateModal(false)} className="flex-1 h-11 bg-white text-gray-400 text-xs font-black uppercase tracking-widest border border-gray-100 rounded-2xl hover:bg-gray-50 transition-all">Cancel</button>
|
|
<button type="submit" className="flex-1 h-11 bg-[#3D4E4B] text-white text-xs font-black uppercase tracking-widest rounded-2xl hover:bg-[#2D3A38] transition-all shadow-lg shadow-[#3D4E4B]/20">Provision</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
</AuthenticatedLayout>
|
|
);
|
|
}
|