Redesign phase B: users, sessions, first-run setup, admin panel

Real identity on top of phase A's schema: scrypt-hashed passwords
(stdlib, no new deps -- parameters baked into each stored hash),
server-side sessions (sha256 of the cookie value stored, 30-day rolling
expiry), and per-session CSRF tokens enforced on every mutating
session-authed request -- via X-CSRF-Token for the JSON API (a fetch()
wrapper in base.html injects it, so the existing page scripts didn't
need touching) and a hidden form field for the HTML forms.

/setup runs once while no users exist: creates admin #1, links every
existing frame to them (owner + controller), and inherits the migrated
Immich creds onto their account -- per-user creds are now the primary
source, with env vars still winning as the operator fallback. /login,
/logout, /settings (display name, Immich creds, password change), and
/admin (enroll users, reset passwords, link users to frames, close a
frame's legacy-token window, delete) round out the pages, all in the
existing template/card style.

The legacy shared token stays accepted on browser routes so the
deployed frame's on-panel manage QR keeps working until phase C swaps
it for the limited manage page; token access renders without nav or
CSRF shim and is exempt from CSRF (explicit credential, not an ambient
cookie). Device routes untouched -- the legacy curl suite passes
verbatim.

Identity is provider-pluggable (identity_provider/provider_subject
already modeled) so OIDC can land later without schema surgery.
This commit is contained in:
2026-07-21 23:28:14 -04:00
parent 9fbbb8ed2b
commit 1e8d6803ac
9 changed files with 863 additions and 39 deletions
+68 -1
View File
@@ -145,6 +145,35 @@
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
.topnav a { color: var(--text-muted); text-decoration: none; }
.topnav a:hover { color: var(--text); }
.inline-form { display: inline; margin: 0; }
button.linklike {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
font-weight: 400;
cursor: pointer;
box-shadow: none;
}
button.linklike:hover { color: var(--text); background: none; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.admin-actions form { margin: 4px 0 0; }
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
.admin-actions input[type="password"] { margin-top: 6px; }
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
.admin-frame:last-child { border-bottom: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
@@ -319,12 +348,50 @@
{% block subtitle %}{% endblock %}
</div>
</div>
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
<div class="topbar-actions">
{% if user %}
<nav class="topnav">
<a href="/">Home</a>
<a href="/settings">Settings</a>
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</nav>
{% endif %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
</header>
{% block content %}{% endblock %}
</div>
{% if csrf_token %}
<script>
// Session-cookie auth needs CSRF proof on mutating requests. Rather
// than touching every fetch() call site in the page scripts, wrap
// fetch once: same-origin non-GET requests automatically carry the
// per-session token. (Legacy shared-token access renders without a
// csrf_token, so this block doesn't exist there at all.)
(function () {
var CSRF = {{ csrf_token | tojson }};
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
</script>
{% endif %}
<script>
// Shared theme toggle: explicit choice wins over the OS preference and
// is remembered; with no explicit choice, the CSS above falls back to