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/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 06c94416b..a52cfda6d 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -29,12 +29,15 @@ 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(); const { data, isLoading, refetch } = api.user.all.useQuery(); const { mutateAsync } = api.user.remove.useMutation(); + const utils = api.useUtils(); + const { data: session } = authClient.useSession(); return (
@@ -81,6 +84,52 @@ 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 = + member.role !== "owner" && + member.role === "member" && + member.user.id !== session?.user?.id; + + // 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 && + (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 && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); + + const canUnlink = + member.role !== "owner" && + member.user.id !== session?.user?.id && + (currentUserRole === "owner" || + (currentUserRole === "admin" && + member.role === "member")); + + const hasAnyAction = + canEditPermissions || + canChangeRole || + canDelete || + canUnlink; + return ( @@ -109,7 +158,7 @@ export const ShowUsers = () => { - {member.role !== "owner" && ( + {hasAnyAction ? ( )} diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 7473fe586..45b6a7e3a 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -158,7 +158,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, @@ -168,7 +169,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 ), }, @@ -179,7 +182,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, @@ -188,7 +196,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, @@ -197,7 +210,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 @@ -264,7 +282,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, @@ -278,7 +297,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, @@ -286,7 +306,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, @@ -295,14 +316,19 @@ const MENU: Menu = { url: "/dashboard/settings/ssh-keys", // Only enabled for admins and users with access to SSH keys isEnabled: ({ auth }) => - !!(auth?.role === "owner" || auth?.canAccessToSSHKeys), + !!( + auth?.role === "owner" || + auth?.canAccessToSSHKeys || + auth?.role === "admin" + ), }, { title: "AI", icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -311,7 +337,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, @@ -319,7 +349,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, @@ -327,7 +358,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"), }, { @@ -336,7 +368,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, @@ -344,7 +377,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, @@ -352,7 +386,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, @@ -718,7 +753,9 @@ function SidebarLogo() {
); })} - {(user?.role === "owner" || isCloud) && ( + {(user?.role === "owner" || + user?.role === "admin" || + isCloud) && ( <> @@ -1082,7 +1119,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 337e8f483..94310fa6a 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -102,7 +102,9 @@ export const UserNav = () => { > Monitoring - {(data?.role === "owner" || data?.canAccessToTraefikFiles) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToTraefikFiles) && ( { @@ -112,7 +114,9 @@ export const UserNav = () => { Traefik )} - {(data?.role === "owner" || data?.canAccessToDocker) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToDocker) && ( { @@ -126,7 +130,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/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, 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/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,