mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-20 06:35:22 +02:00
feat(permissions): Forbid admins to delete themselves and add protections to the route
This commit is contained in:
@@ -35,7 +35,10 @@ 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();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -134,55 +137,14 @@ export const ShowUsers = () => {
|
||||
|
||||
{member.role !== "owner" && (
|
||||
<>
|
||||
{!isCloud && (
|
||||
<DialogAction
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting destination",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
await utils.user.checkUserOrganizations.fetch(
|
||||
{
|
||||
userId: member.user.id,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(orgCount);
|
||||
|
||||
if (orgCount === 1) {
|
||||
{!isCloud &&
|
||||
member.user.id !==
|
||||
session?.user?.id && (
|
||||
<DialogAction
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
})
|
||||
@@ -192,41 +154,78 @@ export const ShowUsers = () => {
|
||||
);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
"Error deleting user",
|
||||
err?.message ||
|
||||
"Error deleting user",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
Delete User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{!(
|
||||
member.role === "admin" &&
|
||||
member.user.id === session?.user?.id
|
||||
) && (
|
||||
<DialogAction
|
||||
title="Unlink User"
|
||||
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({
|
||||
userId: member.user.id,
|
||||
});
|
||||
toast.success(
|
||||
"User deleted successfully",
|
||||
);
|
||||
refetch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await removeMember({
|
||||
memberId: member.id,
|
||||
});
|
||||
toast.success(
|
||||
"User unlinked successfully",
|
||||
);
|
||||
refetch();
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error unlinking user",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } =
|
||||
await authClient.organization.removeMember(
|
||||
{
|
||||
memberIdOrEmail: member.id,
|
||||
},
|
||||
);
|
||||
|
||||
if (!error) {
|
||||
toast.success(
|
||||
"User unlinked successfully",
|
||||
);
|
||||
refetch();
|
||||
} else {
|
||||
toast.error(
|
||||
"Error unlinking user",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
}}
|
||||
>
|
||||
Unlink User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||
onSelect={(e) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
>
|
||||
Unlink User
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -184,4 +184,44 @@ 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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -217,10 +217,51 @@ 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.",
|
||||
});
|
||||
}
|
||||
|
||||
return await removeUserById(input.userId);
|
||||
}),
|
||||
assignPermissions: adminProcedure
|
||||
|
||||
Reference in New Issue
Block a user