mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-12 17:35:21 +02:00
feat: implement audit logs and custom role management components
- Added new components for displaying and managing audit logs, including a data table and filters for user actions. - Introduced a custom roles management interface, allowing users to create and modify roles with specific permissions. - Updated permission checks to ensure proper access control for audit logs and custom roles. - Refactored existing components to integrate new functionality and improve user experience.
This commit is contained in:
@@ -1,230 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { AuditLog } from "@dokploy/server/db/schema";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
FileJson,
|
||||
LogIn,
|
||||
LogOut,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Upload,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import React from "react";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
const ACTION_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; icon: React.ElementType; className: string }
|
||||
> = {
|
||||
create: {
|
||||
label: "Created",
|
||||
icon: PlusCircle,
|
||||
className:
|
||||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
},
|
||||
update: {
|
||||
label: "Updated",
|
||||
icon: RefreshCw,
|
||||
className:
|
||||
"bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
|
||||
},
|
||||
delete: {
|
||||
label: "Deleted",
|
||||
icon: Trash2,
|
||||
className: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
|
||||
},
|
||||
deploy: {
|
||||
label: "Deployed",
|
||||
icon: Upload,
|
||||
className:
|
||||
"bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
},
|
||||
cancel: {
|
||||
label: "Cancelled",
|
||||
icon: XCircle,
|
||||
className:
|
||||
"bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
|
||||
},
|
||||
redeploy: {
|
||||
label: "Redeployed",
|
||||
icon: RotateCcw,
|
||||
className:
|
||||
"bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
|
||||
},
|
||||
login: {
|
||||
label: "Login",
|
||||
icon: LogIn,
|
||||
className:
|
||||
"bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/20",
|
||||
},
|
||||
logout: {
|
||||
label: "Logout",
|
||||
icon: LogOut,
|
||||
className:
|
||||
"bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20",
|
||||
},
|
||||
};
|
||||
|
||||
const RESOURCE_LABELS: Record<string, string> = {
|
||||
project: "Project",
|
||||
service: "Service",
|
||||
environment: "Environment",
|
||||
deployment: "Deployment",
|
||||
user: "User",
|
||||
customRole: "Custom Role",
|
||||
domain: "Domain",
|
||||
certificate: "Certificate",
|
||||
registry: "Registry",
|
||||
server: "Server",
|
||||
sshKey: "SSH Key",
|
||||
gitProvider: "Git Provider",
|
||||
notification: "Notification",
|
||||
settings: "Settings",
|
||||
session: "Session",
|
||||
};
|
||||
|
||||
function MetadataCell({ metadata }: { metadata: string | null }) {
|
||||
if (!metadata)
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
|
||||
const formatted = React.useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(metadata), null, 2);
|
||||
} catch {
|
||||
return metadata;
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs">
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
View
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Metadata</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CodeEditor
|
||||
value={formatted}
|
||||
language="json"
|
||||
lineNumbers={false}
|
||||
readOnly
|
||||
className="min-h-[200px] max-h-[400px] overflow-auto rounded-md"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<AuditLog>[] = [
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Date
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(row.getValue("createdAt")), "MMM d, yyyy HH:mm")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
User
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">{row.getValue("userEmail")}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Action
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const action = row.getValue("action") as string;
|
||||
const config = ACTION_CONFIG[action];
|
||||
if (!config) {
|
||||
return <span className="text-xs text-muted-foreground">{action}</span>;
|
||||
}
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${config.className}`}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceType",
|
||||
header: "Resource",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{RESOURCE_LABELS[row.getValue("resourceType") as string] ??
|
||||
row.getValue("resourceType")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm font-medium">
|
||||
{(row.getValue("resourceName") as string) ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "userRole",
|
||||
header: "Role",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{row.getValue("userRole")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "metadata",
|
||||
header: "Metadata",
|
||||
cell: ({ row }) => <MetadataCell metadata={row.getValue("metadata")} />,
|
||||
},
|
||||
];
|
||||
@@ -1,400 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { AuditLog } from "@dokploy/server/db/schema";
|
||||
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon, ChevronDown, X } from "lucide-react";
|
||||
import React from "react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: "create", label: "Created" },
|
||||
{ value: "update", label: "Updated" },
|
||||
{ value: "delete", label: "Deleted" },
|
||||
{ value: "deploy", label: "Deployed" },
|
||||
{ value: "cancel", label: "Cancelled" },
|
||||
{ value: "redeploy", label: "Redeployed" },
|
||||
{ value: "login", label: "Login" },
|
||||
{ value: "logout", label: "Logout" },
|
||||
];
|
||||
|
||||
const RESOURCE_OPTIONS = [
|
||||
{ value: "project", label: "Projects" },
|
||||
{ value: "service", label: "Applications / Services" },
|
||||
{ value: "environment", label: "Environments" },
|
||||
{ value: "deployment", label: "Deployments" },
|
||||
{ value: "user", label: "Users" },
|
||||
{ value: "customRole", label: "Custom Roles" },
|
||||
{ value: "domain", label: "Domains" },
|
||||
{ value: "certificate", label: "Certificates" },
|
||||
{ value: "registry", label: "Registries" },
|
||||
{ value: "server", label: "Remote Servers" },
|
||||
{ value: "sshKey", label: "SSH Keys" },
|
||||
{ value: "gitProvider", label: "Git Providers" },
|
||||
{ value: "notification", label: "Notifications" },
|
||||
{ value: "settings", label: "Settings" },
|
||||
{ value: "session", label: "Sessions (Login/Logout)" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
|
||||
|
||||
type AuditAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "deploy"
|
||||
| "cancel"
|
||||
| "redeploy"
|
||||
| "login"
|
||||
| "logout";
|
||||
type AuditResourceType =
|
||||
| "project"
|
||||
| "service"
|
||||
| "environment"
|
||||
| "deployment"
|
||||
| "user"
|
||||
| "customRole"
|
||||
| "domain"
|
||||
| "certificate"
|
||||
| "registry"
|
||||
| "server"
|
||||
| "sshKey"
|
||||
| "gitProvider"
|
||||
| "notification"
|
||||
| "settings"
|
||||
| "session";
|
||||
|
||||
export interface AuditLogFilters {
|
||||
userEmail: string;
|
||||
resourceName: string;
|
||||
action: AuditAction | "";
|
||||
resourceType: AuditResourceType | "";
|
||||
dateRange: DateRange | undefined;
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: ColumnDef<AuditLog>[];
|
||||
data: AuditLog[];
|
||||
total: number;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
filters: AuditLogFilters;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onFilterChange: <K extends keyof AuditLogFilters>(
|
||||
key: K,
|
||||
value: AuditLogFilters[K],
|
||||
) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
columns,
|
||||
data,
|
||||
total,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
filters,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onFilterChange,
|
||||
isLoading,
|
||||
}: DataTableProps) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
rowCount: total,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
const pageCount = Math.ceil(total / pageSize);
|
||||
const hasFilters =
|
||||
filters.userEmail ||
|
||||
filters.resourceName ||
|
||||
filters.action ||
|
||||
filters.resourceType ||
|
||||
filters.dateRange;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Input
|
||||
placeholder="Filter by user..."
|
||||
value={filters.userEmail}
|
||||
onChange={(e) => onFilterChange("userEmail", e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Filter by name..."
|
||||
value={filters.resourceName}
|
||||
onChange={(e) => onFilterChange("resourceName", e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select
|
||||
value={filters.action || "__all__"}
|
||||
onValueChange={(value) =>
|
||||
onFilterChange(
|
||||
"action",
|
||||
value === "__all__" ? "" : (value as AuditAction),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All actions</SelectItem>
|
||||
{ACTION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.resourceType || "__all__"}
|
||||
onValueChange={(value) =>
|
||||
onFilterChange(
|
||||
"resourceType",
|
||||
value === "__all__" ? "" : (value as AuditResourceType),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="All resources" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All resources</SelectItem>
|
||||
{RESOURCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 gap-1.5 text-sm font-normal"
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
{filters.dateRange?.from ? (
|
||||
filters.dateRange.to ? (
|
||||
`${format(filters.dateRange.from, "MMM d")} – ${format(filters.dateRange.to, "MMM d, yyyy")}`
|
||||
) : (
|
||||
format(filters.dateRange.from, "MMM d, yyyy")
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">Date range</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={filters.dateRange}
|
||||
onSelect={(range) => onFilterChange("dateRange", range)}
|
||||
numberOfMonths={2}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onFilterChange("userEmail", "");
|
||||
onFilterChange("resourceName", "");
|
||||
onFilterChange("action", "");
|
||||
onFilterChange("resourceType", "");
|
||||
onFilterChange("dateRange", undefined);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto">
|
||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
className="capitalize"
|
||||
checked={col.getIsVisible()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
>
|
||||
{col.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No audit logs found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{total} {total === 1 ? "entry" : "entries"} total
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm whitespace-nowrap">Rows per page</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => onPageSizeChange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[80px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span className="whitespace-nowrap">
|
||||
Page {pageIndex + 1} of {Math.max(1, pageCount)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pageIndex - 1)}
|
||||
disabled={pageIndex === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pageIndex + 1)}
|
||||
disabled={pageIndex + 1 >= pageCount}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import React from "react";
|
||||
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { columns } from "./columns";
|
||||
import { type AuditLogFilters, DataTable } from "./data-table";
|
||||
|
||||
function AuditLogsContent() {
|
||||
const [pageIndex, setPageIndex] = React.useState(0);
|
||||
const [pageSize, setPageSize] = React.useState(50);
|
||||
const [filters, setFilters] = React.useState<AuditLogFilters>({
|
||||
userEmail: "",
|
||||
resourceName: "",
|
||||
action: "",
|
||||
resourceType: "",
|
||||
dateRange: undefined,
|
||||
});
|
||||
|
||||
const [debouncedText, setDebouncedText] = React.useState({
|
||||
userEmail: "",
|
||||
resourceName: "",
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebouncedText({
|
||||
userEmail: filters.userEmail,
|
||||
resourceName: filters.resourceName,
|
||||
});
|
||||
setPageIndex(0);
|
||||
}, 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [filters.userEmail, filters.resourceName]);
|
||||
|
||||
const handleFilterChange = <K extends keyof AuditLogFilters>(
|
||||
key: K,
|
||||
value: AuditLogFilters[K],
|
||||
) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
if (key !== "userEmail" && key !== "resourceName") {
|
||||
setPageIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPageSize(size);
|
||||
setPageIndex(0);
|
||||
};
|
||||
|
||||
const { data, isLoading } = api.auditLog.all.useQuery({
|
||||
userEmail: debouncedText.userEmail || undefined,
|
||||
resourceName: debouncedText.resourceName || undefined,
|
||||
action: filters.action || undefined,
|
||||
resourceType: filters.resourceType || undefined,
|
||||
from: filters.dateRange?.from,
|
||||
to: filters.dateRange?.to,
|
||||
limit: pageSize,
|
||||
offset: pageIndex * pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.logs ?? []}
|
||||
total={data?.total ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
filters={filters}
|
||||
onPageChange={setPageIndex}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShowAuditLogs() {
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl w-full mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Audit Logs",
|
||||
description:
|
||||
"Get full visibility into every action performed across your organization. Audit logs are available as part of Dokploy Enterprise.",
|
||||
ctaLabel: "Manage License",
|
||||
}}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground self-center" />
|
||||
Audit Logs
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Track all actions performed by members in your organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<AuditLogsContent />
|
||||
</CardContent>
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,891 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Loader2, PlusIcon, ShieldCheck, TrashIcon, Users } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/** Labels and descriptions for each resource */
|
||||
const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
project: {
|
||||
label: "Projects",
|
||||
description: "Manage project creation and deletion",
|
||||
},
|
||||
service: {
|
||||
label: "Services",
|
||||
description:
|
||||
"Manage services (applications, databases, compose) within projects",
|
||||
},
|
||||
environment: {
|
||||
label: "Environments",
|
||||
description: "Manage environment creation, viewing, and deletion",
|
||||
},
|
||||
docker: {
|
||||
label: "Docker",
|
||||
description: "Access to Docker containers, images, and volumes management",
|
||||
},
|
||||
sshKeys: {
|
||||
label: "SSH Keys",
|
||||
description: "Manage SSH key configurations for servers and repositories",
|
||||
},
|
||||
gitProviders: {
|
||||
label: "Git Providers",
|
||||
description: "Access to Git providers (GitHub, GitLab, Bitbucket, Gitea)",
|
||||
},
|
||||
traefikFiles: {
|
||||
label: "Traefik Files",
|
||||
description: "Access to the Traefik file system configuration",
|
||||
},
|
||||
api: {
|
||||
label: "API / CLI",
|
||||
description: "Access to API keys and CLI usage",
|
||||
},
|
||||
// Enterprise-only resources
|
||||
volume: {
|
||||
label: "Volumes",
|
||||
description: "Manage persistent volumes and mounts attached to services",
|
||||
},
|
||||
deployment: {
|
||||
label: "Deployments",
|
||||
description: "Trigger, view, and cancel service deployments",
|
||||
},
|
||||
envVars: {
|
||||
label: "Service Env Vars",
|
||||
description: "View and edit environment variables of services",
|
||||
},
|
||||
projectEnvVars: {
|
||||
label: "Project Shared Env Vars",
|
||||
description: "View and edit shared environment variables at project level",
|
||||
},
|
||||
environmentEnvVars: {
|
||||
label: "Environment Shared Env Vars",
|
||||
description:
|
||||
"View and edit shared environment variables at environment level",
|
||||
},
|
||||
server: {
|
||||
label: "Servers",
|
||||
description: "Manage remote servers and nodes",
|
||||
},
|
||||
registry: {
|
||||
label: "Registries",
|
||||
description: "Manage Docker image registries",
|
||||
},
|
||||
certificate: {
|
||||
label: "Certificates",
|
||||
description: "Manage SSL/TLS certificates",
|
||||
},
|
||||
backup: {
|
||||
label: "Backups",
|
||||
description: "Manage database backups and restores",
|
||||
},
|
||||
volumeBackup: {
|
||||
label: "Volume Backups",
|
||||
description: "Manage Docker volume backups and restores",
|
||||
},
|
||||
schedule: {
|
||||
label: "Schedules",
|
||||
description: "Manage scheduled jobs (commands, deployments, scripts)",
|
||||
},
|
||||
domain: {
|
||||
label: "Domains",
|
||||
description: "Manage custom domains assigned to services",
|
||||
},
|
||||
destination: {
|
||||
label: "S3 Destinations",
|
||||
description:
|
||||
"Manage S3-compatible backup destinations (AWS, Cloudflare R2, etc.)",
|
||||
},
|
||||
notification: {
|
||||
label: "Notifications",
|
||||
description:
|
||||
"Manage notification providers (Slack, Discord, Telegram, etc.)",
|
||||
},
|
||||
member: {
|
||||
label: "Users",
|
||||
description: "Manage organization members, invitations, and roles",
|
||||
},
|
||||
logs: {
|
||||
label: "Logs",
|
||||
description: "View service and deployment logs",
|
||||
},
|
||||
monitoring: {
|
||||
label: "Monitoring",
|
||||
description: "View server and service metrics (CPU, RAM, disk)",
|
||||
},
|
||||
auditLog: {
|
||||
label: "Audit Logs",
|
||||
description: "View the audit log of actions performed in the organization",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
const ACTION_META: Record<
|
||||
string,
|
||||
Record<string, { label: string; description: string }>
|
||||
> = {
|
||||
project: {
|
||||
create: { label: "Create", description: "Create new projects" },
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Delete projects and all their content",
|
||||
},
|
||||
},
|
||||
service: {
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Create new services inside projects",
|
||||
},
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View services, logs, and deployments",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Delete services from projects",
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Create new environments in projects",
|
||||
},
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View environments and their services",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Delete environments and their content",
|
||||
},
|
||||
},
|
||||
docker: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View Docker containers, images, networks, and volumes",
|
||||
},
|
||||
},
|
||||
sshKeys: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View SSH key configurations",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Create and edit SSH keys",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove SSH keys",
|
||||
},
|
||||
},
|
||||
gitProviders: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View Git provider connections",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Create and update Git provider connections",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove Git provider connections",
|
||||
},
|
||||
},
|
||||
traefikFiles: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View Traefik configuration files",
|
||||
},
|
||||
write: {
|
||||
label: "Write",
|
||||
description: "Edit and save Traefik configuration files",
|
||||
},
|
||||
},
|
||||
api: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "Create and manage API keys for CLI access",
|
||||
},
|
||||
},
|
||||
volume: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View volumes and mounts attached to services",
|
||||
},
|
||||
create: { label: "Create", description: "Add and edit volumes and mounts" },
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove volumes and mounts from services",
|
||||
},
|
||||
},
|
||||
deployment: {
|
||||
read: { label: "Read", description: "View deployment history and status" },
|
||||
create: {
|
||||
label: "Deploy",
|
||||
description: "Trigger new deployments manually",
|
||||
},
|
||||
cancel: { label: "Cancel", description: "Cancel running deployments" },
|
||||
},
|
||||
envVars: {
|
||||
read: { label: "Read", description: "View environment variable values" },
|
||||
write: {
|
||||
label: "Write",
|
||||
description: "Create, update, and delete environment variables",
|
||||
},
|
||||
},
|
||||
projectEnvVars: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View project-level shared environment variables",
|
||||
},
|
||||
write: {
|
||||
label: "Write",
|
||||
description: "Edit project-level shared environment variables",
|
||||
},
|
||||
},
|
||||
environmentEnvVars: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View environment-level shared environment variables",
|
||||
},
|
||||
write: {
|
||||
label: "Write",
|
||||
description: "Edit environment-level shared environment variables",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View server list and connection details",
|
||||
},
|
||||
create: { label: "Create", description: "Add new remote servers" },
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove servers from the organization",
|
||||
},
|
||||
},
|
||||
registry: {
|
||||
read: { label: "Read", description: "View configured Docker registries" },
|
||||
create: { label: "Create", description: "Add new Docker registries" },
|
||||
delete: { label: "Delete", description: "Remove Docker registries" },
|
||||
},
|
||||
certificate: {
|
||||
read: { label: "Read", description: "View SSL/TLS certificates" },
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Issue and configure new certificates",
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove certificates" },
|
||||
},
|
||||
backup: {
|
||||
read: { label: "Read", description: "View backup history and status" },
|
||||
create: { label: "Create", description: "Trigger manual backups" },
|
||||
delete: { label: "Delete", description: "Delete backup files" },
|
||||
restore: {
|
||||
label: "Restore",
|
||||
description: "Restore a database from a backup",
|
||||
},
|
||||
},
|
||||
volumeBackup: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View volume backup history and status",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Create and trigger volume backups",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Update volume backup configuration",
|
||||
},
|
||||
delete: { label: "Delete", description: "Delete volume backup files" },
|
||||
restore: {
|
||||
label: "Restore",
|
||||
description: "Restore a Docker volume from a backup",
|
||||
},
|
||||
},
|
||||
schedule: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View scheduled jobs and their history",
|
||||
},
|
||||
create: { label: "Create", description: "Create and run scheduled jobs" },
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Update scheduled job configuration",
|
||||
},
|
||||
delete: { label: "Delete", description: "Delete scheduled jobs" },
|
||||
},
|
||||
domain: {
|
||||
read: { label: "Read", description: "View domains assigned to services" },
|
||||
create: { label: "Create", description: "Assign new domains to services" },
|
||||
delete: { label: "Delete", description: "Remove domains from services" },
|
||||
},
|
||||
destination: {
|
||||
read: { label: "Read", description: "View S3 backup destinations" },
|
||||
create: { label: "Create", description: "Add and edit S3 destinations" },
|
||||
delete: { label: "Delete", description: "Remove S3 destinations" },
|
||||
},
|
||||
notification: {
|
||||
read: { label: "Read", description: "View notification providers" },
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add and edit notification providers",
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove notification providers" },
|
||||
},
|
||||
member: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View the list of organization members",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Invite new members to the organization",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Change member roles and permissions",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove members from the organization",
|
||||
},
|
||||
},
|
||||
logs: {
|
||||
read: { label: "Read", description: "View real-time and historical logs" },
|
||||
},
|
||||
monitoring: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View CPU, RAM, disk, and network metrics",
|
||||
},
|
||||
},
|
||||
auditLog: {
|
||||
read: { label: "Read", description: "View the audit log history" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
const HIDDEN_RESOURCES = ["organization", "invitation", "team", "ac"];
|
||||
|
||||
const createRoleSchema = z.object({
|
||||
roleName: z
|
||||
.string()
|
||||
.min(1, "Role name is required")
|
||||
.max(50, "Role name must be 50 characters or less")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Only letters, numbers, hyphens, and underscores allowed",
|
||||
),
|
||||
});
|
||||
|
||||
type CreateRoleSchema = z.infer<typeof createRoleSchema>;
|
||||
|
||||
export const ManageCustomRoles = () => {
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<ShieldCheck className="size-6 text-muted-foreground self-center" />
|
||||
Custom Roles
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create and manage custom roles with fine-grained permissions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="border-t pt-6">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Custom Roles",
|
||||
description:
|
||||
"Custom roles with fine-grained permissions are part of Dokploy Enterprise. Add a valid license to create and assign custom roles.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<CustomRolesContent />
|
||||
</EnterpriseFeatureGate>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface HandleCustomRoleProps {
|
||||
roleName?: string;
|
||||
initialPermissions?: Record<string, string[]>;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function HandleCustomRole({
|
||||
roleName,
|
||||
initialPermissions,
|
||||
onSuccess,
|
||||
}: HandleCustomRoleProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [permissions, setPermissions] = useState<Record<string, string[]>>({});
|
||||
const { data: statements } = api.customRole.getStatements.useQuery();
|
||||
const isEdit = !!roleName;
|
||||
|
||||
const form = useForm<CreateRoleSchema>({
|
||||
defaultValues: { roleName: "" },
|
||||
resolver: zodResolver(createRoleSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPermissions(initialPermissions ? { ...initialPermissions } : {});
|
||||
form.reset({ roleName: isEdit ? (roleName ?? "") : "" });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { mutateAsync: createRole, isPending: isCreating } =
|
||||
api.customRole.create.useMutation();
|
||||
const { mutateAsync: updateRole, isPending: isUpdating } =
|
||||
api.customRole.update.useMutation();
|
||||
|
||||
const visibleResources = statements
|
||||
? Object.entries(statements).filter(
|
||||
([key]) => !HIDDEN_RESOURCES.includes(key),
|
||||
)
|
||||
: [];
|
||||
|
||||
const togglePermission = (resource: string, action: string) => {
|
||||
setPermissions((prev) => {
|
||||
const current = prev[resource] || [];
|
||||
const has = current.includes(action);
|
||||
return {
|
||||
...prev,
|
||||
[resource]: has
|
||||
? current.filter((a) => a !== action)
|
||||
: [...current, action],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: CreateRoleSchema) => {
|
||||
try {
|
||||
if (isEdit) {
|
||||
const newName = data.roleName !== roleName ? data.roleName : undefined;
|
||||
await updateRole({
|
||||
roleName: roleName!,
|
||||
newRoleName: newName,
|
||||
permissions,
|
||||
});
|
||||
toast.success(`Role "${newName ?? roleName}" updated`);
|
||||
} else {
|
||||
await createRole({ roleName: data.roleName, permissions });
|
||||
toast.success(`Role "${data.roleName}" created`);
|
||||
}
|
||||
if (!isEdit) {
|
||||
setOpen(false);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
let message = `Error ${isEdit ? "updating" : "creating"} role`;
|
||||
if (error instanceof Error) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
if (Array.isArray(parsed) && parsed[0]?.message) {
|
||||
message = parsed[0].message;
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
} catch {
|
||||
message = error.message;
|
||||
}
|
||||
}
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{isEdit ? (
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4 mr-1" />
|
||||
Create Role
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] sm:max-w-5xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Edit Role" : "Create Custom Role"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update permissions for this role"
|
||||
: "Define a new role with specific permissions"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="handle-role-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roleName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. developer, viewer, deployer"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<PermissionEditor
|
||||
resources={visibleResources}
|
||||
permissions={permissions}
|
||||
onToggle={togglePermission}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isEdit ? isUpdating : isCreating}
|
||||
form="handle-role-form"
|
||||
type="submit"
|
||||
>
|
||||
{isEdit ? "Save Changes" : "Create Role"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomRolesContent = () => {
|
||||
const {
|
||||
data: customRoles,
|
||||
isPending,
|
||||
refetch,
|
||||
} = api.customRole.all.useQuery();
|
||||
const { mutateAsync: deleteRole } = api.customRole.remove.useMutation();
|
||||
|
||||
const handleDelete = async (roleName: string) => {
|
||||
try {
|
||||
await deleteRole({ roleName });
|
||||
toast.success(`Role "${roleName}" deleted`);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
let message = "Error deleting role";
|
||||
if (error instanceof Error) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
message =
|
||||
Array.isArray(parsed) && parsed[0]?.message
|
||||
? parsed[0].message
|
||||
: error.message;
|
||||
} catch {
|
||||
message = error.message;
|
||||
}
|
||||
}
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<HandleCustomRole onSuccess={refetch} />
|
||||
</div>
|
||||
|
||||
{customRoles?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center text-center py-8">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<ShieldCheck className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No custom roles yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a role to define fine-grained access for your team members.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{customRoles?.map((role) => {
|
||||
const totalPermissions = Object.values(role.permissions).flat()
|
||||
.length;
|
||||
const enabledResources = Object.entries(role.permissions).filter(
|
||||
([, actions]) => (actions as string[]).length > 0,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={role.role}
|
||||
className="rounded-lg border bg-muted/20 p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="rounded-md bg-primary/10 p-1.5 shrink-0">
|
||||
<ShieldCheck className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{role.role}
|
||||
</p>
|
||||
{role.memberCount > 0 && (
|
||||
<MembersBadge
|
||||
roleName={role.role}
|
||||
count={role.memberCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{enabledResources.length} resource
|
||||
{enabledResources.length !== 1 ? "s" : ""} ·{" "}
|
||||
{totalPermissions} permission
|
||||
{totalPermissions !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<HandleCustomRole
|
||||
roleName={role.role}
|
||||
initialPermissions={role.permissions}
|
||||
onSuccess={refetch}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Role"
|
||||
description={
|
||||
<div className="space-y-3">
|
||||
{role.memberCount > 0 && (
|
||||
<AlertBlock type="error">
|
||||
<strong>
|
||||
{role.memberCount} member
|
||||
{role.memberCount !== 1 ? "s are" : " is"}{" "}
|
||||
currently assigned
|
||||
</strong>{" "}
|
||||
to this role. Reassign them before deleting.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<span>
|
||||
Are you sure you want to delete the{" "}
|
||||
<strong>"{role.role}"</strong> role? This action
|
||||
cannot be undone.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
disabled={role.memberCount > 0}
|
||||
type="destructive"
|
||||
onClick={() => handleDelete(role.role)}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<TrashIcon className="size-3.5 text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabledResources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1 border-t">
|
||||
{enabledResources.map(([resource, actions]) => (
|
||||
<div
|
||||
key={resource}
|
||||
className="flex items-center gap-1 rounded-md bg-background border px-2 py-1"
|
||||
>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{RESOURCE_META[resource]?.label || resource}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">·</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(actions as string[])
|
||||
.map((a) => ACTION_META[resource]?.[a]?.label || a)
|
||||
.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function MembersBadge({
|
||||
roleName,
|
||||
count,
|
||||
}: {
|
||||
roleName: string;
|
||||
count: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: members, isLoading } = api.customRole.membersByRole.useQuery(
|
||||
{ roleName },
|
||||
{ enabled: open },
|
||||
);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<Users className="size-3" />
|
||||
{count}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2 px-1">
|
||||
Assigned members
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : members && members.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{members.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||
{(m.firstName?.[0] || m.email?.[0] || "?").toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{(m.firstName || m.lastName) && (
|
||||
<p className="text-xs font-medium truncate">
|
||||
{[m.firstName, m.lastName].filter(Boolean).join(" ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{m.email}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground px-1 py-2">
|
||||
No members found.
|
||||
</p>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable permission toggle grid with descriptions */
|
||||
function PermissionEditor({
|
||||
resources,
|
||||
permissions,
|
||||
onToggle,
|
||||
}: {
|
||||
resources: [string, readonly string[]][];
|
||||
permissions: Record<string, string[]>;
|
||||
onToggle: (resource: string, action: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Permissions</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{resources.map(([resource, actions]) => {
|
||||
const meta = RESOURCE_META[resource];
|
||||
return (
|
||||
<div key={resource} className="rounded-lg border p-3 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{meta?.label || resource}</p>
|
||||
{meta?.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{meta.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{actions.map((action) => {
|
||||
const actionMeta = ACTION_META[resource]?.[action];
|
||||
return (
|
||||
<div
|
||||
key={action}
|
||||
className="flex items-center gap-3 cursor-pointer rounded-md border p-2 hover:bg-muted/50 transition-colors"
|
||||
onClick={() => onToggle(resource, action)}
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
permissions[resource]?.includes(action) ?? false
|
||||
}
|
||||
onCheckedChange={() => onToggle(resource, action)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">
|
||||
{actionMeta?.label || action}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user