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:
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Administration</p>
|
||||
{% 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" }}
|
||||
· 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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Sign in</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Log in</h2>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">Your account</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if saved %}<div class="status ok">Saved.</div>{% endif %}
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card-title">Profile & photo library</h2>
|
||||
<form method="post" action="/settings">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Display name
|
||||
<input type="text" name="display_name" maxlength="64" value="{{ user.display_name }}">
|
||||
</label>
|
||||
<label>Immich URL
|
||||
<input type="text" name="immich_url" placeholder="http://your-immich-host:2283"
|
||||
value="{{ user.immich_url }}">
|
||||
</label>
|
||||
<label>Immich API key
|
||||
<input type="password" name="immich_api_key" autocomplete="off"
|
||||
placeholder="{% if user.immich_api_key %}(unchanged -- enter a new key to replace){% else %}your-immich-api-key{% endif %}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Frames you own pull photos from
|
||||
this Immich library. The key needs read access to albums/assets/faces
|
||||
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||
<label>Current password
|
||||
<input type="password" name="current_password" autocomplete="current-password">
|
||||
</label>
|
||||
<label>New password
|
||||
<input type="password" name="new_password" minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Leave both blank to keep your
|
||||
current password.</p>
|
||||
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">First-run setup</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h2 class="card-title">Create the admin account</h2>
|
||||
<p class="sub">This server has no users yet. The account you create here
|
||||
is the administrator: it can enroll other users and manage every
|
||||
frame. Any frame this server already knows about is linked to it
|
||||
automatically.</p>
|
||||
{% if error %}<div class="status err">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/setup">
|
||||
<label>Username
|
||||
<input type="text" name="username" maxlength="64" required autofocus autocomplete="username">
|
||||
</label>
|
||||
<label>Display name (optional)
|
||||
<input type="text" name="display_name" maxlength="64" autocomplete="name">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" minlength="8" required autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit">Create admin account</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user