settings: add self-service user settings page
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).
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user