46 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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
wmantly 4f432ec448 oauth edit 2026-07-11 00:54:49 -04:00
wmantly bb79247054 oath grpup fixes 2026-07-02 16:49:22 -04:00
wmantly 93df047a21 oath fixes 2026-07-02 16:22:16 -04:00
wmantly cf2418a9eb Login page 2026-07-01 12:08:11 -04:00
wmantly 5644bfa5ec Updated frontend 2026-07-01 11:10:09 -04:00
wmantly e132015871 Added error message when non-admin cant see groups 2024-02-19 10:42:40 -05:00
wmantly 2a6aaafa3d Added permission to block non admins from seeing all users 2024-02-19 10:42:02 -05:00
wmantly 623e52e135 started token API 2021-03-23 00:41:12 -04:00
wmantly 0e212c48cc added white lable name 2021-03-23 00:36:53 -04:00
wmantly 96a97c1d6d Moved timestamps to footer for user cards 2020-12-31 16:07:22 -05:00
wmantly b56094841f Editing a user updates the card and shows a message 2020-12-31 15:19:39 -05:00
wmantly cb51fae2f6 added group owners 2020-12-31 09:53:44 -05:00
wmantly 746ee6e62a Fixed spacing for nav icons 2020-12-30 18:41:08 -05:00
wmantly 5143162510 Better error message failed removing user 2020-12-30 15:27:06 -05:00
wmantly 51b6deee8f localy sourced JS/CSS 2020-10-04 14:48:44 -04:00
wmantly 99a17c83ba added font awseome localy 2020-10-04 14:18:42 -04:00
wmantly e7a022cc29 zoom fixes 2020-08-08 20:52:24 -04:00
wmantly fb3e6ca7dc front end fixes 2020-07-01 16:08:19 -04:00
wmantly b8f632e644 user edit 2020-05-15 15:17:57 -04:00
wmantly 0889832efc groups and reset 2020-05-15 00:40:15 -04:00
wmantly e71fccd27c icons 2020-05-05 23:28:46 -04:00
wmantly 4d51a4ac9e rc1 2020-05-05 23:07:00 -04:00
wmantly 8dc0e946b1 beta 2020-05-03 18:22:51 -04:00