feat: implement transfer ownership functionality and add danger zone component

This commit is contained in:
AlphaLawless 2025-06-01 19:20:26 -03:00
parent 6fa3843832
commit 76bd51ac10
No known key found for this signature in database
GPG Key ID: 2AAB1A79B36DBB3C
6 changed files with 333 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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