Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/self-serve-account-deletion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"dotflowy": minor
---

Add self-serve account deletion (More menu → Delete account). Confirmed by an emailed link, it cancels any active subscription, erases your outline, and deletes your account — the flow the privacy policy commits to.
44 changes: 44 additions & 0 deletions docs/adr/0050-self-serve-account-deletion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Self-serve account deletion: ordered teardown across three systems, aborting before the point of no return

The privacy policy commits to "you can delete your account yourself, in the app" ([#161](https://github.com/cameronapak/dotflowy/issues/161)), and the legal pages can't publish while that promise points at a flow that doesn't exist ([#226](https://github.com/cameronapak/dotflowy/issues/226)) — so deletion is beta-blocking. The hard part isn't the button; it's that one "delete" fans out across **three systems that share no transaction**: the per-user Durable Object (outline + kv side-collections), D1 (Better Auth identity + the Stripe `subscription` row), and Stripe itself (a live subscription, possibly a $99/3-year founding prepay). Any step can succeed while the next fails. This ADR fixes the meaning of "delete" for beta and the teardown order that makes every reachable partial-failure state a _safe_ one.

## The decisions

1. **Immediate hard delete, no undo window.** Deletion is irreversible at the app surface the moment it completes — no soft-delete marker, no "scheduled for deletion" login state, no self-serve undo. Rejected a grace/undo window: it drags in a whole subsystem (a `deletedAt` column + a scheduled purge via DO alarm or cron + a deletion-pending auth state) for a beta-marginal feature. The safety net for an _accidental_ delete is the operator restoring from PITR (decision 8), not a product undo. A self-serve undo window is **fog / post-beta**.

2. **Built on Better Auth's `user.deleteUser`, not a hand-rolled endpoint.** Enabling `user.deleteUser` gives us the `/delete-user` endpoint, the D1 cascade of `session`/`account`/`verification`/`user`, session revocation, and an optional email-token confirmation — all riding the existing `/api/auth/*` routing. A custom Worker endpoint would re-implement session revoke + cascade and drift from Better Auth's contract. The `beforeDelete(user)` and `afterDelete(user)` hooks are the seams for everything Better Auth doesn't know about (Stripe, the DO, the email-bearing side tables). The hooks close over the per-request `env` (auth is built per request in `createAuth(env, origin, ctx)`), so they can reach `env.USER_OUTLINE`, `env.STRIPE_SECRET_KEY`, and `env.DB`.

3. **The confirmation gate is intent + identity: type-to-confirm in the app, then an emailed confirmation link.** Type-to-confirm alone fails the identity test on a shared or unlocked machine, and identity proof alone lets a mis-click through — an irreversible action needs both. Intent is a "type DELETE" gate in the dialog; identity is **clicking a link emailed to the account address** (`sendDeleteAccountVerification`, funneled through the one `worker/email.ts` seam like password reset). Email control is the strongest, uniform proof — it works identically for password AND Google-only accounts, with no per-account-type branching — and an emailed confirmation for permanent deletion is the mature-app norm (GitHub, Google). The callback that the link hits requires a live session and redirects to a client-supplied `callbackURL`, so it also gives us the post-delete hardReset for free (a full navigation). Rejected type-to-confirm-only (no identity proof).

> **Amended during build (kept here, not rewritten away):** the grill picked "password re-auth for password accounts, email-token only for Google-only" to minimize friction on the common case. Better Auth's `deleteUser` route makes that split un-buildable without bypassing its endpoint: once `sendDeleteAccountVerification` is configured, `POST /delete-user` ALWAYS routes to the send-email branch and returns before deleting, even when a valid password is supplied — so configuring the sender forces email confirmation for everyone regardless. Given that, email-confirm-for-everyone is both the library-supported path AND strictly more robust (uniform, strongest proof, less client code), so it wins outright. The extra friction is a feature for an irreversible action. Both `beforeDelete` and `afterDelete` fire on the token-callback path (verified in the Better Auth source), so the teardown below is unaffected.

4. **Teardown order: cancel Stripe → delete the D1 subscription row → wipe the DO → (Better Auth deletes the D1 identity).** Steps 1–3 run inside `beforeDelete(user)`; step 4 is `deleteUser`'s own cascade, reached **only if `beforeDelete` returns without throwing**. The order is derived by ranking the partial-failure states worst-to-mildest and putting the riskiest, most-external, must-not-orphan work first:
- **Worst — identity gone, Stripe still live:** the account no longer exists but Stripe keeps charging a subscription with no account to cancel it from and no webhook target to reconcile it. A silent recurring charge for a deleted account. Cancelling Stripe **first**, where a failure aborts the whole delete, makes this state **unreachable by construction**.
- **Bad-ish — DO wiped, identity survives:** the user logs back in to an empty outline (re-seeds the welcome bullets). Annoying, recoverable, no money or privacy harm.
- **Mildest — Stripe cancelled + sub row gone, identity survives:** they keep their account but lost their paid plan; they can re-subscribe or retry the delete. Cancel never charges, so it's money-safe.
Since `beforeDelete` throwing aborts the D1 identity delete, the only reachable partial-failure states are the _mild_ ones, and each is idempotently fixable by retrying (decision 6). Rejected **DO-wipe-after-identity-delete**: if identity delete succeeds and the DO wipe then fails, the DO **orphans** — live user data in storage with no account pointing at it and no logged-in user left to retry — which directly violates the "we deleted your data" promise. Orphaned _data_ is strictly worse than an orphaned _empty outline_.

5. **Stripe is cancelled immediately, and the `subscription` row is deleted explicitly.** Immediate `stripe.subscriptions.cancel()`, **not** `cancel_at_period_end` — an "active until period end" subscription pointed at a deleted user is precisely the orphan decision 4 designs out. Better Auth's `deleteUser` does **not** cascade the Stripe plugin's `subscription` table (it has no user-delete relation), so we delete rows for `referenceId = user.id` ourselves. A **comped** user (an operator-inserted `active` row with no Stripe ids, per [#170](https://github.com/cameronapak/dotflowy/issues/170)) and a free user (no row) both **skip the Stripe call** and just clear any D1 row — the Stripe step no-ops whenever there's no `stripeSubscriptionId`.

6. **Every step is idempotent; retry after a partial failure is safe.** Skip Stripe when there's no active subscription; deleting an already-absent `subscription` row is a no-op; the DO's `purge()` RPC (`ctx.storage.deleteAll()`) is safe to call twice. And the strongest guard is structural: `deleteUser` revokes the session, so **once the full delete succeeds there is no session left to authenticate a second delete**. A partial failure aborts _before_ session revoke, so the user is still signed in and can simply retry — each step re-runs and no-ops whatever already happened.

7. **Erasure includes the email-bearing side tables, best-effort, in `afterDelete`.** Beyond Better Auth's cascade, `afterDelete(user)` scrubs the `waitlist` and per-email invite rows ([#251](https://github.com/cameronapak/dotflowy/issues/251)) matching the user's email — they carry PII (the email) that the "delete my data" promise covers. This runs _after_ the identity delete and is **non-fatal**: the account is already gone, so a failed scrub is logged (Sentry), not surfaced as an error. Kept deliberately narrow — no attempt to chase every derived record; the account and the outline are the substance.

8. **"Deleted" at the app surface is honest about the 30-day PITR floor.** Durable Objects carry automatic 30-day Point-in-Time Recovery ([#155](https://github.com/cameronapak/dotflowy/issues/155)), so `storage.deleteAll()` removes the outline from every live path immediately but the operator retains a recoverable backup for up to 30 days. The privacy policy must say so plainly — deletion is immediate and irreversible in-app; backups purge automatically within 30 days — matching the map's privacy-honesty rule (per-user DOs isolate users from each other, not from the operator). The confirmation dialog names it in one line; [#226](https://github.com/cameronapak/dotflowy/issues/226) carries the full statement. This is disclosure, not a user-facing undo — PITR restore is a manual operator support action.

9. **Refunds are decoupled from deletion.** Deleting cancels the subscription immediately; it does **not** auto-refund. The ToS's 14-day refund window on annual/founding stays a **manual support action** (Stripe dashboard / support@) — baking prorated-refund logic into an irreversible teardown path is error-prone scope creep. The confirmation copy warns explicitly, which matters most for founding ($99 / 3-year prepay): "Deleting cancels your subscription immediately with no automatic refund; contact support within 14 days if you're eligible."

## UX and mechanism

- **Entry point:** a danger-styled "Delete account" item in the header More menu, opening a `DeleteAccountDialog` mounted once in `__root.tsx` (the `DeleteConfirmDialog` pattern). The dialog carries the type-to-confirm field, the identity proof (password field, or "we'll email you a confirmation link" for Google-only accounts), and the refund + 30-day-backup warnings.
- **Post-delete:** on success the client does a **`hardReset` → `window.location.replace("/")`**, never SPA navigation — module singletons (collections, the sync fiber, view/selection state) persist across the auth boundary, and only a full document load tears them down (the cross-account-leak rule; ADR 0011's hardReset teardown). It lands on the signed-out AuthScreen with a "Your account has been deleted" confirmation.

## Testing

The full flow **isn't e2e-reachable** — `seedOutline` mocks `/api/auth`, so the real `deleteUser` + hook path never runs under Playwright (the same constraint as password reset). So: the **pure guards** get unit tests (skip-Stripe-when-no-subscription, `purge()` idempotency, the row-scrub SQL), and the end-to-end flow gets a **manual verification checklist in the PR** covering the password path, the Google-only email-token path, a comped-user delete (Stripe no-op), and a forced mid-teardown failure to confirm the abort leaves the account intact and retryable.

## Non-goals (v1) and noted edges

- **Self-serve undo / grace window** — deferred (decision 1); the operator PITR restore is the accidental-delete safety net.
- **Automatic refunds** — manual support action (decision 9).
- **Owner edge case:** `OWNER_USER_ID` maps the owner account to the constant `'default'` DO (ADR 0011), which holds the seeded legacy outline; an owner self-deleting via the app would `purge()` that data. Realistically the owner won't self-delete through the UI, so beta ships no special-casing — noted here so a future change to owner semantics revisits it deliberately.
20 changes: 20 additions & 0 deletions src/components/auth-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from "react";
import { toast } from "sonner";

import {
hardReset,
Expand Down Expand Up @@ -62,6 +63,25 @@ export function AuthScreen() {
if (message) setError(message);
}, []);

// Account deletion (ADR 0050) completes server-side and redirects here
// signed-out with ?account-deleted — the positive confirmation that the
// teardown ran. Strip the param so a reload doesn't re-toast, and only the
// first Strict-Mode effect run (which still sees the param) fires.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (!params.has("account-deleted")) return;
params.delete("account-deleted");
const query = params.toString();
window.history.replaceState(
null,
"",
window.location.pathname +
(query ? `?${query}` : "") +
window.location.hash,
);
toast.success("Your account has been deleted.", { duration: 10_000 });
}, []);

async function onGoogle() {
setError(null);
setBusy(true);
Expand Down
172 changes: 172 additions & 0 deletions src/components/delete-account-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Loader2Icon, MailCheckIcon, TriangleAlertIcon } from "lucide-react";
import { useEffect, useState } from "react";

import {
NETWORK_ERROR_MESSAGE,
requestAccountDeletion,
} from "../lib/auth-client";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";

/**
* Self-serve account deletion dialog (ADR 0050, ticket #224), opened from the
* header More menu. Two stages:
*
* 1. "form" — the type-to-confirm intent gate ("DELETE") plus the three
* surprises stated plainly (permanent, cancels the subscription with no
* automatic refund, backups purge in 30 days). Submitting does NOT delete
* anything: it calls requestAccountDeletion, which SENDS a confirmation
* email (identity proof — uniform across password + Google-only accounts).
* 2. "sent" — "check your email"; the emailed link is what actually deletes
* (its callback redirects here signed-out, a full-navigation teardown).
*
* The word to type is "DELETE" (case-insensitive, trimmed) — an intent gate,
* not identity proof; the email link is the identity proof.
*/

const CONFIRM_WORD = "DELETE";

type Stage = "form" | "sent";

export function DeleteAccountDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [stage, setStage] = useState<Stage>("form");
const [confirmText, setConfirmText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

// Reset to a clean form whenever the dialog closes, so reopening never shows
// a stale "sent" screen or a half-typed confirmation.
useEffect(() => {
if (!open) {
setStage("form");
setConfirmText("");
setBusy(false);
setError(null);
}
}, [open]);

const confirmed = confirmText.trim().toUpperCase() === CONFIRM_WORD;

async function onSubmit() {
if (!confirmed || busy) return;
setBusy(true);
setError(null);
try {
const res = await requestAccountDeletion();
if (res.error) {
setError(res.error.message ?? "Couldn't start deletion. Try again.");
setBusy(false);
return;
}
setStage("sent");
} catch {
setError(NETWORK_ERROR_MESSAGE);
} finally {
setBusy(false);
}
}

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
{stage === "form" ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TriangleAlertIcon className="size-5 text-destructive" />
Delete your account
</DialogTitle>
<DialogDescription>
This permanently deletes your account and your entire outline.
It cannot be undone.
</DialogDescription>
</DialogHeader>

<ul className="list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">
<li>Your outline and all your data are permanently erased.</li>
<li>
Any active subscription is cancelled immediately, with no
automatic refund. Contact support within 14 days if you're
eligible.
</li>
<li>Backups are purged within 30 days.</li>
<li>
We'll email you a confirmation link — deletion only happens once
you click it.
</li>
</ul>

<div className="space-y-1.5">
<label htmlFor="delete-confirm" className="text-sm font-medium">
Type <span className="font-semibold">{CONFIRM_WORD}</span> to
confirm
</label>
<Input
id="delete-confirm"
autoComplete="off"
autoFocus
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit();
}}
placeholder={CONFIRM_WORD}
aria-invalid={error ? true : undefined}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>

<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={busy}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onSubmit}
disabled={!confirmed || busy}
>
{busy && <Loader2Icon className="animate-spin" />}
Email me the deletion link
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MailCheckIcon className="size-5" />
Check your email
</DialogTitle>
<DialogDescription>
We sent a confirmation link to your email address. Click it to
permanently delete your account. The link expires in 24 hours;
until you click it, nothing is deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}
Loading
Loading