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:
Mauricio Siu
2026-03-16 11:13:24 -06:00
parent 72fb85f616
commit a4e9c6e890
9 changed files with 38 additions and 23 deletions

View File

@@ -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")} />,
},
];

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}