All files / src/features/profile DeleteAccountSection.tsx

96.87% Statements 31/32
93.75% Branches 15/16
100% Functions 6/6
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 1052x                                 50x 50x 50x 50x 50x 50x 50x   50x       50x 20x       3x 3x 3x 3x 3x 3x 3x   2x 2x   1x 1x 1x       50x                   7x                         5x               1x 1x 1x                                        
"use client";
 
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "@/lib/auth-client";
import { Button, Card, Input } from "@/components/ui";
 
interface DeleteAccountSectionProps {
  email: string;
}
 
// Self-scoped permanent account delete. DELETE /api/users/me takes no body and
// acts on session.user.id (no IDOR). Owned collections are total-erase — they're
// deleted for every co-curator too, so the confirm copy must say so. Strong guard:
// the user retypes their own email (shown on this screen) before the final button
// enables. On success the JWT session is dead, so we signOut() rather than push.
export function DeleteAccountSection({ email }: DeleteAccountSectionProps) {
  const router = useRouter();
  const [confirming, setConfirming] = useState(false);
  const [typed, setTyped] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const inFlight = useRef(false);
  const inputRef = useRef<HTMLInputElement>(null);
 
  const matches = typed.trim().toLowerCase() === email.toLowerCase();
 
  // When the confirm step appears, move focus into the email field so a keyboard
  // user isn't dropped and a screen reader announces the new step.
  useEffect(() => {
    if (confirming) inputRef.current?.focus();
  }, [confirming]);
 
  async function deleteAccount() {
    Iif (inFlight.current || !matches) return;
    inFlight.current = true;
    setBusy(true);
    setError("");
    try {
      const res = await fetch("/api/users/me", { method: "DELETE" });
      if (!res.ok) throw new Error("delete failed");
      // The account is gone — end the (now-dead) session and land on login.
      await signOut();
      router.push("/login");
    } catch {
      setError("That didn’t go through — your account is safe; try again in a moment.");
      setBusy(false);
      inFlight.current = false;
    }
  }
 
  return (
    <Card variant="danger" padding="lg" className="mt-10">
      <h2 className="text-lg text-ink">Delete your account</h2>
      <p className="mt-1 text-sm text-ink-soft">
        This permanently removes your account and everything in it — your collections, items, photos,
        and sorts. Collections you own are deleted for everyone you’ve shared them with. It can’t be
        undone.
      </p>
 
      {!confirming ? (
        <Button variant="danger" className="mt-4" onClick={() => setConfirming(true)}>
          Delete your account
        </Button>
      ) : (
        <div role="group" aria-label="Delete your account" className="mt-4 flex flex-col gap-3">
          <label htmlFor="confirm-delete-account" className="text-sm text-ink">
            Type <span className="font-semibold">{email}</span> to confirm
          </label>
          <Input
            id="confirm-delete-account"
            ref={inputRef}
            type="email"
            value={typed}
            onChange={(e) => setTyped(e.target.value)}
            autoComplete="off"
            aria-label={`Type ${email} to confirm`}
          />
          <div className="flex gap-2">
            <Button
              variant="secondary"
              onClick={() => {
                setConfirming(false);
                setTyped("");
                setError("");
              }}
              disabled={busy}
            >
              Cancel
            </Button>
            <Button variant="danger" onClick={deleteAccount} disabled={busy || !matches}>
              {busy ? "Deleting…" : "Delete account"}
            </Button>
          </div>
          {error && (
            <span role="status" className="text-sm text-error-deep">
              {error}
            </span>
          )}
        </div>
      )}
    </Card>
  );
}