The dashboard had one shared password and no concept of a user. This
adds real accounts: a username field on the login page, per-user
credentials, and the signed-in user recorded on the session, which is
the groundwork for per-user permissions later.
Credentials are salted pbkdf2 hashes in settings.json's `users` map.
Three deliberate choices there:
- settings.json rather than an env var, because a pbkdf2 hash contains
'$' and docker compose interpolates that when it reads .env. It's also
already the bind-mounted file that survives `up -d --build`.
- pbkdf2 rather than Werkzeug's default scrypt, which needs a hashlib
built against OpenSSL — the macOS system Python has no hashlib.scrypt
and would raise at hash time.
- load_users() re-reads the file per check, so adding a user or changing
a password is live in every gunicorn worker with no restart. The old
_runtime_password was per-worker and only ever half-applied.
Multi-user activates only once an account exists. Until then the
original LOGIN_PASSWORD path is used unchanged and the username field is
hidden, so deploying this can't lock the existing install out.
manage_users.py prompts for passwords via getpass — never echoed, never
a CLI argument — and only ever writes hashes; it refuses to remove the
last account, which would silently drop the dashboard back to the shared
password.
Two fixes carried along: login no longer requires the LOGIN_PASSWORD env
var specifically, so a settings.json-only password works instead of
locking you out; and the legacy comparison now uses hmac.compare_digest,
which the old docstring claimed but '==' didn't provide.
Also gitignores settings.json and data/ — they hold API keys and now
password hashes, were excluded from the deploy rsync but not from git.
Verified: legacy fallback still signs in with no accounts configured; a
9-case credential matrix (right/wrong user x right/wrong password, plus
the retired shared password); per-user change-password scoped to the
signed-in account and live without restart; and the add/passwd/remove
guards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app.py's dev server and gunicorn both hardcoded 5000, which is awkward
locally — macOS ControlCenter already holds that port, so the app can't
bind on the dev laptop without editing source.
Both now read PORT, defaulting to 5000 so EXPOSE, the compose
`${HOST_PORT}:5000` mapping and the deployed container are unchanged.
Read at run time rather than import time so a dev harness can assign a
free port.
Also tracks .claude/launch.json with autoPort set. It doesn't actually
launch on this machine — the preview sandbox can't read files under
~/Desktop — so CLAUDE.md notes that running from a shell with an
explicit PORT is the working local path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The footer version is the honest check that a deploy landed, so cut
1.4.0 for the Storage table.
Also record something that has now cost two sessions: the dev machine
isn't on the media stack's network, so every service is offline and the
live-poll endpoints 500 on a timer. That's the normal local baseline,
not a regression. Notes the monkey-patch pattern for exercising a card's
real rendering, plus the two traps that go with it — Jinja's auto_reload
follows app.debug (off), so template edits need a restart rather than a
browser reload, and LOGIN_PASSWORD has to be set explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten disks stacked at --row-h each turned one third of the "Right now"
strip into a ~950px tower. The row is a grid, so Now playing and
Downloads stretched to match and held roughly 1500px of empty card
between them.
The information needed width, not a shorter list. "Right now" drops to
two columns and Storage moves to a full-width row beneath it, where the
same ten disks are a six-column table — disk, used, size, free, usage
bar, temp — at ~270px total. Nothing is hidden and nothing scrolls
except the table itself, which gets its own overflow container below
720px rather than wrapping the sizes into mush.
Also: the array total moves to a bar above the table and the summary
into the card header, so the hero figure isn't repeating what every row
already says. The header summary is a sentence rather than a badge, so
it needs .card-meta-wrap to opt out of the global nowrap — without it
that one string pushed the whole page sideways on a phone.
Local-fallback (no per-disk data) and error states keep the previous
hero-figure treatment. CLAUDE.md now carries the dead-space rule this
came from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three inline style="..." attributes were pure styling with no per-request
data behind them (a hero-block spacing tweak, a trailing note's margin, a
gap between two stacked rows) — moved each into dashboard.css as a scoped
structural selector so future adjustments happen in one place instead of
being split between the template and the stylesheet.
Also bumped .stream-title/.stream-user/.stream-icon in the Now Playing
card one step up the existing --fs-* scale (13px->15px, 12px->13px,
15px->18px) rather than introducing a one-off size.
Documented the "styles belong in CSS, not inline" rule in CLAUDE.md,
with the actual exception: per-request computed values (a meter's width,
a threshold colour) and JS-toggled initial visibility still can't live
in a static stylesheet and are fine to stay inline.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The architecture notes still described one :root token block shared
inside the three templates, which stopped being true when CSS and JS
moved to static/. Document base.css as the single source of truth, the
four-hue + color-mix rule that replaced hardcoded literals, and the
<body data-*> convention for passing server config to JS.
Also documents the footer version line under the container-restart
section it exists to serve — it is the quickest honest check that a
deploy landed, given the Unraid Docker tab's cached container IDs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what wasn't discoverable from the repo alone after today's
outage: Cox blocks inbound port 80 so certs must use DNS-01, the apex
proxy host's destination has drifted before, the NPM UI can show a cert
as valid/in-use while nginx presents none on disk, and restarting via
the Unraid Docker UI throws "No such container" after every deploy
because Compose gives the container a fresh ID each time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit made Settings-saved API keys override .env, but the
key still never survived: settings.json was written to the container
filesystem, and every deploy runs `up -d --build`, which recreates the
container and discards it. Saving a rotated SABnzbd key and restarting
therefore still fell back to the stale .env value.
SETTINGS_FILE is now configurable and pointed at /data/settings.json on
a ./data bind mount, so runtime settings live on the host. The deploy
rsync also excludes data/ — it runs --delete and would otherwise wipe
the directory for not being in the repo, reintroducing the same bug.
This affected everything the Settings page writes, not just API keys:
timezone and a UI-changed login password were being reverted on every
deploy too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the five novelty themes with one professional dark theme built
on a shared token set (surfaces, 6-step type scale, semantic colours,
44px tap target) used across dashboard, login and settings.
Layout now matches the cache tiers in app.py: a "Right now" strip with
live streams, the download queue and array usage sits above the fold,
with library, discovery, requests and watch insights below. Previously
the page opened on rarely-actionable disk stats and buried active
streams under twenty recommendation rows.
Also from the design review:
- Discover lists show 5 with a Show more toggle instead of 10 each
- RT descriptions render inline and clamped, not hover-only tooltips
that touch devices could never reach
- Adding a title is now two-step (tap to arm, tap to confirm) since it
queues a real download
- role=alert on errors, aria-live on polled regions, semantic dl/dt/dd
stats, labelled search inputs
- every text/surface pair verified at >= 5:1 contrast
Drops the dead delete-modal JS, which threw a TypeError on every load,
and the stale root-level template copies that Flask never rendered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>