06/16 Phase 6 foundation: admin blueprint, audit log, user management
Adds the admin backend foundation: AuditLog + Setting models/migration, an admin-only dashboard with KPI cards, and user management (search, ban/suspend/activate, tier override, trust adjust) with audit logging on every write action. Smoke suite extended to 64 checks.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
<nav class="admin-nav">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="{{ 'on' if request.endpoint == 'admin.dashboard' else '' }}">{{ _('Dashboard') }}</a>
|
||||
<a href="{{ url_for('admin.users') }}" class="{{ 'on' if request.endpoint in ('admin.users', 'admin.user_detail') else '' }}">{{ _('Users') }}</a>
|
||||
</nav>
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Dashboard') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Dashboard') }}</h2>
|
||||
<div class="kpi-grid">
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Active listings') }}</div>
|
||||
<div class="kpi-value">{{ kpis.active_listings }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('New users (7d)') }}</div>
|
||||
<div class="kpi-value">{{ kpis.new_users_7d }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('New users (30d)') }}</div>
|
||||
<div class="kpi-value">{{ kpis.new_users_30d }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('MRR') }}</div>
|
||||
<div class="kpi-value">${{ "%.2f"|format(kpis.mrr_cents / 100) }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Revenue (30d): subscriptions + boosts') }}</div>
|
||||
<div class="kpi-value">${{ "%.2f"|format(kpis.revenue_30d_cents / 100) }}</div>
|
||||
</div>
|
||||
<div class="card kpi-card">
|
||||
<div class="kpi-label">{{ _('Flag queue depth') }}</div>
|
||||
<div class="kpi-value">{{ kpis.flag_queue_depth }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · %(email)s', email=user.email) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ user.display_name }} <{{ user.email }}></h2>
|
||||
<table class="attrs">
|
||||
<tr><th>{{ _('Role') }}</th><td><span class="badge cat">{{ user.role.value }}</span></td></tr>
|
||||
<tr><th>{{ _('Status') }}</th><td><span class="badge {{ 'ok' if user.status.value == 'active' else 'warn' }}">{{ user.status.value }}</span></td></tr>
|
||||
<tr><th>{{ _('Trust') }}</th><td>{{ user.trust_tier.value }} ({{ user.trust_score }})</td></tr>
|
||||
<tr><th>{{ _('Tier') }}</th><td>{{ user.tier.name if user.tier else '—' }}</td></tr>
|
||||
<tr><th>{{ _('Email verified') }}</th><td>{{ _('Yes') if user.email_verified else _('No') }}</td></tr>
|
||||
<tr><th>{{ _('Verified badge') }}</th><td>{{ _('Yes') if user.verified else _('No') }}</td></tr>
|
||||
<tr><th>{{ _('Last login') }}</th><td>{{ user.last_login_at or '—' }}</td></tr>
|
||||
<tr><th>{{ _('Joined') }}</th><td>{{ user.created_at.strftime('%Y-%m-%d') }}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="billing-actions" style="margin-top:14px">
|
||||
<form method="post" action="{{ url_for('admin.ban_user', user_id=user.id) }}"
|
||||
onsubmit="return confirm('{{ _('Ban this user?') }}');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn danger" type="submit">{{ _('Ban') }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('admin.suspend_user', user_id=user.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn ghost" type="submit">{{ _('Suspend') }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('admin.activate_user', user_id=user.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn" type="submit">{{ _('Activate') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Tier override') }}</h3>
|
||||
<form method="post" action="{{ url_for('admin.set_tier', user_id=user.id) }}" class="row2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select class="input" name="plan_id">
|
||||
<option value="">{{ _('None') }}</option>
|
||||
{% for p in plans %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if user.tier_id == p.id }}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn" type="submit">{{ _('Update tier') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Adjust trust') }}</h3>
|
||||
<form method="post" action="{{ url_for('admin.trust_adjust', user_id=user.id) }}" class="row2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select class="input" name="event_type">
|
||||
{% for et in TrustEventType %}
|
||||
<option value="{{ et.value }}">{{ et.value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input class="input" type="number" name="delta" value="0">
|
||||
<button class="btn" type="submit">{{ _('Apply') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Listings') }}</h3>
|
||||
{% if not listings %}<p class="muted">{{ _('No listings.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
{% for l in listings %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('listings.detail', listing_id=l.id) }}">{{ l.title }}</a></td>
|
||||
<td><span class="badge {{ 'ok' if l.is_live else 'warn' }}">{{ l.status.value }}</span></td>
|
||||
<td class="muted small">{{ l.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Trust events') }}</h3>
|
||||
{% if not trust_events %}<p class="muted">{{ _('None.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
{% for ev in trust_events %}
|
||||
<tr>
|
||||
<td>{{ ev.type.value }}</td>
|
||||
<td>{{ '%+d'|format(ev.delta) }}</td>
|
||||
<td class="muted small">{{ ev.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>{{ _('Audit history') }}</h3>
|
||||
{% if not audit_history %}<p class="muted">{{ _('None.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
{% for a in audit_history %}
|
||||
<tr>
|
||||
<td>{{ a.action }}</td>
|
||||
<td>{{ a.actor.display_name if a.actor else _('system') }}</td>
|
||||
<td class="muted small">{{ a.meta|tojson if a.meta else '' }}</td>
|
||||
<td class="muted small">{{ a.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Admin · Users') }}{% endblock %}
|
||||
{% block content %}
|
||||
{% include "admin/_nav.html" %}
|
||||
<div class="card">
|
||||
<h2>{{ _('Users') }}</h2>
|
||||
<form method="get" action="{{ url_for('admin.users') }}" class="admin-filter-row">
|
||||
<div class="field">
|
||||
<label>{{ _('Search') }}</label>
|
||||
<input class="input" name="q" value="{{ filters.get('q','') }}" placeholder="{{ _('email or name') }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Role') }}</label>
|
||||
<select class="input" name="role">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for r in ['free', 'subscriber', 'moderator', 'admin'] %}
|
||||
<option value="{{ r }}" {{ 'selected' if filters.get('role') == r }}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ _('Status') }}</label>
|
||||
<select class="input" name="status">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for s in ['active', 'suspended', 'banned'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if filters.get('status') == s }}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" type="submit">{{ _('Apply') }}</button>
|
||||
</form>
|
||||
|
||||
{% if not pagination.items %}<p class="muted">{{ _('No users found.') }}</p>{% endif %}
|
||||
<table class="list">
|
||||
{% for u in pagination.items %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
|
||||
<td>{{ u.display_name }}</td>
|
||||
<td><span class="badge cat">{{ u.role.value }}</span></td>
|
||||
<td><span class="badge {{ 'ok' if u.status.value == 'active' else 'warn' }}">{{ u.status.value }}</span></td>
|
||||
<td>{{ u.trust_tier.value }}</td>
|
||||
<td class="muted small">{{ u.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<div class="pager">
|
||||
{% if pagination.has_prev %}<a href="{{ url_for('admin.users', **merge_query(page=pagination.prev_num)) }}">← {{ _('Prev') }}</a>{% endif %}
|
||||
{% if pagination.has_next %}<a href="{{ url_for('admin.users', **merge_query(page=pagination.next_num)) }}">{{ _('Next') }} →</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user