feat(account-deletion): delete the account from user settings

The privacy policy has always promised deletion on request, with emailing a
person as the only route. This is that promise, self-serve.

The dialog spells out what is lost before it asks, because the consequences
reach past the person clicking: every workspace they own goes, and anyone
sharing one loses that work. Confirmation is typing DELETE, the same bar the
destructive actions elsewhere use.

On success it reloads the page rather than routing, so no cached query
outlives the account it belonged to.
This commit is contained in:
CREDO23 2026-08-21 13:43:44 +02:00
parent 4349de6d29
commit bbad3bc0ea
3 changed files with 112 additions and 0 deletions

View file

@ -0,0 +1,89 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { userApiService } from "@/lib/apis/user-api.service";
import { logout } from "@/lib/auth-utils";
// Not translated: the label interpolates this exact word.
const CONFIRMATION_WORD = "DELETE";
interface DeleteAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** Spell out what leaving costs, then erase the account. */
export function DeleteAccountDialog({ open, onOpenChange }: DeleteAccountDialogProps) {
const t = useTranslations("userSettings");
const [confirmation, setConfirmation] = useState("");
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (open) setConfirmation("");
}, [open]);
const handleDelete = async () => {
setDeleting(true);
try {
await userApiService.deleteMe();
await logout();
// Reload, not a route push: no cache should outlive the account.
window.location.href = "/";
} catch {
toast.error(t("delete_account_error"));
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("delete_account_title")}</DialogTitle>
<DialogDescription>{t("delete_account_consequences")}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirmation">
{t("delete_account_confirm_label", { word: CONFIRMATION_WORD })}
</Label>
<Input
id="delete-confirmation"
autoComplete="off"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={deleting}>
{t("delete_account_cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={confirmation !== CONFIRMATION_WORD || deleting}
className="relative"
>
<span className={deleting ? "opacity-0" : ""}>{t("delete_account_confirm")}</span>
{deleting && <Spinner size="sm" className="absolute" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { getUserAvatarColor, getUserInitials } from "@/lib/user-avatar";
import { DeleteAccountDialog } from "./DeleteAccountDialog";
function AvatarDisplay({
url,
@ -56,6 +57,7 @@ export function ProfileContent() {
const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom);
const [displayName, setDisplayName] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
if (user) {
@ -131,6 +133,20 @@ export function ProfileContent() {
</div>
</form>
)}
{!isUserLoading && (
<div className="mt-10 space-y-3 rounded-lg border border-destructive/30 p-4">
<div className="space-y-1">
<h2 className="text-sm font-medium">{t("delete_account_heading")}</h2>
<p className="text-xs text-muted-foreground">{t("delete_account_description")}</p>
</div>
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
{t("delete_account_heading")}
</Button>
</div>
)}
<DeleteAccountDialog open={deleteOpen} onOpenChange={setDeleteOpen} />
</div>
);
}

View file

@ -21,6 +21,13 @@ class UserApiService {
body: request,
});
};
/**
* Delete the current account. Locks it out immediately; the erase follows.
*/
deleteMe = async () => {
return baseApiService.delete(`/users/me`);
};
}
export const userApiService = new UserApiService();