Add option to delete account (#1110)

This commit is contained in:
Luke Vella 2024-05-18 15:02:51 +08:00 committed by GitHub
parent acbb6e552f
commit 899bb966fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 207 additions and 10 deletions

View file

@ -97,7 +97,6 @@
"addComment": "Add Comment", "addComment": "Add Comment",
"profile": "Profile", "profile": "Profile",
"polls": "Polls", "polls": "Polls",
"showMore": "Show more…",
"timeZoneSelect__noOption": "No option found", "timeZoneSelect__noOption": "No option found",
"timeZoneSelect__inputPlaceholder": "Search…", "timeZoneSelect__inputPlaceholder": "Search…",
"poweredByRallly": "Powered by <a>{name}</a>", "poweredByRallly": "Powered by <a>{name}</a>",
@ -246,5 +245,10 @@
"timeShownIn": "Times shown in {timeZone}", "timeShownIn": "Times shown in {timeZone}",
"pollStatusPausedDescription": "Votes cannot be submitted or edited at this time", "pollStatusPausedDescription": "Votes cannot be submitted or edited at this time",
"eventHostTitle": "Manage Access", "eventHostTitle": "Manage Access",
"eventHostDescription": "You are the creator of this poll" "eventHostDescription": "You are the creator of this poll",
"deleteAccount": "Delete Account",
"deleteAccountDialogTitle": "Delete Account",
"deleteAccountDialogDescription": "Are you sure you want to delete your account?",
"deleteAccountInstruction": "Please confirm your email address to delete your account",
"emailMismatch": "Email does not match the account email"
} }

View file

@ -0,0 +1,125 @@
"use client";
import { Button } from "@rallly/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogProps,
DialogTitle,
} from "@rallly/ui/dialog";
import { Form, FormField, FormItem, FormMessage } from "@rallly/ui/form";
import { Input } from "@rallly/ui/input";
import { signOut } from "next-auth/react";
import { useForm } from "react-hook-form";
import { useTranslation } from "@/app/i18n/client";
import { Trans } from "@/components/trans";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
export function DeleteAccountDialog({
email,
children,
...rest
}: DialogProps & {
email: string;
}) {
const form = useForm<{ email: string }>({
defaultValues: {
email: "",
},
});
const { t } = useTranslation("app");
const trpcUtils = trpc.useUtils();
const posthog = usePostHog();
const deleteAccount = trpc.user.delete.useMutation({
onSuccess() {
posthog?.capture("delete account");
trpcUtils.invalidate();
signOut({
callbackUrl: "/login",
});
},
});
return (
<Form {...form}>
<Dialog {...rest}>
{children}
<DialogContent>
<form
method="POST"
action="/auth/logout"
onSubmit={form.handleSubmit(async () => {
await deleteAccount.mutateAsync();
})}
>
<DialogHeader>
<DialogTitle>
<Trans
i18nKey="deleteAccountDialogTitle"
defaults="Delete Account"
/>
</DialogTitle>
<DialogDescription>
<Trans
i18nKey="deleteAccountDialogDescription"
defaults="Are you sure you want to delete your account?"
/>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
<Trans
i18nKey="deleteAccountInstruction"
defaults="Please confirm your email address to delete your account"
/>
</p>
<FormField
control={form.control}
name="email"
rules={{
validate: (value) => {
if (value !== email) {
return t("emailMismatch", {
defaultValue: "Email does not match the account email",
});
}
return true;
},
}}
render={({ field }) => (
<FormItem>
<Input
error={!!form.formState.errors.email}
placeholder={email}
{...field}
/>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button>
<Trans i18nKey="cancel" defaults="Cancel" />
</Button>
</DialogClose>
<Button
type="submit"
loading={deleteAccount.isLoading}
variant="destructive"
>
<Trans i18nKey="deleteAccount" defaults="Delete Account" />
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Form>
);
}

View file

@ -1,12 +1,16 @@
"use client"; "use client";
import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@rallly/ui/alert";
import { Button } from "@rallly/ui/button";
import { DialogTrigger } from "@rallly/ui/dialog";
import { Icon } from "@rallly/ui/icon";
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import { Label } from "@rallly/ui/label"; import { Label } from "@rallly/ui/label";
import { InfoIcon, LogOutIcon, UserXIcon } from "lucide-react"; import { InfoIcon, LogOutIcon, TrashIcon, UserXIcon } from "lucide-react";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { DeleteAccountDialog } from "@/app/[locale]/(admin)/settings/profile/delete-account-dialog";
import { LogoutButton } from "@/app/components/logout-button"; import { LogoutButton } from "@/app/components/logout-button";
import { ProfileSettings } from "@/components/settings/profile-settings"; import { ProfileSettings } from "@/components/settings/profile-settings";
import { import {
@ -91,6 +95,29 @@ export const ProfilePage = () => {
<Trans i18nKey="logout" defaults="Logout" /> <Trans i18nKey="logout" defaults="Logout" />
</LogoutButton> </LogoutButton>
</SettingsSection> </SettingsSection>
{user.email ? (
<>
<hr />
<SettingsSection
title="Danger Zone"
description="Delete your account permanently. This action cannot be undone."
>
<DeleteAccountDialog email={user.email}>
<DialogTrigger asChild>
<Button variant="destructive">
<Icon>
<TrashIcon />
</Icon>
<Trans
i18nKey="deleteAccount"
defaults="Delete Account"
/>
</Button>
</DialogTrigger>
</DeleteAccountDialog>
</SettingsSection>
</>
) : null}
</SettingsContent> </SettingsContent>
)} )}
</Settings> </Settings>

View file

@ -1,3 +1,4 @@
import { Button } from "@rallly/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
@ -8,7 +9,6 @@ import {
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { LegacyButton } from "@/components/button";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { UserAvatar } from "@/components/user"; import { UserAvatar } from "@/components/user";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
@ -72,14 +72,14 @@ export const ProfileSettings = () => {
)} )}
/> />
<div className="mt-4 flex"> <div className="mt-4 flex">
<LegacyButton <Button
loading={formState.isSubmitting} loading={formState.isSubmitting}
type="primary" variant="primary"
htmlType="submit" type="submit"
disabled={!formState.isDirty} disabled={!formState.isDirty}
> >
<Trans i18nKey="save" /> <Trans i18nKey="save" />
</LegacyButton> </Button>
</div> </div>
</div> </div>
</form> </form>

View file

@ -38,6 +38,47 @@ export const user = router({
}, },
}); });
}), }),
delete: privateProcedure.mutation(async ({ ctx }) => {
await prisma.$transaction(async (tx) => {
const polls = await tx.poll.findMany({
select: { id: true },
where: { userId: ctx.user.id },
});
const pollIds = polls.map((poll) => poll.id);
await tx.comment.deleteMany({
where: { pollId: { in: pollIds } },
});
await tx.option.deleteMany({
where: { pollId: { in: pollIds } },
});
await tx.participant.deleteMany({
where: { OR: [{ pollId: { in: pollIds } }, { userId: ctx.user.id }] },
});
await tx.watcher.deleteMany({
where: { OR: [{ pollId: { in: pollIds } }, { userId: ctx.user.id }] },
});
await tx.vote.deleteMany({
where: { pollId: { in: pollIds } },
});
await tx.event.deleteMany({
where: { userId: ctx.user.id },
});
await tx.poll.deleteMany({
where: { userId: ctx.user.id },
});
await tx.account.deleteMany({
where: { userId: ctx.user.id },
});
await tx.userPaymentData.deleteMany({
where: { userId: ctx.user.id },
});
await tx.user.delete({
where: {
id: ctx.user.id,
},
});
});
}),
subscription: possiblyPublicProcedure.query( subscription: possiblyPublicProcedure.query(
async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => { async ({ ctx }): Promise<{ legacy?: boolean; active: boolean }> => {
if (ctx.user.isGuest) { if (ctx.user.isGuest) {

View file

@ -33,7 +33,7 @@ export function Icon({ children, size, variant }: IconProps) {
<Slot <Slot
className={cn( className={cn(
iconVariants({ size, variant }), iconVariants({ size, variant }),
"group-[.bg-primary]:text-primary-100 group shrink-0", "group-[.bg-primary]:text-primary-100 group-[.bg-destructive]:text-destructive-foreground group shrink-0",
)} )}
> >
{children} {children}

View file

@ -13,7 +13,7 @@ export type InputProps = Omit<
const inputVariants = cva( const inputVariants = cva(
cn( cn(
"focus-visible:border-primary-400 focus-visible:ring-offset-1 focus-visible:outline-none focus-visible:ring-primary-200 focus-visible:ring-1", "w-full focus-visible:border-primary-400 focus-visible:ring-offset-1 focus-visible:outline-none focus-visible:ring-primary-200 focus-visible:ring-1",
"border-input placeholder:text-muted-foreground h-9 rounded border bg-gray-50 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50", "border-input placeholder:text-muted-foreground h-9 rounded border bg-gray-50 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50",
), ),
{ {