- {(data?.canAccessToAPI || data?.role === "owner") &&
}
+ {(data?.canAccessToAPI ||
+ data?.role === "owner" ||
+ data?.role === "admin") &&
}
{/* {isCloud &&
} */}
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 e8ca9eb16..38536cc44 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",
@@ -96,7 +96,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",
@@ -119,7 +119,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",
@@ -194,6 +194,65 @@ export const organizationRouter = createTRPCRouter({
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
}),
+ 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",
+ });
+ }
+
+ // 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)
+ .set({ role: input.role })
+ .where(eq(member.id, input.memberId));
+
+ return true;
+ }),
setDefault: protectedProcedure
.input(
z.object({
diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts
index 86f1802fc..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,
@@ -211,7 +211,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
- await updateUser(ctx.user.id, {
+ await updateUser(ctx.user.ownerId, {
sshPrivateKey: input.sshPrivateKey,
});
@@ -223,7 +223,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,
@@ -250,7 +250,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
- await updateUser(ctx.user.id, {
+ await updateUser(ctx.user.ownerId, {
sshPrivateKey: null,
});
return true;
@@ -310,7 +310,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 baec4d6ac..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,
@@ -87,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",
@@ -223,10 +226,61 @@ 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.",
+ });
+ }
+
+ // 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/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({
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,