- 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)
|
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) ───────────────────────────
|
# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────
|
||||||
|
|
||||||
def _user_data_dir(handle: str) -> Path:
|
def _user_data_dir(handle: str) -> Path:
|
||||||
|
|||||||
@@ -200,6 +200,13 @@ try {
|
|||||||
title=""
|
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"
|
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>
|
></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 -->
|
<!-- Logout button — hidden until logged in -->
|
||||||
<button
|
<button
|
||||||
@@ -484,8 +491,10 @@ try {
|
|||||||
const chk = document.getElementById('upload-keep-original');
|
const chk = document.getElementById('upload-keep-original');
|
||||||
if (chk && user.store_originals_default) chk.checked = true;
|
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) {
|
if (user.is_admin) {
|
||||||
|
const adminLink = document.getElementById('nav-admin');
|
||||||
|
if (adminLink) adminLink.style.display = '';
|
||||||
const badge = document.getElementById('admin-jobs-badge');
|
const badge = document.getElementById('admin-jobs-badge');
|
||||||
async function pollJobs() {
|
async function pollJobs() {
|
||||||
try {
|
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