diff --git a/internal/auth/delete_handlers.go b/internal/auth/delete_handlers.go
new file mode 100644
index 0000000..0f419fb
--- /dev/null
+++ b/internal/auth/delete_handlers.go
@@ -0,0 +1,105 @@
+package auth
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/jleight/meshtender/internal/store"
+ "github.com/jleight/meshtender/internal/web"
+)
+
+// Account deletion. The page's job is to make the consequences legible before
+// anyone clicks: exactly which repeaters would be destroyed (with a link to hand
+// each one to a steward instead), which organizations go or stay, and anything
+// that blocks the deletion outright.
+
+// deleteAccountErr bounces back to the confirm page with an error, so the user
+// keeps the context rather than landing on the account page wondering.
+func deleteAccountErr(w http.ResponseWriter, r *http.Request, msg string) {
+ web.RedirectErr(w, r, "/account/delete", msg)
+}
+
+// pageDeleteAccount renders the deletion confirm page.
+func (s *Handlers) pageDeleteAccount(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ uid := s.Auth.CurrentUserID(ctx)
+ u, err := s.Store.GetUserByID(ctx, uid)
+ if err != nil {
+ s.ServerError(w, r, "could not load account", err)
+ return
+ }
+ preview, err := s.Store.PreviewUserDeletion(ctx, uid)
+ if err != nil {
+ s.ServerError(w, r, "could not load account", err)
+ return
+ }
+ s.Render(w, r, "delete_account.html", map[string]any{
+ "User": u,
+ "Preview": preview,
+ // Which proof of identity to ask for. An account can have both; the password
+ // field is shown when there is one, with the passkey button alongside.
+ "HasPassword": u.PasswordHash != nil,
+ "HasPasskeys": preview.Passkeys > 0,
+ // Repeater transfer lives on the app host, so links out need its origin.
+ "AppOrigin": s.Auth.AppOrigin(r),
+ "Error": r.URL.Query().Get("error"),
+ })
+}
+
+// handleDeleteAccount verifies the person is still present — a password, or a
+// passkey assertion completed in the last ReauthWindow — and then deletes the
+// account. The store re-checks every blocker inside its transaction, so this
+// handler's job is the identity proof and the messaging.
+func (s *Handlers) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ uid := s.Auth.CurrentUserID(ctx)
+ u, err := s.Store.GetUserByID(ctx, uid)
+ if err != nil {
+ s.ServerError(w, r, "could not load account", err)
+ return
+ }
+
+ // A fresh passkey assertion satisfies this outright; otherwise fall back to the
+ // password. An account with neither can't reach here — every account has at
+ // least one sign-in method, enforced when removing one.
+ if !s.Auth.ReauthFresh(ctx) {
+ switch pw := r.FormValue("password"); {
+ case u.PasswordHash == nil:
+ deleteAccountErr(w, r, "Verify with your passkey to delete your account.")
+ return
+ case pw == "":
+ deleteAccountErr(w, r, "Enter your password, or verify with your passkey, to delete your account.")
+ return
+ case !s.Auth.PasswordMatches(u, pw):
+ deleteAccountErr(w, r, "That password is incorrect.")
+ return
+ }
+ }
+
+ switch err := s.Store.DeleteUser(ctx, uid); {
+ case errors.Is(err, store.ErrSoleOrgAdmin):
+ deleteAccountErr(w, r, "You're the only admin of an organization that still has other members. "+
+ "Make someone else an admin there first, then come back.")
+ case errors.Is(err, store.ErrLastSiteAdmin):
+ deleteAccountErr(w, r, "You're the last administrator of this MeshTender instance. "+
+ "Give another account the manage-users capability first.")
+ case errors.Is(err, store.ErrNotFound):
+ // Already gone (a double submit, or deleted in another tab). Treat it as
+ // done rather than as an error: the outcome the user asked for holds.
+ s.finishDeletion(w, r)
+ case err != nil:
+ s.ServerError(w, r, "could not delete account", err)
+ default:
+ s.finishDeletion(w, r)
+ }
+}
+
+// finishDeletion tears down the session and lands on the sign-in page with a
+// confirmation. Every other host drops to anonymous on its next request anyway —
+// the logins row cascaded away with the account, and a missing row reads as
+// revoked — but clearing this host's session too means the browser isn't left
+// holding a cookie for an account that no longer exists.
+func (s *Handlers) finishDeletion(w http.ResponseWriter, r *http.Request) {
+ _ = s.Auth.Logout(r.Context())
+ web.RedirectFlash(w, r, "/login", "ok", "Your account and everything in it have been deleted.")
+}
diff --git a/internal/auth/reauth.go b/internal/auth/reauth.go
new file mode 100644
index 0000000..dff6c96
--- /dev/null
+++ b/internal/auth/reauth.go
@@ -0,0 +1,127 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "time"
+
+ "github.com/jleight/meshtender/internal/web"
+)
+
+// Re-authentication ("sudo mode"): proving, right now, that the person driving
+// an authenticated session is still the account holder. A live session says
+// somebody signed in here at some point — not that the person about to destroy
+// the account is the one who did. Deleting an account is irreversible and one
+// click from an unattended browser, so it demands a fresh proof.
+//
+// Password holders re-enter their password (handled at the point of use, where
+// the form already is); passkey-only accounts complete an assertion here, which
+// stamps the session. Either way, the proof expires quickly.
+
+// sessKeyReauthAt holds the unix seconds of the last successful identity proof.
+// Stored as an int64 rather than a time.Time: scs serializes session data with
+// gob, and an integer needs no type registration to survive a round trip.
+const sessKeyReauthAt = "reauth_at"
+
+// ReauthWindow is how long a proof of presence authorizes a sensitive action.
+// Long enough to read a confirmation page and think, short enough that a walk-
+// away between the ceremony and the click doesn't hand someone the account.
+const ReauthWindow = 5 * time.Minute
+
+// MarkReauth records a successful identity proof on the current session.
+func (s *Service) MarkReauth(ctx context.Context) {
+ s.Sessions.Put(ctx, sessKeyReauthAt, time.Now().Unix())
+}
+
+// ReauthFresh reports whether this session proved its identity within
+// ReauthWindow.
+func (s *Service) ReauthFresh(ctx context.Context) bool {
+ at := s.Sessions.GetInt64(ctx, sessKeyReauthAt)
+ return at != 0 && time.Since(time.Unix(at, 0)) < ReauthWindow
+}
+
+// ReauthPasskeyBegin starts an assertion ceremony against the signed-in user's
+// own credentials. Unlike LoginBegin it takes no username: the account is
+// whoever the session says it is, so this can't be used to probe for accounts.
+func (s *Service) ReauthPasskeyBegin(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ uid := s.CurrentUserID(ctx)
+ if uid == 0 {
+ httpError(w, r, http.StatusUnauthorized, "not signed in", nil)
+ return
+ }
+ u, err := s.store.GetUserByID(ctx, uid)
+ if err != nil {
+ httpError(w, r, http.StatusInternalServerError, "load user", err)
+ return
+ }
+ waUser, err := s.loadWebAuthnUser(ctx, u)
+ if err != nil {
+ httpError(w, r, http.StatusInternalServerError, "load credentials", err)
+ return
+ }
+ if len(waUser.creds) == 0 {
+ httpError(w, r, http.StatusBadRequest, "no passkey registered for this account", nil)
+ return
+ }
+ options, sessionData, err := s.wa.BeginLogin(waUser)
+ if err != nil {
+ httpError(w, r, http.StatusInternalServerError, "begin verification", err)
+ return
+ }
+ if err := s.stashCeremony(ctx, uid, sessionData); err != nil {
+ httpError(w, r, http.StatusInternalServerError, "save ceremony", err)
+ return
+ }
+ writeJSON(w, options)
+}
+
+// ReauthPasskeyFinish completes the assertion and stamps the session as freshly
+// verified. It grants no new access on its own — the sensitive handler decides
+// what a fresh stamp is worth.
+func (s *Service) ReauthPasskeyFinish(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ uid := s.CurrentUserID(ctx)
+ ceremonyUID, sessionData, ok := s.popCeremony(ctx)
+ if !ok {
+ httpError(w, r, http.StatusBadRequest, "no verification in progress", nil)
+ return
+ }
+ // The ceremony must belong to the session driving it. Begin only ever stashes
+ // the current user, so this is belt-and-braces against a ceremony stashed by
+ // some other flow (a half-finished sign-in) being spent as a re-auth here.
+ if uid == 0 || ceremonyUID != uid {
+ httpError(w, r, http.StatusUnauthorized, "verification failed", nil)
+ return
+ }
+ u, err := s.store.GetUserByID(ctx, uid)
+ if err != nil {
+ httpError(w, r, http.StatusInternalServerError, "load user", err)
+ return
+ }
+ waUser, err := s.loadWebAuthnUser(ctx, u)
+ if err != nil {
+ httpError(w, r, http.StatusInternalServerError, "load credentials", err)
+ return
+ }
+ cred, err := s.wa.FinishLogin(waUser, *sessionData, r)
+ if err != nil {
+ // Same treatment as LoginFinish: the go-webauthn detail can leak internals,
+ // so log it and return something generic.
+ web.LogError(r, "webauthn: finish reauth", err)
+ httpError(w, r, http.StatusUnauthorized, "verification failed", nil)
+ return
+ }
+ // Persist the updated sign counter / clone-warning state, as login does.
+ if blob, err := json.Marshal(cred); err == nil {
+ _ = s.store.UpdateCredential(ctx, cred.ID, blob)
+ }
+ s.MarkReauth(ctx)
+ writeJSON(w, authResult{OK: true})
+}
+
+// AppOrigin is the app host's origin for this request, so auth-host pages can
+// link into the app (the delete page offers repeater transfers, which live
+// there).
+func (s *Service) AppOrigin(r *http.Request) string { return s.appOrigin(r) }
diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html
index 20a5537..57e84e0 100644
--- a/internal/auth/templates/account.html
+++ b/internal/auth/templates/account.html
@@ -333,6 +333,22 @@
+{{/* Deletion is a page, not a button here: what it removes depends on what you
+ own, and that has to be shown before anyone commits to it. */}}
+
+
Delete account
+
+
+ Permanently delete your account, your public profile, and the repeaters you own. This can't be
+ undone — the next page shows exactly what would go, and lets you hand any repeater to a steward
+ first.
+
diff --git a/internal/auth/templates/delete_account.html b/internal/auth/templates/delete_account.html
new file mode 100644
index 0000000..d32aed0
--- /dev/null
+++ b/internal/auth/templates/delete_account.html
@@ -0,0 +1,194 @@
+{{define "title"}}Delete your account · MeshTender{{end}}
+{{define "header"}}
+
+
Delete your account?
+
+{{end}}
+{{define "content"}}
+{{if .Error}}
{{.Error}}
{{end}}
+
+
+
+
+{{if .Preview.Blocked}}
+{{/* Blocked: show only what must be resolved. Rendering the confirm form under a
+ "you can't do this yet" banner just invites a click that will fail. */}}
+
+
Not yet — someone has to take over first
+
+
+ Your account can't be deleted while other people depend on it for administration. Once these are
+ resolved, come back here and it'll go through.
+
+ {{if .Preview.LastSiteAdmin}}
+
+
{{template "icon-alert" "alert-icon"}}
+
+
You're the last administrator of this site
+
+ Deleting your account would leave MeshTender with nobody able to manage users. Give another
+ account the manage-users capability first.
+
+
+
+ {{end}}
+ {{if .Preview.OrgsBlocked}}
+
+
{{template "icon-alert" "alert-icon"}}
+
+
+ You're the only admin of {{if eq (len .Preview.OrgsBlocked) 1}}an organization{{else}}some organizations{{end}} with other members
+
+
+ Make someone else an admin in each of these, or remove the other members:
+
+ This deletes your account, your profile at /u/{{.User.Username}}, your sign-in
+ methods, and the things below. It can't be undone, and you'll be signed out
+ everywhere immediately.
+
+
+ {{if .Preview.Repeaters}}
+
Repeaters you own
+
+ These are deleted outright — with their documentation, maintenance history, command log, and
+ everyone else's access to them. If a repeater is staying on the air, hand it to a steward instead;
+ it keeps all of that and its public page address.
+
You're the only member of {{if eq (len .Preview.OrgsDeleted) 1}}this one{{else}}these{{end}}, so nothing is left behind:
+
+ {{range .Preview.OrgsDeleted}}
{{.Name}}
{{end}}
+
+ {{end}}
+
+ {{if .Preview.OrgsLeft}}
+
Organizations you'll simply leave
+
These carry on without you:
+
+ {{range .Preview.OrgsLeft}}
{{.Name}}
{{end}}
+
+ {{end}}
+
+ {{/* Each line is conditional: listing "0 passkeys" or access to "0 repeaters"
+ reads as a bug, and padding the consequences with non-consequences makes
+ the real ones easier to skim past. */}}
+
Also removed
+
+ {{if .Preview.Passkeys}}
+
{{.Preview.Passkeys}} passkey{{if ne .Preview.Passkeys 1}}s{{end}}{{if .HasPassword}} and your password{{end}}
+ {{else if .HasPassword}}
+
Your password
+ {{end}}
+ {{if .Preview.SharedWithUser}}
+
Your access to {{.Preview.SharedWithUser}} repeater{{if ne .Preview.SharedWithUser 1}}s{{end}} other people share with you
+ {{end}}
+
Your display name, bio, location, callsign, links, and email address
+
+
+
+
+
+
What stays
+
+
+ Commands you ran on other people's repeaters stay in those repeaters' logs, and maintenance
+ notes you wrote stay in their history — recorded against the name you had at the time, with no link
+ back to an account. Removing them would tear holes in records their owners rely on. Your username is
+ held in reserve for 90 days so nobody can pick it up and inherit those mentions.
+
+
+
+
+
+
Confirm it's you
+
+
+
+ {{if and .HasPasskeys (not .HasPassword)}}
+ {{/* Passkey-only: there's no password to type, so the ceremony IS the
+ confirmation. The button verifies, then submits the form above. */}}
+
Verify with your passkey to enable deletion.
+
+
+ {{else if .HasPasskeys}}
+
Or verify with a passkey instead of typing your password:
+
+{{end}}
diff --git a/internal/auth/web.go b/internal/auth/web.go
index 06e5e6d..21f0e27 100644
--- a/internal/auth/web.go
+++ b/internal/auth/web.go
@@ -87,6 +87,14 @@ func (s *Handlers) Routes() chi.Router {
r.Post("/account/email/verify", s.handleResendEmailVerification)
r.Post("/account/passkeys/rename", s.handleRenamePasskey)
r.Post("/account/passkeys/delete", s.handleDeletePasskey)
+ r.Get("/account/delete", s.pageDeleteAccount)
+ r.Post("/account/delete", s.handleDeleteAccount)
+ // Re-auth ceremony for passkey holders confirming a sensitive action.
+ // Session-gated (it asserts against the signed-in user's own
+ // credentials), so it lives here rather than with the public /api
+ // sign-in ceremonies.
+ r.Post("/account/reauth/passkey/begin", s.Auth.ReauthPasskeyBegin)
+ r.Post("/account/reauth/passkey/finish", s.Auth.ReauthPasskeyFinish)
})
// Bare visits to the auth host go to the sign-in page.
diff --git a/internal/core/account_delete_test.go b/internal/core/account_delete_test.go
new file mode 100644
index 0000000..edf19f0
--- /dev/null
+++ b/internal/core/account_delete_test.go
@@ -0,0 +1,197 @@
+package core
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/jleight/meshtender/internal/store"
+)
+
+// TestDeleteAccountPageShowsConsequences: the confirm page has to state what
+// would actually be destroyed, and offer the handover for a repeater that has a
+// steward — that link is the whole reason transfer exists.
+func TestDeleteAccountPageShowsConsequences(t *testing.T) {
+ t.Parallel()
+ st, ctx, ts, h := splitServer(t)
+ authSSO(t, ts, h, "bootstrapadmin")
+ sso := authSSO(t, ts, h, "leaving")
+ u, err := st.GetUserByUsername(ctx, "leaving")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rep := newOwnedRepeater(t, st, ctx, u.ID, "Hilltop")
+ steward, err := st.CreateUser(ctx, "keeper", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.AddShare(ctx, rep.ID, steward.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetShareSteward(ctx, rep.ID, steward.ID, true); err != nil {
+ t.Fatal(err)
+ }
+
+ resp := do(t, ts, h.auth, "/account/delete", sso)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET /account/delete = %d, want 200", resp.StatusCode)
+ }
+ raw, _ := io.ReadAll(resp.Body)
+ page := string(raw)
+ if !strings.Contains(page, "Hilltop") {
+ t.Fatal("confirm page doesn't name the repeater that would be destroyed")
+ }
+ if !strings.Contains(page, "/repeaters/"+rep.PublicID+"/transfer") {
+ t.Fatal("confirm page doesn't offer to transfer a repeater that has a steward")
+ }
+ // The account page must lead here.
+ acct := do(t, ts, h.auth, "/account", sso)
+ defer acct.Body.Close()
+ accRaw, _ := io.ReadAll(acct.Body)
+ if !strings.Contains(string(accRaw), `href="/account/delete"`) {
+ t.Fatal("account page has no link to delete the account")
+ }
+}
+
+// TestDeleteAccountRequiresPassword is the re-auth gate: a live session alone
+// must not be enough to destroy the account.
+func TestDeleteAccountRequiresPassword(t *testing.T) {
+ t.Parallel()
+ st, ctx, ts, h := splitServer(t)
+ authSSO(t, ts, h, "bootstrapadmin")
+ sso := authSSO(t, ts, h, "cautious")
+ u, err := st.GetUserByUsername(ctx, "cautious")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // No password at all.
+ empty := post(t, ts, h.auth, "/account/delete", url.Values{}, sso)
+ empty.Body.Close()
+ assertRedirect(t, empty, "/account/delete", "delete with no proof")
+ if loc, _ := url.Parse(empty.Header.Get("Location")); loc.Query().Get("error") == "" {
+ t.Fatal("refusal carried no error message")
+ }
+
+ // Wrong password.
+ wrong := post(t, ts, h.auth, "/account/delete", url.Values{"password": {"not-the-password"}}, sso)
+ wrong.Body.Close()
+ assertRedirect(t, wrong, "/account/delete", "delete with wrong password")
+
+ if _, err := st.GetUserByID(ctx, u.ID); err != nil {
+ t.Fatalf("account was deleted without a valid password: %v", err)
+ }
+}
+
+// TestDeleteAccountSucceeds: with the right password the account and its data go,
+// the session is torn down, and the freed username is held in reserve.
+func TestDeleteAccountSucceeds(t *testing.T) {
+ t.Parallel()
+ st, ctx, ts, h := splitServer(t)
+ authSSO(t, ts, h, "bootstrapadmin")
+ sso := authSSO(t, ts, h, "goodbye")
+ u, err := st.GetUserByUsername(ctx, "goodbye")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rep := newOwnedRepeater(t, st, ctx, u.ID, "Doomed")
+
+ resp := post(t, ts, h.auth, "/account/delete", url.Values{"password": {testPassword}}, sso)
+ resp.Body.Close()
+ assertRedirect(t, resp, "/login", "delete account")
+ if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Query().Get("ok") == "" {
+ t.Fatal("successful deletion carried no confirmation")
+ }
+
+ if _, err := st.GetUserByID(ctx, u.ID); !errors.Is(err, store.ErrNotFound) {
+ t.Fatalf("GetUserByID after delete = %v, want ErrNotFound", err)
+ }
+ if _, err := st.RepeaterIDByPublicID(ctx, rep.PublicID); !errors.Is(err, store.ErrNotFound) {
+ t.Fatalf("owned repeater survived the account: %v", err)
+ }
+
+ // The session is dead everywhere: the old cookie no longer authenticates on
+ // the auth host (the logins row cascaded away with the account).
+ after := do(t, ts, h.auth, "/account", sso)
+ defer after.Body.Close()
+ if after.StatusCode == http.StatusOK {
+ t.Fatal("the deleted account's session still opens the account page")
+ }
+}
+
+// TestDeleteAccountBlockedBySoleOrgAdmin: the page explains the blocker instead
+// of offering a button that would fail, and the POST refuses too (a user who
+// skips the page must not get further than one who reads it).
+func TestDeleteAccountBlockedBySoleOrgAdmin(t *testing.T) {
+ t.Parallel()
+ st, ctx, ts, h := splitServer(t)
+ authSSO(t, ts, h, "bootstrapadmin")
+ sso := authSSO(t, ts, h, "clubadmin")
+ u, err := st.GetUserByUsername(ctx, "clubadmin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ org, err := st.CreateOrg(ctx, "Radio Club", u.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ member, err := st.CreateUser(ctx, "clubmember", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil {
+ t.Fatal(err)
+ }
+
+ page := do(t, ts, h.auth, "/account/delete", sso)
+ defer page.Body.Close()
+ raw, _ := io.ReadAll(page.Body)
+ body := string(raw)
+ if !strings.Contains(body, "Radio Club") {
+ t.Fatal("confirm page doesn't name the org blocking deletion")
+ }
+ if strings.Contains(body, `data-testid="confirm-delete"`) {
+ t.Fatal("confirm page offers a delete button that could only fail")
+ }
+
+ resp := post(t, ts, h.auth, "/account/delete", url.Values{"password": {testPassword}}, sso)
+ resp.Body.Close()
+ assertRedirect(t, resp, "/account/delete", "blocked delete")
+ if _, err := st.GetUserByID(ctx, u.ID); err != nil {
+ t.Fatalf("account deleted despite being an org's only admin: %v", err)
+ }
+}
+
+// TestReauthPasskeyBeginRequiresSession: the re-auth ceremony is session-scoped
+// and takes no username, so it can't be used anonymously or as an account oracle.
+func TestReauthPasskeyBeginRequiresSession(t *testing.T) {
+ t.Parallel()
+ _, _, ts, h := splitServer(t)
+
+ anon := post(t, ts, h.auth, "/account/reauth/passkey/begin", url.Values{})
+ defer anon.Body.Close()
+ // Unauthenticated requests are bounced by the session guard before reaching
+ // the handler; either way they must not get a challenge.
+ if anon.StatusCode == http.StatusOK {
+ t.Fatalf("anonymous reauth/begin = 200, want a rejection")
+ }
+}
+
+// TestReauthPasskeyBeginWithoutPasskey: an account with no passkey gets a clean
+// 400 rather than an unusable challenge.
+func TestReauthPasskeyBeginWithoutPasskey(t *testing.T) {
+ t.Parallel()
+ _, _, ts, h := splitServer(t)
+ authSSO(t, ts, h, "bootstrapadmin")
+ sso := authSSO(t, ts, h, "nopasskey")
+
+ resp := post(t, ts, h.auth, "/account/reauth/passkey/begin", url.Values{}, sso)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("reauth/begin with no passkey = %d, want 400", resp.StatusCode)
+ }
+}
diff --git a/internal/e2e/account_delete_test.go b/internal/e2e/account_delete_test.go
new file mode 100644
index 0000000..4c334fe
--- /dev/null
+++ b/internal/e2e/account_delete_test.go
@@ -0,0 +1,126 @@
+//go:build browser
+
+package e2e
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/chromedp/chromedp"
+
+ "github.com/jleight/meshtender/internal/store"
+)
+
+// waitForUserGone polls until the account is gone, so the assertion doesn't race
+// the server side of the delete request.
+func waitForUserGone(t *testing.T, e *e2eServer, id int64) bool {
+ t.Helper()
+ deadline := time.Now().Add(20 * time.Second)
+ for time.Now().Before(deadline) {
+ _, err := e.store.GetUserByID(e.ctx, id)
+ if errors.Is(err, store.ErrNotFound) {
+ return true
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ return false
+}
+
+// TestE2EDeleteAccountWithPasskey is the passkey-only deletion path, which no Go
+// test can reach: the account has no password, so the ONLY way to prove presence
+// is a real assertion. The button runs the ceremony against a virtual
+// authenticator and, once the server stamps the session, submits the form.
+//
+// It also proves the confirm page runs clean under the strict CSP — this page
+// carries a password toggle, a confirm gate and a WebAuthn ceremony, all of
+// which are exactly the things a CSP breaks silently.
+func TestE2EDeleteAccountWithPasskey(t *testing.T) {
+ e := newE2EServer(t)
+ // The first account is auto-promoted to superadmin and could never delete
+ // itself (it'd be the last administrator), so park one before the subject.
+ e.login(t, "e2edeleteadmin")
+ victim, cookie := e.login(t, "e2edeleteme")
+
+ ctx, cancel, watch := startBrowser(t)
+ defer cancel()
+ if err := virtualAuthenticator(ctx); err != nil {
+ t.Fatalf("set up virtual authenticator: %v", err)
+ }
+ acceptDialogs(ctx)
+
+ // Register a passkey on the account page, so the account has a passkey and no
+ // password — the state that forces the re-auth ceremony.
+ if err := chromedp.Run(ctx,
+ setSessionCookie(cookie),
+ chromedp.Navigate(e.authURL+"/account"),
+ chromedp.WaitVisible("#add-passkey-btn", chromedp.ByID),
+ chromedp.Click("#add-passkey-btn", chromedp.ByID),
+ ); err != nil {
+ t.Fatalf("add a passkey: %v", err)
+ }
+ deadline := time.Now().Add(20 * time.Second)
+ for {
+ creds, err := e.store.GetCredentials(e.ctx, victim.ID)
+ if err != nil {
+ t.Fatalf("GetCredentials: %v", err)
+ }
+ if len(creds) == 1 {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("passkey registration never landed")
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+
+ // Now delete the account: verify with the passkey, which submits the form.
+ if err := chromedp.Run(ctx,
+ chromedp.Navigate(e.authURL+"/account/delete"),
+ chromedp.WaitVisible(`[data-testid="verify-passkey"]`, chromedp.ByQuery),
+ chromedp.Click(`[data-testid="verify-passkey"]`, chromedp.ByQuery),
+ waitForLocation(e.authURL+"/login"),
+ ); err != nil {
+ var status string
+ _ = chromedp.Run(ctx, chromedp.Text("#passkey-status", &status, chromedp.ByID, chromedp.AtLeast(0)))
+ t.Fatalf("drive the delete page: %v (page status = %q)", err, status)
+ }
+
+ if !waitForUserGone(t, e, victim.ID) {
+ t.Fatal("the account still exists after a verified deletion")
+ }
+ watch.assertClean(t)
+}
+
+// TestE2EDeleteAccountWithoutVerifying: clicking delete without proving presence
+// must not destroy the account. This is the whole point of the re-auth gate —
+// a live session on an unattended browser isn't enough.
+func TestE2EDeleteAccountWithoutVerifying(t *testing.T) {
+ e := newE2EServer(t)
+ e.login(t, "e2ekeepadmin")
+ victim, cookie := e.login(t, "e2ekeepme")
+ e.setPassword(t, victim.ID, "correct-horse-battery")
+
+ ctx, cancel, watch := startBrowser(t)
+ defer cancel()
+ acceptDialogs(ctx)
+
+ var errText string
+ if err := chromedp.Run(ctx,
+ setSessionCookie(cookie),
+ chromedp.Navigate(e.authURL+"/account/delete"),
+ // Submit with the password field left empty.
+ chromedp.Click(`[data-testid="confirm-delete"]`, chromedp.ByQuery),
+ waitForLocation(e.authURL+"/account/delete?error="),
+ chromedp.Text(".alert-danger", &errText, chromedp.ByQuery),
+ ); err != nil {
+ t.Fatalf("drive the delete page: %v", err)
+ }
+ if errText == "" {
+ t.Fatal("no error shown after submitting without a password")
+ }
+ if _, err := e.store.GetUserByID(e.ctx, victim.ID); err != nil {
+ t.Fatalf("account was deleted without any proof of presence: %v", err)
+ }
+ watch.assertClean(t)
+}
diff --git a/internal/store/delete_user.go b/internal/store/delete_user.go
new file mode 100644
index 0000000..70f0df6
--- /dev/null
+++ b/internal/store/delete_user.go
@@ -0,0 +1,260 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// Account deletion. The schema does most of the work — every FK to users either
+// cascades or nulls out — so this file is about the two things the schema can't
+// decide: what must BLOCK a deletion (leaving an org or the instance with no
+// admin), and what must be cleaned up alongside the row.
+//
+// What deliberately survives, anonymised: the command log keeps its write-time
+// sender_username, maintenance entries keep author_name, and orgs/config profiles
+// keep their created_by history as NULL. That's by design (see migration 0020) —
+// the record of what was done to a repeater outlives the person who did it.
+
+var (
+ // ErrSoleOrgAdmin blocks deletion: the user is the only admin of an org that
+ // still has other members, which a cascade would leave adminless.
+ ErrSoleOrgAdmin = errors.New("store: sole admin of an org with other members")
+ // ErrLastSiteAdmin blocks deletion: no one else holds cap_manage_users.
+ ErrLastSiteAdmin = errors.New("store: last site administrator")
+)
+
+// DeletionOrg is one of the user's organizations, classified by what deleting
+// their account would do to it.
+type DeletionOrg struct {
+ ID int64
+ Slug string
+ Name string
+ Role string
+ Members int
+}
+
+// DeletionRepeater is one owned repeater that would be deleted, with the number
+// of stewards who could receive it instead (a transfer is the alternative to
+// destroying the site's documentation and history).
+type DeletionRepeater struct {
+ PublicID string
+ Name string
+ Stewards int
+}
+
+// DeletionPreview is everything the confirm page needs to tell the truth about
+// what deletion would do, plus the blockers that would refuse it.
+type DeletionPreview struct {
+ // Repeaters they own; deleting the account deletes these outright.
+ Repeaters []DeletionRepeater
+ // OrgsDeleted are orgs where they're the only member — nobody else is left to
+ // keep them, so they go with the account.
+ OrgsDeleted []DeletionOrg
+ // OrgsLeft are orgs that simply lose a member.
+ OrgsLeft []DeletionOrg
+ // OrgsBlocked are orgs where they're the sole admin but others remain: someone
+ // else must be promoted first.
+ OrgsBlocked []DeletionOrg
+ // LastSiteAdmin is set when no other account holds cap_manage_users.
+ LastSiteAdmin bool
+ // SharedWithUser counts repeaters other people share with them (access lost,
+ // but nothing of anyone else's is destroyed).
+ SharedWithUser int
+ // Passkeys they have registered.
+ Passkeys int
+}
+
+// Blocked reports whether deletion would be refused as things stand.
+func (p *DeletionPreview) Blocked() bool { return p.LastSiteAdmin || len(p.OrgsBlocked) > 0 }
+
+// orgClassifySQL classifies every org the user belongs to in one pass: the org,
+// their role in it, and the member/admin counts that decide whether deleting the
+// account would leave it adminless. $1 is the user id.
+const orgClassifySQL = `
+ SELECT o.id, o.slug, o.name, m.role,
+ (SELECT count(*) FROM org_members x WHERE x.org_id = o.id) AS members,
+ (SELECT count(*) FROM org_members x WHERE x.org_id = o.id AND x.role = 'admin') AS admins
+ FROM org_members m
+ JOIN organizations o ON o.id = m.org_id
+ WHERE m.user_id = $1
+ ORDER BY lower(o.name), o.id`
+
+// classifiedOrg is one row of orgClassifySQL.
+type classifiedOrg struct {
+ DeletionOrg
+ Admins int
+}
+
+// scanClassifiedOrgs reads orgClassifySQL rows.
+func scanClassifiedOrgs(rows pgx.Rows) ([]classifiedOrg, error) {
+ return collectRows(rows, func(r pgx.Row) (classifiedOrg, error) {
+ var c classifiedOrg
+ err := r.Scan(&c.ID, &c.Slug, &c.Name, &c.Role, &c.Members, &c.Admins)
+ return c, err
+ })
+}
+
+// blocksDeletion reports whether this membership stops the account going: the
+// user is an admin, the only one, and other people are still in the org.
+func (c classifiedOrg) blocksDeletion() bool {
+ return c.Role == "admin" && c.Admins <= 1 && c.Members > 1
+}
+
+// goesWithAccount reports whether the org should be deleted alongside the
+// account: the user is its only member, so nothing of anyone else's is in it.
+func (c classifiedOrg) goesWithAccount() bool { return c.Members <= 1 }
+
+// PreviewUserDeletion assembles what deleting userID would do. It is a read-only
+// snapshot for the confirm page — DeleteUser re-checks every blocker inside its
+// transaction, so a stale preview can't let a blocked deletion through.
+func (s *Store) PreviewUserDeletion(ctx context.Context, userID int64) (*DeletionPreview, error) {
+ p := &DeletionPreview{}
+
+ rows, err := s.pool.Query(ctx, `
+ SELECT r.public_id, r.name,
+ (SELECT count(*) FROM repeater_shares rs
+ WHERE rs.repeater_id = r.id AND rs.steward) AS stewards
+ FROM repeaters r WHERE r.owner_id = $1
+ ORDER BY lower(r.name), r.id`, userID)
+ if err != nil {
+ return nil, fmt.Errorf("preview repeaters: %w", err)
+ }
+ p.Repeaters, err = collectRows(rows, func(r pgx.Row) (DeletionRepeater, error) {
+ var d DeletionRepeater
+ err := r.Scan(&d.PublicID, &d.Name, &d.Stewards)
+ return d, err
+ })
+ if err != nil {
+ return nil, fmt.Errorf("scan preview repeaters: %w", err)
+ }
+
+ orgRows, err := s.pool.Query(ctx, orgClassifySQL, userID)
+ if err != nil {
+ return nil, fmt.Errorf("preview orgs: %w", err)
+ }
+ orgs, err := scanClassifiedOrgs(orgRows)
+ if err != nil {
+ return nil, fmt.Errorf("scan preview orgs: %w", err)
+ }
+ for _, o := range orgs {
+ switch {
+ case o.blocksDeletion():
+ p.OrgsBlocked = append(p.OrgsBlocked, o.DeletionOrg)
+ case o.goesWithAccount():
+ p.OrgsDeleted = append(p.OrgsDeleted, o.DeletionOrg)
+ default:
+ p.OrgsLeft = append(p.OrgsLeft, o.DeletionOrg)
+ }
+ }
+
+ if err := s.pool.QueryRow(ctx, `
+ SELECT
+ (SELECT cap_manage_users FROM users WHERE id = $1)
+ AND (SELECT count(*) FROM users WHERE cap_manage_users) <= 1,
+ (SELECT count(*) FROM repeater_shares WHERE user_id = $1),
+ (SELECT count(*) FROM webauthn_credentials WHERE user_id = $1)`,
+ userID).Scan(&p.LastSiteAdmin, &p.SharedWithUser, &p.Passkeys); err != nil {
+ return nil, fmt.Errorf("preview counts: %w", err)
+ }
+ return p, nil
+}
+
+// DeleteUser permanently deletes an account and everything the schema cascades
+// from it: passkeys, logins (which drops every host session at once), profile
+// links, org memberships, shares, and the repeaters they own along with those
+// repeaters' invites, docs, confirmations, maintenance and command history.
+//
+// It refuses with ErrLastSiteAdmin or ErrSoleOrgAdmin rather than leaving the
+// instance or an organization with nobody able to administer it. Both checks run
+// under row locks inside the transaction, so two people deleting simultaneously
+// can't both see "someone else is still an admin" and race the count to zero.
+//
+// Orgs where the user is the only member are deleted with the account — there is
+// nobody left to hand them to, and everything in them is the departing user's.
+//
+// Returns ErrNotFound if the account is already gone.
+func (s *Store) DeleteUser(ctx context.Context, userID int64) error {
+ return s.inTx(ctx, func(tx pgx.Tx) error {
+ var username string
+ var siteAdmin bool
+ if err := tx.QueryRow(ctx,
+ `SELECT username, cap_manage_users FROM users WHERE id = $1 FOR UPDATE`,
+ userID).Scan(&username, &siteAdmin); err != nil {
+ return notFoundOr(err, "lock user")
+ }
+
+ // Locking every site-admin row serializes concurrent admin deletions: the
+ // second one blocks, then re-reads a set that no longer contains the first
+ // and correctly finds itself to be the last.
+ if siteAdmin {
+ rows, err := tx.Query(ctx, `SELECT id FROM users WHERE cap_manage_users FOR UPDATE`)
+ if err != nil {
+ return fmt.Errorf("lock site admins: %w", err)
+ }
+ admins, err := collectRows(rows, scanID)
+ if err != nil {
+ return fmt.Errorf("lock site admins: %w", err)
+ }
+ if len(admins) <= 1 {
+ return ErrLastSiteAdmin
+ }
+ }
+
+ // Lock the membership rows of every org the user belongs to before
+ // classifying them, so a concurrent leave/demote elsewhere can't change the
+ // answer between the check and the delete (the same guarantee
+ // guardLastAdminTx gives the leave path).
+ if _, err := tx.Exec(ctx, `
+ SELECT 1 FROM org_members
+ WHERE org_id IN (SELECT org_id FROM org_members WHERE user_id = $1)
+ FOR UPDATE`, userID); err != nil {
+ return fmt.Errorf("lock org memberships: %w", err)
+ }
+ orgRows, err := tx.Query(ctx, orgClassifySQL, userID)
+ if err != nil {
+ return fmt.Errorf("classify orgs: %w", err)
+ }
+ orgs, err := scanClassifiedOrgs(orgRows)
+ if err != nil {
+ return fmt.Errorf("scan orgs: %w", err)
+ }
+ var orphaned []int64
+ for _, o := range orgs {
+ if o.blocksDeletion() {
+ return ErrSoleOrgAdmin
+ }
+ if o.goesWithAccount() {
+ orphaned = append(orphaned, o.ID)
+ }
+ }
+ if len(orphaned) > 0 {
+ if _, err := tx.Exec(ctx,
+ `DELETE FROM organizations WHERE id = ANY($1)`, orphaned); err != nil {
+ return fmt.Errorf("delete solo orgs: %w", err)
+ }
+ }
+
+ // Reserve the freed username for the usual release cooldown. Profiles are
+ // public at /u/{username} and @handles are baked into command logs and
+ // maintenance notes, so a name freed by deletion must not be claimable the
+ // next minute by someone inheriting that history. The row's user_id nulls
+ // out with the cascade below, and nameReservedByOther treats NULL as "not
+ // you" for every caller — so it's reserved against everyone, which is what
+ // a deleted account needs (nobody can prove they were its owner).
+ //
+ // new_username is empty: this is a release, not a rename to something.
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO username_changes (user_id, old_username, new_username, changed_by)
+ VALUES ($1, $2, '', $1)`, userID, username); err != nil {
+ return fmt.Errorf("reserve released username: %w", err)
+ }
+
+ if _, err := tx.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID); err != nil {
+ return fmt.Errorf("delete user: %w", err)
+ }
+ return nil
+ })
+}
diff --git a/internal/store/delete_user_test.go b/internal/store/delete_user_test.go
new file mode 100644
index 0000000..2aa7561
--- /dev/null
+++ b/internal/store/delete_user_test.go
@@ -0,0 +1,470 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// userFK is one foreign key pointing at users(id), with its ON DELETE action.
+type userFK struct {
+ Table string
+ Column string
+ Action rune // pg_constraint.confdeltype: 'c' cascade, 'n' set null, 'a'/'r' block
+}
+
+// userFKs reads every FK referencing users(id) straight from the catalog, so the
+// tests below cover the schema as it actually is rather than a list that rots.
+func userFKs(t *testing.T, st *Store, ctx context.Context) []userFK {
+ t.Helper()
+ rows, err := st.pool.Query(ctx, `
+ SELECT c.conrelid::regclass::text, a.attname, c.confdeltype::text
+ FROM pg_constraint c
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
+ WHERE c.contype = 'f' AND c.confrelid = 'users'::regclass
+ ORDER BY 1, 2`)
+ if err != nil {
+ t.Fatalf("read user FKs: %v", err)
+ }
+ fks, err := collectRows(rows, func(r pgx.Row) (userFK, error) {
+ var f userFK
+ var action string
+ err := r.Scan(&f.Table, &f.Column, &action)
+ if action != "" {
+ f.Action = rune(action[0])
+ }
+ return f, err
+ })
+ if err != nil {
+ t.Fatalf("scan user FKs: %v", err)
+ }
+ return fks
+}
+
+// bootstrapAdmin creates the instance's first account, which store.CreateUser
+// automatically promotes to superadmin. Deletion tests call this first so their
+// subject is an ORDINARY user — otherwise the subject is itself the last site
+// admin and every deletion is (correctly) refused.
+func bootstrapAdmin(t *testing.T, st *Store, ctx context.Context) *User {
+ t.Helper()
+ admin, err := st.CreateUser(ctx, "instanceadmin", "")
+ if err != nil {
+ t.Fatalf("create bootstrap admin: %v", err)
+ }
+ if !admin.CapManageUsers {
+ t.Fatal("the first account was not promoted to site admin; fixture assumption is stale")
+ }
+ return admin
+}
+
+// populatedDeletionFixture builds an account that has touched as much of the
+// schema as it reasonably can — owned repeater, someone else's shared repeater,
+// passkey, login, auth code, email token, profile link, org membership, console
+// session, command log, maintenance entry, rename history — so the deletion
+// tests are exercising real rows rather than an empty account.
+func populatedDeletionFixture(t *testing.T, st *Store, ctx context.Context) (victim *User, ownRepeater *Repeater) {
+ t.Helper()
+ bootstrapAdmin(t, st, ctx)
+ victim, err := st.CreateUser(ctx, "victim", "Vic Tim")
+ if err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+ other, err := st.CreateUser(ctx, "bystander", "")
+ if err != nil {
+ t.Fatalf("create other: %v", err)
+ }
+
+ ownRepeater, err = st.CreateRepeater(ctx, &Repeater{
+ OwnerID: victim.ID, Name: "Theirs", PublicKeyHex: strings.Repeat("a", 64),
+ RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
+ })
+ if err != nil {
+ t.Fatalf("create repeater: %v", err)
+ }
+ // A repeater owned by someone else, shared with the victim (with a grant):
+ // their access goes, the repeater itself must not.
+ theirs, err := st.CreateRepeater(ctx, &Repeater{
+ OwnerID: other.ID, Name: "Not theirs", PublicKeyHex: strings.Repeat("b", 64),
+ RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
+ })
+ if err != nil {
+ t.Fatalf("create other repeater: %v", err)
+ }
+ if _, err := st.AddShare(ctx, theirs.ID, victim.ID); err != nil {
+ t.Fatalf("add share: %v", err)
+ }
+ catalog, err := st.ListCommands(ctx)
+ if err != nil || len(catalog) == 0 {
+ t.Fatalf("list commands: %v", err)
+ }
+ if err := st.SetShareCommands(ctx, theirs.ID, victim.ID, []int64{catalog[0].ID}); err != nil {
+ t.Fatalf("set share commands: %v", err)
+ }
+
+ if err := st.AddCredential(ctx, victim.ID, []byte("cred-id"), []byte(`{}`), "laptop"); err != nil {
+ t.Fatalf("add credential: %v", err)
+ }
+ loginID, err := st.CreateLogin(ctx, victim.ID)
+ if err != nil {
+ t.Fatalf("create login: %v", err)
+ }
+ if _, err := st.CreateAuthCode(ctx, victim.ID, loginID, "/"); err != nil {
+ t.Fatalf("create auth code: %v", err)
+ }
+ if _, err := st.CreateEmailToken(ctx, victim.ID, PurposeResetPassword, "", time.Hour); err != nil {
+ t.Fatalf("create email token: %v", err)
+ }
+ if err := st.ReplaceUserLinks(ctx, victim.ID, []UserLink{{Platform: "web", URL: "https://example.com"}}); err != nil {
+ t.Fatalf("replace links: %v", err)
+ }
+ // A rename, so username_changes already holds rows for this user.
+ if err := st.SetUsername(ctx, victim.ID, "victim2", UsernameChangeContext{ChangedBy: victim.ID}, false); err != nil {
+ t.Fatalf("rename: %v", err)
+ }
+ // Console, command and maintenance history against SOMEONE ELSE'S repeater.
+ // That's the case worth testing: activity on a node that outlives the account
+ // must stay in its owner's audit trail, anonymised. (The same rows on their own
+ // repeater would simply cascade away with it, proving nothing about SET NULL.)
+ sessID, err := st.StartConsoleSession(ctx, theirs.ID, victim.ID)
+ if err != nil {
+ t.Fatalf("start console session: %v", err)
+ }
+ if _, err := st.LogCommand(ctx, theirs.ID, victim.ID, sessID, catalog[0].ID, "ver"); err != nil {
+ t.Fatalf("log command: %v", err)
+ }
+ if err := st.AddMaintenanceEntry(ctx, theirs.ID, victim.ID, "Vic Tim", "swapped antenna", time.Now()); err != nil {
+ t.Fatalf("add maintenance: %v", err)
+ }
+ return victim, ownRepeater
+}
+
+// TestDeleteUserLeavesNoReferences is the invariant that matters most for a
+// privacy feature: after deletion, NO row anywhere still references the deleted
+// id. It reads the FK list from the catalog rather than hardcoding tables, so a
+// table added later without a cascade fails here instead of quietly retaining
+// personal data (or blocking deletion outright with a NO ACTION rule).
+func TestDeleteUserLeavesNoReferences(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ victim, _ := populatedDeletionFixture(t, st, ctx)
+
+ fks := userFKs(t, st, ctx)
+ // Guard the guard: if the introspection query ever stops finding anything,
+ // this test would pass by checking nothing at all.
+ if len(fks) < 15 {
+ t.Fatalf("found only %d FKs referencing users; introspection is broken", len(fks))
+ }
+ for _, fk := range fks {
+ if fk.Action != 'c' && fk.Action != 'n' {
+ t.Errorf("%s.%s references users with ON DELETE %q: deletion would be blocked, "+
+ "not cascaded — every reference to a user must cascade or null out",
+ fk.Table, fk.Column, string(fk.Action))
+ }
+ }
+
+ if err := st.DeleteUser(ctx, victim.ID); err != nil {
+ t.Fatalf("DeleteUser: %v", err)
+ }
+
+ for _, fk := range fks {
+ var n int
+ q := "SELECT count(*) FROM " + fk.Table + " WHERE " + fk.Column + " = $1" //nolint:gosec // identifiers come from the catalog, not user input
+ if err := st.pool.QueryRow(ctx, q, victim.ID).Scan(&n); err != nil {
+ t.Fatalf("count %s.%s: %v", fk.Table, fk.Column, err)
+ }
+ if n != 0 {
+ t.Errorf("%s.%s still has %d row(s) referencing the deleted user", fk.Table, fk.Column, n)
+ }
+ }
+ if _, err := st.GetUserByID(ctx, victim.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("GetUserByID after delete = %v, want ErrNotFound", err)
+ }
+}
+
+// TestDeleteUserKeepsAnonymisedHistory: the operational record of what was done
+// to a repeater must outlive the account that did it (migration 0020's promise),
+// and other people's repeaters must survive their access being deleted.
+func TestDeleteUserKeepsAnonymisedHistory(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ victim, ownRepeater := populatedDeletionFixture(t, st, ctx)
+
+ // The victim's own repeater goes; the one merely shared with them stays.
+ var otherRepeaters int
+ if err := st.pool.QueryRow(ctx,
+ `SELECT count(*) FROM repeaters WHERE owner_id <> $1`, victim.ID).Scan(&otherRepeaters); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := st.DeleteUser(ctx, victim.ID); err != nil {
+ t.Fatalf("DeleteUser: %v", err)
+ }
+
+ var owned int
+ if err := st.pool.QueryRow(ctx,
+ `SELECT count(*) FROM repeaters WHERE public_id = $1`, ownRepeater.PublicID).Scan(&owned); err != nil {
+ t.Fatal(err)
+ }
+ if owned != 0 {
+ t.Fatal("the deleted user's own repeater survived")
+ }
+ var left int
+ if err := st.pool.QueryRow(ctx, `SELECT count(*) FROM repeaters`).Scan(&left); err != nil {
+ t.Fatal(err)
+ }
+ if left != otherRepeaters {
+ t.Fatalf("repeaters left = %d, want %d (other people's must survive)", left, otherRepeaters)
+ }
+
+ // The command log keeps its write-time username snapshot, with the user nulled.
+ var sender *string
+ var uid *int64
+ if err := st.pool.QueryRow(ctx,
+ `SELECT sender_username, user_id FROM command_log LIMIT 1`).Scan(&sender, &uid); err != nil {
+ t.Fatalf("read command log: %v", err)
+ }
+ if uid != nil {
+ t.Fatal("command_log.user_id was not nulled")
+ }
+ if sender == nil || *sender == "" {
+ t.Fatal("command_log lost its sender_username snapshot; the audit trail is now anonymous AND empty")
+ }
+
+ // Same for the maintenance history on the surviving repeater: the entry stays,
+ // attributed to the write-time name rather than to nobody.
+ entries, err := st.ListMaintenance(ctx, theirsID(t, st, ctx))
+ if err != nil {
+ t.Fatalf("list maintenance: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("maintenance entries = %d, want 1 (the entry outlives its author)", len(entries))
+ }
+ if entries[0].AuthorID != nil {
+ t.Fatal("maintenance author_id was not nulled")
+ }
+ if entries[0].AuthorName != "Vic Tim" {
+ t.Fatalf("maintenance author name = %q, want the write-time snapshot", entries[0].AuthorName)
+ }
+}
+
+// theirsID returns the id of the surviving (other owner's) repeater in the
+// deletion fixture.
+func theirsID(t *testing.T, st *Store, ctx context.Context) int64 {
+ t.Helper()
+ var id int64
+ if err := st.pool.QueryRow(ctx,
+ `SELECT id FROM repeaters WHERE name = 'Not theirs'`).Scan(&id); err != nil {
+ t.Fatalf("find surviving repeater: %v", err)
+ }
+ return id
+}
+
+// TestDeleteUserReservesUsername: a freed handle can't be claimed immediately.
+// Public profiles live at /u/{username} and @handles are quoted in logs and
+// maintenance notes, so an instantly-reusable name would let someone inherit a
+// departed user's history.
+func TestDeleteUserReservesUsername(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ bootstrapAdmin(t, st, ctx)
+ u, err := st.CreateUser(ctx, "departed", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.DeleteUser(ctx, u.ID); err != nil {
+ t.Fatalf("DeleteUser: %v", err)
+ }
+
+ // Nobody may take the freed name — not a new signup...
+ if _, err := st.CreateUser(ctx, "departed", ""); !errors.Is(err, ErrUsernameReserved) {
+ t.Fatalf("CreateUser on a freed name = %v, want ErrUsernameReserved", err)
+ }
+ // ...nor an existing account renaming into it.
+ other, err := st.CreateUser(ctx, "opportunist", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = st.SetUsername(ctx, other.ID, "departed", UsernameChangeContext{ChangedBy: other.ID}, false)
+ if !errors.Is(err, ErrUsernameReserved) {
+ t.Fatalf("rename into a freed name = %v, want ErrUsernameReserved", err)
+ }
+}
+
+// TestDeleteUserSiteAdminGuard: the last administrator can't delete themselves
+// out of the instance, but one of two can.
+func TestDeleteUserSiteAdminGuard(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ first, err := st.CreateUser(ctx, "admin1", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetCapabilities(ctx, first.ID, true, true); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.DeleteUser(ctx, first.ID); !errors.Is(err, ErrLastSiteAdmin) {
+ t.Fatalf("deleting the only admin = %v, want ErrLastSiteAdmin", err)
+ }
+
+ second, err := st.CreateUser(ctx, "admin2", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetCapabilities(ctx, second.ID, true, false); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.DeleteUser(ctx, first.ID); err != nil {
+ t.Fatalf("deleting one of two admins: %v", err)
+ }
+ // And now the remaining one is the last, so they're stuck too.
+ if err := st.DeleteUser(ctx, second.ID); !errors.Is(err, ErrLastSiteAdmin) {
+ t.Fatalf("deleting the now-last admin = %v, want ErrLastSiteAdmin", err)
+ }
+}
+
+// TestDeleteUserOrgRules covers all three org outcomes: an org with other members
+// but no other admin BLOCKS deletion; a solo org is deleted with the account; an
+// org with another admin simply loses a member and survives intact.
+func TestDeleteUserOrgRules(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ bootstrapAdmin(t, st, ctx)
+ leaver, err := st.CreateUser(ctx, "leaver", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ member, err := st.CreateUser(ctx, "member", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // (a) sole admin, other members present → blocked.
+ shared, err := st.CreateOrg(ctx, "Shared Club", leaver.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.AddOrgMember(ctx, shared.ID, member.ID, "member"); err != nil {
+ t.Fatal(err)
+ }
+ // (b) solo org → goes with the account.
+ solo, err := st.CreateOrg(ctx, "Solo Org", leaver.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := st.DeleteUser(ctx, leaver.ID); !errors.Is(err, ErrSoleOrgAdmin) {
+ t.Fatalf("deleting the sole admin of a populated org = %v, want ErrSoleOrgAdmin", err)
+ }
+ // Nothing was half-applied by the refused deletion.
+ if _, err := st.GetOrg(ctx, solo.ID); err != nil {
+ t.Fatalf("solo org was deleted by a refused account deletion: %v", err)
+ }
+ if _, err := st.GetUserByID(ctx, leaver.ID); err != nil {
+ t.Fatalf("user was deleted despite the block: %v", err)
+ }
+
+ // Promoting someone else clears the block.
+ if err := st.SetOrgMemberRole(ctx, shared.ID, member.ID, "admin"); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.DeleteUser(ctx, leaver.ID); err != nil {
+ t.Fatalf("DeleteUser after promoting a second admin: %v", err)
+ }
+
+ // (c) the shared org survives, minus the leaver; the solo org is gone.
+ if _, err := st.GetOrg(ctx, shared.ID); err != nil {
+ t.Fatalf("shared org did not survive: %v", err)
+ }
+ members, err := st.ListOrgMembers(ctx, shared.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(members) != 1 || members[0].UserID != member.ID {
+ t.Fatalf("shared org members = %+v, want just the promoted member", members)
+ }
+ if _, err := st.GetOrg(ctx, solo.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("solo org = %v, want ErrNotFound (deleted with its only member)", err)
+ }
+}
+
+// TestPreviewUserDeletion: the confirm page's numbers must match what deletion
+// actually does — including the steward count that decides whether a repeater
+// can be handed over instead of destroyed.
+func TestPreviewUserDeletion(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ victim, ownRepeater := populatedDeletionFixture(t, st, ctx)
+
+ // Give the owned repeater a steward, so the preview can offer a transfer.
+ successor, err := st.CreateUser(ctx, "successor", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := st.AddShare(ctx, ownRepeater.ID, successor.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SetShareSteward(ctx, ownRepeater.ID, successor.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ solo, err := st.CreateOrg(ctx, "Solo", victim.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ p, err := st.PreviewUserDeletion(ctx, victim.ID)
+ if err != nil {
+ t.Fatalf("PreviewUserDeletion: %v", err)
+ }
+ if p.Blocked() {
+ t.Fatalf("preview reports blocked, want deletable: %+v", p)
+ }
+ if len(p.Repeaters) != 1 || p.Repeaters[0].PublicID != ownRepeater.PublicID {
+ t.Fatalf("preview repeaters = %+v, want the one they own", p.Repeaters)
+ }
+ if p.Repeaters[0].Stewards != 1 {
+ t.Fatalf("preview steward count = %d, want 1 (a transfer is possible)", p.Repeaters[0].Stewards)
+ }
+ if len(p.OrgsDeleted) != 1 || p.OrgsDeleted[0].ID != solo.ID {
+ t.Fatalf("preview OrgsDeleted = %+v, want the solo org", p.OrgsDeleted)
+ }
+ if p.SharedWithUser != 1 {
+ t.Fatalf("preview SharedWithUser = %d, want 1", p.SharedWithUser)
+ }
+ if p.Passkeys != 1 {
+ t.Fatalf("preview Passkeys = %d, want 1", p.Passkeys)
+ }
+
+ // A blocked account reports the blocking org rather than a clean bill.
+ blocker, err := st.CreateUser(ctx, "blocked", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ club, err := st.CreateOrg(ctx, "Club", blocker.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.AddOrgMember(ctx, club.ID, victim.ID, "member"); err != nil {
+ t.Fatal(err)
+ }
+ bp, err := st.PreviewUserDeletion(ctx, blocker.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bp.Blocked() || len(bp.OrgsBlocked) != 1 || bp.OrgsBlocked[0].ID != club.ID {
+ t.Fatalf("preview for a sole admin = %+v, want blocked on the club", bp)
+ }
+}
+
+// TestDeleteUserMissing: deleting an already-deleted account is ErrNotFound, not
+// a silent success.
+func TestDeleteUserMissing(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+ if err := st.DeleteUser(ctx, 999999); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("DeleteUser(missing) = %v, want ErrNotFound", err)
+ }
+}
diff --git a/internal/web/static/webauthn.js b/internal/web/static/webauthn.js
index 34840b8..dddf438 100644
--- a/internal/web/static/webauthn.js
+++ b/internal/web/static/webauthn.js
@@ -153,6 +153,35 @@ async function passkeyLogin() {
}
}
+// reauthPasskey re-verifies the ALREADY signed-in user before a sensitive action
+// (account deletion), then submits the form named by the button's data-form.
+//
+// Unlike the sign-in ceremonies this posts no username — the server asserts
+// against the session's own account — and grants no access by itself: it stamps
+// the session as freshly verified, and the form's handler decides what that's
+// worth. requestSubmit (not submit) so the form's [data-confirm] gate still runs.
+async function reauthPasskey(e) {
+ const btn = e.currentTarget;
+ const form = document.getElementById(btn.getAttribute("data-form") || "");
+ try {
+ setStatus("Starting…");
+ const options = await postJSON("/account/reauth/passkey/begin", {});
+ const cred = await navigator.credentials.get({ publicKey: decodeRequest(options.publicKey) });
+ setStatus("Verifying…");
+ const result = await fetch("/account/reauth/passkey/finish", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(encodeAssertion(cred)),
+ });
+ const data = await result.json().catch(() => ({}));
+ if (!result.ok) throw new Error(data.error || "verification failed");
+ setStatus("Verified.");
+ if (form) form.requestSubmit();
+ } catch (err) {
+ setStatus("Error: " + err.message);
+ }
+}
+
// Tracks an in-flight conditional-mediation request so an explicit action can
// supersede the passive autofill prompt without the two colliding.
let conditionalAbort = null;
@@ -301,6 +330,7 @@ async function initSignupEmphasis() {
["add-passkey-btn", addPasskey],
["passkey-btn", passkeyButton],
["signup-passkey-btn", passkeyRegister],
+ ["delete-reauth-btn", reauthPasskey],
];
bindings.forEach(function (b) {
var el = document.getElementById(b[0]);