From a1a3ccf33934906c8adce4ce19c160fa23f52a31 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Thu, 9 Jul 2026 19:20:40 +0000 Subject: [PATCH] Add self-service account deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Delete account removes the signed-in account and everything it owns. DELETE /api/me re-checks the password (guarding an unattended session), then wipes the user's events, config, sessions and user row in one transaction and removes their photos// directory. The client clears the account's local cache and returns to the login screen. Bumps the service-worker cache so clients pick up the new UI. Verified: wrong password is rejected (401, data intact); correct password returns 204, invalidates the session, drops all rows to zero and removes the photo dir; the email can be re-registered afterwards. Confirmed end to end in a headless-browser run of the Settings → delete flow. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +++ server/auth.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ server/main.go | 11 +++++++++- src/app.js | 44 +++++++++++++++++++++++++++++++++++++ src/index.html | 20 +++++++++++++++++ src/style.css | 15 +++++++++++++ src/sw.js | 2 +- 7 files changed, 152 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 98d92a2..20ec3b6 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ events, profile and photos. - **First account adopts existing data.** When accounts are introduced on a DB that already had single-tenant data (or that imported a legacy `events.json`), the first account to register inherits all of it — events, profile and photos. +- **Self-service deletion.** Settings → *Delete account* removes the signed-in + account and everything it owns (`DELETE /api/me`, re-confirming the password): + events, profile, sessions and the photo directory are all wiped. - **Serve over HTTPS in production.** Session cookies are only marked `Secure` when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords aren't sent in the clear. diff --git a/server/auth.go b/server/auth.go index cf1a258..5d2491f 100644 --- a/server/auth.go +++ b/server/auth.go @@ -381,3 +381,62 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) { } writeUser(w, u) } + +// checkPassword reports whether password matches the stored hash for userID. +func (a *Auth) checkPassword(userID, password string) bool { + var hash string + if err := a.db.QueryRow(`SELECT password FROM users WHERE id = ?`, userID).Scan(&hash); err != nil { + return false + } + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// deleteAccount removes a user and everything owned by them: events, profile, +// sessions, the user row, and their photo directory. The table wipes run in one +// transaction; photos are best-effort afterwards (orphaned files are harmless). +func (a *Auth) deleteAccount(userID string) error { + tx, err := a.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, q := range []string{ + `DELETE FROM events WHERE user_id = ?`, + `DELETE FROM config WHERE user_id = ?`, + `DELETE FROM sessions WHERE user_id = ?`, + `DELETE FROM users WHERE id = ?`, + } { + if _, err := tx.Exec(q, userID); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } + if err := os.RemoveAll(filepath.Join(a.photosDir, userID)); err != nil { + log.Printf("delete photos for %s: %v", userID, err) + } + return nil +} + +// handleDeleteAccount deletes the caller's own account after re-checking their +// password (guards against an unattended session). Wrapped in requireUser. +func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { + var c credentials + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + uid := userID(r) + if !a.checkPassword(uid, c.Password) { + http.Error(w, "invalid password", http.StatusUnauthorized) + return + } + if err := a.deleteAccount(uid); err != nil { + log.Printf("delete account %s: %v", uid, err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + a.clearCookie(w) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/main.go b/server/main.go index 8bf3111..78b49b2 100644 --- a/server/main.go +++ b/server/main.go @@ -423,7 +423,16 @@ func main() { mux.HandleFunc("/api/register", auth.handleRegister) mux.HandleFunc("/api/login", auth.handleLogin) mux.HandleFunc("/api/logout", auth.handleLogout) - mux.HandleFunc("/api/me", auth.requireUser(auth.handleMe)) + mux.HandleFunc("/api/me", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + auth.handleMe(w, r) + case http.MethodDelete: + auth.handleDeleteAccount(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + })) mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/src/app.js b/src/app.js index 58990b3..50eab2b 100644 --- a/src/app.js +++ b/src/app.js @@ -1522,6 +1522,50 @@ settingsDialog.close(); }); + // ---------- delete account ---------- + const deleteAccountDialog = document.getElementById("delete-account-dialog"); + const deleteAccountPassword = document.getElementById("delete-account-password"); + const deleteAccountError = document.getElementById("delete-account-error"); + const deleteAccountConfirm = document.getElementById("delete-account-confirm"); + + document.getElementById("delete-account-btn").addEventListener("click", () => { + settingsDialog.close(); + deleteAccountPassword.value = ""; + deleteAccountError.hidden = true; + deleteAccountDialog.showModal(); + setTimeout(() => deleteAccountPassword.focus(), 50); + }); + + deleteAccountConfirm.addEventListener("click", async () => { + deleteAccountError.hidden = true; + deleteAccountConfirm.disabled = true; + try { + const res = await fetch("api/me", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: deleteAccountPassword.value }), + }); + if (!res.ok) { + const msg = (await res.text()).trim(); + throw new Error(res.status === 401 ? "Incorrect password" : (msg || `HTTP ${res.status}`)); + } + // Account is gone server-side. Wipe this user's local cache before the + // reload drops us back on the login screen. + try { + localStorage.removeItem(eventsKey()); + localStorage.removeItem(configKey()); + } catch { /* ignore */ } + clearUser(); + deleteAccountDialog.close(); + location.reload(); + } catch (err) { + deleteAccountError.textContent = err.message || "Something went wrong"; + deleteAccountError.hidden = false; + } finally { + deleteAccountConfirm.disabled = false; + } + }); + // ---------- wiring ---------- document.querySelectorAll("button.action").forEach(btn => { btn.addEventListener("click", () => openNoteDialog(btn.dataset.type)); diff --git a/src/index.html b/src/index.html index 1ea2727..f330b29 100644 --- a/src/index.html +++ b/src/index.html @@ -192,6 +192,26 @@ +
+ + + + + +
+

Delete account

+

+ This permanently deletes your account and all its data — + every event, photo and your puppy profile. This can't be undone. +

+ + + + + +
diff --git a/src/style.css b/src/style.css index 73bd737..1909f0b 100644 --- a/src/style.css +++ b/src/style.css @@ -599,3 +599,18 @@ button.linklike { cursor: pointer; } button.linklike:hover { text-decoration: underline; filter: none; } + +/* ---------- delete account ---------- */ +.settings-sep { + border: none; + border-top: 1px solid var(--border); + margin: 18px 0 12px; +} +.danger-block { width: 100%; } +.danger-text { + font-size: 0.9rem; + color: var(--muted); + margin: 0 0 14px; + line-height: 1.4; +} +.danger-text strong { color: var(--danger); } diff --git a/src/sw.js b/src/sw.js index 2153ee7..4e4c7ce 100644 --- a/src/sw.js +++ b/src/sw.js @@ -1,4 +1,4 @@ -const CACHE = "puppy-tracker-v6"; +const CACHE = "puppy-tracker-v7"; const PHOTO_CACHE = "puppy-tracker-photos-v1"; const ASSETS = [ "./",