feat: admin observability — platform overview, audit log, purge, notifications
- GET /api/admin/overview: platform totals (users, orgs, links, clicks), a 30-day click series, top links, and per-user quota consumption with near-limit flags (org members show pooled usage). - Audit logging wired into all admin mutations: user create/update/delete, password resets, org create/update/delete, purges. Browsable via GET /api/admin/audit and a new Audit Log section in the admin UI. - POST /api/admin/purge hard-deletes soft-deleted links older than N days, freeing their short codes and click history. - Contact-form submissions now fire a WEBHOOK_URL notification. - Fix: deleting a user who owned links or tags failed with a foreign-key 500 — their links/tags are now deliberately orphaned (kept in the DB, unowned) and their API keys removed, matching the UI's wording. - Admin UI: Platform Overview stat cards, quota-usage table with ⚠ NEAR LIMIT badges, audit table, and a purge action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1906,6 +1906,8 @@ def admin_create_user():
|
||||
)
|
||||
user = conn.execute('SELECT id, username, is_admin, plan, created_at FROM users WHERE username=?',
|
||||
(username,)).fetchone()
|
||||
log_audit(conn, 'user.create', target=username,
|
||||
details=f'plan={plan} admin={bool(is_admin)}')
|
||||
return jsonify(dict(user)), 201
|
||||
|
||||
|
||||
@@ -1942,6 +1944,8 @@ def admin_update_user(user_id):
|
||||
'o.name as org_name FROM users u LEFT JOIN organizations o ON u.org_id=o.id '
|
||||
'WHERE u.id=?', (user_id,)
|
||||
).fetchone()
|
||||
log_audit(conn, 'user.update', target=user['username'],
|
||||
details=', '.join(f'{k}={v}' for k, v in updates.items()))
|
||||
return jsonify(dict(user))
|
||||
|
||||
|
||||
@@ -1954,7 +1958,13 @@ def admin_delete_user(user_id):
|
||||
user = conn.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
|
||||
if not user:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
# Orphan the user's links/tags (they stay in the DB, unowned) and drop
|
||||
# their API keys — otherwise the FK constraints reject the delete.
|
||||
conn.execute('UPDATE links SET user_id=NULL WHERE user_id=?', (user_id,))
|
||||
conn.execute('UPDATE tags SET user_id=NULL WHERE user_id=?', (user_id,))
|
||||
conn.execute('DELETE FROM api_keys WHERE user_id=?', (user_id,))
|
||||
conn.execute('DELETE FROM users WHERE id=?', (user_id,))
|
||||
log_audit(conn, 'user.delete', target=user['username'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@@ -1966,10 +1976,12 @@ def admin_change_password(user_id):
|
||||
if not password:
|
||||
return jsonify({'error': 'Password required'}), 400
|
||||
with get_db() as conn:
|
||||
if not conn.execute('SELECT 1 FROM users WHERE id=?', (user_id,)).fetchone():
|
||||
user = conn.execute('SELECT username FROM users WHERE id=?', (user_id,)).fetchone()
|
||||
if not user:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
conn.execute('UPDATE users SET password_hash=? WHERE id=?',
|
||||
(generate_password_hash(password), user_id))
|
||||
log_audit(conn, 'user.password_reset', target=user['username'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@@ -1992,6 +2004,7 @@ def submit_contact():
|
||||
'INSERT INTO messages (name, email, subject, body, created_at) VALUES (?,?,?,?,?)',
|
||||
(name, email, subject, body, now)
|
||||
)
|
||||
notify_webhook(f'📨 New {APP_NAME} contact message from {name} <{email}>: {subject}')
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@@ -2059,6 +2072,7 @@ def admin_create_organization():
|
||||
org = conn.execute(
|
||||
'SELECT id, name, plan, created_at FROM organizations WHERE name=?', (name,)
|
||||
).fetchone()
|
||||
log_audit(conn, 'org.create', target=name, details=f'plan={plan}')
|
||||
return jsonify(dict(org)), 201
|
||||
|
||||
|
||||
@@ -2089,6 +2103,8 @@ def admin_update_organization(org_id):
|
||||
'FROM organizations o LEFT JOIN users u ON u.org_id=o.id WHERE o.id=?',
|
||||
(org_id,)
|
||||
).fetchone()
|
||||
log_audit(conn, 'org.update', target=org['name'],
|
||||
details=', '.join(f'{k}={v}' for k, v in updates.items()))
|
||||
return jsonify(dict(org))
|
||||
|
||||
|
||||
@@ -2096,14 +2112,118 @@ def admin_update_organization(org_id):
|
||||
@admin_required
|
||||
def admin_delete_organization(org_id):
|
||||
with get_db() as conn:
|
||||
if not conn.execute('SELECT 1 FROM organizations WHERE id=?', (org_id,)).fetchone():
|
||||
org = conn.execute('SELECT name FROM organizations WHERE id=?', (org_id,)).fetchone()
|
||||
if not org:
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
# Remove org_id from all members before deleting
|
||||
conn.execute('UPDATE users SET org_id=NULL WHERE org_id=?', (org_id,))
|
||||
conn.execute('DELETE FROM organizations WHERE id=?', (org_id,))
|
||||
log_audit(conn, 'org.delete', target=org['name'])
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Admin — Platform overview, audit log, data lifecycle
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/admin/overview')
|
||||
@admin_required
|
||||
def admin_overview():
|
||||
"""Bird's-eye platform view: totals, 30-day click series, per-user quota
|
||||
consumption with near-limit flags, and top links platform-wide."""
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
since_30d = (now - timedelta(days=30)).isoformat()
|
||||
since_7d = (now - timedelta(days=7)).isoformat()
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
|
||||
with get_db() as conn:
|
||||
totals = {
|
||||
'users': conn.execute('SELECT COUNT(*) FROM users').fetchone()[0],
|
||||
'organizations':conn.execute('SELECT COUNT(*) FROM organizations').fetchone()[0],
|
||||
'active_links': conn.execute('SELECT COUNT(*) FROM links WHERE is_active=1').fetchone()[0],
|
||||
'deleted_links':conn.execute('SELECT COUNT(*) FROM links WHERE is_active=0').fetchone()[0],
|
||||
'total_clicks': conn.execute('SELECT COALESCE(SUM(clicks),0) FROM links').fetchone()[0],
|
||||
'clicks_today': conn.execute('SELECT COUNT(*) FROM clicks WHERE clicked_at>=?', (today,)).fetchone()[0],
|
||||
'clicks_7d': conn.execute('SELECT COUNT(*) FROM clicks WHERE clicked_at>=?', (since_7d,)).fetchone()[0],
|
||||
'clicks_30d': conn.execute('SELECT COUNT(*) FROM clicks WHERE clicked_at>=?', (since_30d,)).fetchone()[0],
|
||||
}
|
||||
|
||||
daily_rows = conn.execute("""
|
||||
SELECT substr(clicked_at,1,10) as day, COUNT(*) as count
|
||||
FROM clicks WHERE clicked_at>=? GROUP BY day ORDER BY day
|
||||
""", (since_30d,)).fetchall()
|
||||
daily_map = {r['day']: r['count'] for r in daily_rows}
|
||||
daily = []
|
||||
for i in range(30):
|
||||
d = (now - timedelta(days=29-i)).strftime('%Y-%m-%d')
|
||||
daily.append({'date': d, 'clicks': daily_map.get(d, 0)})
|
||||
|
||||
top_links = [dict(r) for r in conn.execute("""
|
||||
SELECT l.code, l.title, l.long_url, l.clicks, u.username as created_by
|
||||
FROM links l LEFT JOIN users u ON l.user_id=u.id
|
||||
WHERE l.is_active=1 ORDER BY l.clicks DESC LIMIT 10
|
||||
""").fetchall()]
|
||||
|
||||
# Per-user quota consumption (org members show pooled usage)
|
||||
users = conn.execute(
|
||||
'SELECT u.id, u.username, u.is_admin, o.name as org_name '
|
||||
'FROM users u LEFT JOIN organizations o ON u.org_id=o.id ORDER BY u.username'
|
||||
).fetchall()
|
||||
usage = []
|
||||
for u in users:
|
||||
plan = get_user_plan(conn, u['id'])
|
||||
limits = PLAN_LIMITS[plan]
|
||||
links_used = get_user_active_link_count(conn, u['id'])
|
||||
clicks_used = get_user_monthly_clicks(conn, u['id'])
|
||||
def _pct(used, limit):
|
||||
return round(used / limit * 100, 1) if limit else None
|
||||
links_pct = _pct(links_used, limits['max_links'])
|
||||
clicks_pct = _pct(clicks_used, limits['monthly_clicks'])
|
||||
usage.append({
|
||||
'id': u['id'], 'username': u['username'], 'is_admin': bool(u['is_admin']),
|
||||
'org_name': u['org_name'], 'plan': plan,
|
||||
'links_used': links_used, 'links_limit': limits['max_links'], 'links_pct': links_pct,
|
||||
'clicks_used': clicks_used,'clicks_limit': limits['monthly_clicks'], 'clicks_pct': clicks_pct,
|
||||
'near_limit': bool((links_pct and links_pct >= 80) or (clicks_pct and clicks_pct >= 80)),
|
||||
})
|
||||
|
||||
return jsonify({'totals': totals, 'daily': daily, 'top_links': top_links, 'usage': usage})
|
||||
|
||||
|
||||
@app.route('/api/admin/audit')
|
||||
@admin_required
|
||||
def admin_audit_log():
|
||||
limit = min(int(request.args.get('limit', 200)), 1000)
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT * FROM audit_log ORDER BY id DESC LIMIT ?', (limit,)
|
||||
).fetchall()
|
||||
return jsonify({'entries': [dict(r) for r in rows]})
|
||||
|
||||
|
||||
@app.route('/api/admin/purge', methods=['POST'])
|
||||
@admin_required
|
||||
def admin_purge_links():
|
||||
"""Hard-delete soft-deleted links (and their clicks/tags) older than N days.
|
||||
Frees their short codes for reuse and reclaims click storage."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
days = max(0, int(data.get('days', 30)))
|
||||
cutoff = (datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)).isoformat()
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT id FROM links WHERE is_active=0 AND (deleted_at IS NULL OR deleted_at < ?)',
|
||||
(cutoff,)
|
||||
).fetchall()
|
||||
ids = [r['id'] for r in rows]
|
||||
if ids:
|
||||
ph = ','.join('?' * len(ids))
|
||||
conn.execute(f'DELETE FROM clicks WHERE link_id IN ({ph})', ids)
|
||||
conn.execute(f'DELETE FROM link_tags WHERE link_id IN ({ph})', ids)
|
||||
conn.execute(f'DELETE FROM links WHERE id IN ({ph})', ids)
|
||||
log_audit(conn, 'links.purge', details=f'{len(ids)} soft-deleted links purged (deleted >{days}d ago)')
|
||||
return jsonify({'purged': len(ids)})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# SEO — robots.txt & sitemap.xml
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
+94
@@ -869,6 +869,16 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
|
||||
</div>
|
||||
|
||||
<div id="orgs-list"><div class="empty-state"><p>Loading...</p></div></div>
|
||||
|
||||
<!-- Audit log & maintenance -->
|
||||
<div class="section-head" style="margin-bottom:24px;margin-top:40px">
|
||||
<div><div class="section-title">Audit Log</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Administrative actions on this instance</div></div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-sm danger" onclick="purgeDeleted()" title="Hard-delete soft-deleted links and free their codes">🗑 Purge deleted links</button>
|
||||
<button class="btn-sm" onclick="loadAudit()">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="audit-list"><div class="empty-state"><p>Loading...</p></div></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
@@ -2224,10 +2234,94 @@ async function doImport() {
|
||||
let allOrgs = [];
|
||||
|
||||
async function loadAdminPanel() {
|
||||
loadAdminOverview();
|
||||
loadAudit();
|
||||
await loadOrgs();
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
// ── Admin — Platform overview & quota usage ────────
|
||||
async function loadAdminOverview() {
|
||||
try {
|
||||
const resp = await authFetch(`${BASE}/api/admin/overview`);
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
document.getElementById('astat-users').textContent = d.totals.users.toLocaleString();
|
||||
document.getElementById('astat-orgs').textContent = d.totals.organizations.toLocaleString();
|
||||
document.getElementById('astat-links').textContent = d.totals.active_links.toLocaleString();
|
||||
document.getElementById('astat-clicks30').textContent = d.totals.clicks_30d.toLocaleString();
|
||||
|
||||
const fmtQuota = (used, limit, pct) => limit === null
|
||||
? `${used.toLocaleString()} <span style="opacity:.5">/ ∞</span>`
|
||||
: `${used.toLocaleString()} / ${limit.toLocaleString()} <span style="color:${pct>=100?'#ff5470':pct>=80?'#ffb020':'var(--muted)'}">(${pct}%)</span>`;
|
||||
|
||||
document.getElementById('admin-usage').innerHTML = `
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
|
||||
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
|
||||
<th style="text-align:left;padding:6px 10px">USER</th>
|
||||
<th style="text-align:left;padding:6px 10px">PLAN</th>
|
||||
<th style="text-align:left;padding:6px 10px">ACTIVE LINKS</th>
|
||||
<th style="text-align:left;padding:6px 10px">MONTHLY CLICKS</th>
|
||||
<th style="text-align:left;padding:6px 10px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${d.usage.map(u=>`
|
||||
<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:8px 10px">${escHtml(u.username)}${u.org_name?` <span style="color:var(--muted);font-size:10px">@${escHtml(u.org_name)}</span>`:''}</td>
|
||||
<td style="padding:8px 10px;color:${u.is_admin?'var(--accent)':'var(--muted)'}">${u.plan.toUpperCase()}</td>
|
||||
<td style="padding:8px 10px">${fmtQuota(u.links_used, u.links_limit, u.links_pct)}</td>
|
||||
<td style="padding:8px 10px">${fmtQuota(u.clicks_used, u.clicks_limit, u.clicks_pct)}</td>
|
||||
<td style="padding:8px 10px">${u.near_limit?'<span style="color:#ffb020;font-size:10px;letter-spacing:.06em">⚠ NEAR LIMIT</span>':''}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Admin — Audit log & maintenance ────────────────
|
||||
async function loadAudit() {
|
||||
const container = document.getElementById('audit-list');
|
||||
try {
|
||||
const resp = await authFetch(`${BASE}/api/admin/audit?limit=100`);
|
||||
if (!resp.ok) { container.innerHTML = '<div class="empty-state"><p>Access denied.</p></div>'; return; }
|
||||
const { entries } = await resp.json();
|
||||
if (!entries.length) { container.innerHTML = '<div class="empty-state"><p>No admin actions recorded yet.</p></div>'; return; }
|
||||
container.innerHTML = `
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
|
||||
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
|
||||
<th style="text-align:left;padding:6px 10px">WHEN</th>
|
||||
<th style="text-align:left;padding:6px 10px">ACTOR</th>
|
||||
<th style="text-align:left;padding:6px 10px">ACTION</th>
|
||||
<th style="text-align:left;padding:6px 10px">TARGET</th>
|
||||
<th style="text-align:left;padding:6px 10px">DETAILS</th>
|
||||
</tr></thead>
|
||||
<tbody>${entries.map(e=>`
|
||||
<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:7px 10px;color:var(--muted);white-space:nowrap">${(e.created_at||'').slice(0,16).replace('T',' ')}</td>
|
||||
<td style="padding:7px 10px">${escHtml(e.actor||'—')}</td>
|
||||
<td style="padding:7px 10px;color:var(--accent)">${escHtml(e.action)}</td>
|
||||
<td style="padding:7px 10px">${escHtml(e.target||'')}</td>
|
||||
<td style="padding:7px 10px;color:var(--muted)">${escHtml(e.details||'')}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch(e) { container.innerHTML = '<div class="empty-state"><p>Failed to load audit log.</p></div>'; }
|
||||
}
|
||||
|
||||
async function purgeDeleted() {
|
||||
const days = prompt('Hard-delete links that were soft-deleted more than N days ago.\nTheir short codes become reusable and click history is removed.\n\nN =', '30');
|
||||
if (days === null) return;
|
||||
const n = parseInt(days);
|
||||
if (isNaN(n) || n < 0) { toast('Enter a number of days','error'); return; }
|
||||
if (!confirm(`Permanently purge links deleted more than ${n} day(s) ago? This cannot be undone.`)) return;
|
||||
try {
|
||||
const resp = await authFetch(`${BASE}/api/admin/purge`, {method:'POST', body:JSON.stringify({days:n})});
|
||||
const d = await resp.json();
|
||||
if (!resp.ok) { toast(d.error||'Purge failed','error'); return; }
|
||||
toast(`Purged ${d.purged} link(s)`);
|
||||
loadAudit(); loadAdminOverview();
|
||||
} catch(e) { toast('Network error','error'); }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const container = document.getElementById('users-list');
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user