From 6022f2f6a3ba7f3f04fc2ce00c3f007c020ce348 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 7 Dec 2025 02:51:03 -0600 Subject: [PATCH] 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,