Files
espresso_frame/server/app/templates/admin.html
T
tfaour 8ac3fc0de3
Build and push server image / build-and-push (push) Successful in 43s
Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.

The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.

All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.

Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
2026-07-21 23:56:18 -04:00

100 lines
4.8 KiB
HTML

{% extends "app_base.html" %}
{% block title %}Admin{% endblock %}
{% block page_title %}Administration{% endblock %}
{% block content %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Users</h2>
<table class="admin-table">
<thead><tr><th>Username</th><th>Display name</th><th>Role</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.display_name }}</td>
<td>{{ "admin" if u.is_admin else "user" }}</td>
<td class="admin-actions">
<details>
<summary>Reset password</summary>
<form method="post" action="/admin/users/{{ u.id }}/reset-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="password" name="password" minlength="8" placeholder="New password" required>
<button type="submit" class="secondary btn-inline">Reset</button>
</form>
</details>
{% if u.id != user.id %}
<form method="post" action="/admin/users/{{ u.id }}/delete"
onsubmit="return confirm('Delete user {{ u.username }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Delete</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2 class="card-title" style="margin-top: 24px;">Enroll a user</h2>
<form method="post" action="/admin/users">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Username
<input type="text" name="username" maxlength="64" required>
</label>
<label>Password
<input type="password" name="password" minlength="8" required autocomplete="new-password">
</label>
<div class="checkbox-row">
<input type="checkbox" id="is_admin" name="is_admin" value="true">
<label for="is_admin">Administrator</label>
</div>
<button type="submit">Create user</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Frames</h2>
{% for f in frames %}
<div class="admin-frame">
<p class="sub">
<strong>#{{ f.id }} {{ f.name }}</strong><br>
device: <code>{{ f.device_id or "not yet reported" }}</code><br>
owner: {{ (f.owner.username if f.owner else none) or "UNCLAIMED" }}
&middot; linked: {{ links_by_frame.get(f.id, []) | map(attribute="username") | join(", ") or "nobody" }}<br>
firmware: {{ f.device_firmware_version or "?" }} ({{ f.device_board_variant or "board unknown" }})
&middot; token ack: {{ "yes" if f.device_token_ack else "no" }}
{% if f.legacy_token_enabled %}&middot; <strong>legacy token window OPEN</strong>{% endif %}
</p>
<form method="post" action="/admin/frames/{{ f.id }}/link-user" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="text" name="username" placeholder="Link user by name" required>
<button type="submit" class="secondary btn-inline">Link</button>
</form>
{% if f.legacy_token_enabled %}
<form method="post" action="/admin/frames/{{ f.id }}/end-legacy" class="admin-inline-form"
onsubmit="return confirm('Close the legacy-token window for frame #{{ f.id }}? Only do this once the device has acknowledged its own token.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Close legacy window</button>
</form>
{% endif %}
<form method="post" action="/admin/frames/{{ f.id }}/delete" class="admin-inline-form"
onsubmit="return confirm('Delete frame #{{ f.id }} and all its history?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary btn-inline">Delete</button>
</form>
</div>
{% endfor %}
{% if not frames %}<p class="sub">No frames yet.</p>{% endif %}
</section>
</div>
</div>
{% endblock %}