feat(tags): enhance tag management with permission checks

- Integrated user permissions for tag creation, updating, and deletion in the TagManager component.
- Updated API routes to enforce permission checks for tag operations.
- Added new permissions for managing tags in the roles configuration.
- Improved error handling for unauthorized access in tag-related operations.
This commit is contained in:
Mauricio Siu
2026-03-19 01:27:54 -06:00
parent aca1c6f621
commit fff91157c4
6 changed files with 127 additions and 40 deletions

View File

@@ -18,6 +18,7 @@ export const TagManager = () => {
const { data: tags, isPending } = api.tag.all.useQuery();
const { mutateAsync: deleteTag, isPending: isRemoving } =
api.tag.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
return (
<div className="w-full">
@@ -47,7 +48,7 @@ export const TagManager = () => {
No tags yet. Create your first tag to start organizing
projects.
</span>
<HandleTag />
{permissions?.tag.create && <HandleTag />}
</div>
) : (
<div className="flex flex-col gap-4 min-h-[25vh]">
@@ -70,8 +71,10 @@ export const TagManager = () => {
)}
</div>
<div className="flex flex-row gap-1 items-center">
<HandleTag tagId={tag.tagId} />
<DialogAction
{permissions?.tag.update && (
<HandleTag tagId={tag.tagId} />
)}
{permissions?.tag.delete && (<DialogAction
title="Delete Tag"
description={`Are you sure you want to delete the tag "${tag.name}"? This will remove the tag from all projects. This action cannot be undone.`}
type="destructive"
@@ -101,15 +104,18 @@ export const TagManager = () => {
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleTag />
</div>
{permissions?.tag.create && (
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
<HandleTag />
</div>
)}
</div>
)}
</>

View File

@@ -143,6 +143,10 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage notification providers (Slack, Discord, Telegram, etc.)",
},
tag: {
label: "Tags",
description: "Manage tags to organize and categorize projects",
},
member: {
label: "Users",
description: "Manage organization members, invitations, and roles",
@@ -379,6 +383,12 @@ const ACTION_META: Record<
},
delete: { label: "Delete", description: "Remove notification providers" },
},
tag: {
read: { label: "Read", description: "View tags" },
create: { label: "Create", description: "Create new tags" },
update: { label: "Update", description: "Edit existing tags" },
delete: { label: "Delete", description: "Delete tags" },
},
member: {
read: {
label: "Read",
@@ -447,6 +457,7 @@ const ROLE_PRESETS: {
domain: ["read"],
destination: ["read"],
notification: ["read"],
tag: ["read"],
member: ["read"],
logs: ["read"],
monitoring: ["read"],
@@ -515,6 +526,7 @@ const ROLE_PRESETS: {
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "delete"],
tag: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],

View File

@@ -656,6 +656,18 @@ export const settingsRouter = createTRPCRouter({
"github",
"gitlab",
"gitea",
"tag",
"patch",
"server",
"volumeBackups",
"environment",
"auditLog",
"customRole",
"whitelabeling",
"sso",
"licenseKey",
"organization",
"previewDeployment",
],
});

View File

@@ -1,3 +1,4 @@
import { findMemberByUserId } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
@@ -11,10 +12,10 @@ import {
projectTags,
tags,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const tagRouter = createTRPCRouter({
create: protectedProcedure
create: withPermission("tag", "create")
.input(apiCreateTag)
.mutation(async ({ input, ctx }) => {
try {
@@ -46,7 +47,7 @@ export const tagRouter = createTRPCRouter({
}
}),
all: protectedProcedure.query(async ({ ctx }) => {
all: withPermission("tag", "read").query(async ({ ctx }) => {
try {
const organizationTags = await db.query.tags.findMany({
where: eq(tags.organizationId, ctx.session.activeOrganizationId),
@@ -63,36 +64,38 @@ export const tagRouter = createTRPCRouter({
}
}),
one: protectedProcedure.input(apiFindOneTag).query(async ({ input, ctx }) => {
try {
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
one: withPermission("tag", "read")
.input(apiFindOneTag)
.query(async ({ input, ctx }) => {
try {
const tag = await db.query.tags.findFirst({
where: and(
eq(tags.tagId, input.tagId),
eq(tags.organizationId, ctx.session.activeOrganizationId),
),
});
if (!tag) {
if (!tag) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found",
});
}
return tag;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "NOT_FOUND",
message: "Tag not found",
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
return tag;
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error fetching tag: ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
}),
update: protectedProcedure
update: withPermission("tag", "update")
.input(apiUpdateTag)
.mutation(async ({ input, ctx }) => {
try {
@@ -142,7 +145,7 @@ export const tagRouter = createTRPCRouter({
}
}),
remove: protectedProcedure
remove: withPermission("tag", "delete")
.input(apiRemoveTag)
.mutation(async ({ input, ctx }) => {
try {
@@ -186,6 +189,11 @@ export const tagRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
const memberRecord = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
@@ -202,6 +210,18 @@ export const tagRouter = createTRPCRouter({
});
}
// Verify the member has access to the project
if (
memberRecord.role !== "owner" &&
memberRecord.role !== "admin" &&
!memberRecord.accessedProjects.includes(input.projectId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
// Verify the tag belongs to the user's organization
const tag = await db.query.tags.findFirst({
where: and(
@@ -257,6 +277,11 @@ export const tagRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
const memberRecord = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
@@ -273,6 +298,18 @@ export const tagRouter = createTRPCRouter({
});
}
// Verify the member has access to the project
if (
memberRecord.role !== "owner" &&
memberRecord.role !== "admin" &&
!memberRecord.accessedProjects.includes(input.projectId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
// Verify the tag belongs to the user's organization
const tag = await db.query.tags.findFirst({
where: and(
@@ -320,6 +357,11 @@ export const tagRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
const memberRecord = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Verify the project belongs to the user's organization
const project = await db.query.projects.findFirst({
where: and(
@@ -336,6 +378,18 @@ export const tagRouter = createTRPCRouter({
});
}
// Verify the member has access to the project
if (
memberRecord.role !== "owner" &&
memberRecord.role !== "admin" &&
!memberRecord.accessedProjects.includes(input.projectId)
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
// Verify all tags belong to the user's organization
if (input.tagIds.length > 0) {
const tagCount = await db.query.tags.findMany({

View File

@@ -84,11 +84,9 @@ export const apiCreateTag = createSchema.pick({
color: true,
});
export const apiFindOneTag = createSchema
.pick({
tagId: true,
})
.required();
export const apiFindOneTag = z.object({
tagId: z.string().min(1),
});
export const apiRemoveTag = createSchema
.pick({

View File

@@ -44,6 +44,7 @@ export const statements = {
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
tag: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
@@ -69,6 +70,7 @@ export const enterpriseOnlyResources = new Set<string>([
"domain",
"destination",
"notification",
"tag",
"logs",
"monitoring",
"auditLog",
@@ -107,6 +109,7 @@ export const ownerRole = ac.newRole({
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
tag: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
@@ -143,6 +146,7 @@ export const adminRole = ac.newRole({
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
tag: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
@@ -186,5 +190,6 @@ export const memberRole = ac.newRole({
certificate: [],
destination: [],
notification: [],
tag: ["read"],
auditLog: [],
});