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.
125 lines
6.3 KiB
HTML
125 lines
6.3 KiB
HTML
{% extends "app_base.html" %}
|
|
|
|
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
|
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
|
|
|
|
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div id="control-banner" class="control-banner" style="display: none;">
|
|
<span id="control-holder"></span>
|
|
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
|
</div>
|
|
|
|
<div class="layout">
|
|
<div class="main-col">
|
|
<section class="card">
|
|
<h2 class="card-title">Display settings</h2>
|
|
<form id="config-form">
|
|
<label>Frame name
|
|
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
|
|
</label>
|
|
<label>Order
|
|
<select id="order">
|
|
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
|
|
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
|
</select>
|
|
</label>
|
|
<label>Orientation
|
|
<select id="orientation">
|
|
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
|
|
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
|
|
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
|
|
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
|
|
</select>
|
|
</label>
|
|
<label>Refresh interval (minutes)
|
|
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
|
|
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
|
|
</label>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="smart_crop_faces" {% if frame.smart_crop_faces %}checked{% endif %}>
|
|
<label for="smart_crop_faces">Center faces in crop</label>
|
|
</div>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
|
|
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
|
|
</div>
|
|
<label>Quiet hours start
|
|
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
|
|
</label>
|
|
<label>Quiet hours end
|
|
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
|
|
</label>
|
|
<label>Timezone
|
|
<select id="timezone">
|
|
{% for tz in timezones %}
|
|
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">Quiet hours times are
|
|
interpreted in this timezone. The device may still wake once right
|
|
at the start of quiet hours -- it can't know ahead of time -- but
|
|
goes right back to sleep until they end.</p>
|
|
<button type="submit">Save</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="side-col">
|
|
<section class="card">
|
|
<h2 class="card-title">Firmware update</h2>
|
|
<p class="sub" id="firmware-board">
|
|
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
|
|
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
|
|
</p>
|
|
<p class="sub" id="firmware-available">
|
|
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
|
|
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
|
</p>
|
|
<input type="file" id="firmware-file" accept=".bin">
|
|
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
|
|
|
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
|
|
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
|
|
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
|
|
</p>
|
|
</div>
|
|
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
|
|
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
|
|
value="{{ frame.firmware_update_repo_url }}">
|
|
</label>
|
|
<div class="checkbox-row">
|
|
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
|
|
<label for="firmware_auto_update">Automatically apply updates</label>
|
|
</div>
|
|
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
|
|
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
|
|
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
|
|
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2 class="card-title">Battery alerts</h2>
|
|
<label>Email me when battery drops below (%)
|
|
<input type="number" id="battery_alert_threshold_pct" min="0" max="100"
|
|
value="{% if frame.battery_alert_threshold_pct >= 0 %}{{ frame.battery_alert_threshold_pct }}{% endif %}"
|
|
placeholder="disabled">
|
|
</label>
|
|
<p class="sub" style="margin-top: 8px;">Sent once per discharge cycle
|
|
to the frame owner's email (set in Settings) -- clear the field to
|
|
disable. Needs SMTP configured by an admin.</p>
|
|
<button type="button" class="secondary" id="battery-alert-save">Save</button>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="result"></div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
|
<script src="/static/frame_config.js"></script>
|
|
{% endblock %}
|