mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-08 21:15:17 +00:00
feat(users): implement ChangeRole component for user role management in dashboard
This commit is contained in:
@@ -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<typeof changeRoleSchema>;
|
||||||
|
|
||||||
|
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<ChangeRoleSchema>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger className="" asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Change Role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[85vh] sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change User Role</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Change the role for <strong>{userEmail}</strong>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-change-role"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="w-full space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
<strong>Admin:</strong> Can manage users and settings.
|
||||||
|
<br />
|
||||||
|
<strong>Member:</strong> Limited permissions, can be
|
||||||
|
customized.
|
||||||
|
<br />
|
||||||
|
<em className="text-muted-foreground text-xs">
|
||||||
|
Note: Owner role is intransferible.
|
||||||
|
</em>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-change-role"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update Role
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AddUserPermissions } from "./add-permissions";
|
import { AddUserPermissions } from "./add-permissions";
|
||||||
|
import { ChangeRole } from "./change-role";
|
||||||
|
|
||||||
export const ShowUsers = () => {
|
export const ShowUsers = () => {
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
@@ -84,11 +85,24 @@ export const ShowUsers = () => {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((member) => {
|
{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 =
|
const canDelete =
|
||||||
member.role !== "owner" &&
|
member.role !== "owner" &&
|
||||||
!isCloud &&
|
!isCloud &&
|
||||||
member.user.id !== session?.user?.id;
|
member.user.id !== session?.user?.id;
|
||||||
|
|
||||||
const canUnlink =
|
const canUnlink =
|
||||||
member.role !== "owner" &&
|
member.role !== "owner" &&
|
||||||
!(
|
!(
|
||||||
@@ -97,7 +111,10 @@ export const ShowUsers = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasAnyAction =
|
const hasAnyAction =
|
||||||
canEditPermissions || canDelete || canUnlink;
|
canEditPermissions ||
|
||||||
|
canChangeRole ||
|
||||||
|
canDelete ||
|
||||||
|
canUnlink;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
@@ -145,6 +162,16 @@ export const ShowUsers = () => {
|
|||||||
Actions
|
Actions
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{canChangeRole && (
|
||||||
|
<ChangeRole
|
||||||
|
memberId={member.id}
|
||||||
|
currentRole={
|
||||||
|
member.role as "admin" | "member"
|
||||||
|
}
|
||||||
|
userEmail={member.user.email}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{canEditPermissions && (
|
{canEditPermissions && (
|
||||||
<AddUserPermissions
|
<AddUserPermissions
|
||||||
userId={member.user.id}
|
userId={member.user.id}
|
||||||
|
|||||||
@@ -316,7 +316,11 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/ssh-keys",
|
url: "/dashboard/settings/ssh-keys",
|
||||||
// Only enabled for admins and users with access to SSH keys
|
// Only enabled for admins and users with access to SSH keys
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
!!(
|
||||||
|
auth?.role === "owner" ||
|
||||||
|
auth?.canAccessToSSHKeys ||
|
||||||
|
auth?.role === "admin"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AI",
|
title: "AI",
|
||||||
|
|||||||
@@ -234,6 +234,55 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
await db.delete(member).where(eq(member.id, input.memberId));
|
await db.delete(member).where(eq(member.id, input.memberId));
|
||||||
return true;
|
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
|
setDefault: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user