- DELETE /api/admin/users/{handle}/activities — deletes all activities/*.json, wipes _merged/ and
index.json, then triggers a rebuild. Admin-only. - /admin/ page — lists all users, each with a "Delete activities" button. Clicking asks for confirmation in a <dialog> before firing the request. Button shows "Deleted (N)" or an error inline. - "Admin" nav link — appears in the top-right for admins only, hidden for everyone else.
This commit is contained in:
@@ -437,6 +437,37 @@ async def admin_jobs(bincio_session: Optional[str] = Cookie(default=None)) -> JS
|
||||
return JSONResponse(jobs)
|
||||
|
||||
|
||||
@app.delete("/api/admin/users/{handle}/activities")
|
||||
async def admin_delete_activities(
|
||||
handle: str,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
"""Delete all activity JSON files for a user and wipe the merged cache."""
|
||||
_require_admin(bincio_session)
|
||||
user_dir = _get_data_dir() / handle
|
||||
if not user_dir.is_dir():
|
||||
raise HTTPException(404, f"No data directory for user '{handle}'")
|
||||
|
||||
deleted = 0
|
||||
activities_dir = user_dir / "activities"
|
||||
if activities_dir.is_dir():
|
||||
for f in activities_dir.glob("*.json"):
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
|
||||
# Wipe merged cache and top-level index so they don't show stale data
|
||||
import shutil
|
||||
merged_dir = user_dir / "_merged"
|
||||
if merged_dir.exists():
|
||||
shutil.rmtree(merged_dir)
|
||||
index_file = user_dir / "index.json"
|
||||
if index_file.exists():
|
||||
index_file.unlink()
|
||||
|
||||
_trigger_rebuild(handle)
|
||||
return JSONResponse({"ok": True, "deleted": deleted})
|
||||
|
||||
|
||||
# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────
|
||||
|
||||
def _user_data_dir(handle: str) -> Path:
|
||||
|
||||
@@ -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>
|
||||
<!-- Admin link — hidden until confirmed admin -->
|
||||
<a
|
||||
id="nav-admin"
|
||||
href={`${baseUrl}admin/`}
|
||||
style="display:none"
|
||||
class="text-xs text-zinc-500 hover:text-white transition-colors px-1"
|
||||
>Admin</a>
|
||||
|
||||
<!-- Logout button — hidden until logged in -->
|
||||
<button
|
||||
@@ -484,8 +491,10 @@ try {
|
||||
const chk = document.getElementById('upload-keep-original');
|
||||
if (chk && user.store_originals_default) chk.checked = true;
|
||||
|
||||
// Admin: poll for active jobs and show a badge in the nav
|
||||
// Admin: show admin link and poll for active jobs
|
||||
if (user.is_admin) {
|
||||
const adminLink = document.getElementById('nav-admin');
|
||||
if (adminLink) adminLink.style.display = '';
|
||||
const badge = document.getElementById('admin-jobs-badge');
|
||||
async function pollJobs() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
---
|
||||
<Base title="Admin — BincioActivity">
|
||||
<div class="max-w-2xl mx-auto px-4 py-10">
|
||||
<h1 class="text-2xl font-bold text-white mb-8">Admin</h1>
|
||||
|
||||
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-3">Users</h2>
|
||||
<div id="user-list" class="space-y-2">
|
||||
<p class="text-zinc-500 text-sm">Loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<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 class="text-sm text-zinc-300 mb-5">Delete all activities for <strong id="confirm-handle" class="text-white"></strong>? This cannot be undone.</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">Delete</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
const listEl = document.getElementById('user-list')!;
|
||||
const dialog = document.getElementById('confirm-dialog') as HTMLDialogElement;
|
||||
const confirmH = document.getElementById('confirm-handle')!;
|
||||
const confirmOk = document.getElementById('confirm-ok')!;
|
||||
const confirmCancel = document.getElementById('confirm-cancel')!;
|
||||
|
||||
let pendingHandle = '';
|
||||
|
||||
async function load() {
|
||||
const r = await fetch('/api/admin/users', { credentials: 'include' });
|
||||
if (!r.ok) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Not authorised or server unavailable.</p>';
|
||||
return;
|
||||
}
|
||||
const users = await r.json();
|
||||
if (!users.length) {
|
||||
listEl.innerHTML = '<p class="text-zinc-500 text-sm">No users.</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = users.map((u: { handle: string; display_name: string; is_admin: boolean }) => `
|
||||
<div class="flex items-center justify-between px-4 py-3 rounded-lg bg-zinc-900 border border-zinc-800" data-handle="${u.handle}">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-white">@${u.handle}</span>
|
||||
${u.display_name ? `<span class="text-zinc-500 text-sm ml-2">${u.display_name}</span>` : ''}
|
||||
${u.is_admin ? '<span class="ml-2 text-xs text-amber-400">admin</span>' : ''}
|
||||
</div>
|
||||
<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}"
|
||||
>Delete activities</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
pendingHandle = btn.dataset.handle!;
|
||||
confirmH.textContent = pendingHandle;
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
confirmCancel.addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
|
||||
|
||||
confirmOk.addEventListener('click', async () => {
|
||||
dialog.close();
|
||||
const row = listEl.querySelector(`[data-handle="${pendingHandle}"]`);
|
||||
const btn = row?.querySelector<HTMLButtonElement>('.delete-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Deleting…'; }
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/admin/users/${pendingHandle}/activities`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.ok) {
|
||||
if (btn) { btn.textContent = `Deleted (${d.deleted})`; btn.classList.add('text-green-500'); }
|
||||
} else {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Error: ' + (d.detail ?? 'failed'); btn.classList.add('text-red-400'); }
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Error'; btn.classList.add('text-red-400'); }
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
</script>
|
||||
Reference in New Issue
Block a user