mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-13 15:35:17 +00:00
feat: add canCreateEnvironments permission for environment creation - Add database field and API validation - Implement permission checking in environment creation - Add UI toggle in user permissions modal - Hide create button for unauthorized users Fixes #2593
This commit is contained in:
@@ -71,6 +71,14 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get current user's permissions
|
||||||
|
const { data: currentUser } = api.user.get.useQuery();
|
||||||
|
|
||||||
|
// Check if user can create environments
|
||||||
|
const canCreateEnvironments = currentUser?.role === "owner" ||
|
||||||
|
currentUser?.role === "admin" ||
|
||||||
|
currentUser?.canCreateEnvironments === true;
|
||||||
|
|
||||||
const haveServices =
|
const haveServices =
|
||||||
selectedEnvironment &&
|
selectedEnvironment &&
|
||||||
((selectedEnvironment?.mariadb?.length || 0) > 0 ||
|
((selectedEnvironment?.mariadb?.length || 0) > 0 ||
|
||||||
@@ -285,13 +293,15 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
{canCreateEnvironments && (
|
||||||
className="cursor-pointer"
|
<DropdownMenuItem
|
||||||
onClick={() => setIsCreateDialogOpen(true)}
|
className="cursor-pointer"
|
||||||
>
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
>
|
||||||
Create Environment
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
</DropdownMenuItem>
|
Create Environment
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { findEnvironmentById } from "@dokploy/server/index";
|
import type { findEnvironmentById } from "@dokploy/server/index";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -166,6 +166,7 @@ const addPermissions = z.object({
|
|||||||
canAccessToAPI: z.boolean().optional().default(false),
|
canAccessToAPI: z.boolean().optional().default(false),
|
||||||
canAccessToSSHKeys: z.boolean().optional().default(false),
|
canAccessToSSHKeys: z.boolean().optional().default(false),
|
||||||
canAccessToGitProviders: z.boolean().optional().default(false),
|
canAccessToGitProviders: z.boolean().optional().default(false),
|
||||||
|
canCreateEnvironments: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddPermissions = z.infer<typeof addPermissions>;
|
type AddPermissions = z.infer<typeof addPermissions>;
|
||||||
@@ -175,6 +176,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AddUserPermissions = ({ userId }: Props) => {
|
export const AddUserPermissions = ({ userId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: projects } = api.project.all.useQuery();
|
const { data: projects } = api.project.all.useQuery();
|
||||||
|
|
||||||
const { data, refetch } = api.user.one.useQuery(
|
const { data, refetch } = api.user.one.useQuery(
|
||||||
@@ -192,29 +194,41 @@ export const AddUserPermissions = ({ userId }: Props) => {
|
|||||||
const form = useForm<AddPermissions>({
|
const form = useForm<AddPermissions>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
accessedProjects: [],
|
accessedProjects: [],
|
||||||
|
accessedEnvironments: [],
|
||||||
accessedServices: [],
|
accessedServices: [],
|
||||||
|
canCreateProjects: false,
|
||||||
|
canCreateServices: false,
|
||||||
|
canDeleteProjects: false,
|
||||||
|
canDeleteServices: false,
|
||||||
|
canAccessToTraefikFiles: false,
|
||||||
|
canAccessToDocker: false,
|
||||||
|
canAccessToAPI: false,
|
||||||
|
canAccessToSSHKeys: false,
|
||||||
|
canAccessToGitProviders: false,
|
||||||
|
canCreateEnvironments: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addPermissions),
|
resolver: zodResolver(addPermissions),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data && isOpen) {
|
||||||
form.reset({
|
form.reset({
|
||||||
accessedProjects: data.accessedProjects || [],
|
accessedProjects: data.accessedProjects || [],
|
||||||
accessedEnvironments: data.accessedEnvironments || [],
|
accessedEnvironments: data.accessedEnvironments || [],
|
||||||
accessedServices: data.accessedServices || [],
|
accessedServices: data.accessedServices || [],
|
||||||
canCreateProjects: data.canCreateProjects,
|
canCreateProjects: data.canCreateProjects || false,
|
||||||
canCreateServices: data.canCreateServices,
|
canCreateServices: data.canCreateServices || false,
|
||||||
canDeleteProjects: data.canDeleteProjects,
|
canDeleteProjects: data.canDeleteProjects || false,
|
||||||
canDeleteServices: data.canDeleteServices,
|
canDeleteServices: data.canDeleteServices || false,
|
||||||
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
|
canAccessToTraefikFiles: data.canAccessToTraefikFiles || false,
|
||||||
canAccessToDocker: data.canAccessToDocker,
|
canAccessToDocker: data.canAccessToDocker || false,
|
||||||
canAccessToAPI: data.canAccessToAPI,
|
canAccessToAPI: data.canAccessToAPI || false,
|
||||||
canAccessToSSHKeys: data.canAccessToSSHKeys,
|
canAccessToSSHKeys: data.canAccessToSSHKeys || false,
|
||||||
canAccessToGitProviders: data.canAccessToGitProviders,
|
canAccessToGitProviders: data.canAccessToGitProviders || false,
|
||||||
|
canCreateEnvironments: data.canCreateEnvironments || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.formState.isSubmitSuccessful, form.reset, data]);
|
}, [form, form.reset, data, isOpen]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddPermissions) => {
|
const onSubmit = async (data: AddPermissions) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -231,17 +245,19 @@ export const AddUserPermissions = ({ userId }: Props) => {
|
|||||||
canAccessToAPI: data.canAccessToAPI,
|
canAccessToAPI: data.canAccessToAPI,
|
||||||
canAccessToSSHKeys: data.canAccessToSSHKeys,
|
canAccessToSSHKeys: data.canAccessToSSHKeys,
|
||||||
canAccessToGitProviders: data.canAccessToGitProviders,
|
canAccessToGitProviders: data.canAccessToGitProviders,
|
||||||
|
canCreateEnvironments: data.canCreateEnvironments,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Permissions updated");
|
toast.success("Permissions updated");
|
||||||
refetch();
|
refetch();
|
||||||
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error updating the permissions");
|
toast.error("Error updating the permissions");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
@@ -343,6 +359,26 @@ export const AddUserPermissions = ({ userId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="canCreateEnvironments"
|
||||||
|
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 Environments</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Allow the user to create environments
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="canAccessToTraefikFiles"
|
name="canAccessToTraefikFiles"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "member" ADD COLUMN "canCreateEnvironments" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "member" ADD COLUMN "canCreateEnvironmentsInProjects" text[] DEFAULT ARRAY[]::text[] NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -778,6 +778,20 @@
|
|||||||
"when": 1757189541734,
|
"when": 1757189541734,
|
||||||
"tag": "0110_red_psynapse",
|
"tag": "0110_red_psynapse",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 111,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1757672897316,
|
||||||
|
"tag": "0111_magical_nova",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 112,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1757673510039,
|
||||||
|
"tag": "0112_serious_hellcat",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
addNewEnvironment,
|
addNewEnvironment,
|
||||||
checkEnvironmentAccess,
|
checkEnvironmentAccess,
|
||||||
|
checkEnvironmentCreationPermission,
|
||||||
createEnvironment,
|
createEnvironment,
|
||||||
deleteEnvironment,
|
deleteEnvironment,
|
||||||
duplicateEnvironment,
|
duplicateEnvironment,
|
||||||
@@ -54,9 +55,12 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
.input(apiCreateEnvironment)
|
.input(apiCreateEnvironment)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
// Check if user has access to the project
|
// Check if user has permission to create environments
|
||||||
// This would typically involve checking project ownership/membership
|
await checkEnvironmentCreationPermission(
|
||||||
// For now, we'll use a basic organization check
|
ctx.user.id,
|
||||||
|
input.projectId,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
|
||||||
if (input.name === "production") {
|
if (input.name === "production") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -76,6 +80,9 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return environment;
|
return environment;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: `Error creating the environment: ${error instanceof Error ? error.message : error}`,
|
message: `Error creating the environment: ${error instanceof Error ? error.message : error}`,
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export const member = pgTable("member", {
|
|||||||
canAccessToTraefikFiles: boolean("canAccessToTraefikFiles")
|
canAccessToTraefikFiles: boolean("canAccessToTraefikFiles")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
canCreateEnvironments: boolean("canCreateEnvironments")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
accessedProjects: text("accesedProjects")
|
accessedProjects: text("accesedProjects")
|
||||||
.array()
|
.array()
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export const apiAssignPermissions = createSchema
|
|||||||
canAccessToAPI: z.boolean().optional(),
|
canAccessToAPI: z.boolean().optional(),
|
||||||
canAccessToSSHKeys: z.boolean().optional(),
|
canAccessToSSHKeys: z.boolean().optional(),
|
||||||
canAccessToGitProviders: z.boolean().optional(),
|
canAccessToGitProviders: z.boolean().optional(),
|
||||||
|
canCreateEnvironments: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,46 @@ export const checkProjectAccess = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const checkEnvironmentCreationPermission = async (
|
||||||
|
userId: string,
|
||||||
|
projectId: string,
|
||||||
|
organizationId: string,
|
||||||
|
) => {
|
||||||
|
// Get user's member record
|
||||||
|
const member = await findMemberById(userId, organizationId);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "User not found in organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owners and admins can always create environments
|
||||||
|
if (member.role === "owner" || member.role === "admin") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has canCreateEnvironments permission
|
||||||
|
if (!member.canCreateEnvironments) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have permission to create environments",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to the project
|
||||||
|
const hasProjectAccess = member.accessedProjects.includes(projectId);
|
||||||
|
if (!hasProjectAccess) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this project",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const findMemberById = async (
|
export const findMemberById = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user