feat(permissions): implement role-based access control and refactor user permissions

- Introduced a new Permissions component to manage role-based access across various components.
- Updated user role checks to utilize the new permissions structure, replacing direct role comparisons with permission checks.
- Refactored multiple components to enhance permission handling, ensuring only authorized users can access specific features.
- Removed deprecated add-permissions component and streamlined user permission management.
- Enhanced role management in the backend to support the new permissions schema, improving overall security and maintainability.
This commit is contained in:
Mauricio Siu
2025-07-13 01:52:08 -06:00
parent db221e5cc4
commit 30d45bf2e5
35 changed files with 435 additions and 728 deletions

View File

@@ -47,11 +47,12 @@ import { useMemo, useState } from "react";
import { toast } from "sonner";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { Permissions } from "../shared/Permissions";
export const ShowProjects = () => {
const utils = api.useUtils();
const { data, isLoading } = api.project.all.useQuery();
const { data: auth } = api.user.get.useQuery();
const { mutateAsync } = api.project.remove.useMutation();
const [searchQuery, setSearchQuery] = useState("");
@@ -83,11 +84,11 @@ export const ShowProjects = () => {
</CardDescription>
</CardHeader>
{(auth?.role === "owner" || auth?.canCreateProjects) && (
<Permissions permissions={[PERMISSIONS.PROJECT.CREATE.name]}>
<div className="">
<HandleProject />
</div>
)}
</Permissions>
</div>
<CardContent className="space-y-2 py-8 border-t gap-4 flex flex-col min-h-[60vh]">
@@ -289,8 +290,11 @@ export const ShowProjects = () => {
<div
onClick={(e) => e.stopPropagation()}
>
{(auth?.role === "owner" ||
auth?.canDeleteProjects) && (
<Permissions
permissions={[
PERMISSIONS.PROJECT.DELETE.name,
]}
>
<AlertDialog>
<AlertDialogTrigger className="w-full">
<DropdownMenuItem
@@ -356,7 +360,7 @@ export const ShowProjects = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</Permissions>
</div>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -14,7 +14,7 @@ export const ShowWelcomeDokploy = () => {
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
if (!isCloud || data?.role !== "admin") {
if (!isCloud || data?.role?.name !== "admin") {
return null;
}
@@ -23,14 +23,14 @@ export const ShowWelcomeDokploy = () => {
!isLoading &&
isCloud &&
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
data?.role === "owner"
data?.role?.name === "owner"
) {
setOpen(true);
}
}, [isCloud, isLoading]);
const handleClose = (isOpen: boolean) => {
if (data?.role === "owner") {
if (data?.role?.name === "owner") {
setOpen(isOpen);
if (!isOpen) {
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal

View File

@@ -32,6 +32,7 @@ import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({
name: z.string(),
email: z.string(),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
@@ -79,6 +80,7 @@ export const ProfileForm = () => {
const form = useForm<Profile>({
defaultValues: {
name: data?.user?.name || "",
email: data?.user?.email || "",
password: "",
image: data?.user?.image || "",
@@ -92,6 +94,7 @@ export const ProfileForm = () => {
if (data) {
form.reset(
{
name: data?.user?.name || "",
email: data?.user?.email || "",
password: form.getValues("password") || "",
image: data?.user?.image || "",
@@ -114,6 +117,7 @@ export const ProfileForm = () => {
const onSubmit = async (values: Profile) => {
await mutateAsync({
name: values.name,
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
@@ -124,6 +128,7 @@ export const ProfileForm = () => {
await refetch();
toast.success("Profile Updated");
form.reset({
name: values.name,
email: values.email,
password: "",
image: values.image,
@@ -167,6 +172,19 @@ export const ProfileForm = () => {
className="grid gap-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"

View File

@@ -1,444 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { extractServices } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
canDeleteServices: z.boolean().optional().default(false),
canAccessToTraefikFiles: z.boolean().optional().default(false),
canAccessToDocker: z.boolean().optional().default(false),
canAccessToAPI: z.boolean().optional().default(false),
canAccessToSSHKeys: z.boolean().optional().default(false),
canAccessToGitProviders: z.boolean().optional().default(false),
});
type AddPermissions = z.infer<typeof addPermissions>;
interface Props {
userId: string;
}
export const AddUserPermissions = ({ userId }: Props) => {
const { data: projects } = api.project.all.useQuery();
const { data, refetch } = api.user.one.useQuery(
{
userId,
},
{
enabled: !!userId,
},
);
const { mutateAsync, isError, error, isLoading } =
api.user.assignPermissions.useMutation();
const form = useForm<AddPermissions>({
defaultValues: {
accessedProjects: [],
accessedServices: [],
},
resolver: zodResolver(addPermissions),
});
useEffect(() => {
if (data) {
form.reset({
accessedProjects: data.accessedProjects || [],
accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
canDeleteServices: data.canDeleteServices,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
});
}
}, [form, form.formState.isSubmitSuccessful, form.reset, data]);
const onSubmit = async (data: AddPermissions) => {
await mutateAsync({
id: userId,
canCreateServices: data.canCreateServices,
canCreateProjects: data.canCreateProjects,
canDeleteServices: data.canDeleteServices,
canDeleteProjects: data.canDeleteProjects,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [],
accessedServices: data.accessedServices || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
})
.then(async () => {
toast.success("Permissions updated");
refetch();
})
.catch(() => {
toast.error("Error updating the permissions");
});
};
return (
<Dialog>
<DialogTrigger className="" asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
Add Permissions
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Permissions</DialogTitle>
<DialogDescription>Add or remove permissions</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-permissions"
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
>
<FormField
control={form.control}
name="canCreateProjects"
render={({ field }) => (
<FormItem className=" flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Projects</FormLabel>
<FormDescription>
Allow the user to create projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteProjects"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Projects</FormLabel>
<FormDescription>
Allow the user to delete projects
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Services</FormLabel>
<FormDescription>
Allow the user to create services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteServices"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Services</FormLabel>
<FormDescription>
Allow the user to delete services
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToTraefikFiles"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Traefik Files</FormLabel>
<FormDescription>
Allow the user to access to the Traefik Tab Files
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToDocker"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Docker</FormLabel>
<FormDescription>
Allow the user to access to the Docker Tab
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToAPI"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to API/CLI</FormLabel>
<FormDescription>
Allow the user to access to the API/CLI
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToSSHKeys"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to SSH Keys</FormLabel>
<FormDescription>
Allow to users to access to the SSH Keys section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToGitProviders"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Access to Git Providers</FormLabel>
<FormDescription>
Allow to users to access to the Git Providers section
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessedProjects"
render={() => (
<FormItem className="md:col-span-2">
<div className="mb-4">
<FormLabel className="text-base">Projects</FormLabel>
<FormDescription>
Select the Projects that the user can access
</FormDescription>
</div>
{projects?.length === 0 && (
<p className="text-sm text-muted-foreground">
No projects found
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{projects?.map((item, index) => {
const applications = extractServices(item);
return (
<FormField
key={`project-${index}`}
control={form.control}
name="accessedProjects"
render={({ field }) => {
return (
<FormItem
key={item.projectId}
className="flex flex-col items-start space-x-4 rounded-lg p-4 border"
>
<div className="flex flex-row gap-4">
<FormControl>
<Checkbox
checked={field.value?.includes(
item.projectId,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.projectId,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.projectId,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-primary">
{item.name}
</FormLabel>
</div>
{applications.length === 0 && (
<p className="text-sm text-muted-foreground">
No services found
</p>
)}
{applications?.map((item, index) => (
<FormField
key={`project-${index}`}
control={form.control}
name="accessedServices"
render={({ field }) => {
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
item.id,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.id,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.id,
),
);
}}
/>
</FormControl>
<FormLabel className="text-sm text-muted-foreground">
{item.name}
</FormLabel>
</FormItem>
);
}}
/>
))}
</FormItem>
);
}}
/>
);
})}
</div>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2">
<Button
isLoading={isLoading}
form="hook-form-add-permissions"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -30,7 +30,6 @@ import { format } from "date-fns";
import { MoreHorizontal, Users } from "lucide-react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { AddUserPermissions } from "./add-permissions";
import { AddUserPermissionsV2 } from "./add-permissions-v2";
export const ShowUsers = () => {
@@ -135,9 +134,6 @@ export const ShowUsers = () => {
</DropdownMenuLabel>
<>
<AddUserPermissions
userId={member.user.id}
/>
<AddUserPermissionsV2
userId={member.user.id}
/>

View File

@@ -0,0 +1,28 @@
import { api } from "@/utils/api";
import type { PermissionName } from "@dokploy/server/lib/permissions";
import { useMemo } from "react";
interface Props {
permissions: PermissionName[];
children: React.ReactNode;
}
export const Permissions = ({ permissions, children }: Props) => {
const { data: auth } = api.user.get.useQuery();
const hasPermission = useMemo(() => {
if (auth?.role?.name === "owner" || auth?.role?.name === "admin") {
return true;
}
return permissions.some((permission) =>
auth?.role?.permissions?.includes(permission),
);
}, [permissions, auth]);
if (!hasPermission) {
return null;
}
return <>{children}</>;
};

View File

@@ -87,6 +87,7 @@ import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { UpdateServerButton } from "./update-server";
import { UserNav } from "./user-nav";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
type AuthQueryOutput = inferRouterOutputs<AppRouter>["user"]["get"];
@@ -152,7 +153,8 @@ const MENU: Menu = {
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
isEnabled: ({ isCloud, auth }) =>
!isCloud && auth?.role?.name === "owner",
},
{
isSingle: true,
@@ -162,7 +164,10 @@ const MENU: Menu = {
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(
PERMISSIONS.TRAEFIK.ACCESS.name,
)) &&
!isCloud
),
},
@@ -173,7 +178,11 @@ const MENU: Menu = {
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
},
{
isSingle: true,
@@ -182,7 +191,11 @@ const MENU: Menu = {
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
},
{
isSingle: true,
@@ -191,7 +204,11 @@ const MENU: Menu = {
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
!!(
(auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) &&
!isCloud
),
},
],
@@ -202,7 +219,8 @@ const MENU: Menu = {
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && !isCloud),
},
{
isSingle: true,
@@ -216,7 +234,7 @@ const MENU: Menu = {
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -224,7 +242,7 @@ const MENU: Menu = {
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -233,14 +251,17 @@ const MENU: Menu = {
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
!!(
auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(PERMISSIONS.SSH_KEYS.ACCESS.name)
),
},
{
title: "AI",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -249,7 +270,12 @@ const MENU: Menu = {
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
!!(
auth?.role?.name === "owner" ||
auth?.role?.permissions?.includes(
PERMISSIONS.GIT_PROVIDERS.ACCESS.name,
)
),
},
{
isSingle: true,
@@ -257,7 +283,7 @@ const MENU: Menu = {
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -265,7 +291,7 @@ const MENU: Menu = {
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
@@ -274,7 +300,7 @@ const MENU: Menu = {
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -282,7 +308,8 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && !isCloud),
},
{
isSingle: true,
@@ -290,7 +317,7 @@ const MENU: Menu = {
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
isEnabled: ({ auth }) => !!(auth?.role?.name === "owner"),
},
{
isSingle: true,
@@ -298,7 +325,8 @@ const MENU: Menu = {
url: "/dashboard/settings/billing",
icon: CreditCard,
// Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
isEnabled: ({ auth, isCloud }) =>
!!(auth?.role?.name === "owner" && isCloud),
},
],
@@ -595,7 +623,7 @@ function SidebarLogo() {
)}
</div>
))}
{(user?.role === "owner" || isCloud) && (
{(user?.role?.name === "owner" || isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
@@ -961,7 +989,7 @@ export default function Page({ children }: Props) {
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role === "owner" && (
{!isCloud && auth?.role?.name === "owner" && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>

View File

@@ -23,6 +23,8 @@ import { ChevronsUpDown } from "lucide-react";
import { useRouter } from "next/router";
import { ModeToggle } from "../ui/modeToggle";
import { SidebarMenuButton } from "../ui/sidebar";
import { Permissions } from "../dashboard/shared/Permissions";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
const _AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
@@ -98,7 +100,7 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
<Permissions permissions={[PERMISSIONS.TRAEFIK.ACCESS.name]}>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -107,8 +109,9 @@ export const UserNav = () => {
>
Traefik
</DropdownMenuItem>
)}
{(data?.role === "owner" || data?.canAccessToDocker) && (
</Permissions>
<Permissions permissions={[PERMISSIONS.DOCKER.VIEW.name]}>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -119,11 +122,11 @@ export const UserNav = () => {
>
Docker
</DropdownMenuItem>
)}
</Permissions>
</>
) : (
<>
{data?.role === "owner" && (
{data?.role?.name === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -136,7 +139,7 @@ export const UserNav = () => {
</>
)}
</DropdownMenuGroup>
{isCloud && data?.role === "owner" && (
{isCloud && data?.role?.name === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -154,9 +157,6 @@ export const UserNav = () => {
await authClient.signOut().then(() => {
router.push("/");
});
// await mutateAsync().then(() => {
// router.push("/");
// });
}}
>
Log out

View File

@@ -3,6 +3,7 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { PERMISSIONS } from "@dokploy/server/lib/permissions";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
@@ -53,12 +54,13 @@ export async function getServerSideProps(
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
if (user.role?.name === "member" || user?.role?.) {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToDocker) {
if (!userR?.role?.permissions?.includes(PERMISSIONS.DOCKER.VIEW.name)) {
return {
redirect: {
permanent: true,

View File

@@ -94,6 +94,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
export type Services = {
appName: string;
@@ -221,7 +222,6 @@ const Project = (
) => {
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId } = props;
const { data: auth } = api.user.get.useQuery();
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("servicesSort") || "createdAt-desc";
@@ -736,30 +736,27 @@ const Project = (
Stop
</Button>
</DialogAction>
{(auth?.role === "owner" ||
auth?.canDeleteServices) && (
<>
<DialogAction
title="Delete Services"
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
type="destructive"
onClick={handleBulkDelete}
<Permissions permissions={["project:delete"]}>
<DialogAction
title="Delete Services"
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
type="destructive"
onClick={handleBulkDelete}
>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
services={applications}
selectedServiceIds={selectedServices}
/>
</>
)}
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
<DuplicateProject
projectId={projectId}
services={applications}
selectedServiceIds={selectedServices}
/>
</Permissions>
<Dialog
open={isMoveDialogOpen}

View File

@@ -37,7 +37,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server/lib/auth";
@@ -54,6 +53,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState =
| "projects"
@@ -88,7 +88,6 @@ const Service = (
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
return (
<div className="pb-10">
@@ -179,9 +178,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={applicationId} type="application" />
)}
</Permissions>
</div>
</div>
</CardHeader>

View File

@@ -33,7 +33,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server/lib/auth";
@@ -51,6 +50,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState =
| "projects"
@@ -78,7 +78,6 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -171,9 +170,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={composeId} type="compose" />
)}
</Permissions>
</div>
</div>
</CardHeader>

View File

@@ -44,6 +44,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -57,7 +58,6 @@ const Mariadb = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -142,9 +142,9 @@ const Mariadb = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={mariadbId} type="mariadb" />
)}
</Permissions>
</div>
</div>
</CardHeader>

View File

@@ -44,6 +44,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -57,8 +58,6 @@ const Mongo = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -143,9 +142,9 @@ const Mongo = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={mongoId} type="mongo" />
)}
</Permissions>
</div>
</div>
</CardHeader>

View File

@@ -29,7 +29,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server/lib/auth";
@@ -44,6 +43,8 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
import { cn } from "@/lib/utils";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -56,7 +57,6 @@ const MySql = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -143,9 +143,9 @@ const MySql = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={mysqlId} type="mysql" />
)}
</Permissions>
</div>
</div>
</CardHeader>

View File

@@ -29,7 +29,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server/lib/auth";
@@ -44,6 +43,8 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
import { cn } from "@/lib/utils";
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
@@ -56,7 +57,6 @@ const Postgresql = (
const { projectId } = router.query;
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -142,9 +142,9 @@ const Postgresql = (
<div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={postgresId} type="postgres" />
)}
</Permissions>
</div>
</div>
</CardHeader>

View File

@@ -43,6 +43,7 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
type TabState = "projects" | "monitoring" | "settings" | "advanced";
@@ -56,8 +57,6 @@ const Redis = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
return (
@@ -142,9 +141,9 @@ const Redis = (
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<Permissions permissions={["service:delete"]}>
<DeleteService id={redisId} type="redis" />
)}
</Permissions>
</div>
</div>
</CardHeader>

View File

@@ -1,27 +1,23 @@
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { Permissions } from "@/components/dashboard/shared/Permissions";
const Page = () => {
const { data } = api.user.get.useQuery();
// const { data: isCloud } = api.settings.isCloud.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<ProfileForm />
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
{/* {isCloud && <RemoveSelfAccount />} */}
<Permissions permissions={["api:access"]}>
<ShowApiKeys />
</Permissions>
</div>
</div>
);

View File

@@ -162,7 +162,7 @@ export const aiRouter = createTRPCRouter({
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.session.activeOrganizationId,
input.projectId,
@@ -215,7 +215,7 @@ export const aiRouter = createTRPCRouter({
}
}
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.session.activeOrganizationId,
ctx.user.ownerId,

View File

@@ -63,7 +63,7 @@ export const applicationRouter = createTRPCRouter({
.input(apiCreateApplication)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -88,7 +88,7 @@ export const applicationRouter = createTRPCRouter({
}
const newApplication = await createApplication(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
newApplication.applicationId,
@@ -110,7 +110,7 @@ export const applicationRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
@@ -201,7 +201,7 @@ export const applicationRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.applicationId,

View File

@@ -64,7 +64,7 @@ export const composeRouter = createTRPCRouter({
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -88,7 +88,7 @@ export const composeRouter = createTRPCRouter({
}
const newService = await createCompose(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
newService.composeId,
@@ -105,7 +105,7 @@ export const composeRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.composeId,
@@ -177,7 +177,7 @@ export const composeRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.composeId,
@@ -469,7 +469,7 @@ export const composeRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -524,7 +524,7 @@ export const composeRouter = createTRPCRouter({
isolatedDeployment: true,
});
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
compose.composeId,

View File

@@ -41,7 +41,7 @@ export const mariadbRouter = createTRPCRouter({
.input(apiCreateMariaDB)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mariadbRouter = createTRPCRouter({
});
}
const newMariadb = await createMariadb(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
newMariadb.mariadbId,
@@ -92,7 +92,7 @@ export const mariadbRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMariaDB)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
@@ -219,7 +219,7 @@ export const mariadbRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,

View File

@@ -41,7 +41,7 @@ export const mongoRouter = createTRPCRouter({
.input(apiCreateMongo)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mongoRouter = createTRPCRouter({
});
}
const newMongo = await createMongo(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
newMongo.mongoId,
@@ -96,7 +96,7 @@ export const mongoRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMongo)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mongoId,
@@ -261,7 +261,7 @@ export const mongoRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mongoId,

View File

@@ -44,7 +44,7 @@ export const mysqlRouter = createTRPCRouter({
.input(apiCreateMySql)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -69,7 +69,7 @@ export const mysqlRouter = createTRPCRouter({
}
const newMysql = await createMysql(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
newMysql.mysqlId,
@@ -100,7 +100,7 @@ export const mysqlRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMySql)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
@@ -260,7 +260,7 @@ export const mysqlRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,

View File

@@ -41,7 +41,7 @@ export const postgresRouter = createTRPCRouter({
.input(apiCreatePostgres)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const postgresRouter = createTRPCRouter({
});
}
const newPostgres = await createPostgres(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
newPostgres.postgresId,
@@ -96,7 +96,7 @@ export const postgresRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOnePostgres)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.postgresId,
@@ -244,7 +244,7 @@ export const postgresRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.postgresId,

View File

@@ -57,7 +57,7 @@ export const projectRouter = createTRPCRouter({
.input(apiCreateProject)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -78,7 +78,7 @@ export const projectRouter = createTRPCRouter({
input,
ctx.session.activeOrganizationId,
);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewProject(
ctx.user.id,
project.projectId,
@@ -99,7 +99,7 @@ export const projectRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneProject)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -164,7 +164,7 @@ export const projectRouter = createTRPCRouter({
return project;
}),
all: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
const { accessedProjects, accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -241,7 +241,7 @@ export const projectRouter = createTRPCRouter({
.input(apiRemoveProject)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkProjectAccess(
ctx.user.id,
"delete",
@@ -314,7 +314,7 @@ export const projectRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -649,7 +649,10 @@ export const projectRouter = createTRPCRouter({
}
}
if (!input.duplicateInSameProject && ctx.user.role === "member") {
if (
!input.duplicateInSameProject &&
(ctx.user.role.name === "member" || !ctx.user.role.isSystem)
) {
await addNewProject(
ctx.user.id,
targetProject.projectId,

View File

@@ -41,7 +41,7 @@ export const redisRouter = createTRPCRouter({
.input(apiCreateRedis)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const redisRouter = createTRPCRouter({
});
}
const newRedis = await createRedis(input);
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await addNewService(
ctx.user.id,
newRedis.redisId,
@@ -89,7 +89,7 @@ export const redisRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneRedis)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.redisId,
@@ -251,7 +251,7 @@ export const redisRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.role.name === "member" || !ctx.user.role.isSystem) {
await checkServiceAccess(
ctx.user.id,
input.redisId,

View File

@@ -5,9 +5,9 @@ import {
createRoleSchema,
role,
updateRoleSchema,
defaultPermissions,
} from "@/server/db/schema";
import { createRole, removeRoleById, updateRoleById } from "@dokploy/server";
import { defaultPermissions } from "@dokploy/server/lib/permissions";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";

View File

@@ -30,7 +30,17 @@ import { ZodError } from "zod";
*/
interface CreateContextOptions {
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
user:
| (User & {
role: {
roleId: string;
name: string;
permissions: string[];
isSystem: boolean;
};
ownerId: string;
})
| null;
session:
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
| null;
@@ -182,7 +192,7 @@ export const uploadProcedure = async (opts: any) => {
};
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
if (!ctx.session || !ctx.user || ctx.user.role.name !== "owner") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
@@ -196,7 +206,11 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
});
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role.name !== "owner" && ctx.user.role.name !== "admin")
) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({

View File

@@ -5,111 +5,6 @@ import { organization, member } from "./account";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const PERMISSIONS = {
PROJECT: {
VIEW: {
name: "project:view",
description: "View projects",
},
CREATE: {
name: "project:create",
description: "Create projects",
},
DELETE: {
name: "project:delete",
description: "Delete projects",
},
},
SERVICE: {
VIEW: {
name: "service:view",
description: "View services",
},
CREATE: {
name: "service:create",
description: "Create services",
},
DELETE: {
name: "service:delete",
description: "Delete services",
},
},
TRAEFIK: {
ACCESS: {
name: "traefik_files:access",
description: "Access traefik files",
},
},
DOCKER: {
VIEW: {
name: "docker:view",
description: "View docker",
},
},
API: {
ACCESS: {
name: "api:access",
description: "Access API",
},
},
SCHEDULES: {
ACCESS: {
name: "schedules:access",
description: "Access schedules",
},
},
} as const;
export const ownerPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
] as const;
export const adminPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.DOCKER.VIEW,
PERMISSIONS.API.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
] as const;
export const memberPermissions = [
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.TRAEFIK.ACCESS,
] as const;
export const defaultPermissions = [
{
name: "owner",
description: "Owner of the organization with full access to all features",
permissions: ownerPermissions,
},
{
name: "admin",
description:
"Administrator with access to manage projects, services and configurations",
permissions: adminPermissions,
},
{
name: "member",
description:
"Regular member with access to create projects and manage services",
permissions: memberPermissions,
},
] as const;
export const role = pgTable(
"member_role",
{

View File

@@ -140,8 +140,18 @@ const { handler, api } = betterAuth({
});
}
} else {
const ownerRole = await db.query.role.findFirst({
where: and(eq(schema.role.name, "owner")),
});
if (!ownerRole) {
throw new APIError("BAD_REQUEST", {
message: "Owner role not found",
});
}
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
where: and(eq(schema.member.roleId, ownerRole.roleId)),
});
if (isAdminPresent) {
throw new APIError("BAD_REQUEST", {
@@ -152,8 +162,11 @@ const { handler, api } = betterAuth({
}
},
after: async (user) => {
const ownerRole = await db.query.role.findFirst({
where: and(eq(schema.role.name, "owner")),
});
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
where: and(eq(schema.member.roleId, ownerRole?.roleId || "")),
});
if (!IS_CLOUD) {
@@ -186,7 +199,6 @@ const { handler, api } = betterAuth({
await tx.insert(schema.member).values({
userId: user.id,
organizationId: organization?.id || "",
role: "owner",
createdAt: new Date(),
roleId: ownerRole?.roleId || "",
});
@@ -202,14 +214,18 @@ const { handler, api } = betterAuth({
where: eq(schema.member.userId, session.userId),
orderBy: desc(schema.member.createdAt),
with: {
role: true,
organization: true,
},
});
console.log(member);
return {
data: {
...session,
activeOrganizationId: member?.organization.id,
roleId: member?.roleId,
},
};
},
@@ -223,7 +239,7 @@ const { handler, api } = betterAuth({
user: {
modelName: "users",
additionalFields: {
role: {
roleId: {
type: "string",
// required: true,
input: false,
@@ -331,6 +347,7 @@ export const validateRequest = async (request: IncomingMessage) => {
),
with: {
organization: true,
role: true,
},
});
@@ -363,7 +380,7 @@ export const validateRequest = async (request: IncomingMessage) => {
createdAt,
updatedAt,
twoFactorEnabled,
role: member?.role || "member",
role: member?.role,
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
},
};
@@ -392,6 +409,15 @@ export const validateRequest = async (request: IncomingMessage) => {
};
}
const mockSession = {
session: {
...session.session,
},
user: {
...session.user,
ownerId: session.user.ownerId,
},
};
if (session?.user) {
const member = await db.query.member.findFirst({
where: and(
@@ -402,17 +428,21 @@ export const validateRequest = async (request: IncomingMessage) => {
),
),
with: {
role: true,
organization: true,
},
});
session.user.role = member?.role || "member";
if (member?.role) {
mockSession.user.role = member.role;
}
if (member) {
session.user.ownerId = member.organization.ownerId;
mockSession.user.ownerId = member.organization.ownerId;
} else {
session.user.ownerId = session.user.id;
mockSession.user.ownerId = session.user.id;
}
}
return session;
return mockSession;
};

View File

@@ -0,0 +1,131 @@
export const PERMISSIONS = {
PROJECT: {
VIEW: {
name: "project:view",
description: "View projects",
},
CREATE: {
name: "project:create",
description: "Create projects",
},
DELETE: {
name: "project:delete",
description: "Delete projects",
},
},
SERVICE: {
VIEW: {
name: "service:view",
description: "View services",
},
CREATE: {
name: "service:create",
description: "Create services",
},
DELETE: {
name: "service:delete",
description: "Delete services",
},
},
TRAEFIK: {
ACCESS: {
name: "traefik_files:access",
description: "Access traefik files",
},
},
DOCKER: {
VIEW: {
name: "docker:view",
description: "View docker",
},
},
API: {
ACCESS: {
name: "api:access",
description: "Access API",
},
},
SCHEDULES: {
ACCESS: {
name: "schedules:access",
description: "Access schedules",
},
},
GIT_PROVIDERS: {
ACCESS: {
name: "git_providers:access",
description: "Access git providers",
},
},
SSH_KEYS: {
ACCESS: {
name: "ssh_keys:access",
description: "Access ssh keys",
},
},
} as const;
export const ownerPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
PERMISSIONS.GIT_PROVIDERS.ACCESS,
PERMISSIONS.SSH_KEYS.ACCESS,
] as const;
export const adminPermissions = [
PERMISSIONS.PROJECT.VIEW,
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.PROJECT.DELETE,
PERMISSIONS.SERVICE.VIEW,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.SERVICE.DELETE,
PERMISSIONS.TRAEFIK.ACCESS,
PERMISSIONS.DOCKER.VIEW,
PERMISSIONS.API.ACCESS,
PERMISSIONS.SCHEDULES.ACCESS,
PERMISSIONS.GIT_PROVIDERS.ACCESS,
PERMISSIONS.SSH_KEYS.ACCESS,
] as const;
export const memberPermissions = [
PERMISSIONS.PROJECT.CREATE,
PERMISSIONS.SERVICE.CREATE,
PERMISSIONS.TRAEFIK.ACCESS,
] as const;
export const defaultPermissions = [
{
name: "owner",
description: "Owner of the organization with full access to all features",
permissions: ownerPermissions,
},
{
name: "admin",
description:
"Administrator with access to manage projects, services and configurations",
permissions: adminPermissions,
},
{
name: "member",
description:
"Regular member with access to create projects and manage services",
permissions: memberPermissions,
},
] as const;
// Utility type to extract all permission names
type ExtractPermissionNames<T> = T extends { name: infer U }
? U
: T extends object
? {
[K in keyof T]: ExtractPermissionNames<T[K]>;
}[keyof T]
: never;
export type PermissionName = ExtractPermissionNames<typeof PERMISSIONS>;

View File

@@ -3,6 +3,7 @@ import {
invitation,
member,
organization,
role,
users,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
@@ -13,9 +14,6 @@ import { findWebServer } from "./web-server";
export const findUserById = async (userId: string) => {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
// with: {
// account: true,
// },
});
if (!user) {
throw new TRPCError({
@@ -37,8 +35,12 @@ export const findOrganizationById = async (organizationId: string) => {
};
export const isAdminPresent = async () => {
const ownerRole = await db.query.role.findFirst({
where: eq(role.name, "owner"),
});
const admin = await db.query.member.findFirst({
where: eq(member.role, "owner"),
where: eq(member.roleId, ownerRole?.roleId || ""),
});
if (!admin) {
@@ -48,8 +50,12 @@ export const isAdminPresent = async () => {
};
export const findOwner = async () => {
const ownerRole = await db.query.role.findFirst({
where: eq(role.name, "owner"),
});
const owner = await db.query.member.findFirst({
where: eq(member.role, "owner"),
where: eq(member.roleId, ownerRole?.roleId || ""),
with: {
user: true,
},

View File

@@ -1,15 +1,17 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import {
adminPermissions,
type createRoleSchema,
member,
memberPermissions,
ownerPermissions,
role,
type updateRoleSchema,
} from "../db/schema";
import type { z } from "zod";
import {
adminPermissions,
memberPermissions,
ownerPermissions,
} from "../lib/permissions";
export const createRole = async (
input: z.infer<typeof createRoleSchema>,

View File

@@ -3,6 +3,7 @@ import { apikey, member, users } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { auth } from "../lib/auth";
import { PERMISSIONS } from "../lib/permissions";
export type User = typeof users.$inferSelect;
@@ -44,13 +45,16 @@ export const canPerformCreationService = async (
projectId: string,
organizationId: string,
) => {
const { accessedProjects, canCreateServices } = await findMemberById(
const { accessedProjects, role } = await findMemberById(
userId,
organizationId,
);
const haveAccessToProject = accessedProjects.includes(projectId);
if (canCreateServices && haveAccessToProject) {
if (
role?.permissions?.includes(PERMISSIONS.SERVICE.CREATE.name) &&
haveAccessToProject
) {
return true;
}
@@ -77,13 +81,16 @@ export const canPeformDeleteService = async (
serviceId: string,
organizationId: string,
) => {
const { accessedServices, canDeleteServices } = await findMemberById(
const { accessedServices, role } = await findMemberById(
userId,
organizationId,
);
const haveAccessToService = accessedServices.includes(serviceId);
if (canDeleteServices && haveAccessToService) {
if (
role?.permissions?.includes(PERMISSIONS.SERVICE.DELETE.name) &&
haveAccessToService
) {
return true;
}
@@ -94,9 +101,9 @@ export const canPerformCreationProject = async (
userId: string,
organizationId: string,
) => {
const { canCreateProjects } = await findMemberById(userId, organizationId);
const { role } = await findMemberById(userId, organizationId);
if (canCreateProjects) {
if (role?.permissions?.includes(PERMISSIONS.PROJECT.CREATE.name)) {
return true;
}
@@ -107,9 +114,9 @@ export const canPerformDeleteProject = async (
userId: string,
organizationId: string,
) => {
const { canDeleteProjects } = await findMemberById(userId, organizationId);
const { role } = await findMemberById(userId, organizationId);
if (canDeleteProjects) {
if (role?.permissions?.includes(PERMISSIONS.PROJECT.DELETE.name)) {
return true;
}
@@ -135,11 +142,8 @@ export const canAccessToTraefikFiles = async (
userId: string,
organizationId: string,
) => {
const { canAccessToTraefikFiles } = await findMemberById(
userId,
organizationId,
);
return canAccessToTraefikFiles;
const { role } = await findMemberById(userId, organizationId);
return role?.permissions?.includes(PERMISSIONS.TRAEFIK.ACCESS.name);
};
export const checkServiceAccess = async (
@@ -183,7 +187,7 @@ export const checkServiceAccess = async (
};
export const checkProjectAccess = async (
authId: string,
userId: string,
action: "create" | "delete" | "access",
organizationId: string,
projectId?: string,
@@ -192,16 +196,16 @@ export const checkProjectAccess = async (
switch (action) {
case "access":
hasPermission = await canPerformAccessProject(
authId,
userId,
projectId as string,
organizationId,
);
break;
case "create":
hasPermission = await canPerformCreationProject(authId, organizationId);
hasPermission = await canPerformCreationProject(userId, organizationId);
break;
case "delete":
hasPermission = await canPerformDeleteProject(authId, organizationId);
hasPermission = await canPerformDeleteProject(userId, organizationId);
break;
default:
hasPermission = false;
@@ -225,6 +229,7 @@ export const findMemberById = async (
),
with: {
user: true,
role: true,
},
});