mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat: implement transfer ownership functionality and add danger zone component
This commit is contained in:
parent
6fa3843832
commit
76bd51ac10
@ -0,0 +1,53 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { TransferOwnership } from "./transfer-ownership";
|
||||
import { api } from "@/utils/api";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export const ShowDangerZone = () => {
|
||||
const { data: members } = api.user.all.useQuery();
|
||||
const { data: currentUser } = authClient.useSession();
|
||||
|
||||
const isCurrentUserOwner = members?.some(
|
||||
member => member.user.id === currentUser?.user?.id && member.role === "owner"
|
||||
);
|
||||
|
||||
if (!isCurrentUserOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<AlertTriangle className="size-6 text-muted-foreground self-center" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible and destructive actions for this organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border border-destructive/20 rounded-lg">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">Transfer Ownership</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Transfer ownership of this organization to another member. You will lose all owner privileges.
|
||||
</p>
|
||||
</div>
|
||||
<TransferOwnership />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -40,7 +40,7 @@ export const ShowInvitations = () => {
|
||||
api.organization.removeInvitation.useMutation();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<section className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
@ -219,6 +219,6 @@ export const ShowInvitations = () => {
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
@ -40,9 +40,9 @@ export const ShowUsers = () => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<section className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Users className="size-6 text-muted-foreground self-center" />
|
||||
@ -61,14 +61,14 @@ export const ShowUsers = () => {
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Users className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Invite users to your Dokploy account
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<Table>
|
||||
<TableCaption>See all users</TableCaption>
|
||||
<TableHeader>
|
||||
@ -76,7 +76,6 @@ export const ShowUsers = () => {
|
||||
<TableHead className="w-[100px]">Email</TableHead>
|
||||
<TableHead className="text-center">Role</TableHead>
|
||||
<TableHead className="text-center">2FA</TableHead>
|
||||
|
||||
<TableHead className="text-center">
|
||||
Created At
|
||||
</TableHead>
|
||||
@ -190,8 +189,6 @@ export const ShowUsers = () => {
|
||||
},
|
||||
);
|
||||
|
||||
console.log(orgCount);
|
||||
|
||||
if (orgCount === 1) {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
@ -254,6 +251,6 @@ export const ShowUsers = () => {
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -0,0 +1,192 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Copy, Crown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const transferOwnership = z.object({
|
||||
newOwnerId: z.string().min(1, "Please select a new owner"),
|
||||
confirmationText: z.string().refine(
|
||||
(val) => val === "TRANSFER OWNERSHIP",
|
||||
"Type 'TRANSFER OWNERSHIP' to confirm"
|
||||
),
|
||||
});
|
||||
|
||||
type TransferOwnership = z.infer<typeof transferOwnership>;
|
||||
|
||||
export const TransferOwnership = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: members } = api.user.all.useQuery();
|
||||
|
||||
const { mutateAsync: transferOwner, isLoading } = api.organization.transferOwner.useMutation()
|
||||
|
||||
const form = useForm<TransferOwnership>({
|
||||
defaultValues: {
|
||||
newOwnerId: "",
|
||||
},
|
||||
resolver: zodResolver(transferOwnership),
|
||||
});
|
||||
|
||||
const eligibleMembers = members?.filter(member => member.role !== "owner") || [];
|
||||
|
||||
const onSubmit = async (data: TransferOwnership) => {
|
||||
try {
|
||||
const result = await transferOwner({
|
||||
newOwnerId: data.newOwnerId,
|
||||
confirmationText: data.confirmationText,
|
||||
});
|
||||
|
||||
toast.success(`Ownership transferred to ${result.newOwner.email}`);
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
|
||||
utils.user.all.invalidate();
|
||||
|
||||
setTimeout(() => {
|
||||
toast.info("Redirecting to projects...")
|
||||
router.push("/dashboard/projects")
|
||||
}, 1000)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "Error transferring ownership");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="hover:bg-destructive hover:text-destructive-foreground" size="sm">
|
||||
<Crown className="h-4 w-4 mr-2" />
|
||||
Transfer Ownership
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-2">Transfer Ownership</DialogTitle>
|
||||
<AlertBlock type="error">
|
||||
<strong>Warning:</strong> This action cannot be undone. You will lose all owner privileges and become an admin.
|
||||
</AlertBlock>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="transfer-ownership-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newOwnerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Owner</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select new owner" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{eligibleMembers.map((member) => (
|
||||
<SelectItem key={member.user.id} value={member.user.id}>
|
||||
{member.user.email} ({member.role})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmationText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
copy('TRANSFER OWNERSHIP');
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}}
|
||||
>TRANSFER OWNERSHIP
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
form="transfer-ownership-form"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
import { ShowDangerZone } from "@/components/dashboard/settings/users/show-danger-zone";
|
||||
import { ShowInvitations } from "@/components/dashboard/settings/users/show-invitations";
|
||||
import { ShowUsers } from "@/components/dashboard/settings/users/show-users";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
@ -14,6 +15,7 @@ const Page = () => {
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowUsers />
|
||||
<ShowInvitations />
|
||||
<ShowDangerZone />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -184,4 +184,81 @@ export const organizationRouter = createTRPCRouter({
|
||||
.delete(invitation)
|
||||
.where(eq(invitation.id, input.invitationId));
|
||||
}),
|
||||
transferOwner: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
newOwnerId: z.string(),
|
||||
confirmationText: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const newOwnerMember = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, input.newOwnerId),
|
||||
eq(member.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
with: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!newOwnerMember) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "New owner not found in this organization",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.confirmationText !== "TRANSFER OWNERSHIP") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Confirmation text is incorrect",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.id === input.newOwnerId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot transfer ownership to yourself",
|
||||
});
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({
|
||||
ownerId: input.newOwnerId
|
||||
}).
|
||||
where(eq(organization.id, ctx.session.activeOrganizationId))
|
||||
|
||||
await tx
|
||||
.update(member)
|
||||
.set({
|
||||
role: "admin",
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, ctx.user.id),
|
||||
eq(member.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
);
|
||||
|
||||
await tx
|
||||
.update(member)
|
||||
.set({
|
||||
role: "owner",
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, input.newOwnerId),
|
||||
eq(member.organizationId, ctx.session.activeOrganizationId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newOwner: newOwnerMember.user,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user