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:
@@ -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!;
|
||||
|
||||
Reference in New Issue
Block a user