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:
HarikrishnanD
2025-09-12 17:56:02 +05:30
parent 24729f35ec
commit 30b28afbac
11 changed files with 13275 additions and 23 deletions
@@ -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
+14
View File
@@ -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
} }
] ]
} }
+10 -3
View File
@@ -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}`,
+3
View File
@@ -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()
+1
View File
@@ -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();
+40
View File
@@ -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,