feat(v3): restructure project + add complete frontend
- 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
This commit is contained in:
383
frontend/src/pages/admin/UserRoles.tsx
Normal file
383
frontend/src/pages/admin/UserRoles.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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 { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Search, Loader2, UserCog, Shield, Key, Eye, ChevronRight, Users,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { rolesService } from "@/services/roles.service";
|
||||
import type { UserWithRoles, UserRoleDetail, Role, Permission } from "@/types/role-permission";
|
||||
|
||||
const ROLE_COLORS = [
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
"bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300",
|
||||
"bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
|
||||
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
||||
];
|
||||
|
||||
function roleColor(idx: number) {
|
||||
return ROLE_COLORS[idx % ROLE_COLORS.length];
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE: Record<string, string> = {
|
||||
exam: "bg-violet-100 text-violet-700",
|
||||
user: "bg-blue-100 text-blue-700",
|
||||
entity: "bg-emerald-100 text-emerald-700",
|
||||
assignment: "bg-amber-100 text-amber-700",
|
||||
classroom: "bg-rose-100 text-rose-700",
|
||||
payment: "bg-cyan-100 text-cyan-700",
|
||||
report: "bg-orange-100 text-orange-700",
|
||||
};
|
||||
|
||||
function catBadge(cat: string) {
|
||||
return CATEGORY_BADGE[cat.toLowerCase()] ?? "bg-gray-100 text-gray-700";
|
||||
}
|
||||
|
||||
export default function UserRoles() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editUserId, setEditUserId] = useState<number | null>(null);
|
||||
const [editRoleIds, setEditRoleIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [viewUserId, setViewUserId] = useState<number | null>(null);
|
||||
|
||||
const usersQ = useQuery({
|
||||
queryKey: ["users-with-roles", search, roleFilter],
|
||||
queryFn: () => rolesService.listUsersWithRoles({
|
||||
...(search ? { search } : {}),
|
||||
...(roleFilter !== "all" ? { role_id: Number(roleFilter) } : {}),
|
||||
size: 200,
|
||||
}),
|
||||
});
|
||||
const rolesQ = useQuery({ queryKey: ["roles"], queryFn: () => rolesService.listRoles() });
|
||||
const viewQ = useQuery({
|
||||
queryKey: ["user-roles-detail", viewUserId],
|
||||
queryFn: () => rolesService.getUserRoles(viewUserId!),
|
||||
enabled: viewUserId !== null,
|
||||
});
|
||||
|
||||
const users: UserWithRoles[] = usersQ.data?.data ?? [];
|
||||
const allRoles: Role[] = rolesQ.data?.data ?? [];
|
||||
|
||||
const saveRolesMut = useMutation({
|
||||
mutationFn: ({ userId, roleIds }: { userId: number; roleIds: number[] }) =>
|
||||
rolesService.updateUserRoles(userId, roleIds),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["users-with-roles"] });
|
||||
qc.invalidateQueries({ queryKey: ["roles"] });
|
||||
setEditOpen(false);
|
||||
toast({ title: "Roles updated successfully" });
|
||||
},
|
||||
onError: () => toast({ title: "Failed to update roles", variant: "destructive" }),
|
||||
});
|
||||
|
||||
const loading = usersQ.isLoading || rolesQ.isLoading;
|
||||
|
||||
const totalWithRoles = users.filter(u => u.role_ids.length > 0).length;
|
||||
const totalWithoutRoles = users.filter(u => u.role_ids.length === 0).length;
|
||||
|
||||
const openEditRoles = (u: UserWithRoles) => {
|
||||
setEditUserId(u.id);
|
||||
setEditRoleIds(new Set(u.role_ids));
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openViewPerms = (u: UserWithRoles) => {
|
||||
setViewUserId(u.id);
|
||||
setViewOpen(true);
|
||||
};
|
||||
|
||||
const toggleRole = (rid: number) => {
|
||||
setEditRoleIds(prev => {
|
||||
const s = new Set(prev);
|
||||
if (s.has(rid)) s.delete(rid); else s.add(rid);
|
||||
return s;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editUserId !== null) {
|
||||
saveRolesMut.mutate({ userId: editUserId, roleIds: Array.from(editRoleIds) });
|
||||
}
|
||||
};
|
||||
|
||||
const viewDetail: UserRoleDetail | undefined = viewQ.data;
|
||||
const effectivePerms: Permission[] = viewDetail?.effective_permissions ?? [];
|
||||
const permsByCategory = effectivePerms.reduce<Record<string, Permission[]>>((acc, p) => {
|
||||
const cat = p.category || "General";
|
||||
(acc[cat] ??= []).push(p);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
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">
|
||||
<UserCog className="h-7 w-7 text-primary" />
|
||||
User Roles
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Assign roles to users and view their effective permissions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center"><Users className="h-5 w-5 text-primary" /></div>
|
||||
<div><p className="text-xl font-bold">{users.length}</p><p className="text-xs text-muted-foreground">Total Users</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-emerald-500/10 flex items-center justify-center"><Shield className="h-5 w-5 text-emerald-500" /></div>
|
||||
<div><p className="text-xl font-bold">{totalWithRoles}</p><p className="text-xs text-muted-foreground">With Roles</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-amber-500/10 flex items-center justify-center"><UserCog className="h-5 w-5 text-amber-500" /></div>
|
||||
<div><p className="text-xl font-bold">{totalWithoutRoles}</p><p className="text-xs text-muted-foreground">No Roles</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-5 pb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center"><Key className="h-5 w-5 text-violet-500" /></div>
|
||||
<div><p className="text-xl font-bold">{allRoles.length}</p><p className="text-xs text-muted-foreground">Available Roles</p></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<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="Search users..." className="pl-9" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filter by role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Roles</SelectItem>
|
||||
{allRoles.map(r => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Email / Login</TableHead>
|
||||
<TableHead>Assigned Roles</TableHead>
|
||||
<TableHead className="text-center">Permissions</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-12">No users found.</TableCell></TableRow>
|
||||
)}
|
||||
{users.map(u => (
|
||||
<TableRow key={u.id} className="group">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-xs bg-primary/10 text-primary font-medium">{initials(u.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{u.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{u.email || u.login}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.roles.length === 0 && <span className="text-xs text-muted-foreground italic">No roles</span>}
|
||||
{u.roles.map((r, i) => (
|
||||
<Badge key={r.id} variant="outline" className={`text-[11px] px-1.5 py-0 ${roleColor(i)}`}>
|
||||
<Shield className="h-2.5 w-2.5 mr-0.5" />{r.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className="text-xs">{u.effective_permission_count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Tooltip><TooltipTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="h-8 gap-1 text-xs" onClick={() => openViewPerms(u)}>
|
||||
<Eye className="h-3.5 w-3.5" /> View
|
||||
</Button>
|
||||
</TooltipTrigger><TooltipContent>View effective permissions</TooltipContent></Tooltip>
|
||||
<Tooltip><TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={() => openEditRoles(u)}>
|
||||
<UserCog className="h-3.5 w-3.5" /> Assign <ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger><TooltipContent>Manage role assignments</TooltipContent></Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Assign Roles Dialog ── */}
|
||||
<Dialog open={editOpen} onOpenChange={o => { setEditOpen(o); if (!o) setEditUserId(null); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCog className="h-5 w-5 text-primary" />
|
||||
Assign Roles
|
||||
</DialogTitle>
|
||||
<CardDescription>
|
||||
Toggle roles for user: <strong>{users.find(u => u.id === editUserId)?.name}</strong>
|
||||
</CardDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 max-h-[50vh] overflow-y-auto pr-1">
|
||||
{allRoles.length === 0 && <p className="text-sm text-muted-foreground text-center py-8">No roles available. Create roles first.</p>}
|
||||
{allRoles.map((r, i) => (
|
||||
<label key={r.id} className={`flex items-center gap-3 px-4 py-3 rounded-lg border cursor-pointer transition-all ${
|
||||
editRoleIds.has(r.id)
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-border hover:border-muted-foreground/30 hover:bg-muted/30"
|
||||
}`}>
|
||||
<Switch checked={editRoleIds.has(r.id)} onCheckedChange={() => toggleRole(r.id)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${roleColor(i)}`}>
|
||||
<Shield className="h-2.5 w-2.5 mr-0.5" />{r.name}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{r.permissions.length} permission{r.permissions.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
{r.description && <p className="text-xs text-muted-foreground mt-0.5 truncate">{r.description}</p>}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 mr-auto text-sm text-muted-foreground">
|
||||
<Shield className="h-4 w-4" /> {editRoleIds.size} of {allRoles.length} selected
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button disabled={saveRolesMut.isPending} onClick={handleSave}>
|
||||
{saveRolesMut.isPending ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||
Save Roles
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── View Effective Permissions Dialog ── */}
|
||||
<Dialog open={viewOpen} onOpenChange={o => { setViewOpen(o); if (!o) setViewUserId(null); }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-primary" />
|
||||
Effective Permissions
|
||||
</DialogTitle>
|
||||
{viewDetail && (
|
||||
<CardDescription>
|
||||
<strong>{viewDetail.user.name}</strong> ({viewDetail.user.login})
|
||||
</CardDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{viewQ.isLoading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-primary" /></div>
|
||||
) : viewDetail ? (
|
||||
<div className="space-y-4 overflow-y-auto max-h-[60vh] pr-1">
|
||||
{/* Assigned roles summary */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Assigned Roles</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{viewDetail.roles.filter(r => r.assigned).map((r, i) => (
|
||||
<Badge key={r.id} variant="outline" className={`${roleColor(i)} text-xs px-2 py-0.5`}>
|
||||
<Shield className="h-3 w-3 mr-1" />{r.name}
|
||||
<span className="ml-1 text-[10px] opacity-70">({r.permission_count})</span>
|
||||
</Badge>
|
||||
))}
|
||||
{viewDetail.roles.filter(r => r.assigned).length === 0 && (
|
||||
<span className="text-sm text-muted-foreground italic">No roles assigned</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions grouped by category */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">
|
||||
Effective Permissions ({effectivePerms.length})
|
||||
</p>
|
||||
{effectivePerms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic py-4 text-center">No permissions (assign roles to grant permissions)</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(permsByCategory).map(([cat, perms]) => (
|
||||
<Card key={cat} className="border-muted">
|
||||
<CardHeader className="py-2 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={`${catBadge(cat)} font-medium text-[10px]`}>{cat}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{perms.length}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
<div className="grid gap-1">
|
||||
{perms.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-2 py-1">
|
||||
<div className="h-5 w-5 rounded bg-emerald-500/15 flex items-center justify-center">
|
||||
<Key className="h-2.5 w-2.5 text-emerald-600" />
|
||||
</div>
|
||||
<code className="text-[10px] font-mono bg-muted px-1 rounded text-muted-foreground">{p.code}</code>
|
||||
<span className="text-xs">{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setViewOpen(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user