From 544408886eac37e5ba14880b9483a40a0ff9a7eb Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 02:01:14 +0300 Subject: [PATCH 1/9] feat(permissions): Add multiple admins capability --- .../settings/users/add-invitation.tsx | 1 + apps/dokploy/components/layouts/side.tsx | 64 ++++++++++++++----- apps/dokploy/components/layouts/user-nav.tsx | 10 ++- apps/dokploy/pages/dashboard/schedules.tsx | 2 +- .../pages/dashboard/settings/profile.tsx | 4 +- .../server/api/routers/organization.ts | 6 +- apps/dokploy/server/api/routers/settings.ts | 8 +-- apps/dokploy/server/api/routers/stripe.ts | 16 +++-- apps/dokploy/server/api/routers/user.ts | 6 +- apps/dokploy/server/api/trpc.ts | 12 +++- 10 files changed, 90 insertions(+), 39 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 6e0384554..e778f2e96 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -158,6 +158,7 @@ export const AddInvitation = () => { Member + Admin diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index d1d4ae273..b4806c4fc 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -156,7 +156,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 === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -166,7 +167,9 @@ 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 === "owner" || + auth?.role === "admin" || + auth?.canAccessToTraefikFiles) && !isCloud ), }, @@ -177,7 +180,12 @@ 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 === "owner" || + auth?.role === "admin" || + auth?.canAccessToDocker) && + !isCloud + ), }, { isSingle: true, @@ -186,7 +194,12 @@ 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 === "owner" || + auth?.role === "admin" || + auth?.canAccessToDocker) && + !isCloud + ), }, { isSingle: true, @@ -195,7 +208,12 @@ 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 === "owner" || + auth?.role === "admin" || + auth?.canAccessToDocker) && + !isCloud + ), }, // Legacy unused menu, adjusted to the new structure @@ -262,7 +280,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 === "owner" || auth?.role === "admin") && !isCloud), }, { isSingle: true, @@ -276,7 +295,8 @@ const MENU: Menu = { url: "/dashboard/settings/servers", icon: Server, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -284,7 +304,8 @@ const MENU: Menu = { icon: Users, url: "/dashboard/settings/users", // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -300,7 +321,8 @@ const MENU: Menu = { icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -317,7 +339,8 @@ const MENU: Menu = { url: "/dashboard/settings/registry", icon: Package, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -325,7 +348,8 @@ const MENU: Menu = { url: "/dashboard/settings/destinations", icon: Database, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { @@ -334,7 +358,8 @@ const MENU: Menu = { url: "/dashboard/settings/certificates", icon: ShieldCheck, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -342,7 +367,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 === "owner" || auth?.role === "admin") && !isCloud), }, { isSingle: true, @@ -350,7 +376,8 @@ const MENU: Menu = { url: "/dashboard/settings/notifications", icon: Bell, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -358,7 +385,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 === "owner" || auth?.role === "admin") && isCloud), }, ], @@ -654,7 +682,9 @@ function SidebarLogo() { )} ))} - {(user?.role === "owner" || isCloud) && ( + {(user?.role === "owner" || + user?.role === "admin" || + isCloud) && ( <> @@ -1018,7 +1048,7 @@ export default function Page({ children }: Props) { - {!isCloud && auth?.role === "owner" && ( + {!isCloud && (auth?.role === "owner" || auth?.role === "admin") && ( diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index e476a5f50..07605f08c 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -101,7 +101,9 @@ export const UserNav = () => { > Monitoring - {(data?.role === "owner" || data?.canAccessToTraefikFiles) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToTraefikFiles) && ( { @@ -111,7 +113,9 @@ export const UserNav = () => { Traefik )} - {(data?.role === "owner" || data?.canAccessToDocker) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToDocker) && ( { @@ -125,7 +129,7 @@ export const UserNav = () => { )} ) : ( - data?.role === "owner" && ( + (data?.role === "owner" || data?.role === "admin") && ( { diff --git a/apps/dokploy/pages/dashboard/schedules.tsx b/apps/dokploy/pages/dashboard/schedules.tsx index 17d04b29a..113b079e8 100644 --- a/apps/dokploy/pages/dashboard/schedules.tsx +++ b/apps/dokploy/pages/dashboard/schedules.tsx @@ -40,7 +40,7 @@ export async function getServerSideProps( }; } const { user } = await validateRequest(ctx.req); - if (!user || user.role !== "owner") { + if (!user || (user.role !== "owner" && user.role !== "admin")) { return { redirect: { permanent: true, diff --git a/apps/dokploy/pages/dashboard/settings/profile.tsx b/apps/dokploy/pages/dashboard/settings/profile.tsx index 90cc345e4..34f8126e4 100644 --- a/apps/dokploy/pages/dashboard/settings/profile.tsx +++ b/apps/dokploy/pages/dashboard/settings/profile.tsx @@ -18,7 +18,9 @@ const Page = () => {
- {(data?.canAccessToAPI || data?.role === "owner") && } + {(data?.canAccessToAPI || + data?.role === "owner" || + data?.role === "admin") && } {/* {isCloud && } */}
diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index a015310d1..0f3d1c82e 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "owner" && !IS_CLOUD) { + if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the organization owner can create an organization", @@ -86,7 +86,7 @@ export const organizationRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "owner" && !IS_CLOUD) { + if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the organization owner can update it", @@ -109,7 +109,7 @@ export const organizationRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "owner" && !IS_CLOUD) { + if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the organization owner can delete it", diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 02678b990..dc084a735 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -201,7 +201,7 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } - await updateUser(ctx.user.id, { + await updateUser(ctx.user.ownerId, { sshPrivateKey: input.sshPrivateKey, }); @@ -213,7 +213,7 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } - const user = await updateUser(ctx.user.id, { + const user = await updateUser(ctx.user.ownerId, { host: input.host, ...(input.letsEncryptEmail && { letsEncryptEmail: input.letsEncryptEmail, @@ -240,7 +240,7 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } - await updateUser(ctx.user.id, { + await updateUser(ctx.user.ownerId, { sshPrivateKey: null, }); return true; @@ -300,7 +300,7 @@ export const settingsRouter = createTRPCRouter({ } } } else if (!IS_CLOUD) { - const userUpdated = await updateUser(ctx.user.id, { + const userUpdated = await updateUser(ctx.user.ownerId, { enableDockerCleanup: input.enableDockerCleanup, }); diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index 288924436..d2a000324 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -56,15 +56,16 @@ export const stripeRouter = createTRPCRouter({ }); const items = getStripeItems(input.serverQuantity, input.isAnnual); - const user = await findUserById(ctx.user.id); + // Always operate on the organization owner's Stripe customer + const owner = await findUserById(ctx.user.ownerId); - let stripeCustomerId = user.stripeCustomerId; + let stripeCustomerId = owner.stripeCustomerId; if (stripeCustomerId) { const customer = await stripe.customers.retrieve(stripeCustomerId); if (customer.deleted) { - await updateUser(user.id, { + await updateUser(owner.id, { stripeCustomerId: null, }); stripeCustomerId = null; @@ -78,7 +79,7 @@ export const stripeRouter = createTRPCRouter({ customer: stripeCustomerId, }), metadata: { - adminId: user.id, + adminId: owner.id, }, allow_promotion_codes: true, success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`, @@ -88,15 +89,16 @@ export const stripeRouter = createTRPCRouter({ return { sessionId: session.id }; }), createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => { - const user = await findUserById(ctx.user.id); + // Use the organization's owner account for billing portal + const owner = await findUserById(ctx.user.ownerId); - if (!user.stripeCustomerId) { + if (!owner.stripeCustomerId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Stripe Customer ID not found", }); } - const stripeCustomerId = user.stripeCustomerId; + const stripeCustomerId = owner.stripeCustomerId; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-09-30.acacia", diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 2e7c7a0c5..fb113f566 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -86,7 +86,11 @@ export const userRouter = createTRPCRouter({ // Allow access if: // 1. User is requesting their own information // 2. User has owner role (admin permissions) AND user is in the same organization - if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") { + if ( + memberResult.userId !== ctx.user.id && + ctx.user.role !== "owner" && + ctx.user.role !== "admin" + ) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized to access this user", diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index c99f9104d..7b4e2e3f0 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -183,7 +183,11 @@ 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 !== "owner" && ctx.user.role !== "admin") + ) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ @@ -197,7 +201,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 !== "owner" && ctx.user.role !== "admin") + ) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ From 95bf60ac75729f023bdd3c0a71ea8649cbdb7d77 Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 02:20:28 +0300 Subject: [PATCH 2/9] fix(template): space for correct checkbox displaying --- .github/pull_request_template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0b849afc0..d45c3dac0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about. Before submitting this PR, please make sure that: -- [] You created a dedicated branch based on the `canary` branch. -- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request -- [] You have tested this PR in your local instance. +- [ ] You created a dedicated branch based on the `canary` branch. +- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request +- [ ] You have tested this PR in your local instance. ## Issues related (if applicable) From a47a5f3b9ef32d2949d63566dee5f6db16d14b47 Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 16:36:22 +0300 Subject: [PATCH 3/9] feat(permissions): Forbid admins to delete themselves and add protections to the route --- .../dashboard/settings/users/show-users.tsx | 157 +++++++++--------- .../server/api/routers/organization.ts | 40 +++++ apps/dokploy/server/api/routers/user.ts | 43 ++++- 3 files changed, 160 insertions(+), 80 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 51d8704a3..e2eab13d1 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -35,7 +35,10 @@ export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isLoading, refetch } = api.user.all.useQuery(); const { mutateAsync } = api.user.remove.useMutation(); + const { mutateAsync: removeMember } = + api.organization.removeMember.useMutation(); const utils = api.useUtils(); + const { data: session } = authClient.useSession(); return (
@@ -134,55 +137,14 @@ export const ShowUsers = () => { {member.role !== "owner" && ( <> - {!isCloud && ( - { - await mutateAsync({ - userId: member.user.id, - }) - .then(() => { - toast.success( - "User deleted successfully", - ); - refetch(); - }) - .catch(() => { - toast.error( - "Error deleting destination", - ); - }); - }} - > - - e.preventDefault() - } - > - Delete User - - - )} - - { - if (!isCloud) { - const orgCount = - await utils.user.checkUserOrganizations.fetch( - { - userId: member.user.id, - }, - ); - - console.log(orgCount); - - if (orgCount === 1) { + {!isCloud && + member.user.id !== + session?.user?.id && ( + { await mutateAsync({ userId: member.user.id, }) @@ -192,41 +154,78 @@ export const ShowUsers = () => { ); refetch(); }) - .catch(() => { + .catch((err) => { toast.error( - "Error deleting user", + err?.message || + "Error deleting user", ); }); - return; + }} + > + + e.preventDefault() + } + > + Delete User + + + )} + + {!( + member.role === "admin" && + member.user.id === session?.user?.id + ) && ( + { + try { + if (!isCloud) { + const orgCount = + await utils.user.checkUserOrganizations.fetch( + { + userId: member.user.id, + }, + ); + if (orgCount === 1) { + await mutateAsync({ + userId: member.user.id, + }); + toast.success( + "User deleted successfully", + ); + refetch(); + return; + } + } + await removeMember({ + memberId: member.id, + }); + toast.success( + "User unlinked successfully", + ); + refetch(); + } catch (error: any) { + toast.error( + error?.message || + "Error unlinking user", + ); } - } - - const { error } = - await authClient.organization.removeMember( - { - memberIdOrEmail: member.id, - }, - ); - - if (!error) { - toast.success( - "User unlinked successfully", - ); - refetch(); - } else { - toast.error( - "Error unlinking user", - ); - } - }} - > - e.preventDefault()} + }} > - Unlink User - - + + e.preventDefault() + } + > + Unlink User + + + )} )} diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 0f3d1c82e..dc815d150 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -184,4 +184,44 @@ export const organizationRouter = createTRPCRouter({ .delete(invitation) .where(eq(invitation.id, input.invitationId)); }), + removeMember: adminProcedure + .input(z.object({ memberId: z.string() })) + .mutation(async ({ ctx, input }) => { + // Fetch the target member within the active organization + const target = await db.query.member.findFirst({ + where: eq(member.id, input.memberId), + with: { user: true }, + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + } + + if (target.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not allowed to remove this member", + }); + } + + // Disallow removing the organization owner + if (target.role === "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You cannot unlink the organization owner", + }); + } + + // Admin self-protection: an admin cannot unlink themselves + if (target.role === "admin" && target.userId === ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Admins cannot unlink themselves. Ask the owner or another admin.", + }); + } + + await db.delete(member).where(eq(member.id, input.memberId)); + return true; + }), }); diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index fb113f566..9072f56c7 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -217,10 +217,51 @@ export const userRouter = createTRPCRouter({ userId: z.string(), }), ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { if (IS_CLOUD) { return true; } + + // Ensure the acting user has admin privileges in the active organization + if (ctx.user.role !== "owner" && ctx.user.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owners or admins can delete users", + }); + } + + // Fetch target member within the active organization + const targetMember = await db.query.member.findFirst({ + where: and( + eq(member.userId, input.userId), + eq(member.organizationId, ctx.session?.activeOrganizationId || ""), + ), + }); + + if (!targetMember) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Target user is not a member of this organization", + }); + } + + // Never allow deleting the organization owner via this endpoint + if (targetMember.role === "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You cannot delete the organization owner", + }); + } + + // Admin self-protection: an admin cannot delete themselves + if (targetMember.role === "admin" && input.userId === ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Admins cannot delete themselves. Ask the owner or another admin.", + }); + } + return await removeUserById(input.userId); }), assignPermissions: adminProcedure From 178ccb3f45938fc405991895d3cd9161e66ae630 Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 16:46:55 +0300 Subject: [PATCH 4/9] feat(ui): Improve UI for admins and owners - Make 3 dots unclickable if there no available actions for an user. - Remove "Add permissions" for admins because they have same permissions as owner --- .../dashboard/settings/users/show-users.tsx | 231 ++++++++++-------- 1 file changed, 124 insertions(+), 107 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index e2eab13d1..e0b3425b6 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -86,6 +86,21 @@ export const ShowUsers = () => { {data?.map((member) => { + const canEditPermissions = member.role === "member"; + const canDelete = + member.role !== "owner" && + !isCloud && + member.user.id !== session?.user?.id; + const canUnlink = + member.role !== "owner" && + !( + member.role === "admin" && + member.user.id === session?.user?.id + ); + + const hasAnyAction = + canEditPermissions || canDelete || canUnlink; + return ( @@ -114,122 +129,124 @@ export const ShowUsers = () => { - - - - - - - Actions - + {hasAnyAction ? ( + + + + + + + Actions + - {member.role !== "owner" && ( - - )} + {canEditPermissions && ( + + )} - {member.role !== "owner" && ( - <> - {!isCloud && - member.user.id !== - session?.user?.id && ( - { - await mutateAsync({ - userId: member.user.id, - }) - .then(() => { - toast.success( - "User deleted successfully", - ); - refetch(); - }) - .catch((err) => { - toast.error( - err?.message || - "Error deleting user", - ); - }); - }} - > - - e.preventDefault() - } - > - Delete User - - - )} - - {!( - member.role === "admin" && - member.user.id === session?.user?.id - ) && ( - { - try { - if (!isCloud) { - const orgCount = - await utils.user.checkUserOrganizations.fetch( - { - userId: member.user.id, - }, - ); - if (orgCount === 1) { - await mutateAsync({ - userId: member.user.id, - }); - toast.success( - "User deleted successfully", - ); - refetch(); - return; - } - } - await removeMember({ - memberId: member.id, - }); + {canDelete && ( + { + await mutateAsync({ + userId: member.user.id, + }) + .then(() => { toast.success( - "User unlinked successfully", + "User deleted successfully", ); refetch(); - } catch (error: any) { + }) + .catch((err) => { toast.error( - error?.message || - "Error unlinking user", + err?.message || + "Error deleting user", ); - } - }} + }); + }} + > + e.preventDefault()} > - - e.preventDefault() + Delete User + + + )} + + {canUnlink && ( + { + try { + if (!isCloud) { + const orgCount = + await utils.user.checkUserOrganizations.fetch( + { + userId: member.user.id, + }, + ); + if (orgCount === 1) { + await mutateAsync({ + userId: member.user.id, + }); + toast.success( + "User deleted successfully", + ); + refetch(); + return; + } } - > - Unlink User - - - )} - - )} - - + await removeMember({ + memberId: member.id, + }); + toast.success( + "User unlinked successfully", + ); + refetch(); + } catch (error: any) { + toast.error( + error?.message || + "Error unlinking user", + ); + } + }} + > + e.preventDefault()} + > + Unlink User + + + )} + + + ) : ( + + )} ); From a9ae39dc941212c66dc0dfed83e4e330e6fdedc8 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 7 Dec 2025 02:25:54 -0600 Subject: [PATCH 5/9] feat(side-menu): update permissions to include admin role for Git provider access --- apps/dokploy/components/layouts/side.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 0b10d8cd2..1a85213f9 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -333,7 +333,11 @@ 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 === "owner" || + auth?.canAccessToGitProviders || + auth?.role === "admin" + ), }, { isSingle: true, From 568293ef3cf77b86af7f183dc2d80bc49b11278c Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 7 Dec 2025 02:32:41 -0600 Subject: [PATCH 6/9] feat(users): implement ChangeRole component for user role management in dashboard --- .../dashboard/settings/users/change-role.tsx | 159 ++++++++++++++++++ .../dashboard/settings/users/show-users.tsx | 31 +++- apps/dokploy/components/layouts/side.tsx | 6 +- .../server/api/routers/organization.ts | 49 ++++++ 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/users/change-role.tsx diff --git a/apps/dokploy/components/dashboard/settings/users/change-role.tsx b/apps/dokploy/components/dashboard/settings/users/change-role.tsx new file mode 100644 index 000000000..93dc4dfc0 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/users/change-role.tsx @@ -0,0 +1,159 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +const changeRoleSchema = z.object({ + role: z.enum(["admin", "member"]), +}); + +type ChangeRoleSchema = z.infer; + +interface Props { + memberId: string; + currentRole: "admin" | "member"; + userEmail: string; +} + +export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const utils = api.useUtils(); + + const { mutateAsync, isError, error, isLoading } = + api.organization.updateMemberRole.useMutation(); + + const form = useForm({ + defaultValues: { + role: currentRole, + }, + resolver: zodResolver(changeRoleSchema), + }); + + useEffect(() => { + if (isOpen) { + form.reset({ + role: currentRole, + }); + } + }, [form, currentRole, isOpen]); + + const onSubmit = async (data: ChangeRoleSchema) => { + await mutateAsync({ + memberId, + role: data.role, + }) + .then(async () => { + toast.success("Role updated successfully"); + await utils.user.all.invalidate(); + setIsOpen(false); + }) + .catch((error) => { + toast.error(error?.message || "Error updating role"); + }); + }; + + return ( + + + e.preventDefault()} + > + Change Role + + + + + Change User Role + + Change the role for {userEmail} + + + {isError && {error?.message}} + +
+ + ( + + Role + + + Admin: Can manage users and settings. +
+ Member: Limited permissions, can be + customized. +
+ + Note: Owner role is intransferible. + +
+ +
+ )} + /> + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 497b4a450..ab0589767 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -29,6 +29,7 @@ import { import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { AddUserPermissions } from "./add-permissions"; +import { ChangeRole } from "./change-role"; export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -84,11 +85,24 @@ export const ShowUsers = () => { {data?.map((member) => { - const canEditPermissions = member.role === "member"; + // Owner never has "Edit Permissions" (they're absolute owner) + // Other users can edit permissions if target is not themselves and target is a member + const canEditPermissions = + member.role !== "owner" && + member.role === "member" && + member.user.id !== session?.user?.id; + + // Can change role if target is not owner and not the current user + // Owner role is intransferible + const canChangeRole = + member.role !== "owner" && + member.user.id !== session?.user?.id; + const canDelete = member.role !== "owner" && !isCloud && member.user.id !== session?.user?.id; + const canUnlink = member.role !== "owner" && !( @@ -97,7 +111,10 @@ export const ShowUsers = () => { ); const hasAnyAction = - canEditPermissions || canDelete || canUnlink; + canEditPermissions || + canChangeRole || + canDelete || + canUnlink; return ( @@ -145,6 +162,16 @@ export const ShowUsers = () => { Actions + {canChangeRole && ( + + )} + {canEditPermissions && ( - !!(auth?.role === "owner" || auth?.canAccessToSSHKeys), + !!( + auth?.role === "owner" || + auth?.canAccessToSSHKeys || + auth?.role === "admin" + ), }, { title: "AI", diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 1f22e1bef..7c26de100 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -234,6 +234,55 @@ export const organizationRouter = createTRPCRouter({ await db.delete(member).where(eq(member.id, input.memberId)); return true; }), + updateMemberRole: adminProcedure + .input( + z.object({ + memberId: z.string(), + role: z.enum(["admin", "member"]), + }), + ) + .mutation(async ({ ctx, input }) => { + // Fetch the target member + const target = await db.query.member.findFirst({ + where: eq(member.id, input.memberId), + with: { user: true }, + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + } + + if (target.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not allowed to update this member's role", + }); + } + + // Prevent users from changing their own role + if (target.userId === ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You cannot change your own role", + }); + } + + // Owner role is intransferible - cannot change to or from owner + if (target.role === "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "The owner role is intransferible", + }); + } + + // Update the target member's role + await db + .update(member) + .set({ role: input.role }) + .where(eq(member.id, input.memberId)); + + return true; + }), setDefault: protectedProcedure .input( z.object({ From 075e387bb647cf275096fcb886418469acc8561c Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 7 Dec 2025 02:38:34 -0600 Subject: [PATCH 7/9] refactor(users): remove member unlinking logic from ShowUsers component and update billing access check for owner role only --- .../dokploy/components/dashboard/settings/users/show-users.tsx | 3 --- apps/dokploy/components/layouts/side.tsx | 3 +-- apps/dokploy/pages/dashboard/settings/billing.tsx | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index ab0589767..203c295da 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -235,9 +235,6 @@ export const ShowUsers = () => { return; } } - await removeMember({ - memberId: member.id, - }); toast.success( "User unlinked successfully", ); diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 57468d072..45b6a7e3a 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -395,8 +395,7 @@ const MENU: Menu = { url: "/dashboard/settings/billing", icon: CreditCard, // Only enabled for admins in cloud environments - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.role === "admin") && isCloud), + isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), }, ], diff --git a/apps/dokploy/pages/dashboard/settings/billing.tsx b/apps/dokploy/pages/dashboard/settings/billing.tsx index 69ab8da28..cb55dadad 100644 --- a/apps/dokploy/pages/dashboard/settings/billing.tsx +++ b/apps/dokploy/pages/dashboard/settings/billing.tsx @@ -30,7 +30,7 @@ export async function getServerSideProps( } const { req, res } = ctx; const { user, session } = await validateRequest(req); - if (!user || user.role === "member") { + if (!user || user.role !== "owner") { return { redirect: { permanent: true, From 6022f2f6a3ba7f3f04fc2ce00c3f007c020ce348 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 7 Dec 2025 02:51:03 -0600 Subject: [PATCH 8/9] refactor(auth): replace findAdmin with findOwner in user management logic and update role-based permissions in the dashboard --- .../dashboard/settings/users/show-users.tsx | 87 ++++++++++++------- apps/dokploy/reset-2fa.ts | 4 +- apps/dokploy/reset-password.ts | 4 +- .../server/api/routers/organization.ts | 50 +++-------- apps/dokploy/server/api/routers/user.ts | 11 ++- packages/server/src/services/admin.ts | 12 +-- .../server/src/utils/access-log/handler.ts | 18 ++-- 7 files changed, 97 insertions(+), 89 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 203c295da..a52cfda6d 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -35,8 +35,7 @@ export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isLoading, refetch } = api.user.all.useQuery(); const { mutateAsync } = api.user.remove.useMutation(); - const { mutateAsync: removeMember } = - api.organization.removeMember.useMutation(); + const utils = api.useUtils(); const { data: session } = authClient.useSession(); @@ -85,6 +84,10 @@ export const ShowUsers = () => { {data?.map((member) => { + const currentUserRole = data?.find( + (m) => m.user.id === session?.user?.id, + )?.role; + // Owner never has "Edit Permissions" (they're absolute owner) // Other users can edit permissions if target is not themselves and target is a member const canEditPermissions = @@ -92,23 +95,34 @@ export const ShowUsers = () => { member.role === "member" && member.user.id !== session?.user?.id; - // Can change role if target is not owner and not the current user - // Owner role is intransferible + // Can change role based on hierarchy: + // - Owner: Can change anyone's role (except themselves and other owners) + // - Admin: Can only change member roles (not other admins or owners) + // - Owner role is intransferible const canChangeRole = member.role !== "owner" && - member.user.id !== session?.user?.id; + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); + // Delete/Unlink follow same hierarchy as role changes + // - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted) + // - Admin: Can only delete/unlink members (not other admins or owner) const canDelete = member.role !== "owner" && !isCloud && - member.user.id !== session?.user?.id; + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); const canUnlink = member.role !== "owner" && - !( - member.role === "admin" && - member.user.id === session?.user?.id - ); + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); const hasAnyAction = canEditPermissions || @@ -216,33 +230,48 @@ export const ShowUsers = () => { description="Are you sure you want to unlink this user?" type="destructive" onClick={async () => { - try { - if (!isCloud) { - const orgCount = - await utils.user.checkUserOrganizations.fetch( - { - userId: member.user.id, - }, - ); - if (orgCount === 1) { - await mutateAsync({ + if (!isCloud) { + const orgCount = + await utils.user.checkUserOrganizations.fetch( + { userId: member.user.id, + }, + ); + + if (orgCount === 1) { + await mutateAsync({ + userId: member.user.id, + }) + .then(() => { + toast.success( + "User deleted successfully", + ); + refetch(); + }) + .catch(() => { + toast.error( + "Error deleting user", + ); }); - toast.success( - "User deleted successfully", - ); - refetch(); - return; - } + return; } + } + + const { error } = + await authClient.organization.removeMember( + { + memberIdOrEmail: member.id, + }, + ); + + if (!error) { toast.success( "User unlinked successfully", ); refetch(); - } catch (error: any) { + } else { toast.error( - error?.message || - "Error unlinking user", + "Error unlinking user", ); } }} diff --git a/apps/dokploy/reset-2fa.ts b/apps/dokploy/reset-2fa.ts index 77af3d506..bff547db7 100644 --- a/apps/dokploy/reset-2fa.ts +++ b/apps/dokploy/reset-2fa.ts @@ -1,11 +1,11 @@ -import { findAdmin } from "@dokploy/server"; +import { findOwner } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { user } from "@dokploy/server/db/schema"; import { eq } from "drizzle-orm"; (async () => { try { - const result = await findAdmin(); + const result = await findOwner(); const update = await db .update(user) diff --git a/apps/dokploy/reset-password.ts b/apps/dokploy/reset-password.ts index 80b0661fa..5b1db9bcf 100644 --- a/apps/dokploy/reset-password.ts +++ b/apps/dokploy/reset-password.ts @@ -1,4 +1,4 @@ -import { findAdmin, generateRandomPassword } from "@dokploy/server"; +import { findOwner, generateRandomPassword } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { account } from "@dokploy/server/db/schema"; import { eq } from "drizzle-orm"; @@ -7,7 +7,7 @@ import { eq } from "drizzle-orm"; try { const randomPassword = await generateRandomPassword(); - const result = await findAdmin(); + const result = await findOwner(); const update = await db .update(account) diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 7c26de100..38536cc44 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -194,46 +194,6 @@ export const organizationRouter = createTRPCRouter({ .delete(invitation) .where(eq(invitation.id, input.invitationId)); }), - removeMember: adminProcedure - .input(z.object({ memberId: z.string() })) - .mutation(async ({ ctx, input }) => { - // Fetch the target member within the active organization - const target = await db.query.member.findFirst({ - where: eq(member.id, input.memberId), - with: { user: true }, - }); - - if (!target) { - throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); - } - - if (target.organizationId !== ctx.session.activeOrganizationId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You are not allowed to remove this member", - }); - } - - // Disallow removing the organization owner - if (target.role === "owner") { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You cannot unlink the organization owner", - }); - } - - // Admin self-protection: an admin cannot unlink themselves - if (target.role === "admin" && target.userId === ctx.user.id) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Admins cannot unlink themselves. Ask the owner or another admin.", - }); - } - - await db.delete(member).where(eq(member.id, input.memberId)); - return true; - }), updateMemberRole: adminProcedure .input( z.object({ @@ -275,6 +235,16 @@ export const organizationRouter = createTRPCRouter({ }); } + // Only owners can change admin roles + // Admins can only change member roles + if (ctx.user.role === "admin" && target.role === "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Only the organization owner can change admin roles. Admins can only modify member roles.", + }); + } + // Update the target member's role await db .update(member) diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 2434b7e05..217090678 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -1,6 +1,5 @@ import { createApiKey, - findAdmin, findNotificationById, findOrganizationById, findUserById, @@ -272,6 +271,16 @@ export const userRouter = createTRPCRouter({ }); } + // Only owners can delete admins + // Admins can only delete members + if (ctx.user.role === "admin" && targetMember.role === "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Only the organization owner can delete admins. Admins can only delete members.", + }); + } + return await removeUserById(input.userId); }), assignPermissions: adminProcedure diff --git a/packages/server/src/services/admin.ts b/packages/server/src/services/admin.ts index 0cbb20785..0e8612415 100644 --- a/packages/server/src/services/admin.ts +++ b/packages/server/src/services/admin.ts @@ -46,7 +46,7 @@ export const isAdminPresent = async () => { return true; }; -export const findAdmin = async () => { +export const findOwner = async () => { const admin = await db.query.member.findFirst({ where: eq(member.role, "owner"), with: { @@ -107,11 +107,11 @@ export const getDokployUrl = async () => { if (IS_CLOUD) { return "https://app.dokploy.com"; } - const admin = await findAdmin(); + const owner = await findOwner(); - if (admin.user.host) { - const protocol = admin.user.https ? "https" : "http"; - return `${protocol}://${admin.user.host}`; + if (owner.user.host) { + const protocol = owner.user.https ? "https" : "http"; + return `${protocol}://${owner.user.host}`; } - return `http://${admin.user.serverIp}:${process.env.PORT}`; + return `http://${owner.user.serverIp}:${process.env.PORT}`; }; diff --git a/packages/server/src/utils/access-log/handler.ts b/packages/server/src/utils/access-log/handler.ts index 1590f9d8d..237a68f17 100644 --- a/packages/server/src/utils/access-log/handler.ts +++ b/packages/server/src/utils/access-log/handler.ts @@ -1,5 +1,5 @@ import { paths } from "@dokploy/server/constants"; -import { findAdmin } from "@dokploy/server/services/admin"; +import { findOwner } from "@dokploy/server/services/admin"; import { updateUser } from "@dokploy/server/services/user"; import { scheduledJobs, scheduleJob } from "node-schedule"; import { execAsync } from "../process/execAsync"; @@ -29,9 +29,9 @@ export const startLogCleanup = async ( } }); - const admin = await findAdmin(); - if (admin) { - await updateUser(admin.user.id, { + const owner = await findOwner(); + if (owner) { + await updateUser(owner.user.id, { logCleanupCron: cronExpression, }); } @@ -51,9 +51,9 @@ export const stopLogCleanup = async (): Promise => { } // Update database - const admin = await findAdmin(); - if (admin) { - await updateUser(admin.user.id, { + const owner = await findOwner(); + if (owner) { + await updateUser(owner.user.id, { logCleanupCron: null, }); } @@ -69,8 +69,8 @@ export const getLogCleanupStatus = async (): Promise<{ enabled: boolean; cronExpression: string | null; }> => { - const admin = await findAdmin(); - const cronExpression = admin?.user.logCleanupCron ?? null; + const owner = await findOwner(); + const cronExpression = owner?.user.logCleanupCron ?? null; return { enabled: cronExpression !== null, cronExpression, From a9b8beb50b3473bd9bc083b5ff64e06170bdb23d Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 7 Dec 2025 03:02:01 -0600 Subject: [PATCH 9/9] refactor(settings): reorganize cleanup functions and update imports for better clarity --- apps/dokploy/server/api/routers/settings.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 985cdd9bc..5610e8d96 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -1,13 +1,13 @@ import { canAccessToTraefikFiles, checkGPUStatus, - cleanupContainers, - cleanupBuilders, - cleanupSystem, - cleanupImages, - cleanupVolumes, - cleanupAll, checkPortInUse, + cleanupAll, + cleanupBuilders, + cleanupContainers, + cleanupImages, + cleanupSystem, + cleanupVolumes, DEFAULT_UPDATE_DATA, execAsync, findServerById,