refactor(auth): replace findAdmin with findOwner in user management logic and update role-based permissions in the dashboard

This commit is contained in:
Mauricio Siu
2025-12-07 02:51:03 -06:00
parent 075e387bb6
commit 6022f2f6a3
7 changed files with 97 additions and 89 deletions
@@ -35,8 +35,7 @@ export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isLoading, refetch } = api.user.all.useQuery(); const { data, isLoading, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation(); const { mutateAsync } = api.user.remove.useMutation();
const { mutateAsync: removeMember } =
api.organization.removeMember.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
@@ -85,6 +84,10 @@ export const ShowUsers = () => {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.map((member) => { {data?.map((member) => {
const currentUserRole = data?.find(
(m) => m.user.id === session?.user?.id,
)?.role;
// Owner never has "Edit Permissions" (they're absolute owner) // Owner never has "Edit Permissions" (they're absolute owner)
// Other users can edit permissions if target is not themselves and target is a member // Other users can edit permissions if target is not themselves and target is a member
const canEditPermissions = const canEditPermissions =
@@ -92,23 +95,34 @@ export const ShowUsers = () => {
member.role === "member" && member.role === "member" &&
member.user.id !== session?.user?.id; member.user.id !== session?.user?.id;
// Can change role if target is not owner and not the current user // Can change role based on hierarchy:
// Owner role is intransferible // - 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 = const canChangeRole =
member.role !== "owner" && 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 = const canDelete =
member.role !== "owner" && member.role !== "owner" &&
!isCloud && !isCloud &&
member.user.id !== session?.user?.id; member.user.id !== session?.user?.id &&
(currentUserRole === "owner" ||
(currentUserRole === "admin" &&
member.role === "member"));
const canUnlink = const canUnlink =
member.role !== "owner" && member.role !== "owner" &&
!( member.user.id !== session?.user?.id &&
member.role === "admin" && (currentUserRole === "owner" ||
member.user.id === session?.user?.id (currentUserRole === "admin" &&
); member.role === "member"));
const hasAnyAction = const hasAnyAction =
canEditPermissions || canEditPermissions ||
@@ -216,33 +230,48 @@ export const ShowUsers = () => {
description="Are you sure you want to unlink this user?" description="Are you sure you want to unlink this user?"
type="destructive" type="destructive"
onClick={async () => { onClick={async () => {
try { if (!isCloud) {
if (!isCloud) { const orgCount =
const orgCount = await utils.user.checkUserOrganizations.fetch(
await utils.user.checkUserOrganizations.fetch( {
{
userId: member.user.id,
},
);
if (orgCount === 1) {
await mutateAsync({
userId: member.user.id, 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( return;
"User deleted successfully",
);
refetch();
return;
}
} }
}
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
},
);
if (!error) {
toast.success( toast.success(
"User unlinked successfully", "User unlinked successfully",
); );
refetch(); refetch();
} catch (error: any) { } else {
toast.error( toast.error(
error?.message || "Error unlinking user",
"Error unlinking user",
); );
} }
}} }}
+2 -2
View File
@@ -1,11 +1,11 @@
import { findAdmin } from "@dokploy/server"; import { findOwner } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { user } from "@dokploy/server/db/schema"; import { user } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
(async () => { (async () => {
try { try {
const result = await findAdmin(); const result = await findOwner();
const update = await db const update = await db
.update(user) .update(user)
+2 -2
View File
@@ -1,4 +1,4 @@
import { findAdmin, generateRandomPassword } from "@dokploy/server"; import { findOwner, generateRandomPassword } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { account } from "@dokploy/server/db/schema"; import { account } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
try { try {
const randomPassword = await generateRandomPassword(); const randomPassword = await generateRandomPassword();
const result = await findAdmin(); const result = await findOwner();
const update = await db const update = await db
.update(account) .update(account)
+10 -40
View File
@@ -194,46 +194,6 @@ export const organizationRouter = createTRPCRouter({
.delete(invitation) .delete(invitation)
.where(eq(invitation.id, input.invitationId)); .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 updateMemberRole: adminProcedure
.input( .input(
z.object({ 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 // Update the target member's role
await db await db
.update(member) .update(member)
+10 -1
View File
@@ -1,6 +1,5 @@
import { import {
createApiKey, createApiKey,
findAdmin,
findNotificationById, findNotificationById,
findOrganizationById, findOrganizationById,
findUserById, 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); return await removeUserById(input.userId);
}), }),
assignPermissions: adminProcedure assignPermissions: adminProcedure
+6 -6
View File
@@ -46,7 +46,7 @@ export const isAdminPresent = async () => {
return true; return true;
}; };
export const findAdmin = async () => { export const findOwner = async () => {
const admin = await db.query.member.findFirst({ const admin = await db.query.member.findFirst({
where: eq(member.role, "owner"), where: eq(member.role, "owner"),
with: { with: {
@@ -107,11 +107,11 @@ export const getDokployUrl = async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return "https://app.dokploy.com"; return "https://app.dokploy.com";
} }
const admin = await findAdmin(); const owner = await findOwner();
if (admin.user.host) { if (owner.user.host) {
const protocol = admin.user.https ? "https" : "http"; const protocol = owner.user.https ? "https" : "http";
return `${protocol}://${admin.user.host}`; return `${protocol}://${owner.user.host}`;
} }
return `http://${admin.user.serverIp}:${process.env.PORT}`; return `http://${owner.user.serverIp}:${process.env.PORT}`;
}; };
@@ -1,5 +1,5 @@
import { paths } from "@dokploy/server/constants"; 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 { updateUser } from "@dokploy/server/services/user";
import { scheduledJobs, scheduleJob } from "node-schedule"; import { scheduledJobs, scheduleJob } from "node-schedule";
import { execAsync } from "../process/execAsync"; import { execAsync } from "../process/execAsync";
@@ -29,9 +29,9 @@ export const startLogCleanup = async (
} }
}); });
const admin = await findAdmin(); const owner = await findOwner();
if (admin) { if (owner) {
await updateUser(admin.user.id, { await updateUser(owner.user.id, {
logCleanupCron: cronExpression, logCleanupCron: cronExpression,
}); });
} }
@@ -51,9 +51,9 @@ export const stopLogCleanup = async (): Promise<boolean> => {
} }
// Update database // Update database
const admin = await findAdmin(); const owner = await findOwner();
if (admin) { if (owner) {
await updateUser(admin.user.id, { await updateUser(owner.user.id, {
logCleanupCron: null, logCleanupCron: null,
}); });
} }
@@ -69,8 +69,8 @@ export const getLogCleanupStatus = async (): Promise<{
enabled: boolean; enabled: boolean;
cronExpression: string | null; cronExpression: string | null;
}> => { }> => {
const admin = await findAdmin(); const owner = await findOwner();
const cronExpression = admin?.user.logCleanupCron ?? null; const cronExpression = owner?.user.logCleanupCron ?? null;
return { return {
enabled: cronExpression !== null, enabled: cronExpression !== null,
cronExpression, cronExpression,