- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
382 lines
22 KiB
TypeScript
382 lines
22 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import {
|
|
Plus, Search, Trash2, Pencil, Shield, Key, Users, Loader2, ChevronRight,
|
|
} from "lucide-react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { rolesService } from "@/services/roles.service";
|
|
import type { Role, Permission, RoleCreateRequest, PermissionCreateRequest } from "@/types/role-permission";
|
|
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
exam: "bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
|
|
user: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
|
entity: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
|
assignment: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
|
classroom: "bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300",
|
|
payment: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
|
|
report: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
|
};
|
|
|
|
function categoryBadgeClass(cat: string) {
|
|
return CATEGORY_COLORS[cat.toLowerCase()] ?? "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
|
|
}
|
|
|
|
export default function RolesPermissions() {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
const [search, setSearch] = useState("");
|
|
const [tab, setTab] = useState("roles");
|
|
|
|
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
|
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
|
const [roleForm, setRoleForm] = useState<RoleCreateRequest>({ name: "", description: "" });
|
|
|
|
const [permDialogOpen, setPermDialogOpen] = useState(false);
|
|
const [permForm, setPermForm] = useState<PermissionCreateRequest>({ name: "", code: "", description: "", category: "" });
|
|
|
|
const [permAssignOpen, setPermAssignOpen] = useState(false);
|
|
const [assigningRole, setAssigningRole] = useState<Role | null>(null);
|
|
const [selectedPermIds, setSelectedPermIds] = useState<Set<number>>(new Set());
|
|
|
|
const rolesQ = useQuery({ queryKey: ["roles", search], queryFn: () => rolesService.listRoles(search ? { search } : undefined) });
|
|
const permsQ = useQuery({ queryKey: ["permissions"], queryFn: () => rolesService.listPermissions() });
|
|
|
|
const roles: Role[] = rolesQ.data?.data ?? [];
|
|
const permissions: Permission[] = permsQ.data?.data ?? [];
|
|
|
|
const createRoleMut = useMutation({
|
|
mutationFn: (d: RoleCreateRequest) => rolesService.createRole(d),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setRoleDialogOpen(false); toast({ title: "Role created" }); },
|
|
onError: () => toast({ title: "Failed to create role", variant: "destructive" }),
|
|
});
|
|
const updateRoleMut = useMutation({
|
|
mutationFn: ({ id, data }: { id: number; data: Partial<RoleCreateRequest> }) => rolesService.updateRole(id, data),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setRoleDialogOpen(false); setEditingRole(null); toast({ title: "Role updated" }); },
|
|
onError: () => toast({ title: "Failed to update role", variant: "destructive" }),
|
|
});
|
|
const deleteRoleMut = useMutation({
|
|
mutationFn: rolesService.deleteRole,
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); toast({ title: "Role deleted" }); },
|
|
onError: () => toast({ title: "Failed to delete role", variant: "destructive" }),
|
|
});
|
|
const assignPermsMut = useMutation({
|
|
mutationFn: ({ id, permIds }: { id: number; permIds: number[] }) => rolesService.updateRolePermissions(id, permIds),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["roles"] }); setPermAssignOpen(false); toast({ title: "Permissions updated" }); },
|
|
onError: () => toast({ title: "Failed to update permissions", variant: "destructive" }),
|
|
});
|
|
const createPermMut = useMutation({
|
|
mutationFn: (d: PermissionCreateRequest) => rolesService.createPermission(d),
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["permissions"] }); setPermDialogOpen(false); toast({ title: "Permission created" }); },
|
|
onError: () => toast({ title: "Failed to create permission", variant: "destructive" }),
|
|
});
|
|
const deletePermMut = useMutation({
|
|
mutationFn: rolesService.deletePermission,
|
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ["permissions"] }); toast({ title: "Permission deleted" }); },
|
|
onError: () => toast({ title: "Failed to delete permission", variant: "destructive" }),
|
|
});
|
|
|
|
const loading = rolesQ.isLoading || permsQ.isLoading;
|
|
|
|
const openNewRole = () => { setEditingRole(null); setRoleForm({ name: "", description: "" }); setRoleDialogOpen(true); };
|
|
const openEditRole = (r: Role) => { setEditingRole(r); setRoleForm({ name: r.name, description: r.description }); setRoleDialogOpen(true); };
|
|
const openAssignPerms = (r: Role) => { setAssigningRole(r); setSelectedPermIds(new Set(r.permission_ids)); setPermAssignOpen(true); };
|
|
const openNewPerm = () => { setPermForm({ name: "", code: "", description: "", category: "" }); setPermDialogOpen(true); };
|
|
|
|
const handleSaveRole = () => {
|
|
if (!roleForm.name.trim()) return;
|
|
if (editingRole) {
|
|
updateRoleMut.mutate({ id: editingRole.id, data: roleForm });
|
|
} else {
|
|
createRoleMut.mutate(roleForm);
|
|
}
|
|
};
|
|
|
|
const togglePerm = (pid: number) => {
|
|
setSelectedPermIds(prev => { const s = new Set(prev); if (s.has(pid)) s.delete(pid); else s.add(pid); return s; });
|
|
};
|
|
|
|
const categorizedPerms = permissions.reduce<Record<string, Permission[]>>((acc, p) => {
|
|
const cat = p.category || "General";
|
|
(acc[cat] ??= []).push(p);
|
|
return acc;
|
|
}, {});
|
|
|
|
const filteredPerms = search
|
|
? permissions.filter(p => p.name.toLowerCase().includes(search.toLowerCase()) || p.code.toLowerCase().includes(search.toLowerCase()))
|
|
: permissions;
|
|
|
|
if (loading) {
|
|
return <div className="flex items-center justify-center min-h-[400px]"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
<Shield className="h-7 w-7 text-primary" />
|
|
Roles & Permissions
|
|
</h1>
|
|
<p className="text-muted-foreground">Manage platform roles and their permission assignments.</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{tab === "roles" ? (
|
|
<Button onClick={openNewRole}><Plus className="mr-2 h-4 w-4" /> New Role</Button>
|
|
) : (
|
|
<Button onClick={openNewPerm}><Plus className="mr-2 h-4 w-4" /> New Permission</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<Card className="border-l-4 border-l-primary">
|
|
<CardContent className="pt-5 pb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><Shield className="h-5 w-5 text-primary" /></div>
|
|
<div><p className="text-2xl font-bold">{roles.length}</p><p className="text-xs text-muted-foreground">Total Roles</p></div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-l-4 border-l-amber-500">
|
|
<CardContent className="pt-5 pb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center"><Key className="h-5 w-5 text-amber-500" /></div>
|
|
<div><p className="text-2xl font-bold">{permissions.length}</p><p className="text-xs text-muted-foreground">Total Permissions</p></div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border-l-4 border-l-emerald-500">
|
|
<CardContent className="pt-5 pb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-lg bg-emerald-500/10 flex items-center justify-center"><Users className="h-5 w-5 text-emerald-500" /></div>
|
|
<div><p className="text-2xl font-bold">{Object.keys(categorizedPerms).length}</p><p className="text-xs text-muted-foreground">Permission Categories</p></div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={tab} onValueChange={setTab}>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<TabsList>
|
|
<TabsTrigger value="roles" className="gap-1.5"><Shield className="h-4 w-4" /> Roles ({roles.length})</TabsTrigger>
|
|
<TabsTrigger value="permissions" className="gap-1.5"><Key className="h-4 w-4" /> Permissions ({permissions.length})</TabsTrigger>
|
|
</TabsList>
|
|
<div className="relative max-w-xs w-full">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input placeholder={tab === "roles" ? "Search roles..." : "Search permissions..."} className="pl-9" value={search} onChange={e => setSearch(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Roles Tab ── */}
|
|
<TabsContent value="roles" className="mt-4">
|
|
{roles.length === 0 ? (
|
|
<Card><CardContent className="py-16 text-center text-muted-foreground">No roles configured yet. Create one to get started.</CardContent></Card>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{roles.map(r => (
|
|
<Card key={r.id} className="group hover:shadow-md transition-shadow">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
<Shield className="h-4 w-4 text-primary" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-base">{r.name}</CardTitle>
|
|
<CardDescription className="text-xs">{r.user_count} user{r.user_count !== 1 ? "s" : ""} assigned</CardDescription>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Tooltip><TooltipTrigger asChild>
|
|
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={() => openEditRole(r)}><Pencil className="h-3.5 w-3.5" /></Button>
|
|
</TooltipTrigger><TooltipContent>Edit</TooltipContent></Tooltip>
|
|
<Tooltip><TooltipTrigger asChild>
|
|
<Button size="icon" variant="ghost" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete role "${r.name}"?`)) deleteRoleMut.mutate(r.id); }}><Trash2 className="h-3.5 w-3.5" /></Button>
|
|
</TooltipTrigger><TooltipContent>Delete</TooltipContent></Tooltip>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{r.description && <p className="text-sm text-muted-foreground line-clamp-2">{r.description}</p>}
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{r.permissions.slice(0, 5).map(p => (
|
|
<Badge key={p.id} variant="outline" className={`text-[10px] px-1.5 py-0 ${categoryBadgeClass(p.category)}`}>{p.code}</Badge>
|
|
))}
|
|
{r.permissions.length > 5 && <Badge variant="secondary" className="text-[10px] px-1.5 py-0">+{r.permissions.length - 5}</Badge>}
|
|
{r.permissions.length === 0 && <span className="text-xs text-muted-foreground italic">No permissions</span>}
|
|
</div>
|
|
<Button variant="outline" size="sm" className="w-full mt-2" onClick={() => openAssignPerms(r)}>
|
|
<Key className="mr-1.5 h-3.5 w-3.5" /> Manage Permissions <ChevronRight className="ml-auto h-3.5 w-3.5" />
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* ── Permissions Tab ── */}
|
|
<TabsContent value="permissions" className="mt-4">
|
|
{Object.entries(categorizedPerms).length === 0 ? (
|
|
<Card><CardContent className="py-16 text-center text-muted-foreground">No permissions defined yet.</CardContent></Card>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{Object.entries(categorizedPerms).map(([cat, perms]) => {
|
|
const visible = search ? perms.filter(p => filteredPerms.includes(p)) : perms;
|
|
if (visible.length === 0) return null;
|
|
return (
|
|
<Card key={cat}>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<Badge className={`${categoryBadgeClass(cat)} font-medium`}>{cat}</Badge>
|
|
<span className="text-muted-foreground text-xs font-normal">{visible.length} permission{visible.length !== 1 ? "s" : ""}</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[200px]">Code</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead className="hidden md:table-cell">Description</TableHead>
|
|
<TableHead className="w-[80px]">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{visible.map(p => (
|
|
<TableRow key={p.id}>
|
|
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{p.code}</code></TableCell>
|
|
<TableCell className="font-medium">{p.name}</TableCell>
|
|
<TableCell className="hidden md:table-cell text-muted-foreground text-sm max-w-xs truncate">{p.description || "—"}</TableCell>
|
|
<TableCell>
|
|
<Button size="icon" variant="ghost" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete permission "${p.code}"?`)) deletePermMut.mutate(p.id); }}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
{/* ── Create / Edit Role Dialog ── */}
|
|
<Dialog open={roleDialogOpen} onOpenChange={o => { setRoleDialogOpen(o); if (!o) setEditingRole(null); }}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>{editingRole ? "Edit Role" : "Create New Role"}</DialogTitle></DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2"><Label>Role Name</Label><Input placeholder="e.g. Exam Manager" value={roleForm.name} onChange={e => setRoleForm(f => ({ ...f, name: e.target.value }))} /></div>
|
|
<div className="space-y-2"><Label>Description</Label><Textarea placeholder="What does this role do?" value={roleForm.description} onChange={e => setRoleForm(f => ({ ...f, description: e.target.value }))} className="h-20" /></div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>Cancel</Button>
|
|
<Button disabled={!roleForm.name.trim() || createRoleMut.isPending || updateRoleMut.isPending} onClick={handleSaveRole}>
|
|
{(createRoleMut.isPending || updateRoleMut.isPending) ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
|
{editingRole ? "Save Changes" : "Create Role"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Create Permission Dialog ── */}
|
|
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>Create Permission</DialogTitle></DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-2"><Label>Name</Label><Input placeholder="View Exams" value={permForm.name} onChange={e => setPermForm(f => ({ ...f, name: e.target.value }))} /></div>
|
|
<div className="space-y-2"><Label>Code</Label><Input placeholder="exam.view" value={permForm.code} onChange={e => setPermForm(f => ({ ...f, code: e.target.value }))} /></div>
|
|
</div>
|
|
<div className="space-y-2"><Label>Category</Label><Input placeholder="exam" value={permForm.category} onChange={e => setPermForm(f => ({ ...f, category: e.target.value }))} /></div>
|
|
<div className="space-y-2"><Label>Description</Label><Textarea placeholder="Optional description" value={permForm.description} onChange={e => setPermForm(f => ({ ...f, description: e.target.value }))} className="h-16" /></div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setPermDialogOpen(false)}>Cancel</Button>
|
|
<Button disabled={!permForm.name.trim() || !permForm.code.trim() || createPermMut.isPending} onClick={() => createPermMut.mutate(permForm)}>
|
|
{createPermMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
|
Create Permission
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Assign Permissions Dialog ── */}
|
|
<Dialog open={permAssignOpen} onOpenChange={o => { setPermAssignOpen(o); if (!o) setAssigningRole(null); }}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh]">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Shield className="h-5 w-5 text-primary" />
|
|
Permissions for "{assigningRole?.name}"
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto max-h-[55vh] space-y-4 pr-1">
|
|
{Object.entries(categorizedPerms).map(([cat, catPerms]) => (
|
|
<div key={cat} className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Badge className={`${categoryBadgeClass(cat)} font-medium`}>{cat}</Badge>
|
|
<Button variant="ghost" size="sm" className="text-xs h-6" onClick={() => {
|
|
const allInCat = catPerms.map(p => p.id);
|
|
const allSelected = allInCat.every(id => selectedPermIds.has(id));
|
|
setSelectedPermIds(prev => {
|
|
const s = new Set(prev);
|
|
allInCat.forEach(id => allSelected ? s.delete(id) : s.add(id));
|
|
return s;
|
|
});
|
|
}}>
|
|
{catPerms.every(p => selectedPermIds.has(p.id)) ? "Deselect All" : "Select All"}
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
{catPerms.map(p => (
|
|
<label key={p.id} className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/50 cursor-pointer transition-colors">
|
|
<Switch checked={selectedPermIds.has(p.id)} onCheckedChange={() => togglePerm(p.id)} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{p.name}</span>
|
|
<code className="text-[10px] bg-muted px-1 rounded font-mono text-muted-foreground">{p.code}</code>
|
|
</div>
|
|
{p.description && <p className="text-xs text-muted-foreground truncate">{p.description}</p>}
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{permissions.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">No permissions available. Create some first.</p>}
|
|
</div>
|
|
<DialogFooter>
|
|
<div className="flex items-center gap-2 mr-auto text-sm text-muted-foreground">
|
|
<Key className="h-4 w-4" /> {selectedPermIds.size} of {permissions.length} selected
|
|
</div>
|
|
<Button variant="outline" onClick={() => setPermAssignOpen(false)}>Cancel</Button>
|
|
<Button disabled={assignPermsMut.isPending} onClick={() => { if (assigningRole) assignPermsMut.mutate({ id: assigningRole.id, permIds: Array.from(selectedPermIds) }); }}>
|
|
{assignPermsMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
|
Save Permissions
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|