209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { Plus, Loader2, Shield, Pencil, Trash2 } from "lucide-react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableHead,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
} from "@/components/ui/table";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import {
|
||
|
|
AlertDialog,
|
||
|
|
AlertDialogAction,
|
||
|
|
AlertDialogCancel,
|
||
|
|
AlertDialogContent,
|
||
|
|
AlertDialogDescription,
|
||
|
|
AlertDialogFooter,
|
||
|
|
AlertDialogHeader,
|
||
|
|
AlertDialogTitle,
|
||
|
|
} from "@/components/ui/alert-dialog";
|
||
|
|
|
||
|
|
interface Role {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
description: string | null;
|
||
|
|
permissions: Record<string, string[]>;
|
||
|
|
is_system: boolean;
|
||
|
|
user_count: string;
|
||
|
|
created_at: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function RoleTable() {
|
||
|
|
const router = useRouter();
|
||
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
||
|
|
const [isLoading, setIsLoading] = useState(true);
|
||
|
|
const [deleteRoleId, setDeleteRoleId] = useState<string | null>(null);
|
||
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
||
|
|
|
||
|
|
async function fetchRoles() {
|
||
|
|
setIsLoading(true);
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/admin/roles");
|
||
|
|
if (!response.ok) throw new Error("Failed to fetch roles");
|
||
|
|
const data = await response.json();
|
||
|
|
setRoles(data.roles);
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error fetching roles:", error);
|
||
|
|
toast.error("Failed to load roles");
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchRoles();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
async function handleDelete() {
|
||
|
|
if (!deleteRoleId) return;
|
||
|
|
|
||
|
|
setIsDeleting(true);
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/admin/roles/${deleteRoleId}`, {
|
||
|
|
method: "DELETE",
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const data = await response.json();
|
||
|
|
throw new Error(data.error || "Failed to delete role");
|
||
|
|
}
|
||
|
|
|
||
|
|
toast.success("Role deleted");
|
||
|
|
fetchRoles();
|
||
|
|
} catch (error) {
|
||
|
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||
|
|
} finally {
|
||
|
|
setIsDeleting(false);
|
||
|
|
setDeleteRoleId(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function countPermissions(permissions: Record<string, string[]>): number {
|
||
|
|
return Object.values(permissions).reduce(
|
||
|
|
(total, actions) => total + actions.length,
|
||
|
|
0
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex justify-end">
|
||
|
|
<Button onClick={() => router.push("/admin/roles/new")}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
Create Role
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-md border">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead>Name</TableHead>
|
||
|
|
<TableHead>Description</TableHead>
|
||
|
|
<TableHead>Permissions</TableHead>
|
||
|
|
<TableHead>Users</TableHead>
|
||
|
|
<TableHead className="w-[100px]"></TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{isLoading ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={5} className="text-center py-8">
|
||
|
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : roles.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||
|
|
No roles found
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
roles.map((role) => (
|
||
|
|
<TableRow key={role.id}>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
||
|
|
<span className="font-medium">{role.name}</span>
|
||
|
|
{role.is_system && (
|
||
|
|
<Badge variant="outline" className="text-xs">System</Badge>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-muted-foreground">
|
||
|
|
{role.description || "-"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant="secondary">
|
||
|
|
{countPermissions(role.permissions)} permissions
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant="outline">{role.user_count} users</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => router.push(`/admin/roles/${role.id}`)}
|
||
|
|
>
|
||
|
|
<Pencil className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
{!role.is_system && (
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setDeleteRoleId(role.id)}
|
||
|
|
disabled={parseInt(role.user_count) > 0}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<AlertDialog open={!!deleteRoleId} onOpenChange={() => setDeleteRoleId(null)}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>Delete Role</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
Are you sure you want to delete this role? This action cannot be undone.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||
|
|
<AlertDialogAction
|
||
|
|
onClick={handleDelete}
|
||
|
|
disabled={isDeleting}
|
||
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||
|
|
>
|
||
|
|
{isDeleting ? (
|
||
|
|
<>
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
Deleting...
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
"Delete"
|
||
|
|
)}
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|