The admin UI is a traditional multi-page app that loads ~13 separate
vendor/app JS+CSS files on every full navigation; none were compressed and
Cache-Control was max-age=0 (Express's default), forcing a revalidation
round-trip for every asset on every page view. Add gzip (compression
middleware) and sane Cache-Control (7d for vendor libs under
/static-modules, 1h for the app's own /static JS/CSS, which isn't
cache-busted). Matches the equivalent fix in theta42/proxy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
theta42/proxy fronts an arbitrary number of hosts behind SSO, each with its
own callback URL (https://<host>/__proxy_auth/callback) — proxy's own code
comment already assumed "a wildcard redirect URI covers all", but no
wildcard matching existed here, so every proxied host's callback had to be
registered on the shared OAuth client individually or /oauth/authorize
would reject it with InvalidRedirectURI.
Add `*` (one hostname label) / `**` (any number of labels) wildcard support
to redirect_uri matching, e.g. `https://**.example.com/__proxy_auth/callback`
now covers every host proxy fronts under example.com. Exact matches still
work exactly as before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Replace the separate Profile/API Tokens nav items with a single link
showing the logged-in user's name, pointing at their own profile.
- Merge admin.ejs + notifications.ejs into a new dashboard.ejs page.
/admin and /notifications now 301-redirect to /dashboard.
- Fold the API Tokens page into profile.ejs as a self-service-only
section, gated on isOwnProfile so it never appears when an admin
views another user's profile via /users/:uid. /api-tokens 301s to /.
- Fix: the section must not carry class="row" — app-base.js runs a
page-wide $('div.row').fadeIn() on every page load that would reveal
it regardless of the isOwnProfile check, since it fires before this
page's own gating logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported: creating any user via the API failed with
{"name":"InvalidSyntaxError","message":"gidNumber: value #0 invalid per syntax Code: 0x15"}
Root cause: addPosixGroup() computes the next gidNumber as
`Math.max(...groups.map(i => i.gidNumber)) + 1`. theta-env's
bootstrap.js creates the first admin via raw ldapadd with a hardcoded
uidNumber/gidNumber (10000) directly on the user entry, but never
creates a matching posixGroup entry -- so on a theta-env-bootstrapped
directory there are zero posixGroup entries, `Math.max()` on an empty
array is `-Infinity` in JS (not 0), and `-Infinity + 1` stringifies to
"-Infinity" -- an invalid LDAP integer, rejected by the directory. This
broke every single user creation, not just this one.
Separately: the reporter's intended scheme is for organically-created
users to start at uidNumber/gidNumber 1500, distinct from the
bootstrap admin's reserved 10000. Fixing the crash with a bare "floor
of 1500" alone wouldn't achieve that, since addPosixAccount's own
Math.max() would still find the admin's posixAccount entry (uidNumber
10000, found via a different, correctly-indexed search) and allocate
10001 for the next user.
Added a shared nextPosixId(entries, key) helper: takes the highest
existing value strictly below conf.ldap.uidGidReservedFloor (default
9000) plus one, or conf.ldap.uidGidMin (default 1500) if there are no
such entries. Ids at/above the reserved floor -- like the bootstrap
admin's 10000 -- are ignored entirely when computing the next
available number, so real users always start at 1500 and grow upward
regardless of the admin's reserved id.
Verified against a real theta-env deployment end to end:
- Reproduced the exact reported crash on a fresh bootstrap
- After the fix: first real user gets uidNumber/gidNumber "1500",
second gets "1501" -- admin's 10000 never enters the calculation
- New unit tests (nodejs/tests/posix_id.test.js, no LDAP required):
6/6 pass, covering the empty-array case, the reserved-floor
exclusion, and the NaN-from-missing-value case
- npm test: 18/18 passing tests still pass (unchanged); the other 155
failures are pre-existing/environmental (no LDAP server in this
sandbox) -- confirmed via git stash before starting this fix
* Fix commit hash not showing in Docker builds
build_info.js computed buildHash via `git rev-parse --short HEAD` at
runtime, but the final image intentionally has no git binary and no
.git directory (kept lean, per .dockerignore) — so this always failed
silently and the footer's version line showed "unknown" for every
Docker deployment. Working correctly only for bare-metal/dev, where
git + .git are actually present.
Added a throwaway gitinfo build stage that reuses the main base image
(no extra pull) with git installed just for this stage, reads .git
from the build context (now no longer excluded — see .dockerignore),
and bakes the resolved short hash into a small file that IS copied
into the final image. build_info.js reads that file first, falling
back to the old git-rev-parse behavior (still needed for bare-metal).
Verified against a real build: `docker exec sso-manager cat
/app/.build_commit` matches `git rev-parse --short HEAD` on the host,
and the footer now shows the real hash instead of "unknown". Same fix
already applied to proxy (theta42/proxy#133).
* Support GIT_COMMIT build-arg override for submodule builds
The gitinfo stage from the previous commit works for a standalone
clone (.git is a real directory) but not when this repo is built as a
git submodule (e.g. from theta-env): a submodule's .git is a pointer
FILE, not a directory — the real object database lives in the
superproject's .git/modules/, outside this repo's own directory and
therefore outside Docker's build context entirely. `git rev-parse`
can never resolve it from in here no matter what, so builds via
theta-env still baked in "unknown" despite the earlier fix.
Add an optional GIT_COMMIT build-arg that, when set, wins over the
in-context git resolution. theta-env's setup.sh now computes it on the
host (where the submodule DOES resolve correctly) and passes it via
docker-compose.yml's build.args. Same fix in proxy: theta42/proxy#133.
Verified via theta-env's actual setup.sh end to end: rebuilding with
this change, `docker exec sso-manager cat /app/.build_commit` now
matches `git -C sso-manager-node rev-parse --short HEAD` on the host
(previously: "unknown").
Auditing mobile/responsive views turned up two classes of bug:
1. Missing .table-responsive wrapper (token.ejs and profile.ejs's other
table already had it): admin.ejs (2 tables), invites.ejs (6 columns),
notifications.ejs (5 columns), users.ejs (7 columns — the worst case).
Without it these overflow the page horizontally on narrow viewports
instead of scrolling within the table.
2. groups.ejs's search/sort/count toolbar was a plain `d-flex` (no
flex-wrap) containing a search input, a <select> with a hard
min-width:175px, and a nowrap count span — on a narrow viewport
there's nowhere for that content to go but off-screen. Added
flex-wrap and gave the search input a flex-basis so it wraps onto
its own line first when space is tight.
Verified: EJS compiles for all five templates, npm test 192/192 pass,
and fetched each route from a running instance to confirm the fixes
are present in the served HTML.
* docs: cleanup for public release (fix stale/wrong API docs, LICENSE, versions)
Documentation cleanup ahead of the public release announcement. Fixes a set
of confirmed issues from a prior audit:
- LICENSE: fill in MIT template placeholders (theta42, 2026).
- README.md: fix broken API docs link (api.md -> API.md), correct required
Node.js version (13.x -> 20.x), add the missing app_sso_invite group to
the LDAP groups table, scrub hardcoded dc=theta42,dc=com to the generic
dc=example,dc=com used elsewhere, add a "Recommended: Docker or
install.sh" section pointing to DEPLOYMENT.md/docs before the manual
OpenLDAP walkthrough, and drop an emoji from a warning callout.
- nodejs/api.md: deleted — it was a stale/legacy doc with wrong routes,
wrong request bodies, and endpoints that are dead/commented-out code.
The root API.md is the accurate, current reference; README now links
there directly.
- API.md: add the missing app_sso_invite permission group, fix the
documented invite response to match the real {token, link, mail_sent}
payload, document the previously-undocumented GET/PUT/DELETE
/api/user/invite endpoints, add the real allowed_groups field to the
OAuth client management examples, and document POST /api/oauth/authorize
(the endpoint that actually issues the code after consent).
- nodejs/routes/auth.js + API.md: fix "emaill address" typo in the
password-reset response message (source and docs kept in sync).
- DEPLOYMENT.md: fix the top-level summary to mention Redis, matching
docs/deployment.md and the entrypoint behavior it already documents.
Flagged, not changed: tos.md reads like a personal home-lab acceptable-use
policy (Emby/Gitea/Proxmox/Discord/Signal, first-person "the admin") rather
than generic OSS docs. Left in place pending a manual decision to
genericize, relocate, or remove it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: genericize tos.md template, track runtime-editable terms in #39
Removes operator-specific references (Emby, Gitea, Proxmox, Discord,
Signal, first-person "the admin") so the shipped tos.md reads as a
neutral starting template rather than one operator's internal policy.
Actual runtime editability (admin/legal editing terms without a code
change) is tracked in issue #39, not implemented here.
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
- api_tokens.ejs: created_on/last_used_on come back from Redis as strings
(model-redis only coerces fields with an explicit `type`), so `new Date(ms)`
yielded "Invalid date". Use `moment(ms, "x")` (the hosts.ejs/dns.ejs
precedent) which parses a numeric string-or-number as a Unix-ms timestamp.
- api_tokens.ejs: `isExpired` is a class getter not serialized to the client
JSON, so the "expired" badge never showed — compute expiry in the view via
`Date.now() > Number(expires_at)`. Also guard the `last_used_on: 0` / falsy
case (string "0" is truthy) so unset timestamps render "—" not "1970".
- middleware/auth.js: authIO did `checkToken(socket.handshake.auth.token || 0)`,
so any socket connect without a token (login page, pre-login) did an
`AuthToken.get(0)` lookup and logged a noisy `EntryNotFound` trace. Guard:
reject the socket with a generic 401 when there's no token (behavior-
preserving — unauth sockets were already rejected; just no Redis lookup / 404).
Co-authored-by: Claude <noreply@anthropic.com>
Personal access tokens so scripts/CI can call the management API without a
browser session. Each logged-in user mints their own token; it authenticates as
the creator (carries their LDAP group permissions, re-resolved live), so the
existing permission.byGroup checks apply unchanged.
- models/api_token.js: new ApiToken model (sso_<id>_<secret> format; id is the
lookup key, secret bcrypt-hashed + isPrivate, shown once). add()/rotate()/
authenticate(); optional expires_at; best-effort last_used_on. No _ttl
(persists; lifetime via expires_at).
- routes/api_token.js: self-service CRUD (list/get/update/delete/rotate),
owner-scoped (created_by === req.user.uid, 403 otherwise).
- middleware/auth.js + models/auth.js: accept `Authorization: Bearer sso_...`
(precedence over the auth-token session header); checkApiToken collapses
every failure to one generic 401 (no existence/secret/expiry leak).
- views/api_tokens.ejs + routes/index.js (GET /api-tokens): self-service page
(forceLogin, no group gate) — create (token shown once), edit, rotate, revoke.
- views/top.ejs: "API Tokens" nav entry visible to all logged-in users.
- public/js/app.js: app.apiToken client module.
- DEPLOYMENT.md + docs/deployment.md: API tokens section.
Co-authored-by: Claude <noreply@anthropic.com>