feat(permissions): Forbid admins to delete themselves and add protections to the route

This commit is contained in:
Vlad Vladov
2025-09-03 16:36:22 +03:00
parent 95bf60ac75
commit a47a5f3b9e
3 changed files with 160 additions and 80 deletions

View File

@@ -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>

View File

@@ -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;
}),
});

View File

@@ -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