diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 23356dcd49..8ba710ef09 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -35,7 +35,9 @@ import { ChangeRole } from "./change-role"; export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isPending, refetch } = api.user.all.useQuery(); - const { mutateAsync } = api.user.remove.useMutation(); + const { mutateAsync: removeUser } = api.user.remove.useMutation(); + const { mutateAsync: transferOwnership } = + api.organization.transferOwnership.useMutation(); const { data: permissions } = api.user.getPermissions.useQuery(); const { data: hasValidLicense } = api.licenseKey.haveValidLicenseKey.useQuery(); @@ -122,7 +124,7 @@ export const ShowUsers = () => { // Can change role based on hierarchy: // - Owner: Can change anyone's role (except themselves and other owners) // - Admin: Can only change member/custom roles (not other admins or owners) - // - Owner role is nontransferable + // - Owner role changes use the dedicated transfer flow const canChangeRole = member.role !== "owner" && member.user.id !== session?.user?.id && @@ -130,6 +132,11 @@ export const ShowUsers = () => { (currentUserRole === "admin" && member.role !== "admin")); + const canTransferOwnership = + currentUserRole === "owner" && + member.role !== "owner" && + member.user.id !== session?.user?.id; + const canDeleteMember = permissions?.member.delete ?? false; @@ -149,6 +156,7 @@ export const ShowUsers = () => { const hasAnyAction = canEditPermissions || canChangeRole || + canTransferOwnership || canDelete || canUnlink; @@ -211,6 +219,37 @@ export const ShowUsers = () => { /> )} + {canTransferOwnership && ( + { + await transferOwnership({ + memberId: member.id, + }) + .then(() => { + toast.success( + "Ownership transferred successfully", + ); + refetch(); + }) + .catch((err) => { + toast.error( + err?.message || + "Error transferring ownership", + ); + }); + }} + > + e.preventDefault()} + > + Transfer Ownership + + + )} + {canEditPermissions && ( { description="Are you sure you want to delete this user?" type="destructive" onClick={async () => { - await mutateAsync({ + await removeUser({ userId: member.user.id, }) .then(() => { @@ -265,7 +304,7 @@ export const ShowUsers = () => { ); if (orgCount === 1) { - await mutateAsync({ + await removeUser({ userId: member.user.id, }) .then(() => { diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 6af018ed81..3a8e9a786c 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -486,6 +486,97 @@ export const organizationRouter = createTRPCRouter({ resourceName: target.user.email, metadata: { before: target.role, after: input.role }, }); + return true; + }), + transferOwnership: protectedProcedure + .input( + z.object({ + memberId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.activeOrganizationId; + + const org = await db.query.organization.findFirst({ + where: eq(organization.id, organizationId), + }); + + if (!org) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Organization not found", + }); + } + + if (org.ownerId !== ctx.user.id || ctx.user.role !== "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the current organization owner can transfer ownership", + }); + } + + const target = await db.query.member.findFirst({ + where: and( + eq(member.id, input.memberId), + eq(member.organizationId, organizationId), + ), + with: { user: true }, + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + } + + if (target.userId === ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You cannot transfer ownership to yourself", + }); + } + + const currentOwner = await db.query.member.findFirst({ + where: and( + eq(member.userId, ctx.user.id), + eq(member.organizationId, organizationId), + ), + }); + + if (!currentOwner || currentOwner.role !== "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Current owner membership not found", + }); + } + + await db.transaction(async (tx) => { + await tx + .update(organization) + .set({ ownerId: target.userId }) + .where(eq(organization.id, organizationId)); + + await tx + .update(member) + .set({ role: "admin" }) + .where(eq(member.id, currentOwner.id)); + + await tx + .update(member) + .set({ role: "owner" }) + .where(eq(member.id, target.id)); + }); + + await audit(ctx, { + action: "update", + resourceType: "organization", + resourceId: organizationId, + resourceName: org.name, + metadata: { + type: "transferOwnership", + from: ctx.user.id, + to: target.userId, + }, + }); + return true; }), setDefault: protectedProcedure