Add self-service account deletion
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/<user_id>/ 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.
This commit is contained in:
@@ -82,6 +82,9 @@ events, profile and photos.
|
|||||||
- **First account adopts existing data.** When accounts are introduced on a DB
|
- **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`),
|
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.
|
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`
|
- **Serve over HTTPS in production.** Session cookies are only marked `Secure`
|
||||||
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
|
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
|
||||||
aren't sent in the clear.
|
aren't sent in the clear.
|
||||||
|
|||||||
@@ -381,3 +381,62 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeUser(w, u)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+10
-1
@@ -423,7 +423,16 @@ func main() {
|
|||||||
mux.HandleFunc("/api/register", auth.handleRegister)
|
mux.HandleFunc("/api/register", auth.handleRegister)
|
||||||
mux.HandleFunc("/api/login", auth.handleLogin)
|
mux.HandleFunc("/api/login", auth.handleLogin)
|
||||||
mux.HandleFunc("/api/logout", auth.handleLogout)
|
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) {
|
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
|||||||
+44
@@ -1522,6 +1522,50 @@
|
|||||||
settingsDialog.close();
|
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 ----------
|
// ---------- wiring ----------
|
||||||
document.querySelectorAll("button.action").forEach(btn => {
|
document.querySelectorAll("button.action").forEach(btn => {
|
||||||
btn.addEventListener("click", () => openNoteDialog(btn.dataset.type));
|
btn.addEventListener("click", () => openNoteDialog(btn.dataset.type));
|
||||||
|
|||||||
@@ -192,6 +192,26 @@
|
|||||||
<button value="cancel" class="ghost">Cancel</button>
|
<button value="cancel" class="ghost">Cancel</button>
|
||||||
<button value="save" id="settings-save">Save</button>
|
<button value="save" id="settings-save">Save</button>
|
||||||
</menu>
|
</menu>
|
||||||
|
<hr class="settings-sep" />
|
||||||
|
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="delete-account-dialog">
|
||||||
|
<form method="dialog" id="delete-account-form">
|
||||||
|
<h3>Delete account</h3>
|
||||||
|
<p class="danger-text">
|
||||||
|
This permanently deletes your account and <strong>all its data</strong> —
|
||||||
|
every event, photo and your puppy profile. This can't be undone.
|
||||||
|
</p>
|
||||||
|
<label>Confirm your password
|
||||||
|
<input type="password" id="delete-account-password" autocomplete="current-password" />
|
||||||
|
</label>
|
||||||
|
<p id="delete-account-error" class="auth-error" hidden></p>
|
||||||
|
<menu>
|
||||||
|
<button value="cancel" class="ghost">Cancel</button>
|
||||||
|
<button type="button" id="delete-account-confirm" class="danger">Delete forever</button>
|
||||||
|
</menu>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -599,3 +599,18 @@ button.linklike {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button.linklike:hover { text-decoration: underline; filter: none; }
|
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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user