mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-12 15:05:19 +00:00
- Introduced new test files for permission checks, including `check-permission.test.ts`, `enterprise-only-resources.test.ts`, `resolve-permissions.test.ts`, and `service-access.test.ts`. - Implemented permission checks in various components to ensure actions are gated by user permissions, including `ShowTraefikConfig`, `UpdateTraefikConfig`, `ShowVolumes`, `ShowDomains`, and others. - Enhanced the logic for displaying UI elements based on user permissions, ensuring that only authorized users can access or modify resources.
892 lines
24 KiB
TypeScript
892 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|