feat: add accessedEnvironments field to user and member schemas, enhancing permission management for environment access

This commit is contained in:
Mauricio Siu
2025-09-05 00:13:04 -06:00
parent 42548f310e
commit 16c37c3ceb
9 changed files with 6960 additions and 100 deletions

View File

@@ -155,6 +155,7 @@ export const extractServices = (data: Environment | undefined) => {
const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false),
@@ -200,6 +201,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
if (data) {
form.reset({
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices,
@@ -223,6 +225,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
canDeleteProjects: data.canDeleteProjects,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [],
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
@@ -456,91 +459,317 @@ export const AddUserPermissions = ({ userId }: Props) => {
No projects found
</p>
)}
<div className="grid md:grid-cols-2 gap-4">
{projects?.map((item, index) => {
const applications = item.environments.flatMap((env) =>
extractServices(env),
);
<div className="grid md:grid-cols-1 gap-4">
{projects?.map((project, projectIndex) => {
return (
<FormField
key={`project-${index}`}
key={`project-${projectIndex}`}
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"
key={project.projectId}
className="flex flex-col items-start rounded-lg p-4 border"
>
<div className="flex flex-row gap-4">
{/* Project Header */}
<div className="flex flex-row gap-4 items-center w-full">
<FormControl>
<Checkbox
checked={field.value?.includes(
item.projectId,
project.projectId,
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
item.projectId,
])
: field.onChange(
field.value?.filter(
(value) =>
value !== item.projectId,
),
if (checked) {
// Add the project
field.onChange([
...(field.value || []),
project.projectId,
]);
} else {
// Remove the project
field.onChange(
field.value?.filter(
(value) =>
value !== project.projectId,
),
);
// Also remove all environments and services from this project
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
const currentServices =
form.getValues(
"accessedServices",
) || [];
// Get all environment IDs from this project
const projectEnvIds =
project.environments.map(
(env) => env.environmentId,
);
// Get all service IDs from this project
const projectServiceIds =
project.environments.flatMap(
(env) =>
extractServices(env).map(
(service) => service.id,
),
);
// Remove environments and services from this project
form.setValue(
"accessedEnvironments",
currentEnvs.filter(
(envId) =>
!projectEnvIds.includes(envId),
),
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!projectServiceIds.includes(
serviceId,
),
),
);
}
}}
/>
</FormControl>
<FormLabel className="text-sm font-medium text-primary">
{item.name}
<FormLabel className="text-base font-semibold text-primary">
{project.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 }) => {
{/* Environments */}
<div className="ml-6 w-full space-y-3">
{project.environments.length === 0 && (
<p className="text-sm text-muted-foreground">
No environments found
</p>
)}
{project.environments.map(
(environment, envIndex) => {
const services =
extractServices(environment);
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
<div
key={`env-${envIndex}`}
className="border-l-2 border-muted pl-4"
>
<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,
),
{/* Environment Header with Checkbox */}
<FormField
key={`env-${envIndex}`}
control={form.control}
name="accessedEnvironments"
render={({ field: envField }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 mb-2">
<FormControl>
<Checkbox
checked={envField.value?.includes(
environment.environmentId,
)}
onCheckedChange={(
checked,
) => {
if (checked) {
// Add the environment
envField.onChange([
...(envField.value ||
[]),
environment.environmentId,
]);
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the environment
envField.onChange(
envField.value?.filter(
(value) =>
value !==
environment.environmentId,
),
);
// Also remove all services from this environment
const currentServices =
form.getValues(
"accessedServices",
) || [];
const environmentServiceIds =
services.map(
(service) =>
service.id,
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!environmentServiceIds.includes(
serviceId,
),
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<FormLabel className="text-sm font-medium text-foreground cursor-pointer">
{environment.name}
</FormLabel>
<span className="text-xs text-muted-foreground">
({services.length} services)
</span>
</div>
</FormItem>
)}
/>
{/* Services */}
<div className="ml-4 space-y-2">
{services.length === 0 && (
<p className="text-xs text-muted-foreground">
No services found
</p>
)}
{services.map(
(service, serviceIndex) => (
<FormField
key={`service-${serviceIndex}`}
control={form.control}
name="accessedServices"
render={({
field: serviceField,
}) => {
return (
<FormItem
key={service.id}
className="flex flex-row items-center space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={serviceField.value?.includes(
service.id,
)}
onCheckedChange={(
checked,
) => {
if (checked) {
// Add the service
serviceField.onChange(
[
...(serviceField.value ||
[]),
service.id,
],
);
// Auto-select the environment if not already selected
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
if (
!currentEnvs.includes(
environment.environmentId,
)
) {
form.setValue(
"accessedEnvironments",
[
...currentEnvs,
environment.environmentId,
],
);
}
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the service
serviceField.onChange(
serviceField.value?.filter(
(value) =>
value !==
service.id,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div
className={`w-1.5 h-1.5 rounded-full ${
service.type ===
"application"
? "bg-green-500"
: service.type ===
"compose"
? "bg-purple-500"
: "bg-orange-500"
}`}
/>
<FormLabel className="text-sm text-muted-foreground cursor-pointer">
{service.name}
</FormLabel>
<span className="text-xs text-muted-foreground/70 capitalize">
({service.type})
</span>
</div>
</FormItem>
);
}}
/>
</FormControl>
<FormLabel className="text-sm text-muted-foreground">
{item.name}
</FormLabel>
</FormItem>
}}
/>
),
)}
</div>
</div>
);
}}
/>
))}
},
)}
</div>
</FormItem>
);
}}

View File

@@ -0,0 +1 @@
ALTER TABLE "member" ADD COLUMN "accessedEnvironments" text[] DEFAULT ARRAY[]::text[] NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -764,6 +764,13 @@
"when": 1756955718127,
"tag": "0108_lazy_next_avengers",
"breakpoints": true
},
{
"idx": 109,
"version": "7",
"when": 1757052053574,
"tag": "0109_remarkable_sauron",
"breakpoints": true
}
]
}

View File

@@ -786,40 +786,43 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
</ProjectEnvironment>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Create Service
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2"
align="end"
>
<DropdownMenuLabel className="text-sm font-normal">
Actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
<AddApplication
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddDatabase
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddCompose
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddTemplate environmentId={environmentId} />
<AddAiAssistant
projectName={projectData?.name}
environmentId={environmentId}
/>
</DropdownMenuContent>
</DropdownMenu>
{auth?.role === "owner" ||
(auth?.canCreateServices && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Create Service
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2"
align="end"
>
<DropdownMenuLabel className="text-sm font-normal">
Actions
</DropdownMenuLabel>
<DropdownMenuSeparator />
<AddApplication
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddDatabase
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddCompose
projectName={projectData?.name}
environmentId={environmentId}
/>
<AddTemplate environmentId={environmentId} />
<AddAiAssistant
projectName={projectData?.name}
environmentId={environmentId}
/>
</DropdownMenuContent>
</DropdownMenu>
))}
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import {
duplicateEnvironment,
findEnvironmentById,
findEnvironmentsByProjectId,
findMemberById,
updateEnvironmentById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -51,6 +52,48 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment",
});
}
// Check environment access and filter services for members
if (ctx.user.role === "member") {
const { accessedEnvironments, accessedServices } =
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to access this environment",
});
}
// Filter services based on member permissions
const filteredEnvironment = {
...environment,
applications: environment.applications.filter((app) =>
accessedServices.includes(app.applicationId),
),
mariadb: environment.mariadb.filter((db) =>
accessedServices.includes(db.mariadbId),
),
mongo: environment.mongo.filter((db) =>
accessedServices.includes(db.mongoId),
),
mysql: environment.mysql.filter((db) =>
accessedServices.includes(db.mysqlId),
),
postgres: environment.postgres.filter((db) =>
accessedServices.includes(db.postgresId),
),
redis: environment.redis.filter((db) =>
accessedServices.includes(db.redisId),
),
compose: environment.compose.filter((comp) =>
accessedServices.includes(comp.composeId),
),
};
return filteredEnvironment;
}
return environment;
} catch (error) {
throw new TRPCError({
@@ -65,6 +108,8 @@ export const environmentRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
try {
const environments = await findEnvironmentsByProjectId(input.projectId);
// Check organization access
if (
environments.some(
(environment) =>
@@ -77,6 +122,22 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment",
});
}
// Filter environments for members based on their permissions
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Filter environments to only show those the member has access to
const filteredEnvironments = environments.filter((environment) =>
accessedEnvironments.includes(environment.environmentId),
);
return filteredEnvironments;
}
return environments;
} catch (error) {
throw new TRPCError({
@@ -100,6 +161,22 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment",
});
}
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to delete this environment",
});
}
}
const deletedEnvironment = await deleteEnvironment(input.environmentId);
return deletedEnvironment;
} catch (error) {
@@ -125,6 +202,24 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment",
});
}
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (
!accessedEnvironments.includes(currentEnvironment.environmentId)
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to update this environment",
});
}
}
const environment = await updateEnvironmentById(
environmentId,
updateData,
@@ -152,6 +247,22 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment",
});
}
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to duplicate this environment",
});
}
}
const duplicatedEnvironment = await duplicateEnvironment(input);
return duplicatedEnvironment;
} catch (error) {

View File

@@ -44,6 +44,7 @@ import {
apiUpdateProject,
applications,
compose,
environments,
mariadb,
mongo,
mysql,
@@ -178,15 +179,22 @@ export const projectRouter = createTRPCRouter({
}),
all: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role === "member") {
const { accessedProjects, accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
const { accessedProjects, accessedEnvironments, accessedServices } =
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
if (accessedProjects.length === 0) {
return [];
}
// Build environment filter
const environmentFilter =
accessedEnvironments.length === 0
? sql`false`
: sql`${environments.environmentId} IN (${sql.join(
accessedEnvironments.map((envId) => sql`${envId}`),
sql`, `,
)})`;
return await db.query.projects.findMany({
where: and(
sql`${projects.projectId} IN (${sql.join(
@@ -197,6 +205,7 @@ export const projectRouter = createTRPCRouter({
),
with: {
environments: {
where: environmentFilter,
with: {
applications: {
where: buildServiceFilter(

View File

@@ -112,6 +112,10 @@ export const member = pgTable("member", {
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
accessedEnvironments: text("accessedEnvironments")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
accessedServices: text("accesedServices")
.array()
.notNull()

View File

@@ -175,6 +175,7 @@ export const apiAssignPermissions = createSchema
})
.extend({
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional(),
canCreateServices: z.boolean().optional(),