From 568293ef3cf77b86af7f183dc2d80bc49b11278c Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 7 Dec 2025 02:32:41 -0600 Subject: [PATCH] feat(users): implement ChangeRole component for user role management in dashboard --- .../dashboard/settings/users/change-role.tsx | 159 ++++++++++++++++++ .../dashboard/settings/users/show-users.tsx | 31 +++- apps/dokploy/components/layouts/side.tsx | 6 +- .../server/api/routers/organization.ts | 49 ++++++ 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/users/change-role.tsx diff --git a/apps/dokploy/components/dashboard/settings/users/change-role.tsx b/apps/dokploy/components/dashboard/settings/users/change-role.tsx new file mode 100644 index 000000000..93dc4dfc0 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/users/change-role.tsx @@ -0,0 +1,159 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +const changeRoleSchema = z.object({ + role: z.enum(["admin", "member"]), +}); + +type ChangeRoleSchema = z.infer; + +interface Props { + memberId: string; + currentRole: "admin" | "member"; + userEmail: string; +} + +export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const utils = api.useUtils(); + + const { mutateAsync, isError, error, isLoading } = + api.organization.updateMemberRole.useMutation(); + + const form = useForm({ + defaultValues: { + role: currentRole, + }, + resolver: zodResolver(changeRoleSchema), + }); + + useEffect(() => { + if (isOpen) { + form.reset({ + role: currentRole, + }); + } + }, [form, currentRole, isOpen]); + + const onSubmit = async (data: ChangeRoleSchema) => { + await mutateAsync({ + memberId, + role: data.role, + }) + .then(async () => { + toast.success("Role updated successfully"); + await utils.user.all.invalidate(); + setIsOpen(false); + }) + .catch((error) => { + toast.error(error?.message || "Error updating role"); + }); + }; + + return ( + + + e.preventDefault()} + > + Change Role + + + + + Change User Role + + Change the role for {userEmail} + + + {isError && {error?.message}} + +
+ + ( + + Role + + + Admin: Can manage users and settings. +
+ Member: Limited permissions, can be + customized. +
+ + Note: Owner role is intransferible. + +
+ +
+ )} + /> + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 497b4a450..ab0589767 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -29,6 +29,7 @@ import { import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { AddUserPermissions } from "./add-permissions"; +import { ChangeRole } from "./change-role"; export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -84,11 +85,24 @@ export const ShowUsers = () => { {data?.map((member) => { - const canEditPermissions = member.role === "member"; + // Owner never has "Edit Permissions" (they're absolute owner) + // Other users can edit permissions if target is not themselves and target is a member + const canEditPermissions = + member.role !== "owner" && + member.role === "member" && + member.user.id !== session?.user?.id; + + // Can change role if target is not owner and not the current user + // Owner role is intransferible + const canChangeRole = + member.role !== "owner" && + member.user.id !== session?.user?.id; + const canDelete = member.role !== "owner" && !isCloud && member.user.id !== session?.user?.id; + const canUnlink = member.role !== "owner" && !( @@ -97,7 +111,10 @@ export const ShowUsers = () => { ); const hasAnyAction = - canEditPermissions || canDelete || canUnlink; + canEditPermissions || + canChangeRole || + canDelete || + canUnlink; return ( @@ -145,6 +162,16 @@ export const ShowUsers = () => { Actions + {canChangeRole && ( + + )} + {canEditPermissions && ( - !!(auth?.role === "owner" || auth?.canAccessToSSHKeys), + !!( + auth?.role === "owner" || + auth?.canAccessToSSHKeys || + auth?.role === "admin" + ), }, { title: "AI", diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 1f22e1bef..7c26de100 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -234,6 +234,55 @@ export const organizationRouter = createTRPCRouter({ await db.delete(member).where(eq(member.id, input.memberId)); return true; }), + updateMemberRole: adminProcedure + .input( + z.object({ + memberId: z.string(), + role: z.enum(["admin", "member"]), + }), + ) + .mutation(async ({ ctx, input }) => { + // Fetch the target member + 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 update this member's role", + }); + } + + // Prevent users from changing their own role + if (target.userId === ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You cannot change your own role", + }); + } + + // Owner role is intransferible - cannot change to or from owner + if (target.role === "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "The owner role is intransferible", + }); + } + + // Update the target member's role + await db + .update(member) + .set({ role: input.role }) + .where(eq(member.id, input.memberId)); + + return true; + }), setDefault: protectedProcedure .input( z.object({