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:
Davide Scaini
2026-04-15 20:24:04 +02:00
parent 764da09130
commit 4fd5ba428e
3 changed files with 504 additions and 1 deletions
+145
View File
@@ -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:
+10 -1
View File
@@ -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"
></span>
<!-- Settings link — hidden until logged in -->
<a
id="nav-settings"
href={`${baseUrl}settings/`}
style="display:none"
class="text-xs text-zinc-500 hover:text-white transition-colors px-1"
>Settings</a>
<!-- Admin link — hidden until confirmed admin -->
<a
id="nav-admin"
@@ -494,7 +501,9 @@ try {
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
});
// Show logout button
// Show settings + logout links
const settingsEl = document.getElementById('nav-settings');
if (settingsEl) settingsEl.style.display = '';
const logoutEl = document.getElementById('nav-logout');
if (logoutEl) logoutEl.style.display = '';
+349
View File
@@ -0,0 +1,349 @@
---
import Base from '../../layouts/Base.astro';
---
<Base title="Settings — BincioActivity">
<div class="max-w-lg mx-auto px-4 py-10">
<h1 class="text-2xl font-bold text-white mb-8">Settings</h1>
<!-- Storage card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-4">Storage</h2>
<div id="storage-loading" class="text-zinc-500 text-sm">Loading…</div>
<div id="storage-content" class="hidden space-y-2">
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Activities</span>
<span id="st-activities" class="text-white tabular-nums"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Original files</span>
<span id="st-originals" class="text-white tabular-nums"></span>
</div>
<div id="st-strava-row" class="flex justify-between text-sm pl-4 hidden">
<span class="text-zinc-500">↳ Strava originals</span>
<span id="st-strava" class="text-zinc-400 tabular-nums"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-zinc-400">Photos</span>
<span id="st-images" class="text-white tabular-nums"></span>
</div>
<div class="border-t border-zinc-800 mt-2 pt-2 flex justify-between text-sm font-medium">
<span class="text-zinc-300">Total</span>
<span id="st-total" class="text-white tabular-nums"></span>
</div>
</div>
<!-- Delete originals -->
<div id="del-originals-area" class="mt-4 hidden">
<p class="text-xs text-zinc-500 mb-3">
Original files are kept for reprocessing. Once your activities look correct you can free this space — the extracted data is not affected.
</p>
<button id="del-originals-btn"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-amber-900 hover:text-amber-300 text-zinc-300 transition-colors">
Delete original files
</button>
<p id="del-originals-status" class="text-xs mt-2 hidden"></p>
</div>
</section>
<!-- Profile card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-4">Profile</h2>
<form id="display-name-form" class="flex gap-2 items-end">
<div class="flex-1">
<label class="block text-xs text-zinc-500 mb-1" for="display-name-input">Display name</label>
<input id="display-name-input" type="text" maxlength="60" autocomplete="name"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent] text-sm"
placeholder="Your name" />
</div>
<button type="submit"
class="px-4 py-2 rounded-lg text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors shrink-0">
Save
</button>
</form>
<p id="display-name-status" class="text-xs mt-2 hidden"></p>
</section>
<!-- Password card -->
<section class="mb-6 rounded-xl bg-zinc-900 border border-zinc-800 p-5">
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-4">Password</h2>
<form id="password-form" class="space-y-3">
<div>
<label class="block text-xs text-zinc-500 mb-1" for="pw-current">Current password</label>
<input id="pw-current" type="password" autocomplete="current-password" required
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-[--accent] text-sm" />
</div>
<div>
<label class="block text-xs text-zinc-500 mb-1" for="pw-new">New password</label>
<input id="pw-new" type="password" autocomplete="new-password" minlength="8" required
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-[--accent] text-sm" />
<p class="text-zinc-600 text-xs mt-1">At least 8 characters</p>
</div>
<p id="pw-status" class="text-xs hidden"></p>
<button type="submit"
class="px-4 py-2 rounded-lg text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors">
Change password
</button>
</form>
</section>
<!-- Danger zone -->
<section class="rounded-xl bg-zinc-900 border border-red-900/40 p-5">
<h2 class="text-sm font-semibold text-red-400/70 uppercase tracking-wider mb-4">Danger zone</h2>
<!-- Delete activities -->
<div class="mb-5">
<p class="text-sm text-zinc-300 font-medium mb-1">Reset activity data</p>
<p class="text-xs text-zinc-500 mb-3">Wipes all extracted activities, edits, and photos. Your account is kept. Cannot be undone.</p>
<button id="del-activities-btn"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors">
Delete all activities
</button>
</div>
<div class="border-t border-zinc-800 pt-5">
<p class="text-sm text-zinc-300 font-medium mb-1">Delete account</p>
<p class="text-xs text-zinc-500 mb-3">Permanently deletes your account and all data. Cannot be undone.</p>
<button id="del-account-btn"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors">
Delete account
</button>
</div>
</section>
</div>
<!-- Confirmation modal (shared for activities + account deletion) -->
<dialog id="confirm-dialog"
class="rounded-xl bg-zinc-900 border border-zinc-700 p-6 text-white max-w-sm w-full backdrop:bg-black/60">
<p id="confirm-title" class="text-sm text-zinc-300 mb-1 font-medium"></p>
<p id="confirm-desc" class="text-xs text-zinc-500 mb-4"></p>
<div class="mb-4">
<label class="block text-xs text-zinc-500 mb-1" for="confirm-password">Confirm with your password</label>
<input id="confirm-password" type="password" autocomplete="current-password"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white focus:outline-none focus:border-red-500 text-sm" />
</div>
<p id="confirm-error" class="text-red-400 text-xs mb-3 hidden"></p>
<div class="flex gap-3 justify-end">
<button id="confirm-cancel"
class="px-4 py-2 rounded-lg text-sm bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">Cancel</button>
<button id="confirm-ok"
class="px-4 py-2 rounded-lg text-sm bg-red-700 hover:bg-red-600 text-white font-medium transition-colors">Confirm</button>
</div>
</dialog>
</Base>
<script>
function fmtMb(mb: number): string {
if (mb >= 1024) return (mb / 1024).toFixed(2) + ' GB';
if (mb >= 1) return mb.toFixed(0) + ' MB';
return (mb * 1024).toFixed(0) + ' KB';
}
function setStatus(el: HTMLElement, msg: string, ok: boolean) {
el.textContent = msg;
el.style.color = ok ? '#4ade80' : '#f87171';
el.classList.remove('hidden');
}
// ── Storage ─────────────────────────────────────────────────────────────────
async function loadStorage() {
const loading = document.getElementById('storage-loading')!;
const content = document.getElementById('storage-content')!;
try {
const r = await fetch('/api/me/storage', { credentials: 'include' });
if (r.status === 401) { window.location.href = `/login/?next=/settings/`; return; }
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
document.getElementById('st-activities')!.textContent =
`${fmtMb(d.activities_mb)} (${d.activities_count} activities)`;
document.getElementById('st-originals')!.textContent = fmtMb(d.originals_mb);
document.getElementById('st-images')!.textContent = fmtMb(d.images_mb);
document.getElementById('st-total')!.textContent = fmtMb(d.total_mb);
if (d.strava_originals_mb > 0) {
document.getElementById('st-strava')!.textContent =
`${fmtMb(d.strava_originals_mb)} (${d.strava_originals_count} files)`;
document.getElementById('st-strava-row')!.classList.remove('hidden');
}
if (d.originals_mb > 0) {
document.getElementById('del-originals-area')!.classList.remove('hidden');
}
loading.classList.add('hidden');
content.classList.remove('hidden');
} catch (e: any) {
loading.textContent = 'Could not load storage info.';
}
}
document.getElementById('del-originals-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('del-originals-btn') as HTMLButtonElement;
const statusEl = document.getElementById('del-originals-status')!;
if (!confirm('Delete all original files? The extracted activities are not affected.')) return;
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
const r = await fetch('/api/me/originals', { method: 'DELETE', credentials: 'include' });
const d = await r.json();
if (r.ok) {
setStatus(statusEl, `Freed ${fmtMb(d.freed_mb)}.`, true);
btn.closest('div')!.querySelector('button')!.remove();
loadStorage();
} else {
btn.disabled = false;
btn.textContent = 'Delete original files';
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
btn.disabled = false;
btn.textContent = 'Delete original files';
setStatus(statusEl, 'Could not reach server', false);
}
});
// ── Display name ─────────────────────────────────────────────────────────────
async function loadMe() {
try {
const r = await fetch('/api/me', { credentials: 'include' });
if (!r.ok) return;
const d = await r.json();
(document.getElementById('display-name-input') as HTMLInputElement).value = d.display_name ?? '';
} catch {}
}
document.getElementById('display-name-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const statusEl = document.getElementById('display-name-status')!;
const val = (document.getElementById('display-name-input') as HTMLInputElement).value.trim();
try {
const r = await fetch('/api/me/display-name', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: val }),
});
const d = await r.json();
if (r.ok) {
setStatus(statusEl, 'Saved.', true);
setTimeout(() => statusEl.classList.add('hidden'), 3000);
} else {
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
setStatus(statusEl, 'Could not reach server', false);
}
});
// ── Password ─────────────────────────────────────────────────────────────────
document.getElementById('password-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const statusEl = document.getElementById('pw-status')!;
const btn = (e.target as HTMLFormElement).querySelector('button[type=submit]') as HTMLButtonElement;
const current = (document.getElementById('pw-current') as HTMLInputElement).value;
const newPw = (document.getElementById('pw-new') as HTMLInputElement).value;
statusEl.classList.add('hidden');
btn.disabled = true;
try {
const r = await fetch('/api/me/password', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: current, new_password: newPw }),
});
const d = await r.json();
if (r.ok) {
setStatus(statusEl, 'Password changed.', true);
(document.getElementById('pw-current') as HTMLInputElement).value = '';
(document.getElementById('pw-new') as HTMLInputElement).value = '';
} else {
setStatus(statusEl, d.detail ?? 'Failed', false);
}
} catch {
setStatus(statusEl, 'Could not reach server', false);
}
btn.disabled = false;
});
// ── Confirmation modal ────────────────────────────────────────────────────────
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
const confirmTitle = document.getElementById('confirm-title')!;
const confirmDesc = document.getElementById('confirm-desc')!;
const confirmPw = document.getElementById('confirm-password') as HTMLInputElement;
const confirmErr = document.getElementById('confirm-error')!;
const confirmOk = document.getElementById('confirm-ok') as HTMLButtonElement;
const confirmCancel = document.getElementById('confirm-cancel')!;
let pendingAction: 'activities' | 'account' | null = null;
function openConfirm(action: 'activities' | 'account') {
pendingAction = action;
confirmErr.classList.add('hidden');
confirmPw.value = '';
if (action === 'activities') {
confirmTitle.textContent = 'Delete all activity data?';
confirmDesc.textContent = 'Removes all extracted activities, edits, and photos. Your account is kept.';
} else {
confirmTitle.textContent = 'Delete your account?';
confirmDesc.textContent = 'Permanently deletes your account and all associated data. This cannot be undone.';
}
dialog.showModal();
setTimeout(() => confirmPw.focus(), 50);
}
confirmCancel.addEventListener('click', () => dialog.close());
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
dialog.addEventListener('keydown', e => { if (e.key === 'Escape') dialog.close(); });
confirmOk.addEventListener('click', async () => {
confirmErr.classList.add('hidden');
const password = confirmPw.value;
if (!password) {
confirmErr.textContent = 'Enter your password to confirm.';
confirmErr.classList.remove('hidden');
return;
}
confirmOk.disabled = true;
confirmOk.textContent = 'Working…';
const url = pendingAction === 'account' ? '/api/me' : '/api/me/activities';
try {
const r = await fetch(url, {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const d = await r.json();
if (r.ok) {
dialog.close();
if (pendingAction === 'account') {
window.location.href = '/';
} else {
window.location.reload();
}
} else {
confirmErr.textContent = d.detail ?? 'Failed';
confirmErr.classList.remove('hidden');
confirmOk.disabled = false;
confirmOk.textContent = 'Confirm';
}
} catch {
confirmErr.textContent = 'Could not reach server';
confirmErr.classList.remove('hidden');
confirmOk.disabled = false;
confirmOk.textContent = 'Confirm';
}
});
document.getElementById('del-activities-btn')?.addEventListener('click', () => openConfirm('activities'));
document.getElementById('del-account-btn')?.addEventListener('click', () => openConfirm('account'));
// ── Init ─────────────────────────────────────────────────────────────────────
loadMe();
loadStorage();
</script>