Commit Graph

89 Commits

Author SHA1 Message Date
wmantly 95ae50a924 fix: generate package-lock.json with node 20 for CI 2026-07-22 22:06:31 -04:00
wmantly 0076784fae fix: sync package-lock.json version 2026-07-22 22:03:53 -04:00
wmantly c4d7a1a8e9 feat: actionable metrics, LDAP log parsing, UI updates 2026-07-22 21:58:06 -04:00
wmantly 81ad538e50 Release 1.1.18: fix Sites page crash, refresh screenshots
The Sites & Replication page (added in the prior multi-master LDAP
release) 500'd on every load: views/sites.ejs included nonexistent
partials 'header'/'footer' instead of this app's actual 'top'/'bottom'.
Fixed to match every other view.

Also refreshed all README screenshots against the current UI and added
a new Sites & Replication screenshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 02:03:55 -04:00
wmantly 2c11226793 feat: Add documentation and Sites status dashboard page 2026-07-21 00:06:59 -04:00
wmantly 80d88b083c feat: N-Way Multi-Master LDAP replication and Location property 2026-07-20 23:56:13 -04:00
wmantly b4fa824609 feat: configurable LDAPS hostname (ldapsHost/ldapsPort) and extensive docs (#89)
Add conf.ldap.ldapsHost / conf.ldap.ldapsPort so the /integrations page
can advertise an internal-only LDAPS hostname separate from the public
OAuth issuer. This avoids forcing admins to port-forward 636 publicly.

- routes/index.js derives LDAPS URL from ldapsHost/ldapsPort with issuer fallback
- integrations.ejs adds a contextual help panel explaining TLS hostname
  validation, the public-issuer default, and recommended internal-DNS /
  Docker-internal alternatives
- conf/base.js, secrets.js.example, DEPLOYMENT.md, docs/configuration.md,
  and docs/ldap.md document and expose the new options
- Add tests/integrations.test.js for default and custom ldapsHost behavior
- Bump version to 1.1.17

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 01:13:43 -04:00
wmantly cf80c966eb security: swap sanitizer to xss and harden logging
- Replace isomorphic-dompurify with xss to avoid ESM-only transitive
  dependencies (jsdom/htmlparser2) that break the existing Jest test suite.
- Sanitize rendered docs and Terms-of-Service HTML via xss() in routes/docs.js
  and routes/index.js.
- Remove full-object new-user logging from models/user_ldap.js and reduce
  login-path error output to error.name/error.message only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:03:33 -04:00
wmantly 07819a6254 security: sanitize markdown output and reduce PII logging
- Add isomorphic-dompurify to sanitize rendered docs HTML and Terms of Service
- Remove addLdapUser full-object logging that included password hashes
- Log only error name/message on auth/login failures instead of full LDAP error objects

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:56:35 -04:00
wmantly efe3e514b0 chore(release): public-release readiness and security fixes for 1.1.16
Security:
- Escape user-supplied values in LDAP filters and DNs (group_ldap.js, user_ldap.js)
- Replace Math.random() token/UUID/OTP generation with crypto.randomUUID / crypto.randomInt
- Refuse startup when oauth.jwtSecret is missing or placeholder

Fixes:
- Correct from-address template rendering in email.js

Packaging:
- Remove private flag and bump version to 1.1.16

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:08:11 -04:00
wmantly b77704089b Bump version to 1.1.15; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:18:05 -04:00
wmantly fe23d231be Bump version to 1.1.14; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:44:34 -04:00
wmantly f154bb8db0 Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
conf 1.2.0 adds CONF_SECRETS, an env var to point at the secrets file
directly -- use it in the Docker entrypoint instead of symlinking the
mounted file into /app/conf/secrets.js, so the app no longer needs
write access to its own conf/ directory to pick up mounted secrets.
jq-repeat 2.2.0 is a compatible feature release (sort(), replace(),
faster leading-edge update() timing); no call-site changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:38:56 -04:00
wmantly f6552cb741 Resolve doc cross-links by real filename as a fallback
The new concept docs (and their "See also" reciprocal links) reference
each other by real filename -- "concepts-accounts.html" -- which is the
correct, working URL on the Jekyll/GitHub Pages build (a page's URL there
IS its filename stem), but doesn't match this viewer's own short slugs
(DOCS keys, e.g. "accounts" -> /docs/accounts), so fixDocLinks() left
those links unrewritten and 404ing in-app.

Rather than rewrite the docs to two different link forms depending on
target, resolve by filename as a fallback when the slug lookup misses --
one link written in a doc now works correctly on both targets.

Bumps to v1.1.13.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 22:03:52 -04:00
wmantly 4e5a2aa4f9 Add plain-language concept docs; fix docs viewer rendering; link API tokens
- New docs/concepts-{accounts,oauth-apps,api-tokens}.md -- plain-language
  guides aimed at less technical readers, each linking onward to the
  existing schema/protocol-level doc for anyone who wants that detail.
  Card help links (Users, Groups, OAuth cards, My groups, Members of
  <uid>'s group) now point here instead of straight at the technical
  docs; the LDAP-protocol-wiring cards (raw connection details for
  connecting a 3rd-party app) stay pointed at the technical ldap.md,
  since that's genuinely the right depth for that task.
- The "New API Token" card had no help link at all -- added, pointing to
  the new API Tokens doc.
- Fixed the in-app docs viewer rendering every docs/*.md page with a
  garbled heading + stray <hr> at the top: Jekyll front matter (meant
  only for the GitHub Pages build) was never stripped before being
  handed to the markdown renderer. Also fixed: cross-doc links
  (ldap.html, index.html, etc.) never resolved in-app, since this
  viewer serves docs at /docs/<slug> with no .html suffix -- rewritten
  to the correct in-app URL, same idea as the existing image-path fix.

Bumps to v1.1.12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 21:49:27 -04:00
wmantly 4c59b1fabb Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics (e.g. Integrations has both OAuth and LDAP cards). Removed it and
added a small help icon directly to each card that has real corresponding
doc content, linking straight to that doc -- Invite User/Add new
user/User List/Service Accounts (users.ejs), group cards (groups.ejs),
OAuth Apps + LDAP connection cards (integrations.ejs), My groups/Members
of <uid>'s group/New API Token (profile.ejs).

Bumps to v1.1.11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:54:19 -04:00
wmantly 077c41844d Add header help icon and in-app docs search
- A ? icon in the top-right header deep-links to the doc most relevant to
  the current page (client-side path mapping, same pattern already used
  for top-nav active-link highlighting -- no server-side "current section"
  local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
  allowlisted doc set. No new dependency, stays usable with no internet
  access.

Bumps to v1.1.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:22:27 -04:00
wmantly 82f703f560 Add personal Unix group member management
Every account gets a personal posixGroup at creation (its primary GID
holder) but there was no way to manage its memberUid list -- add
add/remove endpoints and a profile-page UI (admin-only), reusing the
userSelect widget already built for the manager field.

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:28:41 -04:00
wmantly 5d7c0bd594 Fix account-editing bugs from real-world feedback, add editable group membership
- Edit form's Mobile Phone field was effectively required (stray validate
  attribute) -- removed.
- Service account profiles always showed the literal filler name "Service
  Account" -- hidden now, since it's not meaningful. Required computing
  isServiceAccount in User.get(), not just listDetail().
- Fresh service accounts could look uncategorized (missing from the
  Service Accounts tab, wrong isServiceAccount) for up to 5 minutes after
  creation, due to a cache-staleness race in the create route -- the user
  gets cached via User.get() before the route marks it as a service
  account. Cleared and re-fetched after marking.
- memberOf came back as a bare string instead of a one-element array for
  users in exactly one group, causing client-side permission checks to
  iterate character-by-character and incorrectly deny access -- normalized
  alongside the existing manager normalization.
- Added editable group membership on the profile page ("My groups"),
  admin-only, using the existing per-group member endpoints.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:00:50 -04:00
wmantly cdc5d1528c Unify service accounts to one kind, add manager field, make homeDirectory/loginShell editable
Removes the LDAP bind-only service account type in favor of a single
Unix/POSIX account model, surfaced in a new Users > Service Accounts tab.
Adds a multi-valued `manager` field to every account (defaults to the
creator, editable, and grants edit rights on the accounts a person manages
without needing app_sso_admin). homeDirectory and loginShell are now
editable from the profile edit form.

Bumps to v1.1.7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 00:32:19 -04:00
wmantly ea65a85aa9 Bump version to 1.1.6; update CHANGELOG 2026-07-16 20:22:42 -04:00
wmantly f12ce8c600 Bump version to 1.1.5; update CHANGELOG 2026-07-16 18:35:27 -04:00
wmantly 88387f3117 Update jq-repeat to 2.1.0; fix editProfile update->slideDown race
jq-repeat 2.1.0 (release notes: https://github.com/wmantly/jq-repeat/releases/tag/v2.1.0)
brings real fixes (throttled-update race conditions, sorted-list
reverse() leaking elements, nested-scope isolation) and a few
behavior changes. Audited every usage in this repo against the
changelog before upgrading:

- push()/unshift() now return the new array length -- every call
  site in this repo is a bare statement, none consume the return
  value. No risk.
- __setPut/__setTake, jr-order-reverse, nested jq-repeat templates:
  not used anywhere in this repo (unlike proxy's companion PR, which
  needed the __setPut/__setTake fix).

Real risk found and fixed: update() is now trailing-edge throttled
(~50ms) even on the first call, not just rapid subsequent ones.
profile.ejs's editUser()/editUserSeccess() call $.scope.editProfile
.update()/renderProfile() (which itself calls update()) and
immediately slideDown() the same element -- with the old synchronous
behavior the form was already populated by then; with throttling it
could briefly show stale/empty data. Deferred both slideUp/slideDown
pairs by 60ms (past the throttle window), per the library's own
migration guidance. Verified live (real bundled image + Playwright,
logged in as admin): the edit form's fields show real data, not
empty/stale, when checked right as the slide-open completes.
2026-07-16 18:27:58 -04:00
wmantly a466128c21 Bump version to 1.1.4; update CHANGELOG 2026-07-16 17:44:21 -04:00
wmantly be41597502 Fix routes/oauth.js's separate pageLocals object missing conf.logo
routes/oauth.js has its own pageLocals object (distinct from
routes/index.js's values and routes/docs.js's own copy) used by
oauth_authorize.ejs/oauth_logout.ejs -- missed in the white-label
change since a grep alias in this environment silently treats this
particular file as binary and skips it. Caught by CI (oauth.test.js),
not local testing. Added logo: conf.logo to match the other two
copies of this locals object.
2026-07-16 17:31:00 -04:00
wmantly 21f2cda2ee White-label: title/logo now driven by conf (closes #6)
conf.name was already plumbed into routes/index.js's values object,
but never actually rendered anywhere -- <title>, the navbar brand,
and the favicon were all still hardcoded "SSO - Theta 42"/"SSO
Manager". Now render <%- name %>/<%- logo %> in top.ejs; new
conf.logo key (default: the existing theta42.svg) drives the navbar
image and favicon.

Also fixes a pre-existing broken favicon: top.ejs referenced
/static/favicon.svg, which was never actually served from public/ --
only public/img/theta42.svg existed. The favicon now uses that same
file via conf.logo instead of a nonexistent path.

Footer copyright/logo/GitHub links are left as-is (open-source
attribution, not deployment branding).
2026-07-16 17:26:01 -04:00
wmantly bc5bca2e28 Add CI (Jest against the real bundled image); fix ppolicy pwdLockout default
- New GitHub Actions workflow: builds the real Dockerfile.openldap
  image, starts it, seeds the LDAP fixtures the test suite expects
  (uid 'test' + 'wmantly', matching the existing "wmantly is always
  present in the test LDAP" assumption in several test files), then
  runs the full Jest suite against it on Node 18/20/22. This repo
  previously had unit tests but no automated workflow running them.
- Found while building this: the bundled default ppolicy entry
  (docker-entrypoint.sh + ops/ldap-setup.sh) sets pwdLockout: FALSE,
  which is backwards -- it silently makes the admin "deactivate user"
  action a no-op for auto-lockout-after-failed-attempts (a related
  but distinct ppolicy feature from pwdAccountLockedTime). Fixed to
  TRUE in both places; ldap-setup.sh also gets a drift-correction
  path so an existing deployment can pick up the fix by re-running it.
- Separately, deactivating a user still doesn't block their LDAP bind
  in the bundled image even with this fix -- filed as #68, since it's
  a deeper OpenLDAP ppolicy overlay question unrelated to the CI/test
  setup here. tests/user_admin.test.js now soft-skips that specific
  assertion (with a console warning pointing at #68) instead of
  failing, so this known environment gap doesn't block CI.
2026-07-16 16:54:05 -04:00
wmantly 3e67c23008 Bump version to 1.1.3; update CHANGELOG 2026-07-16 16:04:08 -04:00
wmantly f323a45fef Add CHANGELOG.md, serve it in-app at /docs/changelog (closes theta42/theta-env#43)
GitHub Releases already carried real changelog notes per tag, but
those require internet access to view -- exactly what the /docs
route exists to avoid. CHANGELOG.md is a committed, Keep-a-Changelog
style file (backfilled from the v1.1.0/v1.1.1/v1.1.2 release notes),
linked from README and served at /docs/changelog alongside the rest
of the project's docs.
2026-07-16 16:00:59 -04:00
wmantly c2851ea537 Bump version to 1.1.2 2026-07-16 15:36:59 -04:00
wmantly 955189d08a Air-gap: remove dead CDN reference + in-app /docs
- Removed a dead IE<9-only html5shim script tag pointing at a domain
  that no longer resolves.
- New GET /docs (index) and /docs/:slug routes render this project's
  own README, DEPLOYMENT, API.md, docs/*.md, and directory_spec.md
  server-side via marked -- so the documentation is readable from the
  running app with no route to GitHub Pages, where it otherwise only
  lives. Public, no auth, rate-limited (middleware/rate_limit.js) like
  the other public routes.
- .dockerignore/Dockerfile.openldap updated to copy DEPLOYMENT.md,
  API.md, directory_spec.md, and docs/ into the image, mirroring the
  existing tos.md -> /tos.md convention.
2026-07-16 15:33:46 -04:00
wmantly f3885bb3df Bump version to 1.1.1 2026-07-16 13:57:07 -04:00
wmantly aaa538c7f9 Make Terms of Service editable at runtime by admins (closes #39)
tos.md was baked into the repo and read once at startup, so changing
the terms required a code change and deploy. It's now a Redis-backed
singleton (models/tos.js), editable from a new "Terms of Service" card
on the admin Dashboard, with the bundled tos.md used only as a
one-time seed for new deployments.

- routes/tos.js: GET (any authenticated user) / PUT (app_sso_admin
  only) via /api/tos. Saving can optionally reset every user's
  tos_accepted flag so they're asked to re-accept -- off by default,
  since a wording fix shouldn't re-prompt everyone.
- routes/index.js: /tos and /onboarding now render the live content
  instead of a module-level constant computed once at process start.
2026-07-16 13:44:46 -04:00
wmantly 3b3e0c85af Bump version to 1.1.0 2026-07-15 22:39:18 -04:00
wmantly 4b0a9e9038 Add standalone backup script and admin update-check banner
ops/backup.sh snapshots LDAP (slapcat), Redis (BGSAVE, dynamic RDB path
lookup), and ./config for standalone deployments, with retention. A
background service polls GitHub releases every 24h and surfaces an
admin-only banner in the UI when a newer version is published.
2026-07-15 22:33:55 -04:00
wmantly 3790e8001a Add Unix/POSIX service accounts, distinct from LDAP bind-only ones
The Integrations page's Service Accounts (bind-only, organizationalRole)
don't cover the other real use case: an account something actually runs
as on a Linux host -- a media manager, a torrent client, Emby -- with a
real uidNumber/gidNumber that owns files, and a group other accounts
join for write access (e.g. a `stuff_manager` group granting write
rights to a media library). That needs a real posixAccount, which the
bind-only model can't be.

- New well-known group `app_sso_service_account`, seeded the same way as
  app_sso_admin/app_sso_invite/app_sso_oauth_admin (docker-entrypoint.sh,
  ops/ldap-setup.sh). Not a permission gate -- a marker.
- "Add new user" form gets a "This is a service account" checkbox: swaps
  the person-shaped fields (first/last name, birthday, ToS agreement)
  for a single account-name field, since none of those make sense for a
  non-person account. On create, the route adds the user to
  app_sso_service_account.
- User.listDetail() annotates each user with isServiceAccount (checked
  against the marker group's member list once per call, not the memberof
  overlay's reverse attribute -- not reliably returned by every LDAP
  server this app might point at, confirmed against a real external
  directory during testing). Users page shows a "service" badge.
- Notification broadcasts (filter_type=all/all_active) exclude service
  accounts by default -- nobody reads mail as `stuff_manager`.
- Fixed a real, previously-unrelated bug this surfaced: addPosixAccount
  unconditionally set `mail: data.mail` in the LDAP entry even when
  undefined, and ldapts/slapd reject an attribute given an explicit
  undefined value ("no values for attribute type") rather than treating
  it as absent. This meant creating ANY user without an email already
  failed outright -- not something a service account (which commonly has
  no real mailbox) could route around. Made mail conditional, matching
  how mobile/sshPublicKey/dob already work.
- docs/ldap.md now explains both kinds of service account side by side
  and when to use which.

Verified end-to-end against a real external LDAP server (not a local
sandbox): created a service account with no email, confirmed it's
correctly flagged and excluded from broadcast recipient resolution,
confirmed a normal user is unaffected, confirmed the code degrades
gracefully if the marker group doesn't exist yet (pre-upgrade
deployments).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 20:52:50 -04:00
wmantly 0b701dfc6f Merge OAuth Apps + LDAP Info into one tabbed page; add Service Accounts
- OAuth Apps and LDAP Info are both "how do other apps/hosts plug into
  this SSO" concerns -- merged into a single /integrations page with
  tabs, replacing the two separate nav items with one. /oauth-clients
  and /ldap-info 301-redirect there for compat.
- Add a Service Accounts section under the LDAP tab: bind-only LDAP
  identities (organizationalRole + simpleSecurityObject, no
  posixAccount) for apps/hosts, as opposed to real people. Create,
  rotate password, and delete, all from the UI -- previously the only
  such account (theta-env's bootstrap-created cn=ldapclient) was
  invisible to the Users page entirely (filtered out by
  conf.ldap.userFilter) and had no GUI way to see or rotate it; the new
  ServiceAccount model uses the exact same objectClasses bootstrap.js
  already creates cn=ldapclient with, so it recognizes and manages that
  account too, not just ones created through this UI.
- The ldap-client bash snippet now points at "create one under Service
  Accounts above" instead of a bare textual example.

Verified against a real LDAP server (not just the dev sandbox's usual
unreachable one): created a service account, confirmed it binds
successfully with the generated password, rotated its password, and
deleted it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:57:32 -04:00
wmantly edd5a26e44 Add an LDAP Info page: dynamic connection details + a ready-to-run ldap-client setup script
New admin-only page (nav: "LDAP Info") that answers "what do I put in my
app's LDAP settings" without reading a doc: LDAPS URL, base DN, user/group
search bases, user filter, username attribute, and an example bind DN, all
derived from the running conf.ldap + request host rather than hardcoded --
so it's always correct for the actual deployment, copy-button on every
field.

Also generates a copy-pasteable bash snippet that clones
theta42/ldap-client and writes its ldap.vars file with the real host/base
DN/sso_url already filled in (bind password and SSO API token left as
placeholders with inline instructions, since those need to be created,
not derived).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 19:34:30 -04:00
wmantly ed62e70678 Stop the notification Compose form from defaulting to "email everyone"
The Compose form's "Send to" radio group had "All active users" checked
by default with no confirmation before Send -- anyone opening the
Dashboard to see how the feature works, typing a test subject/message,
and clicking Send would broadcast to every active user. Remove the
default (a target must now be explicitly chosen) and require a confirm
step before actually sending to "all" or "all_active".

Also add a hard safety net in models/email.js: Mail.send is a no-op
under NODE_ENV=test, so the automated test suite (which exercises the
real notification/password-reset/invite/OTP-by-email routes with
NODE_ENV=test) can never deliver real mail regardless of what recipient
list a test resolves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 17:02:57 -04:00
wmantly f609b21f72 Remove hardcoded Services card from profile.ejs
It linked to fixed personal infrastructure (718it.biz, vm42.us,
git.theta42.com) with an admin's name in one label — not appropriate for
what should be a generic, publicly-releasable template. Flagged in
theta42/sso-manager-node#45, tracked as #46; resolves that issue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 00:55:07 -04:00
wmantly ef62fc1a90 Add gzip compression and caching for static assets
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>
2026-07-15 00:42:38 -04:00
wmantly 4c6b1e38b1 Support wildcard redirect_uri patterns for OAuth clients
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>
2026-07-15 00:42:32 -04:00
wmantly 2788dcd796 Unify nav: merge Admin+Notifications into Dashboard, fold API Tokens into Profile
- 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>
2026-07-14 23:42:28 -04:00
wmantly f45349e0cd Fix uid/gidNumber allocation crash, add a configurable id floor (#44)
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
2026-07-14 23:03:59 -04:00
wmantly 38cc6696a4 Fix commit hash not showing in Docker builds (#43)
* 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").
2026-07-14 22:32:35 -04:00
wmantly f72e8881cf Fix mobile layout: wrap unresponsive tables, let the group filter bar wrap (#42)
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.
2026-07-14 21:18:01 -04:00
wmantly 3ceeeeeca1 Clean up footer: fix copyright, move GitHub link out of the nav (#41)
- Copyright was "© <year> <name>" where <name> is conf.name — an
  operator-configurable display name (e.g. whatever CFG_ORG is set
  to), not a real copyright holder. Changed to "© <year> theta42",
  matching the LICENSE file. Also dropped "All rights reserved",
  which contradicts the MIT license this project ships under; added
  an explicit MIT License link instead.
- Moved the GitHub icon link out of the top nav (where it competed
  with actual navigation items) and into the footer, alongside the
  license link and version/build info.
- Deduplicated the identical buildVersion/buildHash/buildYear
  computation that was copy-pasted in both routes/index.js and
  routes/oauth.js into a shared nodejs/utils/build_info.js.

Verified by rendering top+bottom with the real ejs package: no
template errors, GitHub link present exactly once (in the footer,
not the nav), "All rights reserved" gone, MIT License link present.
npm test failures (155) are pre-existing/environmental (no LDAP
server here) — identical failure count with these changes stashed
out.
2026-07-14 20:54:36 -04:00
wmantly 158109de59 Documentation cleanup for public release (#38)
* 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>
2026-07-13 23:21:27 -04:00
wmantly 796e013234 Fix api-tokens date display + quiet authIO no-token log (#36)
- 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>
2026-07-12 17:42:00 -04:00
wmantly b91ef2792d Add self-service API tokens (PATs) with UI + Bearer auth (#35)
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>
2026-07-12 17:12:35 -04:00