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