feat: scheduled Strava sync + admin suspend/delete account

- Add bincio sync-strava command: headless multi-user Strava sync
  designed for systemd timer. Discovers users via strava_token.json,
  skips users without their own strava_credentials.json, respects
  Strava visibility (only_me → unlisted). Treats 404 stream errors as
  no-GPS activities rather than retrying every run.
- Add deploy/systemd/bincio-sync.{service,timer}: runs every 3 hours,
  Persistent=true to catch up after downtime.
- Add POST /api/internal/rebuild: webhook for sync timer to trigger
  site rebuild, authenticated via X-Sync-Secret header.
- Add suspended column to users table with auto-migration on open_db.
  Suspended users are blocked at login and session lookup (covers both
  activity site and wiki, which share instance.db).
- Add POST /api/admin/users/{handle}/suspend|unsuspend and
  DELETE /api/admin/users/{handle}/account endpoints.
- Admin panel: Suspend/Unsuspend toggle, Del account button, suspended
  badge on user row.
This commit is contained in:
Davide Scaini
2026-05-08 10:36:21 +02:00
parent 680ef9d440
commit 12693dbd60
9 changed files with 465 additions and 8 deletions
+69 -2
View File
@@ -129,6 +129,9 @@ import Base from '../../layouts/Base.astro';
const stravaNote = u.originals_strava_mb > 0
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
: '';
const suspendBtn = u.suspended
? `<button class="unsuspend-btn text-xs px-3 py-1.5 rounded-lg bg-amber-900/60 hover:bg-amber-800 text-amber-300 transition-colors" data-handle="${u.handle}" title="Re-enable this account">Unsuspend</button>`
: `<button class="suspend-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-amber-900 hover:text-amber-300 text-zinc-400 transition-colors" data-handle="${u.handle}" title="Block login and invalidate all sessions">Suspend</button>`;
const actionButtons = u.in_db
? `<button
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
@@ -150,11 +153,17 @@ import Base from '../../layouts/Base.astro';
data-handle="${u.handle}"
title="Generate a one-time password reset code for this user"
>Reset pwd</button>
${suspendBtn}
<button
class="delete-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
title="Wipe all activities, originals, edits and images — account is kept"
>Reset data</button>`
>Reset data</button>
<button
class="del-account-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
data-handle="${u.handle}"
title="Delete the database account — data directory is NOT removed"
>Del account</button>`
: `<button
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
data-handle="${u.handle}"
@@ -170,7 +179,9 @@ import Base from '../../layouts/Base.astro';
<td class="px-4 py-3">
<div class="flex items-center gap-1">
<a href="/u/${u.handle}/" class="text-white hover:text-zinc-300">@${u.handle}</a>
${ghostBadge}${leaked}
${ghostBadge}
${u.suspended ? '<span class="text-amber-400 text-xs ml-1 font-medium">suspended</span>' : ''}
${leaked}
</div>
${bar(rowPct)}
</td>
@@ -336,6 +347,62 @@ import Base from '../../layouts/Base.astro';
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.suspend-btn, .unsuspend-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;
const isSuspend = btn.classList.contains('suspend-btn');
const action = isSuspend ? 'Suspend' : 'Unsuspend';
if (!confirm(`${action} @${h}?${isSuspend ? ' All active sessions will be invalidated.' : ''}`)) return;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch(`/api/admin/users/${h}/${isSuspend ? 'suspend' : 'unsuspend'}`, {
method: 'POST', credentials: 'include',
});
if (r.ok) {
setTimeout(() => load(), 400);
} else {
const d = await r.json().catch(() => ({}));
btn.disabled = false;
btn.textContent = 'Error: ' + (d.detail ?? r.status);
btn.classList.add('text-red-400');
}
} catch {
btn.disabled = false;
btn.textContent = 'Error';
btn.classList.add('text-red-400');
}
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.del-account-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;
if (!confirm(`Delete the account for @${h}?\n\nThe data directory will NOT be removed — use "Reset data" first if you want to wipe it.\n\nThis cannot be undone.`)) return;
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
const r = await fetch(`/api/admin/users/${h}/account`, {
method: 'DELETE', credentials: 'include',
});
if (r.ok) {
btn.textContent = 'Deleted';
btn.classList.add('text-green-500');
setTimeout(() => load(), 1000);
} else {
const d = await r.json().catch(() => ({}));
btn.disabled = false;
btn.textContent = 'Error: ' + (d.detail ?? r.status);
btn.classList.add('text-red-400');
}
} catch {
btn.disabled = false;
btn.textContent = 'Error';
btn.classList.add('text-red-400');
}
});
});
tbodyEl.querySelectorAll<HTMLButtonElement>('.rmdir-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const h = btn.dataset.handle!;