Build and push server image / build-and-push (push) Successful in 40s
Admin-configured SMTP (server/port/username/password/from address/ STARTTLS, a singleton server_settings row set from /admin -- not env vars, since it's operator infrastructure a household admin sets up once through the UI) powers two features, both requiring the relevant user to have an email set in their own Settings: - "Forgot password?" on /login emails a one-hour single-use reset link (password_reset_tokens table). The endpoint always returns the same generic "check your email" response regardless of whether the address matched an account, so it can't be used to enumerate registered users. - A frame's Configuration tab can set a battery-alert threshold (Frame.battery_alert_threshold_pct, -1 = disabled); POST /frame/battery emails the owner the first time a report drops to or below it, then stays quiet for the rest of that discharge cycle (battery_alert_sent, reset alongside battery_history whenever the existing recharge-jump detection fires) -- not once per wake. New app/mail.py wraps stdlib smtplib (no new dependency); send_email() never raises, so a broken mail server can't 500 a battery report or a password-reset request. Schema migration v2 adds users.email and the two frame columns via ALTER TABLE (safe against the live, already- populated database) plus the two new tables via the existing create_all-based migration runner. Verified against a real (already-migrated, real user/frame data) database: the v1->v2 migration, admin SMTP config + test-email button, full forgot/reset-password roundtrip (including single-use token invalidation and the no-enumeration response), and the battery alert firing exactly once per crossing against a hand-rolled fake SMTP server -- all via curl end-to-end, plus the standing legacy-device curl suite to confirm the device protocol is untouched.
133 lines
6.6 KiB
HTML
133 lines
6.6 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;">Email (SMTP)</h2>
|
|
<p class="sub">Used for "forgot password" links and battery-low
|
|
alerts (set per frame in its Configuration tab). Each user needs
|
|
an email set in their own Settings for either to reach them.</p>
|
|
<form method="post" action="/admin/smtp">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<label>SMTP server
|
|
<input type="text" name="smtp_host" placeholder="smtp.example.com" value="{{ smtp.smtp_host }}">
|
|
</label>
|
|
<label>Port
|
|
<input type="number" name="smtp_port" min="1" max="65535" value="{{ smtp.smtp_port }}">
|
|
</label>
|
|
<label>Username
|
|
<input type="text" name="smtp_username" autocomplete="off" value="{{ smtp.smtp_username }}">
|
|
</label>
|
|
<label>Password
|
|
<input type="password" name="smtp_password" autocomplete="off"
|
|
placeholder="{% if smtp.smtp_password %}(unchanged -- enter a new one to replace){% else %}smtp password{% endif %}">
|
|
</label>
|
|
<label>From address
|
|
<input type="text" name="smtp_from_address" placeholder="[email protected]" value="{{ smtp.smtp_from_address }}">
|
|
</label>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="smtp_use_tls" name="smtp_use_tls" value="true" {% if smtp.smtp_use_tls %}checked{% endif %}>
|
|
<label for="smtp_use_tls">Use STARTTLS</label>
|
|
</div>
|
|
<button type="submit">Save SMTP settings</button>
|
|
</form>
|
|
<form method="post" action="/admin/smtp/test" style="margin-top: 8px;">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<button type="submit" class="secondary">Send test email to myself</button>
|
|
</form>
|
|
|
|
<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" }}
|
|
· 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" }})
|
|
· token ack: {{ "yes" if f.device_token_ack else "no" }}
|
|
{% if f.legacy_token_enabled %}· <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 %}
|