From 4fd5ba428e01ac36d5dc7405106d9d2de3cdb26b Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Wed, 15 Apr 2026 20:24:04 +0200 Subject: [PATCH] settings: add self-service user settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API endpoints (all auth-gated to the logged-in user): - GET /api/me/storage — per-category disk breakdown - DELETE /api/me/originals — free originals/ dir (post-extraction cleanup) - DELETE /api/me/activities — wipe all activity data (password confirm) - DELETE /api/me — delete account + all data (password confirm) - PUT /api/me/display-name — update display name - PUT /api/me/password — change password (requires current password) Page at /settings/: - Storage card: activities / originals / Strava originals / photos / total with one-click 'Delete original files' when originals exist - Profile card: display name field with inline save - Password card: change password form - Danger zone: delete all activities or delete account (both require password confirmation in a modal before proceeding) Nav: 'Settings' link appears in the top bar after login (same as Admin). --- bincio/serve/server.py | 145 ++++++++++++ site/src/layouts/Base.astro | 11 +- site/src/pages/settings/index.astro | 349 ++++++++++++++++++++++++++++ 3 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/settings/index.astro diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 260cf85..acac2dc 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -887,6 +887,151 @@ async def admin_delete_user_directory( +# ── Self-service user settings ──────────────────────────────────────────────── + +@app.get("/api/me/storage") +async def me_storage(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Return per-category disk usage for the logged-in user.""" + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + + def _mb(path: Path) -> float: + if not path.exists(): + return 0.0 + total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + return round(total / 1_048_576, 2) + + def _count(path: Path, pattern: str = "*") -> int: + if not path.exists(): + return 0 + return sum(1 for f in path.glob(pattern) if f.is_file()) + + activities_mb = _mb(dd / "activities") + originals_mb = _mb(dd / "originals") + strava_mb = _mb(dd / "originals" / "strava") + images_mb = _mb(dd / "edits" / "images") + total_mb = _mb(dd) + + return JSONResponse({ + "total_mb": total_mb, + "activities_mb": activities_mb, + "activities_count": _count(dd / "activities", "*.json"), + "originals_mb": originals_mb, + "strava_originals_mb": strava_mb, + "strava_originals_count": _count(dd / "originals" / "strava", "*.json"), + "images_mb": images_mb, + }) + + +@app.delete("/api/me/originals") +async def me_delete_originals(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Delete the user's originals/ directory (frees space after re-extraction).""" + user = _require_user(bincio_session) + originals = _get_data_dir() / user.handle / "originals" + if not originals.exists(): + return JSONResponse({"ok": True, "freed_mb": 0.0}) + + freed = round( + sum(f.stat().st_size for f in originals.rglob("*") if f.is_file()) / 1_048_576, 2 + ) + shutil.rmtree(originals) + return JSONResponse({"ok": True, "freed_mb": freed}) + + +@app.delete("/api/me/activities") +async def me_delete_activities( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Wipe all extracted activity data (activities/, edits/, _merged/, index/athlete JSON). + + Requires the user's current password in the request body for confirmation. + """ + user = _require_user(bincio_session) + body = await request.json() + password = body.get("password", "") + if not authenticate(_get_db(), user.handle, password): + raise HTTPException(401, "Wrong password") + + user_dir = _get_data_dir() / user.handle + deleted = _wipe_user_activities(user_dir) + _trigger_rebuild(user.handle) + return JSONResponse({"ok": True, "deleted": deleted}) + + +@app.delete("/api/me") +async def me_delete_account( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Delete the account and all data permanently. + + Requires the user's current password. Deletes the DB row, all sessions, + and the entire user data directory. The root shard manifest is updated. + """ + user = _require_user(bincio_session) + body = await request.json() + password = body.get("password", "") + if not authenticate(_get_db(), user.handle, password): + raise HTTPException(401, "Wrong password") + + # Wipe data directory + user_dir = _get_data_dir() / user.handle + if user_dir.is_dir(): + shutil.rmtree(user_dir) + + # Remove from DB (cascades to sessions, invites, reset_codes) + from bincio.serve.db import delete_user as _delete_user + _delete_user(_get_db(), user.handle) + + # Update root manifest so the shard disappears + from bincio.render.cli import _write_root_manifest + try: + _write_root_manifest(_get_data_dir()) + except Exception: + pass + + resp = JSONResponse({"ok": True}) + resp.delete_cookie(_SESSION_COOKIE) + return resp + + +@app.put("/api/me/display-name") +async def me_update_display_name( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Update the logged-in user's display name.""" + user = _require_user(bincio_session) + body = await request.json() + display_name = str(body.get("display_name", "")).strip() + if len(display_name) > 60: + raise HTTPException(400, "Display name too long (max 60 characters)") + db = _get_db() + db.execute("UPDATE users SET display_name = ? WHERE handle = ?", (display_name, user.handle)) + db.commit() + return JSONResponse({"ok": True, "display_name": display_name}) + + +@app.put("/api/me/password") +async def me_change_password( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Change the logged-in user's password. Requires current password.""" + from bincio.serve.db import change_password as _change_password + user = _require_user(bincio_session) + body = await request.json() + current = body.get("current_password", "") + new_pw = body.get("new_password", "") + if not authenticate(_get_db(), user.handle, current): + raise HTTPException(401, "Current password is wrong") + if len(new_pw) < 8: + raise HTTPException(400, "New password must be at least 8 characters") + _change_password(_get_db(), user.handle, new_pw) + return JSONResponse({"ok": True}) + + # ── Write API (ported from bincio edit, auth-gated) ─────────────────────────── def _user_data_dir(handle: str) -> Path: diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 8b92e23..85ab878 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -200,6 +200,13 @@ try { title="" class="text-xs px-2 py-0.5 rounded-full bg-amber-900/60 text-amber-300 border border-amber-700/50 animate-pulse cursor-default" > + + +
+

Settings

+ + +
+

Storage

+
Loading…
+ + + +
+ + +
+

Profile

+
+
+ + +
+ +
+ +
+ + +
+

Password

+
+
+ + +
+
+ + +

At least 8 characters

+
+ + +
+
+ + +
+

Danger zone

+ + +
+

Reset activity data

+

Wipes all extracted activities, edits, and photos. Your account is kept. Cannot be undone.

+ +
+ +
+

Delete account

+

Permanently deletes your account and all data. Cannot be undone.

+ +
+
+
+ + + +

+

+
+ + +
+ +
+ + +
+
+ + +