Compare commits

...

46 Commits

Author SHA1 Message Date
wmantly 3babf18fe4 Merge pull request #96 from theta42/release/v1.3.1
Release 1.3.1
2026-07-23 03:29:01 -04:00
wmantly d78f1dfabf Release 1.3.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:24:23 -04:00
wmantly c3c206b830 Merge pull request #95 from theta42/docs/directory-doc
Surface the Directory documentation — register in-app and link
2026-07-23 03:20:05 -04:00
wmantly 0a21dce0d7 docs: surface the Directory doc — register in-app, link from UI and site
docs/directory.md existed but was orphaned: not in the /docs registry,
not linked anywhere. Now:

- registered as /docs/directory ("Directory & Inventory")
- help icon on the Directory page header links to it (same pattern as
  users/groups/profile pages)
- linked from the docs site index feature list
- extended with the shared slug conventions (site_<name>, host_<hostname>),
  the automatic registration story (theta-env stack seeding, ldap-client
  Linux host enrollment), and the admin + read-only API surface (the
  read-only graph routes live at /api/discovery, not /api/directory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:17:50 -04:00
wmantly 5940880d9b Merge pull request #94 from theta42/docs/ldap-not-legacy
docs: direct LDAP binds are first-class, not "legacy"
2026-07-23 02:57:20 -04:00
wmantly 17fcf2fed0 docs: direct LDAP binds are first-class, not "legacy"
Linux hosts are a primary consumer of the directory — PAM/SSSD login,
LDAP-backed sudo rules (sudoRole), and SSH public keys (openssh-lpk) —
which is exactly what the custom schemas exist for. Describe LDAPS /
StartTLS consumers as "Linux hosts and LDAP-native apps" instead of
"legacy apps" across README, DEPLOYMENT, docs, and the Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:55:11 -04:00
wmantly ec76054e41 Merge pull request #93 from theta42/release/v1.3.0
Release 1.3.0
2026-07-23 02:25:27 -04:00
wmantly 5c0fc4f016 Release 1.3.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:22:46 -04:00
wmantly 43dae2a3eb Merge branch 'simpleworkjs' (superseded by tested ORM-port fixes on this branch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:21:50 -04:00
wmantly 20c0a48199 ui: remove mobile phone field from user form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 12da7140c2 test: dockerized test suite (openldap + redis + test-runner)
docker-compose.test.yml spins up the all-in-one OpenLDAP image, a
standalone Redis, and a test-runner that seeds the test user and runs
jest against them. globalSetup honors REDIS_URL; tests/setup.js
initializes the ORM and flushes test Redis keys before the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly dfd5f46095 feat: OAuth client management API at /api/oauth/client
CRUD + secret rotation for OAuth clients (app_sso_oauth_admin group),
backed by the Resource model. Normalizes form-style string inputs
(newline-separated redirect_uris/allowed_groups, space-separated
scopes, bracketed token_lifetime fields).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 5dcc75195c fix: complete ORM port — token/oauth-client API mismatches, use published orm 0.2.8
- Use published @simpleworkjs/orm ^0.2.8 (fixes redis adapter write path)
  and model-redis ^1.6.0 instead of a local file: link that broke docker
  npm ci with a misleading "no lockfile" error.
- OtpToken.issue/verify: replace nonexistent find()/listDetail() with
  list({where}).
- routes/auth.js: ImpersonationToken.listDetail() -> list({where}).
- routes/token.js: drop listDetail() call; 404 on missing token instead
  of returning {results: null} with 200 (orm get() returns null, does
  not throw like model-redis Table.get did).
- OAuthClient: Resource has no is_valid column, so every client read as
  disabled and all /oauth/authorize requests 400'd — validity now lives
  in metadata (absent = valid). Also generate a unique slug on create
  (Resource.slug is required+unique) and use Resource.get() for lookup.
- User.login: 401 cleanly when neither uid nor username is supplied.
- models/index.js: log ORM init and surface init failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:49 -04:00
wmantly 12a99b550c Bump @simpleworkjs/orm to 0.2.8 to fix redis bugs 2026-07-22 23:24:41 -04:00
wmantly 4ce5a5f492 Monkey-patch RedisAdapter 2026-07-22 22:41:57 -04:00
wmantly c84141b2f3 Restore package-lock.json 2026-07-22 22:37:51 -04:00
wmantly f76d93d840 Fix User.login credentials check and flush Redis before tests 2026-07-22 22:37:45 -04:00
wmantly e910a492ba Fix Token API compatibility 2026-07-22 22:30:26 -04:00
wmantly 1ef868e23c Fix ORM API usages in Token subclasses 2026-07-22 22:26:26 -04:00
wmantly e11e39c23a fix: initialize ORM in tests 2026-07-22 22:17:23 -04:00
wmantly b91089ad4c fix: use published @simpleworkjs/orm instead of local path 2026-07-22 22:11:11 -04:00
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 a100f755ce Merge pull request #91 from theta42/release/v1.1.18
Release v1.1.18: fix Sites page crash, refresh screenshots
2026-07-21 02:22:24 -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 6ce36b4a14 Merge pull request #90 from theta42/feature/multi-master-ldap-location
feat: N-Way Multi-Master LDAP & User Location
2026-07-21 00:14:22 -04:00
wmantly cda76d3889 docs: Add Why and When for replication and update README features 2026-07-21 00:10:25 -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 5a8030fd7d chore(release): public-release readiness and security fixes for 1.1.16
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-18 23:14:38 -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 1b3e842006 ci: set app_oauth__jwtSecret for test runs
routes/oauth.js now validates jwtSecret at module load time, so CI must
provide a non-placeholder value for the test runner.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:16:30 -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 37f2ece172 Merge pull request #87 from theta42/release-1.1.15
Bump version to 1.1.15
2026-07-18 01:20:13 -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 114d8c86ca Merge pull request #86 from theta42/install-script-rework
Rewrite install.sh as a git-clone installer, add a one-line install
2026-07-18 01:17:18 -04:00
wmantly 3e87ad86ab Rewrite install.sh as a git-clone installer, add a one-line install
Replaces the old flag-driven, copy-based installer with an idempotent
git-clone-and-symlink installer matching theta42/proxy's ops/install.sh
pattern, so `wget -O - .../install.sh | sudo bash` works the same way
for both apps:

- Installs to /opt/theta42/sso-manager (was /opt/sso-manager, and the
  repo had to already be checked out locally -- now it clones itself).
- First run only: bootstraps OpenLDAP (modules, overlays, schema,
  directory tree, SSO groups -- ops/ldap-setup.sh) with a generated
  admin password + JWT secret, and seeds /etc/sso-manager/secrets.js
  (was /opt/sso-manager/conf/secrets.js, hand-filled from CLI flags).
  Later runs never touch LDAP or the secrets file again.
- ops/systemd/sso-manager.service now points at the new install path
  and sets CONF_SECRETS=/etc/sso-manager/secrets.js (requires
  @simpleworkjs/conf >= 1.2.0, already the pinned version) instead of
  the app needing a config file inside the repo checkout.
- Prints the version it's updating from/to (or "Already up to date")
  on every run, instead of updating silently.

Two real bugs found and fixed while testing this end-to-end in a clean
container:
- The debconf `slapd/domain` value was computed as
  `${LDAP_BASE_DN#dc=}` ("example,dc=com" for "dc=example,dc=com")
  instead of a proper dotted domain -- slapd's postinst hangs
  indefinitely on a malformed domain instead of failing cleanly.
  Fixed to derive it the same way the secrets file already did
  ("example.com").
- ops/ldap-setup.sh's ppolicy-overlay checks used an LDAP substring
  filter, `(olcOverlay=*ppolicy*)`, against an attribute that doesn't
  support substring matching -- it silently matched nothing even when
  the overlay was correctly configured (stored as "{0}ppolicy"),
  so the final verification always reported failure and `set -e`
  aborted the installer after LDAP was set up but before the app was.
  Fixed to filter on `(objectClass=olcOverlayConfig)` and let the
  existing DN-based grep narrow it down, matching the pattern already
  used by every other check in that script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:13:51 -04:00
wmantly c5a2c0a71d Merge pull request #85 from theta42/release-1.1.14
Bump version to 1.1.14
2026-07-17 23:46:43 -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 3a612dbed7 Merge pull request #84 from theta42/bump-conf-jqrepeat
Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
2026-07-17 23:41:19 -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 9fc5abda2a Merge pull request #83 from theta42/fix-changelog-corruption
Fix CHANGELOG.md corruption (v1.1.10 merged into v1.1.11)
2026-07-17 22:22:21 -04:00
wmantly a6ee985de4 Fix CHANGELOG.md: restore separate heading for v1.1.10
A repeated Edit-tool bump had overwritten the previous top version
heading instead of inserting a new one above it, silently merging
v1.1.10's release notes into v1.1.11's section with the v1.1.10
heading missing entirely. The underlying content was still present,
just missing its own "## [1.1.10]" header -- restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 22:20:02 -04:00
77 changed files with 5818 additions and 2888 deletions
+3 -3
View File
@@ -20,9 +20,9 @@
!directory_spec.md
!docs/**/*.md
# Tests
nodejs/tests/
nodejs/*.test.js
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
# nodejs/tests/
# nodejs/*.test.js
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
+4
View File
@@ -136,6 +136,10 @@ jobs:
# directory layout (dc=example,dc=com) -- only the admin password
# (normally supplied via a gitignored secrets.js) needs setting.
app_ldap__bindPassword: your-ldap-password
# routes/oauth.js now refuses to start without a real jwtSecret.
# This is a non-secret test value; the container under test uses
# secrets.js.example's jwtSecret independently.
app_oauth__jwtSecret: ci-test-jwt-secret-do-not-use-in-production
run: npm test
test-summary:
+95 -5
View File
@@ -4,14 +4,102 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.3.1] - 2026-07-23
### Added
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
### Changed
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
## [1.3.0] - 2026-07-23
### Added
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
### Fixed
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
### Changed
- Removed the Mobile Phone field from the user create/edit form.
## [1.2.1] - 2026-07-22
### Added
- **Actionable Metrics**: New real-time metrics tracking for failed logins, top IPs, and service usage per user.
- **LDAP Monitor**: Background service to parse OpenLDAP binds over port 389 and track metrics for legacy apps.
- **UI Updates**: Executive dashboard now displays actionable metrics cards instead of raw logs. User profiles show individual service usage stats to admins.
- **Directory Management**: Integrated site/host/service abstractions into directory UI and allowed associating OAuth apps directly to services.
## [1.1.18] - 2026-07-21
### Added
- N-Way Multi-Master LDAP replication: `LDAP_SERVER_ID` + `LDAP_REPLICATION_HOSTS` configure `syncrepl` peers in the bundled OpenLDAP, and a new `/sites` page (nav: **Sites**) shows each configured peer's LDAP URL and live reachability.
- A `location` property on users, editable from the profile and user-edit forms.
### Fixed
- `/sites` (added above) 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.
### Changed
- Refreshed all README screenshots (dashboard, users, groups, OAuth apps) against the current UI, and added a new Sites & Replication screenshot.
## [1.1.17] - 2026-07-18
### Added
- `conf.ldap.ldapsHost` and `conf.ldap.ldapsPort` config options (also settable via `app_ldap__ldapsHost` / `app_ldap__ldapsPort`). When `ldapsHost` is set, the `/integrations` page advertises that hostname for direct LDAPS binds instead of deriving it from the public OAuth issuer. This lets operators use an internal-only hostname (e.g. `ldap.internal.example.com` or `sso-manager` on the Docker network) and avoid port-forwarding 636 to the internet.
- A contextual help panel on `/integrations` → LDAP explaining why LDAPS needs a hostname (not an IP), why 636 should not be publicly forwarded, and the recommended internal-DNS / Docker-internal alternatives.
### Changed
- `routes/index.js` now computes the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host for backward compatibility.
- `secrets.js.example`, `docs/configuration.md`, `docs/ldap.md`, and `DEPLOYMENT.md` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
- Bumped version to `1.1.17` in `nodejs/package.json`.
## [1.1.16] - 2026-07-18
### Security
- Hardened LDAP filter and DN construction against injection. All user-supplied values interpolated into group filters (`models/group_ldap.js`) and RDN values used when adding users/groups (`models/user_ldap.js`) are now escaped before being sent to the LDAP server.
- Replaced `Math.random()`-based token generation in `models/token.js`, `models/oauth_code.js`, and `models/oauth_client.js` with `crypto.randomUUID()` for session tokens, OAuth codes, access/refresh tokens, and client IDs.
- Replaced `Math.random()`-based OTP generation in `OtpToken.issue()` with `crypto.randomInt()`.
- `routes/oauth.js` now refuses to start if `oauth.jwtSecret` is missing or still set to the placeholder value, instead of falling back to a hardcoded public string.
- Rendered docs and Terms-of-Service HTML in `routes/docs.js` and `routes/index.js` are now sanitized with `xss` to prevent stored XSS from malicious markdown.
- Removed a `console.log` that wrote new-user data (including password hashes) to the log in `models/user_ldap.js`; reduced login-path error logging to `error.name`/`error.message` only.
### Changed
- Public-release packaging: removed `"private": true` from `nodejs/package.json` and bumped version to `1.1.16`.
- CI workflow (`.github/workflows/pr-tests.yml`) now sets `app_oauth__jwtSecret` so the test suite can run against the new startup-time JWT validation.
### Fixed
- `models/email.js`: fixed a template bug where the rendered `from` address used `template.message` instead of `template.from`.
## [1.1.15] - 2026-07-18
### Changed
- Rewrote `install.sh` as an idempotent git-clone installer, replacing the old flag-driven, copy-based one — `wget -O - .../install.sh | sudo bash` now works the same way it does for theta42/proxy. Installs to `/opt/theta42/sso-manager` (was `/opt/sso-manager`). First run only: bootstraps OpenLDAP with a generated admin password + JWT secret and seeds `/etc/sso-manager/secrets.js` (was `/opt/sso-manager/conf/secrets.js`, hand-filled from CLI flags); later runs never touch LDAP or the secrets file again. `ops/systemd/sso-manager.service` sets `CONF_SECRETS=/etc/sso-manager/secrets.js` to match.
- `install.sh` now prints the version it's updating from/to (or "Already up to date") on every run.
### Fixed
- `install.sh` could hang indefinitely on a fresh host if a base package pulled in `tzdata` as a new dependency (no TTY for the interactive timezone prompt), or if the debconf `slapd/domain` value was malformed (a raw DN fragment instead of a dotted domain) — slapd's postinst hangs rather than failing cleanly on a bad domain. Both fixed.
- `ops/ldap-setup.sh`'s ppolicy-overlay checks used an LDAP substring filter against an attribute that doesn't support substring matching, so they always reported the overlay as unconfigured even when it was correctly set up (stored as `{0}ppolicy`) — the final verification step always failed as a result. Fixed to filter on `(objectClass=olcOverlayConfig)` instead, matching every other check in that script.
## [1.1.14] - 2026-07-17
### Changed
- Bumped `@simpleworkjs/conf` to 1.2.0 and `jq-repeat` to 2.2.0. The Docker entrypoint now sets the new `CONF_SECRETS` env var to point directly at a mounted `sso-secrets.js` instead of symlinking it into `/app/conf/secrets.js` — the app no longer needs write access to its own `conf/` directory to pick up mounted secrets.
## [1.1.13] - 2026-07-17
### Fixed
- The new concept docs' cross-links (`concepts-accounts.html` etc.) are the correct, working URL on the Jekyll/GitHub Pages build (where the page's URL is its filename stem) but didn't resolve in the in-app docs viewer, which serves docs at a separate short slug (`/docs/accounts`). The in-app renderer now also resolves a doc's real filename as a fallback, so one link written in a doc works on both targets.
Bumps to v1.1.13.
## [1.1.12] - 2026-07-17
### Added
- Three new plain-language docs aimed at less technical readers, replacing the schema-level LDAP/OAuth/API docs as the target of most card help links: **Accounts, Groups & Managers**, **Connecting Apps (SSO)**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed) now links to its own doc.
@@ -19,12 +107,12 @@ Bumps to v1.1.13.
### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule 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 — they're now rewritten to the correct in-app URL, the same way image paths already were.
Bumps to v1.1.12.
## [1.1.11] - 2026-07-17
### Changed
- Moved the help (❓) link out of the global header and onto each relevant card individually (Invite User, Add new user, User List, Service Accounts, group cards, OAuth/LDAP integration cards, My groups, Members of `<uid>`'s group, New API Token) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
Bumps to v1.1.11.
## [1.1.10] - 2026-07-17
### Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
@@ -101,7 +189,9 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...HEAD
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
[1.1.13]: https://github.com/theta42/sso-manager-node/compare/v1.1.12...v1.1.13
[1.1.12]: https://github.com/theta42/sso-manager-node/compare/v1.1.11...v1.1.12
[1.1.11]: https://github.com/theta42/sso-manager-node/compare/v1.1.10...v1.1.11
+53 -35
View File
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
- Health check: `http://localhost:3001/health``{"status":"ok"}`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS)
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
### API tokens (personal access tokens)
@@ -176,6 +176,8 @@ docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
| `PORT` | `3001` | host port mapped to the UI |
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
Any `app_*` var may also be set directly to override any config value (see the
table at the top).
@@ -188,6 +190,12 @@ valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on
`ldap-certs` volume so it persists across container recreation — clients don't need
to re-trust on every rebuild.
The `/integrations` page derives its LDAPS URL from the OAuth issuer by default.
To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com`
or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your
secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for
recommended network layouts and how to match the cert SAN to the hostname.
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
out of the container and add it to the client's trusted CA store, or set
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
@@ -332,10 +340,17 @@ OAuth clients live in SSO Redis and are preserved by the volume.
## Method 2: Bare metal (Debian/Ubuntu)
`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and
configures OpenLDAP (modules + overlays + custom schema + directory tree +
required groups), deploys the app to `/opt/sso-manager`, and creates a systemd
unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based).
`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
config from the repo. Re-run it to update — it prints the version you're
updating from and to (or "Already up to date" if there's nothing new).
On the **first run only** it also installs and configures OpenLDAP (modules +
overlays + custom schema + directory tree + required groups — see
`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated
LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that
file exists it's never touched again, and LDAP is never re-bootstrapped —
edit the file and restart the service to change anything.
### Prerequisites
@@ -346,47 +361,50 @@ unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based
### Install
```bash
sudo ./install.sh \
-p 'your-ldap-password' \
-b 'dc=yourdomain,dc=com' \
-n 'Your Org' \
-o 3001
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
```
| Flag | Env var | Description |
|------|---------|-------------|
| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) |
| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) |
| `-n, --org-name` | `ORG_NAME` | Org name (default `SSO Manager`) |
| `-o, --port` | `PORT` | HTTP port (default `3001`) |
| `-j, --jwt-secret` | `JWT_SECRET` | JWT secret (default auto-generated) |
| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` |
| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) |
| `--skip-app` | `SKIP_APP` | LDAP setup only |
| `--dry-run` | `DRY_RUN` | Show actions without making changes |
or, if you already have the repo checked out:
```bash
sudo ./install.sh
```
| Env var | Description |
|---------|-------------|
| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
| `JWT_SECRET` | JWT secret (default auto-generated) — first run only |
| `ORG_NAME` | Org name (default `SSO Manager`) — first run only |
| `PORT` | HTTP port (default `3001`) — first run only |
| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) |
| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults |
### Post-install
```bash
sudo systemctl enable --now sso-manager
sudo systemctl status sso-manager
journalctl -fu sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
```
### What `install.sh` does
1. Installs Node.js 20.x (NodeSource).
2. Installs OpenLDAP (`slapd`) with: `pw-sha2`, `ppolicy`, `memberof`, `refint`
modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes;
`ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups.
3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`.
4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults).
5. Installs `sso-manager.service` (systemd), enabled on boot.
1. Installs Node.js 22.x (NodeSource) and Redis.
2. Clones/updates the repo at `/opt/theta42/sso-manager`.
3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
`memberof`, `refint` modules + overlays; the custom `theta42Person` schema
(`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
`pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and
runs `npm ci --omit=dev`.
5. Enables and (re)starts the service.
> For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the
> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use
> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer
> `ops/ldap-setup.sh` (idempotent, auto-detects the user database).
> For an existing LDAP server, run with `SKIP_LDAP=true` and write
> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
> starting the service. To (re)configure overlays on an already-installed
> slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user
> database).
---
@@ -465,8 +483,8 @@ netstat -tlnp | grep 389
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should
use `ldaps://…:636` or StartTLS.
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
set `JWT_SECRET`, issued tokens invalidate on container recreation.
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
+4 -1
View File
@@ -55,6 +55,8 @@ RUN apk add --no-cache \
openldap-overlay-ppolicy \
openldap-overlay-memberof \
openldap-overlay-refint \
openldap-overlay-syncprov \
openldap-overlay-auditlog \
openldap-passwd-sha2 \
dumb-init \
bash \
@@ -129,7 +131,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
# if you want LAN clients to bind without TLS (not recommended).
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS)
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
EXPOSE 3001 389 636
# Health check
+50
View File
@@ -0,0 +1,50 @@
# Test-runner image for SSO Manager.
#
# Installs all dependencies (including dev) and bundles the app code plus
# the seed script. The entrypoint waits for LDAP + Redis, seeds the test
# user, then runs whatever command is given (default: npm test).
FROM node:20-alpine
# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script
RUN apk add --no-cache openldap-clients bash
WORKDIR /app
# Copy and install dependencies (including devDependencies for jest/supertest)
COPY nodejs/package*.json ./
RUN npm ci
# Copy the application source
COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY nodejs/tests ./tests
# SQLite database directory (config/inventory.sqlite for Resource model's ORM)
RUN mkdir -p /app/config
# Files expected at the flattened /app path (see Dockerfile.openldap notes)
COPY tos.md /tos.md
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Seed script and utility
COPY test_seed.js ./test_seed.js
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
RUN chmod +x /usr/local/bin/seed-test-user
# Default command: seed the test user, then run the test suite
CMD ["sh", "-c", "seed-test-user && npm test"]
+24 -17
View File
@@ -25,6 +25,10 @@ phone-home, no hosted control plane, and no per-user pricing.
| --- | --- |
| [![Groups](docs/images/groups.png)](docs/images/groups.png) | [![OAuth clients](docs/images/oauth-clients.png)](docs/images/oauth-clients.png) |
| Sites & Replication |
| --- |
| [![Sites](docs/images/sites.png)](docs/images/sites.png) |
## Features
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
@@ -37,13 +41,15 @@ phone-home, no hosted control plane, and no per-user pricing.
- **Web management UI** — manage users, groups, and OAuth clients from a
browser; invite and password-reset flows over email; user self-service for
profile and API tokens.
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
directory, so you don't maintain a second user database for them.
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
same directory, so you don't maintain a second user database for them.
- **Personal access tokens** — any user can mint a long-lived bearer token to
drive the management API from scripts or CI, scoped to their own permissions.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
the pieces separately against your own LDAP/Redis via `app_*` env config.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
## Why this over the alternatives
@@ -107,23 +113,24 @@ vars, LDAPS/TLS, and backups.
### 3. Bare metal on Debian/Ubuntu
`install.sh` is an idempotent installer: it installs Node.js 20.x and OpenLDAP,
configures the directory (modules, overlays, schema, the SSO groups), deploys
the app to `/opt/sso-manager`, and creates a systemd unit.
The only thing it requires is the LDAP admin password; the domain (base DN)
defaults to `dc=example,dc=com` if you don't pass one:
An automated installer installs Node.js, Redis, and (on first run) OpenLDAP
configuring the directory (modules, overlays, schema, the SSO groups) and
seeding `/etc/sso-manager/secrets.js` with a generated admin password and JWT
secret — then deploys the app to `/opt/theta42/sso-manager` and starts a
systemd service:
```bash
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com'
sudo systemctl enable --now sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
```
Run `sudo ./install.sh -h` for all flags (`-n` org name, `-o` port, `-j` JWT
secret, `-s` SMTP, `--skip-ldap` to use an existing LDAP, `--dry-run`). Re-run
it to update. Full details in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2:
Bare metal*.
That's it — LDAP and the app are both live afterward. Edit
`/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
restart the service to customize. It's idempotent and safe to re-run —
re-running it updates the app in place (never touching LDAP or the secrets
file again) and prints the version you're updating from and to (e.g. `Updated
v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
## Architecture
@@ -145,7 +152,7 @@ Bare metal*.
┌────────────────────────┐
│ OpenLDAP (slapd) │
│ - users / groups │
│ - LDAPS :636 │─── legacy apps bind directly
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
│ - StartTLS :389 │
└────────────────────────┘
```
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
oidc: {
clientId: '',
clientSecret: '',
},
};
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Example secrets configuration file (file-based config).
//
// Bare-metal: install.sh seeds a filled-in version of this file at
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
// SMTP is left as a placeholder). Only write this one by hand if you're
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
// manually.
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
//
// Values here override conf/base.js and win over <environment>.js. `app_*` env
// vars (if any are set) override this file too — so the Docker stack passes NO
// app_* env, keeping this file authoritative.
//
// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth).
// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read
// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh)
// and ignored by the app — safe to leave them out for bare-metal use.
module.exports = {
port: 3001,
name: 'SSO Manager', // shown in UI and outbound email
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
ldap: {
url: 'ldap://localhost', // or ldaps://host:636 for TLS
bindDN: 'cn=admin,dc=example,dc=com',
bindPassword: 'ldap-admin-pass',
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
// direct LDAPS binds on /integrations. Leave empty to derive from the
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
// ldapsPort: 636,
},
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false, // true for 465, false for other ports
user: 'noreply@example.com',
pass: 'your-smtp-password',
from: 'SSO Manager <noreply@example.com>',
},
voipms: {
username: '', // VoIP.ms username (optional)
password: '', // VoIP.ms password (optional)
did: '', // VoIP.ms DID (optional)
},
oauth: {
issuer: 'https://sso.example.com', // falls back to the request host at runtime
jwtSecret: 'a-long-random-development-jwt-secret-value-1234567890',
token_lifetime: {
access_token: 3600, // 1 hour in seconds
refresh_token: 2592000 // 30 days in seconds
}
},
// ── Orchestrator-only keys (ignored by the app) ──────────────────────────
// Read by docker-entrypoint.sh (server-side slapd config + validation), the
// superproject bootstrap script, and setup.sh. Omit for bare-metal use.
stack: {
ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs).
// The base DN also appears in ldap.bindDN/userBase/groupBase above and
// in oauth.issuer — keep them consistent with this value
// (cn=admin,<dn>, ou=people,<dn>, ou=groups,<dn>, https://<ssoHost>).
ldapDomain: 'example.com', // default cert CN + OAuth issuer host
ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain
ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL)
proxyHost: 'proxy.example.com', // public proxy hostname
},
bootstrap: {
adminUid: 'admin', // initial SSO admin username
adminPass: 'AdminPass123!', // initial SSO admin password
adminEmail: 'admin@example.com', // initial SSO admin email
},
serviceAccountPass: 'proxy-service-pass', // LDAP password the proxy binds with
};
+17 -11
View File
@@ -95,13 +95,24 @@ common query fields can be promoted to columns later.
| column | type | notes |
|--------------|-------------|-------|
| `id` | uuid / pk | |
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` |
| `kind` | enum | `site` \| `host` \| `service` |
| `name` | text | display name ("Home Assistant", "ct101") |
| `slug` | text unique | url-safe id used by the API |
| `description`| text | free text |
| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` |
| `metadata` | jsonb | `{ subType, ip, macAddress, address, vmid, port, externalPort, gitRepo, installPath, systemdService, os, kernel, isProduction, isExternalReachable, isPublic }` |
| `created_at` / `updated_at` | timestamptz | |
**Parent Enforcement Rules:**
- A **Host** MUST have a parent **Site** or **Host**.
- A **Service** MUST have a parent **Host**.
- An **OAuth Integration** MUST have a parent **Service**.
**LDAP Group Auto-Creation:**
When a Host or Service is created, the system will automatically create two LDAP groups in the directory (if they do not already exist):
- `<slug>_access` (for standard user access)
- `<slug>_admin` (for administrative access)
Additional groups can still be linked manually.
### `resource_edge` — directed relationships (the graph)
| column | type | notes |
|--------------|--------|-------|
@@ -109,7 +120,7 @@ common query fields can be promoted to columns later.
| `child_id` | fk → resource | |
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
Represents host←container←service (`hosts`/`runs_on`) and service→service
Represents site←host←service (`hosts`/`runs_on`) and service→service
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
multiple parents/children and multiple relation types.
@@ -164,12 +175,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
- **Interactive users:** existing session auth — `middleware.auth` validating the
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service
token — `AuthToken` is session-oriented. **Proposed small addition:** a
`ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`),
long-lived, read-only, passed in the same `auth-token` header. Track as its own
task; the discovery API should assume it exists but degrade to normal auth tokens
until then.
- **CI/CD (machine) access:** scripts and external integrations (like jump hosts) will use the existing `ApiToken` system (Personal Access Tokens) passed in the `Authorization: Bearer sso_...` header. The `ApiToken` inherits the exact LDAP group permissions of the user who created it, seamlessly mapping to existing access controls.
- **Read visibility (decision to confirm):** either (a) any authenticated user may
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
@@ -195,7 +201,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
## 7. Roadmap
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
`/api/discovery/*` endpoints, `ServiceToken` for CI/CD.
`/api/discovery/*` endpoints, `ApiToken` for CI/CD.
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
@@ -213,4 +219,4 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
for auth, SQL for inventory.
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
5. **Service token scope:** read-only globally, or per-token resource/kind scoping?
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
+36
View File
@@ -0,0 +1,36 @@
services:
site1:
build: .
container_name: sso_site1
environment:
- LDAP_SERVER_ID=1
- LDAP_REPLICATION_HOSTS=ldap://site2:389
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
ports:
- "3001:3001"
- "10389:389"
volumes:
- site1-ldap:/var/lib/ldap
- site1-redis:/data
site2:
build: .
container_name: sso_site2
environment:
- LDAP_SERVER_ID=2
- LDAP_REPLICATION_HOSTS=ldap://site1:389
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
ports:
- "3002:3001"
- "20389:389"
volumes:
- site2-ldap:/var/lib/ldap
- site2-redis:/data
volumes:
site1-ldap:
site1-redis:
site2-ldap:
site2-redis:
+81
View File
@@ -0,0 +1,81 @@
# Docker Compose for running the SSO Manager test suite.
#
# Spins up:
# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app)
# redis — Standalone Redis for the app's model/token storage
# test-runner — Seeds the test user, then runs `npm test`
#
# Usage:
# docker compose -f docker-compose.test.yml up --build
# # Or to run a specific test file:
# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js
#
# The LDAP service uses the same Dockerfile.openldap image as production but
# overrides the command to only start slapd + redis (the entrypoint handles
# slapd.conf generation, directory initialization, and Redis startup before
# running the given command — "sleep infinity" keeps it alive).
#
# The test-runner connects to ldap:389 and redis:6379 via Docker networking.
# app_* env vars override conf/secrets.js (highest precedence in
# @simpleworkjs/conf), so the production secrets.js is never read.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
# The entrypoint starts slapd + redis, then runs whatever command is given.
# "sleep infinity" keeps the container alive so the test-runner can connect.
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
test-runner:
build:
context: .
dockerfile: Dockerfile.test-runner
environment:
# Tell the app which environment it's in (loads conf/test.js for Redis prefix)
- NODE_ENV=test
# LDAP — point at the ldap service container
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
# Redis — point at the redis service container
- app_redis__redisConf__url=redis://redis:6379
# Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf)
- REDIS_URL=redis://redis:6379
# JWT secret (required by the app, not sensitive in test)
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
# App name
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+48 -13
View File
@@ -6,12 +6,12 @@
# production, run a dedicated LDAP server and point the app at it via app_*
# env vars (or a mounted conf/secrets.js) using the app-only image.
#
# The app reads its configuration from conf/base.js + conf/secrets.js, deep-merged
# by @simpleworkjs/conf, with `app_*` environment variables as the
# highest-precedence override layer. This entrypoint exports those `app_*`
# vars so the app connects to the bundled slapd without any mounted secrets
# file. Any `app_*` var already set in the environment wins (the values below
# are defaults/fallbacks only).
# The app reads its configuration from conf/base.js + a secrets file, deep-merged
# by @simpleworkjs/conf (requires >= 1.2.0, pinned in nodejs/package-lock.json),
# with `app_*` environment variables as the highest-precedence override layer.
# This entrypoint exports those `app_*` vars so the app connects to the bundled
# slapd without any mounted secrets file. Any `app_*` var already set in the
# environment wins (the values below are defaults/fallbacks only).
set -e
@@ -33,14 +33,15 @@ error() { echo "[ERROR] $*" >&2; }
# ── Optional: load operational config from a mounted secrets.js ──────────────
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and
# treats it as the authoritative source for the SSO's config (LDAP base, admin
# password, org name, JWT secret, ...). When present, symlink it into
# /app/conf/secrets.js so @simpleworkjs/conf reads it, and override the
# env-derived operational vars below with the file's values. When absent
# (standalone / env-var deployments) the env vars set above stay in effect and
# the app_* exports further down are emitted as before.
# password, org name, JWT secret, ...). When present, point CONF_SECRETS at it
# so @simpleworkjs/conf reads it directly (no write access to /app/conf
# needed), and override the env-derived operational vars below with the
# file's values. When absent (standalone / env-var deployments) the env vars
# set above stay in effect and the app_* exports further down are emitted as
# before.
SECRETS_JS_MODE=0
if [[ -f /config/sso-secrets.js ]]; then
ln -sf /config/sso-secrets.js /app/conf/secrets.js
export CONF_SECRETS=/config/sso-secrets.js
SECRETS_JS_MODE=1
# Pull the entrypoint's operational vars out of secrets.js in one node call.
# Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and
@@ -124,6 +125,8 @@ include /etc/openldap/schema/theta42.schema
include /etc/openldap/schema/sudo.schema
include /etc/openldap/schema/openssh-lpk.schema
SERVER_ID_PLACEHOLDER
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
@@ -136,6 +139,8 @@ moduleload pw-sha2
moduleload ppolicy
moduleload memberof
moduleload refint
moduleload auditlog
SYNCPROV_MODULE_PLACEHOLDER
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
# generated/mounted above. We accept clients without their own cert (the common
@@ -183,6 +188,12 @@ memberof-memberof-ad memberOf
overlay refint
refint_attributes memberOf member manager owner
# auditlog overlay (LDIF audit trail of all changes)
overlay auditlog
auditlog /var/lib/ldap/auditlog.ldif
REPLICATION_BLOCK_PLACEHOLDER
# Access controls
access to attrs=userPassword
by dn="BIND_DN_PLACEHOLDER" write
@@ -210,6 +221,30 @@ else
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
fi
# ── Multi-Master Replication Configuration ──
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
sed -i "s|^SERVER_ID_PLACEHOLDER|ServerID ${LDAP_SERVER_ID}|" /etc/openldap/slapd.conf
sed -i "s|^SYNCPROV_MODULE_PLACEHOLDER|moduleload syncprov|" /etc/openldap/slapd.conf
# Generate syncrepl blocks
REPL_BLOCK="overlay syncprov\nsyncprov-checkpoint 100 10\nsyncprov-sessionlog 100\n\n"
RID=100
for HOST in ${LDAP_REPLICATION_HOSTS}; do
RID=$((RID + 1))
REPL_BLOCK="${REPL_BLOCK}syncrepl rid=${RID}\n provider=${HOST}\n type=refreshAndPersist\n retry=\"60 +\"\n searchbase=\"${LDAP_BASE_DN}\"\n bindmethod=simple\n binddn=\"${LDAP_BIND_DN}\"\n credentials=\"${LDAP_ADMIN_PASS}\"\n\n"
done
REPL_BLOCK="${REPL_BLOCK}mirrormode on\n"
# Replace placeholder (awk is safer for multiline replacements than sed)
awk -v repl="$(printf '%b' "$REPL_BLOCK")" '{gsub(/REPLICATION_BLOCK_PLACEHOLDER/, repl)}1' /etc/openldap/slapd.conf > /etc/openldap/slapd.conf.tmp
mv /etc/openldap/slapd.conf.tmp /etc/openldap/slapd.conf
else
sed -i "/^SERVER_ID_PLACEHOLDER/d" /etc/openldap/slapd.conf
sed -i "/^SYNCPROV_MODULE_PLACEHOLDER/d" /etc/openldap/slapd.conf
sed -i "/^REPLICATION_BLOCK_PLACEHOLDER/d" /etc/openldap/slapd.conf
fi
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
@@ -219,7 +254,7 @@ info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
# Alpine and the container only uses simple bind over ldap://localhost:389.
slapd -d 0 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" &
slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
SLAPD_PID=$!
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
+3
View File
@@ -31,6 +31,9 @@ nav:
- title: LDAP
page: /ldap.html
icon: fa-address-book
- title: Directory
page: /directory.html
icon: fa-server
- title: Changelog
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
icon: fa-list
+2
View File
@@ -32,6 +32,8 @@ raw strings otherwise.
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
+97
View File
@@ -0,0 +1,97 @@
---
layout: default
title: Directory Management
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
---
# Directory Management
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
## Architecture
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
There are three primary **Kinds** of resources you can define:
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
## Automatic LDAP Group Creation
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
1. `<slug>_access` (Member level access)
2. `<slug>_admin` (Owner level access)
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
## Resource Metadata
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
### Common Metadata
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
- **IP Address**: The internal IP address of the resource.
- **MAC Address**: The hardware address of the primary interface.
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
- **Production Environment**: A boolean toggle indicating if the resource is in production.
### Host Metadata
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
### Service Metadata
- **Internal Port**: The local port the service binds to (e.g. `8080`).
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
## Automatic registration
You don't have to build the graph by hand — the theta42 tooling registers itself:
### The stack itself (theta-env)
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 284 KiB

+5 -2
View File
@@ -52,10 +52,13 @@ backend, that's the niche.
- **Web management UI** — users, groups, and OAuth clients from a browser;
invite and password-reset flows over email; self-service profile + API
tokens.
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
Emby, …) uses LDAPS/StartTLS against the same directory.
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
directory.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client.
## Get it
+78 -2
View File
@@ -14,8 +14,9 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
authenticates against it over `localhost:389` (inside the all-in-one container)
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
directly — Gitea, Emby, the theta42/proxy, etc.
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
theta42/proxy, etc.
## Directory layout
@@ -118,6 +119,81 @@ volumes:
The entrypoint leaves existing certs untouched (idempotent).
## Choosing the LDAPS hostname
The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
default it derives that URL from the public OAuth issuer (e.g.
`https://sso.example.com``ldaps://sso.example.com:636`). That is convenient,
but it implies LDAP clients reach your directory through the same public
hostname — which usually means port-forwarding 636 through your router.
**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
have no rate limiting and are a brute-force target. Instead, use one of these
internal-only patterns and set `conf.ldap.ldapsHost` (or
`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
### 1. Same Docker / local network host (best for apps on this machine)
If the LDAP client runs on the same Docker network as the SSO Manager (for
example, the bundled `theta-env` stack), use the internal service name:
```
ldaps://sso-manager:636
```
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'sso-manager',
ldapsPort: 636,
}
```
The proxy in theta-env already uses this internally. The bundled slapd cert
includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
so hostname verification works without extra setup.
### 2. LAN host behind your router (best for separate home-lan machines)
Create an internal-only DNS record — e.g. `ldap.internal.example.com`
`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
or generate a cert whose SAN/CN matches that internal name:
- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
public domain and can complete DNS-01 challenge; the record itself can stay
private/routable only inside your LAN.
- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
`ldap.internal.example.com`, and distribute the CA cert to clients.
- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
the generated `ldap.crt` to each client and trust it.
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'ldap.internal.example.com',
ldapsPort: 636,
}
```
The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
### 3. Public hostname (acceptable only behind a VPN/firewall)
If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
etc.) or a tightly locked-down firewall rule. In that case the public hostname
may be appropriate, but the LDAPS port should still not be reachable from the
open internet.
### Why not just use the LDAP server's IP address?
TLS clients verify the server name against the certificate. Connecting to
`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
will fail hostname verification unless you disable cert checks — which removes
most of the security benefit of LDAPS. Always use a hostname that matches the
cert.
## Service accounts
A service account is a normal `posixAccount` for something that isn't a
+8 -12
View File
@@ -53,20 +53,16 @@ An OAuth client represents an app that authenticates against the SSO. Each has:
### Managing clients
Clients are managed from the web UI (as a member of the `app_sso_oauth_admin`
group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header
from a login):
Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
| Method | Path | Action |
|--------|------|--------|
| `GET` | `/api/oauth/client` | list clients |
| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) |
| `GET` | `/api/oauth/client/:id` | get one |
| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups |
| `DELETE` | `/api/oauth/client/:id` | delete |
| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret once) |
| Action | How to do it |
|--------|--------------|
| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
> All client-management endpoints are gated by the `app_sso_oauth_admin` group.
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
## Scopes
+55
View File
@@ -0,0 +1,55 @@
---
layout: default
title: Geo-Location Scaling (Replication)
---
# Geo-Location Scaling (Replication)
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
## Why and when to use this?
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
## How it works
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
## Configuration
To enable replication, you must pass two environment variables to the `sso-manager` container:
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
### Example using `theta-env` / Docker Compose
**Site 1 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=1
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
```
**Site 2 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=2
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
```
**Site 3 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=3
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
```
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
## User Locations
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
+213 -702
View File
@@ -1,719 +1,230 @@
#!/usr/bin/env bash
# install.sh - Idempotent standalone installer for Theta42 SSO Manager
# For Debian/Ubuntu systems
#
# This script:
# 1. Installs Node.js 20.x
# 2. Installs and configures OpenLDAP with required schemas/overlays
# 3. Deploys the SSO Manager application
# 4. Sets up systemd services
# Install / update Theta42 SSO Manager on a fresh or existing host.
#
# Usage:
# sudo ./install.sh [OPTIONS]
# This script is idempotent: run it to install, and re-run it to update. It
# installs system dependencies (Node, OpenLDAP, Redis), force-syncs the repo at
# $REPO_DIR to its remote branch, and symlinks the systemd config straight from
# the repo. Because the config is symlinked, an update is just "sync the repo +
# restart" -- the files under /etc/systemd always track the repo.
#
# Options:
# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env)
# -b, --base-dn DN Base DN (default: dc=example,dc=com)
# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager)
# -o, --port PORT HTTP port for SSO Manager (default: 3001)
# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated)
# -s, --smtp-config CONFIG SMTP config as host:port:user:pass
# --skip-ldap Skip LDAP installation (use existing LDAP)
# --skip-app Skip application installation (LDAP setup only)
# --dry-run Show what would be done without making changes
# -h, --help Show this help
# Secrets live at $SECRETS_FILE (/etc/sso-manager/secrets.js by default),
# outside the repo checkout so they survive the hard reset below. FIRST RUN
# ONLY (no $SECRETS_FILE yet): installs and configures OpenLDAP (modules,
# overlays, custom schema, directory tree, required SSO groups -- see
# ops/ldap-setup.sh), generates an LDAP admin password + JWT secret unless
# given via env, and seeds $SECRETS_FILE with those values plus SMTP
# placeholders. Edit that file (SMTP, org name, ...) and re-run this script to
# apply changes -- once it exists it is never touched again, and LDAP is never
# re-bootstrapped.
#
# Environment variables (alternative to flags):
# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_*
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
# hard-reset to origin/$BRANCH on every run, so the box deterministically
# mirrors the repo (any drift on the box is discarded).
#
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
# SECRETS_FILE=, LDAP_BASE_DN=, LDAP_ADMIN_PASS=,
# JWT_SECRET=, ORG_NAME=, PORT=, SKIP_LDAP=true)
set -euo pipefail
# Never block on an interactive git credential prompt in CI.
export GIT_TERMINAL_PROMPT=0
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
# dependency of redis-server/slapd on a box that's never configured it).
export DEBIAN_FRONTEND=noninteractive
# ── Defaults ──────────────────────────────────────────────────────────────────
BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
ADMIN_PASS="${LDAP_ADMIN_PASS:-}"
REPO_URL="${REPO_URL:-https://github.com/theta42/sso-manager-node.git}"
REPO_DIR="${REPO_DIR:-/opt/theta42/sso-manager}"
BRANCH="${BRANCH:-master}"
NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/sso-manager/secrets.js}"
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
ORG_NAME="${ORG_NAME:-SSO Manager}"
PORT="${PORT:-3001}"
JWT_SECRET="${JWT_SECRET:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PASS="${SMTP_PASS:-}"
SKIP_LDAP="${SKIP_LDAP:-false}"
SKIP_APP="${SKIP_APP:-false}"
DRY_RUN="${DRY_RUN:-false}"
INSTALL_DIR="/opt/sso-manager"
SYSTEMD_DIR="/etc/systemd/system"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# ── Helper functions ──────────────────────────────────────────────────────────
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
dry_run() { if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] $*"; fi; }
usage() {
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p|--admin-pass)
ADMIN_PASS="$2"
shift 2
;;
-b|--base-dn)
BASE_DN="$2"
shift 2
;;
-n|--org-name)
ORG_NAME="$2"
shift 2
;;
-o|--port)
PORT="$2"
shift 2
;;
-j|--jwt-secret)
JWT_SECRET="$2"
shift 2
;;
-s|--smtp-config)
IFS=':' read -r SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS <<< "$2"
shift 2
;;
--skip-ldap)
SKIP_LDAP="true"
shift
;;
--skip-app)
SKIP_APP="true"
shift
;;
--dry-run)
DRY_RUN="true"
shift
;;
-h|--help)
usage
;;
*)
error "Unknown option: $1"
usage
;;
esac
done
# Validate required parameters
if [[ -z "$ADMIN_PASS" ]]; then
error "LDAP admin password is required (-p or LDAP_ADMIN_PASS env)"
exit 1
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (try: sudo $0)" >&2
exit 1
fi
# Generate JWT secret if not provided
if [[ -z "$JWT_SECRET" ]]; then
JWT_SECRET=$(openssl rand -hex 32)
info "Generated JWT secret: ${JWT_SECRET:0:8}..."
# Symlink $1 -> $2, replacing whatever is already at $2 (idempotent).
link(){
ln -sfn "$1" "$2"
echo "linked $2 -> $1"
}
# Read the "version" field out of a package.json without depending on Node
# being installed yet (this runs before the Node.js install step below).
pkg_version(){
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
# Installed version before this run touches anything, for the upgrade banner
# at the end. Empty on a fresh install (no prior checkout).
CURRENT_VERSION=""
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
fi
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email
# sender defaults. Override with LDAP_DOMAIN if set.
if [[ -z "${LDAP_DOMAIN:-}" ]]; then
LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g')
# FIRST_RUN gates OpenLDAP bootstrap + secrets seeding below -- both only ever
# happen once, the first time this script runs on a host (i.e. before
# $SECRETS_FILE exists). Every later run only updates the code.
FIRST_RUN=0
[ -f "$SECRETS_FILE" ] || FIRST_RUN=1
echo "==> Base packages"
apt-get update
apt-get install -y --no-install-recommends \
build-essential redis-server \
wget gnupg ca-certificates curl git
echo "==> Node.js ${NODE_MAJOR}.x apt source"
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
echo "==> Install Node.js"
apt-get update
apt-get install -y nodejs
echo "==> Redis"
systemctl enable --now redis-server
echo "==> Repo checkout at ${REPO_DIR} (branch ${BRANCH})"
install -d "$(dirname "$REPO_DIR")"
if [ -d "$REPO_DIR/.git" ]; then
# Force the box to match the remote branch exactly. No human edits configs
# on prod, so discarding local drift is the desired, deterministic behavior.
git -C "$REPO_DIR" fetch --prune origin
git -C "$REPO_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
git -C "$REPO_DIR" reset --hard "origin/$BRANCH"
git -C "$REPO_DIR" clean -fd
else
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi
# ── System checks ─────────────────────────────────────────────────────────────
check_root() {
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (sudo)"
exit 1
fi
}
check_os() {
if [[ ! -f /etc/debian_version ]]; then
error "This script is for Debian/Ubuntu systems only"
exit 1
fi
info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)"
}
# ── Package installation ──────────────────────────────────────────────────────
install_package() {
local pkg="$1"
if dpkg -l | grep -q "^ii $pkg "; then
info "Package $pkg is already installed"
return 0
fi
dry_run "Would install package: $pkg"
[[ "$DRY_RUN" == "true" ]] && return 0
apt-get update -qq
apt-get install -y -qq "$pkg"
info "Installed $pkg"
}
install_nodejs() {
if command -v node &>/dev/null && node --version | grep -q "v20"; then
info "Node.js 20.x is already installed"
return 0
fi
dry_run "Would install Node.js 20.x"
[[ "$DRY_RUN" == "true" ]] && return 0
info "Installing Node.js 20.x..."
# Use NodeSource repository for Node.js 20.x
apt-get update -qq
apt-get install -y -qq curl gnupg ca-certificates
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
apt-get install -y -qq nodejs
info "Installed Node.js $(node --version)"
}
# ── OpenLDAP installation and configuration ───────────────────────────────────
install_openldap() {
if command -v slapd &>/dev/null; then
info "OpenLDAP is already installed"
return 0
fi
dry_run "Would install OpenLDAP"
[[ "$DRY_RUN" == "true" ]] && return 0
info "Installing OpenLDAP..."
# Pre-seed debconf for non-interactive installation
debconf-set-selections << EOF
slapd slapd/internal/adminpw string $ADMIN_PASS
slapd slapd/password1 string $ADMIN_PASS
slapd slapd/password2 string $ADMIN_PASS
slapd slapd/domain string ${BASE_DN#dc=}
slapd slapd/backend string MDB
slapd shared/organization string $ORG_NAME
slapd slapd/purge_database boolean true
slapd slapd/move_old_database boolean true
slapd slapd/invalid_config boolean true
EOF
apt-get update -qq
apt-get install -y -qq slapd ldap-utils
# Configure ldap.conf
cat > /etc/ldap/ldap.conf << LDAPCONF
BASE $BASE_DN
URI ldap://localhost
LDAPCONF
# Set proper permissions
chmod 644 /etc/ldap/ldap.conf
info "OpenLDAP installed"
}
configure_openldap() {
info "Configuring OpenLDAP..."
dry_run "Would configure OpenLDAP with base DN: $BASE_DN"
[[ "$DRY_RUN" == "true" ]] && return 0
# Wait for slapd to be ready
for i in {1..10}; do
if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then
info "OpenLDAP is ready"
break
fi
sleep 1
done
# Detect the database DN for our suffix
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
"(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \
| grep "^dn:" | head -1 | sed 's/^dn: //')
if [[ -z "$DB_DN" ]]; then
# Try to find any database and update its suffix
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
"(objectClass=olcDatabaseConfig)" dn 2>/dev/null \
| grep "^dn:" | head -1 | sed 's/^dn: //')
if [[ -n "$DB_DN" ]]; then
info "Updating database suffix to $BASE_DN"
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: $DB_DN
changetype: modify
replace: olcSuffix
olcSuffix: $BASE_DN
EOF
fi
fi
if [[ -z "$DB_DN" ]]; then
error "Could not detect OpenLDAP database configuration"
return 1
fi
info "Using database: $DB_DN"
# 1. Load pw-sha2 module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "pw-sha2"; then
info "Loading pw-sha2 module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: pw-sha2
EOF
else
info "pw-sha2 module already loaded"
fi
# 2. Load ppolicy module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "ppolicy"; then
info "Loading ppolicy module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: ppolicy
EOF
else
info "ppolicy module already loaded"
fi
# 3. Load memberof module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "memberof"; then
info "Loading memberof module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof
EOF
else
info "memberof module already loaded"
fi
# 4. Load refint module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "refint"; then
info "Loading refint module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: refint
EOF
else
info "refint module already loaded"
fi
# 5. Add ppolicy overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn 2>/dev/null | grep -qi "ppolicy"; then
info "Adding ppolicy overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=ppolicy,$DB_DN
objectClass: olcOverlayConfig
objectClass: olcPPolicyConfig
olcOverlay: ppolicy
olcPPolicyDefault: cn=ppolicy,ou=policies,$BASE_DN
olcPPolicyUseLockout: TRUE
EOF
else
info "ppolicy overlay already configured"
fi
# 6. Add memberof overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*memberof*)" dn 2>/dev/null | grep -qi "memberof"; then
info "Adding memberof overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=memberof,$DB_DN
objectClass: olcConfig
objectClass: olcMemberOf
objectClass: olcOverlayConfig
objectClass: top
olcOverlay: memberof
olcMemberOfDangling: ignore
olcMemberOfRefInt: TRUE
olcMemberOfGroupOC: groupOfNames
olcMemberOfMemberAD: member
olcMemberOfMemberOfAD: memberOf
EOF
else
info "memberof overlay already configured"
fi
# 7. Add refint overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*refint*)" dn 2>/dev/null | grep -qi "refint"; then
info "Adding refint overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=refint,$DB_DN
objectClass: olcConfig
objectClass: olcOverlayConfig
objectClass: olcRefintConfig
objectClass: top
olcOverlay: refint
olcRefintAttribute: memberof member manager owner
EOF
else
info "refint overlay already configured"
fi
# 8. Add database indexes
info "Configuring database indexes..."
for index in "mail eq,sub" "uid eq,sub" "cn eq,sub" "member eq" "uidNumber eq" "gidNumber eq"; do
attr=$(echo "$index" | cut -d' ' -f1)
types=$(echo "$index" | cut -d' ' -f2)
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF || true
dn: $DB_DN
changetype: modify
add: olcDbIndex
olcDbIndex: $attr $types
EOF
done
# 9. Load custom theta42 schema
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(olcObjectClasses=*theta42Person*)" olcObjectClasses 2>/dev/null | grep -q "theta42"; then
info "Loading custom theta42 schema..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=theta42,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: theta42
olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1
NAME 'dateOfBirth'
DESC 'Date of birth in ISO 8601 format YYYY-MM-DD'
EQUALITY caseExactMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE )
olcObjectClasses: ( 1.3.6.1.4.1.99999.2.1
NAME 'theta42Person'
DESC 'Theta42 SSO extended person attributes'
AUXILIARY
MAY ( dateOfBirth ) )
EOF
else
info "theta42 schema already loaded"
fi
# 10. Create base directory structure
BIND_DN="cn=admin,$BASE_DN"
# Create base DN if it doesn't exist
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating base DN structure..."
DC_VALUE="${BASE_DN#dc=}"
DC_VALUE="${DC_VALUE%%,*}"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $BASE_DN
objectClass: dcObject
objectClass: organization
dc: $DC_VALUE
o: $ORG_NAME
EOF
else
info "Base DN already exists"
fi
# Create OUs
for ou in people groups policies; do
dn="ou=$ou,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating $ou OU..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: ou=$ou,$BASE_DN
objectClass: organizationalUnit
ou: $ou
EOF
else
info "OU $ou already exists"
fi
done
# 11. Create default ppolicy
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "cn=ppolicy,ou=policies,$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating default ppolicy..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: cn=ppolicy,ou=policies,$BASE_DN
objectClass: top
objectClass: organizationalRole
objectClass: pwdPolicy
cn: ppolicy
pwdAttribute: 2.5.4.35
pwdLockout: FALSE
pwdMustChange: FALSE
pwdAllowUserChange: TRUE
EOF
else
info "Default ppolicy already exists"
fi
# 12. Create required SSO groups
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
dn="cn=$group,ou=groups,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating group: $group"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $dn
objectClass: groupOfNames
objectClass: top
cn: $group
description: $ORG_NAME $group group
member: $BIND_DN
EOF
else
info "Group $group already exists"
fi
done
info "OpenLDAP configuration complete"
}
# ── Application installation ──────────────────────────────────────────────────
install_app() {
info "Installing SSO Manager application..."
dry_run "Would install application to $INSTALL_DIR"
[[ "$DRY_RUN" == "true" ]] && return 0
# Create installation directory
mkdir -p "$INSTALL_DIR"
# Copy application files
info "Copying application files..."
cp -r "$SCRIPT_DIR/nodejs/"* "$INSTALL_DIR/"
# Install npm dependencies
info "Installing npm dependencies..."
cd "$INSTALL_DIR"
npm ci --only=production --quiet
# Create secrets configuration
info "Creating application configuration..."
cat > "$INSTALL_DIR/conf/secrets.js" << SECRETEOF
'use strict';
module.exports = {
port: $PORT,
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '$ADMIN_PASS',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
},
smtp: {
host: '${SMTP_HOST:-localhost}',
port: ${SMTP_PORT:-587},
user: '${SMTP_USER:-}',
pass: '${SMTP_PASS:-}',
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
},
voipms: {
username: '${VOIPMS_USER:-}',
password: '${VOIPMS_PASS:-}',
did: '${VOIPMS_DID:-}',
},
oauth: {
issuer: '',
jwtSecret: '$JWT_SECRET',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
};
SECRETEOF
# Create base configuration
cat > "$INSTALL_DIR/conf/base.js" << BASEEOF
'use strict';
module.exports = {
name: "$ORG_NAME",
userModel: 'ldap',
redis: {
prefix: 'sso_manager_'
},
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '__IN SECRETS FILE__',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid'
},
oauth: {
issuer: '',
jwtSecret: '__in secrets file__',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
smtp: {
host: 'localhost',
port: 587,
secure: false,
from: '$ORG_NAME <noreply@$LDAP_DOMAIN>',
},
};
BASEEOF
# Set ownership
chown -R root:root "$INSTALL_DIR"
chmod -R 755 "$INSTALL_DIR"
info "Application installed to $INSTALL_DIR"
}
# ── Systemd service configuration ─────────────────────────────────────────────
install_systemd() {
info "Installing systemd service..."
dry_run "Would install systemd service"
[[ "$DRY_RUN" == "true" ]] && return 0
cat > "$SYSTEMD_DIR/sso-manager.service" << UNITEOF
[Unit]
Description=Theta42 SSO Manager
Documentation=file://$INSTALL_DIR/README.md
After=network.target slapd.service
Wants=slapd.service
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/node $INSTALL_DIR/bin/www
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=NODE_PORT=$PORT
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
UNITEOF
systemctl daemon-reload
systemctl enable sso-manager.service
info "Systemd service installed"
}
# ── Verification ──────────────────────────────────────────────────────────────
verify_installation() {
info "Verifying installation..."
local errors=0
# Check OpenLDAP
if command -v slapd &>/dev/null; then
if systemctl is-active --quiet slapd; then
info "✓ OpenLDAP is running"
else
warn "✗ OpenLDAP is not running"
((errors++))
fi
else
warn "✗ OpenLDAP is not installed"
((errors++))
fi
# Check application
if [[ -d "$INSTALL_DIR" ]]; then
info "✓ Application is installed"
else
warn "✗ Application is not installed"
((errors++))
fi
# Check systemd service
if systemctl is-enabled --quiet sso-manager.service 2>/dev/null; then
info "✓ Systemd service is enabled"
else
warn "✗ Systemd service is not enabled"
((errors++))
fi
if [[ $errors -eq 0 ]]; then
info "Installation verified successfully"
else
warn "Installation completed with $errors issue(s)"
fi
return $errors
}
# ── Main execution ────────────────────────────────────────────────────────────
main() {
echo
echo "=============================================="
echo " Theta42 SSO Manager Installer"
echo "=============================================="
echo
echo "Configuration:"
echo " Base DN: $BASE_DN"
echo " Port: $PORT"
echo " Install dir: $INSTALL_DIR"
echo " Skip LDAP: $SKIP_LDAP"
echo " Skip App: $SKIP_APP"
echo
check_root
check_os
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "=== Installing OpenLDAP ==="
install_openldap
configure_openldap
fi
if [[ "$SKIP_APP" != "true" ]]; then
echo
info "=== Installing SSO Manager ==="
install_nodejs
install_app
install_systemd
fi
echo
verify_installation
echo
echo "=============================================="
echo " Installation Complete!"
echo "=============================================="
echo
if [[ "$SKIP_APP" != "true" ]]; then
info "Start the service with: systemctl start sso-manager"
info "View logs with: journalctl -fu sso-manager"
info "Access the UI at: http://localhost:$PORT"
fi
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "LDAP Configuration:"
info " Base DN: $BASE_DN"
info " Bind DN: cn=admin,$BASE_DN"
info " Admin pass: (set by you)"
echo
info "Required SSO groups created:"
info " - app_sso_admin"
info " - app_sso_invite"
info " - app_sso_oauth_admin"
fi
echo
}
main
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
if [ "$FIRST_RUN" -eq 1 ] && [ "$SKIP_LDAP" != "true" ]; then
echo "==> First run: bootstrapping OpenLDAP (base DN: ${LDAP_BASE_DN})"
LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$(openssl rand -base64 24 | tr -d '=+/')}"
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
BIND_DN="cn=admin,${LDAP_BASE_DN}"
# slapd/domain wants a dotted DNS domain (e.g. "example.com"), not the raw
# DN -- "dc=foo,dc=bar" -> "foo.bar". A malformed value here (e.g. the raw
# DN with only the leading "dc=" stripped) makes slapd's postinst hang
# indefinitely instead of failing cleanly.
LDAP_DOMAIN="$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')"
if ! command -v slapd >/dev/null 2>&1; then
debconf-set-selections <<-EOF
slapd slapd/internal/adminpw password ${LDAP_ADMIN_PASS}
slapd slapd/password1 password ${LDAP_ADMIN_PASS}
slapd slapd/password2 password ${LDAP_ADMIN_PASS}
slapd slapd/domain string ${LDAP_DOMAIN}
slapd shared/organization string ${ORG_NAME}
slapd slapd/purge_database boolean true
slapd slapd/move_old_database boolean true
EOF
apt-get install -y slapd ldap-utils
cat > /etc/ldap/ldap.conf <<-EOF
BASE ${LDAP_BASE_DN}
URI ldap://localhost
EOF
systemctl enable --now slapd
else
echo " slapd already installed -- assuming it already serves ${LDAP_BASE_DN}"
fi
echo "==> Directory structure (ou=people, ou=groups)"
for ou in people groups; do
dn="ou=${ou},${LDAP_BASE_DN}"
if ldapsearch -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "^dn:"; then
echo " ${dn} already exists"
else
ldapadd -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost <<-EOF
dn: ${dn}
objectClass: organizationalUnit
ou: ${ou}
EOF
echo " ${dn} created"
fi
done
echo "==> LDAP modules, overlays, schema, policy, SSO groups"
"$REPO_DIR/ops/ldap-setup.sh" -p "$LDAP_ADMIN_PASS" -b "$LDAP_BASE_DN" -D "$BIND_DN"
echo "==> Seeding ${SECRETS_FILE}"
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
cat > "$SECRETS_FILE" <<-SECRETSEOF
'use strict';
// Generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ). Edit freely --
// this file is never overwritten by a later run of install.sh.
// LDAP admin password + JWT secret below were auto-generated; SMTP is a
// placeholder (email delivery won't work until you fill it in).
module.exports = {
port: ${PORT},
name: '${ORG_NAME}',
ldap: {
url: 'ldap://localhost',
bindDN: '${BIND_DN}',
bindPassword: '${LDAP_ADMIN_PASS}',
userBase: 'ou=people,${LDAP_BASE_DN}',
groupBase: 'ou=groups,${LDAP_BASE_DN}',
},
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false,
user: 'noreply@${LDAP_DOMAIN}',
pass: 'set-me',
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
},
oauth: {
issuer: '',
jwtSecret: '${JWT_SECRET}',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000,
},
},
};
SECRETSEOF
chmod 600 "$SECRETS_FILE"
echo " seeded ${SECRETS_FILE} (LDAP + JWT are live; SMTP is a placeholder)"
echo " \$EDITOR ${SECRETS_FILE}"
echo " then re-run this script (or: sudo systemctl restart sso-manager)"
elif [ "$FIRST_RUN" -eq 1 ]; then
echo "==> SKIP_LDAP=true -- not bootstrapping OpenLDAP or seeding ${SECRETS_FILE}"
echo " Write it yourself (see secrets.js.example) before starting sso-manager."
else
echo "==> ${SECRETS_FILE} already exists, leaving LDAP + secrets untouched"
fi
echo "==> Symlink systemd config from the repo"
link "$REPO_DIR/ops/systemd/sso-manager.service" /etc/systemd/system/sso-manager.service
echo "==> Node dependencies"
# Deterministic, production-only install from the lockfile. Falls back to a
# plain install if the lockfile and manifest are out of step.
( cd "$REPO_DIR/nodejs" && { npm ci --omit=dev || npm install --omit=dev; } )
echo "==> Services"
systemctl daemon-reload
systemctl enable --now sso-manager.service
systemctl restart sso-manager.service
echo "==> Done."
if [ -z "$CURRENT_VERSION" ]; then
echo " Installed v${NEW_VERSION}."
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
echo " Already up to date (v${NEW_VERSION})."
else
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
fi
echo " Update later with: sudo BRANCH=${BRANCH} $0"
+9 -3
View File
@@ -25,6 +25,7 @@ app.contoller = require('./controller');
// Background services (self-initializing on require).
require('./services/update_check');
require('./services/ldap_monitor');
// Push pubsub over the socket and back.
app.onListen.push(function(){
@@ -83,16 +84,18 @@ app.use('/api/token', middleware.auth, require('./routes/token'));
app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
app.use('/api/tos', middleware.auth, require('./routes/tos'));
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.use('/api/oauth', middleware.auth, oauthApiRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.get('/.well-known/openid-configuration', discovery);
@@ -105,7 +108,10 @@ app.use(function(req, res, next) {
next(err);
});
// Error handler. This is where `next()` will go on error
// Discovery API
app.use('/api/discovery', middleware.auth, require('./routes/api_discovery'));
// Error handling
app.use(function(err, req, res, next) {
const SILENT_404S = ['/.well-known/'];
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
+12 -6
View File
@@ -25,13 +25,19 @@ var server = http.createServer(app);
var io = require('socket.io')(server);
app.io = io;
/**
* Listen on provided port, on all network interfaces.
*/
const models = require('../models');
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Initialize ORM, then Listen on provided port, on all network interfaces.
*/
models.initORM().then(() => {
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
}).catch(err => {
console.error('Failed to initialize ORM:', err);
process.exit(1);
});
/**
* Normalize a port into a number, string, or false.
+7
View File
@@ -22,6 +22,13 @@ module.exports = {
groupBase: 'ou=groups,dc=example,dc=com',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid',
// Hostname/port advertised on the /integrations page for direct-LDAP
// clients. Leave ldapsHost empty to derive it from the OAuth issuer host.
// Set it to an internal-only name (e.g. 'ldap.internal.example.com' or
// 'sso-manager' on the Docker network) so external clients don't need a
// public 636 port forward. See docs/ldap.md.
ldapsHost: '',
ldapsPort: 636,
// New users/personal groups (see addPosixAccount/addPosixGroup in
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
// Existing entries >= uidGidReservedFloor are ignored when computing
Binary file not shown.
+17 -4
View File
@@ -9,10 +9,23 @@ async function auth(req, res, next){
// the same /api/* routes the UI uses.
const authz = req.header('authorization') || '';
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
const user = await Auth.checkApiToken(authz.slice(7));
if(user && user.uid){
req.user = user;
return next();
const tokenStr = authz.slice(7);
if (tokenStr.startsWith('sso_')) {
const user = await Auth.checkApiToken(tokenStr);
if(user && user.uid){
req.user = user;
return next();
}
} else {
// Machine token (ServiceToken)
const { ServiceToken } = require('../models/token');
let svcToken;
try { svcToken = await ServiceToken.get(tokenStr); } catch(e) {}
if (svcToken && svcToken.is_valid) {
req.user = { uid: svcToken.resource_id, isMachine: true, name: 'Machine Account' };
req.resourceId = svcToken.resource_id;
return next();
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ Auth.login = async function(data){
return {user, token}
}catch(error){
console.error("AUTH LOGIN error:", error);
console.error("AUTH LOGIN error:", error.name, error.message);
throw this.errors.login();
}
};
+1 -1
View File
@@ -58,7 +58,7 @@ Mail.sendTemplate = async function(to, template, context, from){
to,
mustache.render(template.subject, context),
mustache.render(template.message, context),
from || (template.from && mustache.render(template.message, context))
from || (template.from && mustache.render(template.from, context))
)
};
+30 -3
View File
@@ -4,6 +4,31 @@ const { Client, Attribute, Change } = require('ldapts');
const { LRUCache } = require('lru-cache');
const conf = require('@simpleworkjs/conf').ldap;
// Escape a value used inside an LDAP search filter (RFC 4515).
function escapeLDAPSearchValue(val) {
return String(val)
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns
// are normally alphanumeric, but this prevents metacharacter injection.
function escapeLDAPDNValue(val) {
return String(val)
.replace(/\\/g, '\\\\')
.replace(/,/g, '\\,')
.replace(/\+/g, '\\+')
.replace(/"/g, '\\"')
.replace(/</g, '\\<')
.replace(/>/g, '\\>')
.replace(/;/g, '\\;')
.replace(/=/g, '\\=')
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
}
function makeClient() {
return new Client({ url: conf.url });
}
@@ -19,7 +44,7 @@ async function withClient(fn) {
}
async function getGroups(client, member){
let memberFilter = member ? `(member=${member})`: ''
let memberFilter = member ? `(member=${escapeLDAPSearchValue(member)})`: ''
let groups = (await client.search(conf.groupBase, {
scope: 'sub',
@@ -35,7 +60,8 @@ async function getGroups(client, member){
}
async function addGroup(client, data){
await client.add(`cn=${data.name},${conf.groupBase}`, {
const safeName = escapeLDAPDNValue(data.name);
await client.add(`cn=${safeName},${conf.groupBase}`, {
cn: data.name,
member: data.owner,
description: data.description,
@@ -139,9 +165,10 @@ Group.get = async function(data){
}
return withClient(async (client) => {
const safeName = escapeLDAPSearchValue(data.name);
let group = (await client.search(conf.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(cn=${data.name}))`,
filter: `(&(objectClass=groupOfNames)(cn=${safeName}))`,
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
})).searchEntries[0];
+33 -4
View File
@@ -1,14 +1,43 @@
'use strict';
const conf = require('@simpleworkjs/conf');
const {setUpTable} = require('model-redis');
const { setUpTable } = require('model-redis');
// Keep model-redis for the ones not yet ported
const Table = setUpTable(conf.redis);
module.exports = Table;
require('./token');
const { Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken } = require('./token');
require('./verification');
require('./oauth_client');
require('./oauth_code');
require('./api_token');
const { init } = require('@simpleworkjs/orm');
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
storage: './config/inventory.sqlite',
logging: false
};
ormConf.redis = conf.redis;
console.log('[initORM] Starting ORM initialization...');
try {
await init({
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
}
module.exports.initORM = initORM;
+87 -31
View File
@@ -1,50 +1,106 @@
'use strict';
const Table = require('.');
const { Resource } = require('./resource');
const bcrypt = require('bcrypt');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const UUID = () => crypto.randomUUID();
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
access_token: 3600,
refresh_token: 2592000
};
class OAuthClient extends Table {
static _key = 'client_id';
static _keyMap = {
'client_id': {default: UUID, type: 'string'},
'client_secret_hash': {isRequired: true, type: 'string', isPrivate: true},
'name': {isRequired: true, type: 'string', min: 1, max: 255},
'description': {default: '', type: 'string'},
'redirect_uris': {default: [], type: 'object'},
'scopes': {default: ['openid', 'profile', 'email', 'groups'], type: 'object'},
'allowed_groups': {default: [], type: 'object'},
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
'created_by': {isRequired: true, type: 'string'},
'created_on': {default: function(){ return (new Date).getTime() }},
'is_valid': {default: true, type: 'boolean'},
}
class OAuthClient {
static async add(data) {
const raw_secret = UUID();
data.client_secret_hash = await bcrypt.hash(raw_secret, 10);
data.client_id = UUID();
const client = await this.create(data);
client._raw_secret = raw_secret;
return client;
const raw_secret = crypto.randomUUID();
const client_id = crypto.randomUUID();
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
// Generate a unique slug from the client name
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
// Ensure uniqueness by appending a suffix if needed
const existing = await Resource.list({ where: { slug } });
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
const r = await Resource.create({
id: client_id,
kind: 'oauth',
name: data.name,
slug: slug,
description: data.description || '',
owner: data.created_by,
metadata: {
client_secret_hash,
redirect_uris: data.redirect_uris || [],
scopes: data.scopes || ['openid', 'profile', 'email', 'groups'],
allowed_groups: data.allowed_groups || [],
token_lifetime: data.token_lifetime || { ...defaultLifetime }
}
});
r._raw_secret = raw_secret;
r.client_id = client_id;
return r;
}
static async get(client_id) {
let r;
try {
r = await Resource.get(client_id);
} catch (_) {
throw new Error('OAuthClient not found');
}
if (r.kind !== 'oauth') throw new Error('OAuthClient not found');
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
r.client_id = r.id;
r.client_secret_hash = r.metadata.client_secret_hash;
r.redirect_uris = r.metadata.redirect_uris || [];
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
r.allowed_groups = r.metadata.allowed_groups || [];
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
// Resource has no is_valid column; validity lives in metadata (absent = valid)
r.is_valid = r.metadata.is_valid !== false;
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
r.rotateSecret = async () => {
const raw_secret = crypto.randomUUID();
r.metadata.client_secret_hash = await bcrypt.hash(raw_secret, 10);
await r.update({ metadata: r.metadata });
return raw_secret;
};
// proxy update to handle metadata correctly
const originalUpdate = r.update.bind(r);
r.update = async (data) => {
if (data.redirect_uris !== undefined) r.metadata.redirect_uris = data.redirect_uris;
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
const updateData = { metadata: r.metadata };
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
return originalUpdate(updateData);
};
return r;
}
async verifySecret(secret) {
return bcrypt.compare(secret, this.client_secret_hash);
static async list() {
const resources = await Resource.list({ where: { kind: 'oauth' } });
return Promise.all(resources.map(r => this.get(r.id)));
}
async rotateSecret() {
const raw_secret = UUID();
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) });
return raw_secret;
static async listDetail() {
return this.list();
}
static async verifySecret(client_id, secret) {
const client = await this.get(client_id);
return client.verifySecret(secret);
}
}
OAuthClient.register();
module.exports = { OAuthClient };
+2 -1
View File
@@ -1,7 +1,8 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
// Shared base keyMap matching Token's schema so these behave as tokens
const tokenKeyMap = {
+183
View File
@@ -0,0 +1,183 @@
const { Model } = require('@simpleworkjs/orm');
const { Group } = require('./group_ldap');
class Resource extends Model {
static exposedMethods = [
{ method: 'search', route: 'resources', verb: 'get', args: { from: 'query' } },
{ method: 'getBySlug', route: 'resources/:slug', verb: 'get', args: { from: 'params', names: ['slug'] } },
{ method: 'getGraph', route: 'graph', verb: 'get' },
{ method: 'getMyAccess', route: 'me', verb: 'get', args: { from: 'user' } }
];
static async search(query) {
const graph = await this.getGraph();
let resources = graph.resources;
if (query.kind) {
resources = resources.filter(r => r.kind === query.kind);
}
if (query.group) {
const rgs = await ResourceGroup.list({ where: { groupCn: query.group } });
const allowedIds = new Set(rgs.map(rg => rg.resourceId));
resources = resources.filter(r => allowedIds.has(r.id));
}
if (query.parent) {
const parents = graph.resources.filter(r => r.slug === query.parent);
if (parents.length > 0) {
const parentId = parents[0].id;
const childIds = new Set(graph.edges.filter(e => e.parentId === parentId).map(e => e.childId));
resources = resources.filter(r => childIds.has(r.id));
} else {
resources = [];
}
}
return resources;
}
static async getBySlug(slug) {
const graph = await this.getGraph();
const resource = graph.resources.find(r => r.slug === slug);
if (!resource) {
let err = new Error('Resource not found');
err.status = 404;
throw err;
}
const parents = graph.edges.filter(e => e.childId === resource.id);
const children = graph.edges.filter(e => e.parentId === resource.id);
return {
...resource,
parents,
children
};
}
static async getGraph() {
const resources = await this.list();
const edges = await ResourceEdge.list();
// Convert to simple objects so we can mutate metadata properties safely
const resObjs = resources.map(r => {
const obj = r.toJSON ? r.toJSON() : { ...r };
obj.metadata = obj.metadata || {};
return obj;
});
// Bubble up production status: if any child is prod, parent is prod
const isProdCache = new Map();
function checkProd(resId, visited = new Set()) {
if (isProdCache.has(resId)) return isProdCache.get(resId);
if (visited.has(resId)) return false; // Cycle prevention
visited.add(resId);
const r = resObjs.find(x => x.id === resId);
if (!r) return false;
// If intrinsically prod, return true
if (r.metadata.isProduction) {
isProdCache.set(resId, true);
return true;
}
// Check children
const childrenIds = edges.filter(e => e.parentId === resId).map(e => e.childId);
for (const cid of childrenIds) {
if (checkProd(cid, visited)) {
isProdCache.set(resId, true);
return true;
}
}
isProdCache.set(resId, false);
return false;
}
resObjs.forEach(r => {
r.metadata.isProduction = checkProd(r.id);
});
return { resources: resObjs, edges };
}
static async getMyAccess(userDn) {
const userGroups = await Group.list(userDn);
if (!userGroups || userGroups.length === 0) return [];
const resourceGroups = await ResourceGroup.list({
where: { groupCn: { in: userGroups } }
});
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
if (resourceIds.length === 0) return [];
const resources = await this.list({ where: { id: { in: resourceIds } } });
// Resolve inherited addresses from the graph
const graph = await this.getGraph();
function resolveHost(resId, visited = new Set()) {
if (visited.has(resId)) return null; // prevent cycles
visited.add(resId);
const res = graph.resources.find(r => r.id === resId);
if (!res) return null;
if (res.metadata && res.metadata.address) return res.metadata.address;
if (res.metadata && res.metadata.ip) return res.metadata.ip;
const parentEdges = graph.edges.filter(e => e.childId === resId);
for (const edge of parentEdges) {
const found = resolveHost(edge.parentId, visited);
if (found) return found;
}
return null;
}
return resources.map(r => {
const data = { ...r };
data.metadata = data.metadata || {};
data.resolvedAddress = resolveHost(r.id);
return data;
});
}
static fields = {
id: { type: 'uuid', primaryKey: true },
kind: { type: 'string', isRequired: true },
name: { type: 'string', isRequired: true },
slug: { type: 'string', isRequired: true, unique: true },
owner: { type: 'string' },
description: { type: 'text' },
metadata: { type: 'json', default: {} },
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
};
}
class ResourceEdge extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
parent: { type: 'hasOne', model: 'Resource' }, // Creates parentId
child: { type: 'hasOne', model: 'Resource' }, // Creates childId
relation: { type: 'string', isRequired: true }
};
}
class ResourceGroup extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
resource: { type: 'hasOne', model: 'Resource' }, // Creates resourceId
groupCn: { type: 'string', isRequired: true },
accessLevel: { type: 'string', isRequired: true }
};
}
module.exports = {
Resource,
ResourceEdge,
ResourceGroup
};
+36 -38
View File
@@ -1,21 +1,17 @@
'use strict';
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
const { Model } = require('@simpleworkjs/orm');
const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
class Token extends Table{
static _key = 'token';
static _keyMap = {
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
super(...args);
class Token extends Model {
static adapterName = 'redis';
static fields = {
token: { type: 'string', primaryKey: true, default: UUID, isPrivate: true, min: 36, max: 36 },
created_by: { isRequired: true, type: 'string', min: 3, max: 500 },
created_on: { type: 'integer', default: function(){return (new Date).getTime()} },
updated_on: { type: 'integer', default: function(){return (new Date).getTime()}, always: true },
is_valid: { default: true, type: 'boolean' }
}
async check(){
@@ -27,12 +23,10 @@ class Token extends Table{
}
}
Token.register();
class AuthToken extends Token{
static _keyMap = {
...super._keyMap,
user: {model: 'User', rel: 'one', localKey: 'created_by'},
static fields = {
...Token.fields,
user: {model: 'User', type: 'hasOne', localKey: 'created_by'},
}
static async create(data){
@@ -41,11 +35,10 @@ class AuthToken extends Token{
}
}
AuthToken.register();
class InviteToken extends Token{
static _keyMap = {
...super._keyMap,
static fields = {
...Token.fields,
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
mail: {default: '__NONE__', type: 'string'},
mail_token: {default: '__NONE__', type: 'string'},
@@ -67,14 +60,13 @@ class InviteToken extends Token{
}
}
}
InviteToken.register();
class ImpersonationToken extends Token {
static _keyMap = {
...super._keyMap,
static fields = {
...Token.fields,
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'number'},
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'integer'},
}
get isExpired() {
@@ -86,42 +78,48 @@ class ImpersonationToken extends Token {
return this.create(data);
}
}
ImpersonationToken.register();
class PasswordResetToken extends Token {}
PasswordResetToken.register();
class OtpToken extends Token {
static _keyMap = {
...Token._keyMap,
static fields = {
...Token.fields,
uid: {isRequired: true, type: 'string'},
code: {isRequired: true, type: 'string'},
method: {isRequired: true, type: 'string'},
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'number'},
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'integer'},
};
get isExpired() {
return (new Date).getTime() > this.expires_at;
}
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
static async issue(uid, method) {
const existing = await this.listDetail({uid});
const existing = await this.list({where: {uid}});
for (const t of existing) {
if (t.is_valid) await t.update({is_valid: false});
}
const code = String(Math.floor(100000 + Math.random() * 900000));
const code = String(crypto.randomInt(100000, 1000000));
return this.create({uid, code, method, created_by: uid});
}
static async verify(uid, code) {
const tokens = await this.listDetail({uid});
const tokens = await this.list({where: {uid}});
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
if (!match) return null;
await match.update({is_valid: false});
return match;
}
}
OtpToken.register();
class ServiceToken extends Token {
static fields = {
...Token.fields,
resource_id: {isRequired: true, type: 'string'}
}
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken};
static async issue(resource_id, created_by) {
return this.create({resource_id, created_by});
}
}
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken};
+41 -5
View File
@@ -45,6 +45,20 @@ function escapeLDAPSearchValue(val) {
.replace(/\0/g, '\\00');
}
// Escape a value used in an LDAP DN (RFC 4514).
function escapeLDAPDNValue(val) {
return String(val)
.replace(/\\/g, '\\\\')
.replace(/,/g, '\\,')
.replace(/\+/g, '\\+')
.replace(/"/g, '\\"')
.replace(/</g, '\\<')
.replace(/>/g, '\\>')
.replace(/;/g, '\\;')
.replace(/=/g, '\\=')
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
}
// Compute the next available uid/gidNumber: the highest existing value below
// conf.uidGidReservedFloor, plus one -- or conf.uidGidMin if there are no
// such entries yet. Entries at/above the reserved floor (e.g. a bootstrap
@@ -72,7 +86,8 @@ async function addPosixGroup(client, data){
data.gidNumber = nextPosixId(groups, 'gidNumber');
await client.add(`cn=${data.cn},${conf.groupBase}`, {
const safeCn = escapeLDAPDNValue(data.cn);
await client.add(`cn=${safeCn},${conf.groupBase}`, {
cn: data.cn,
gidNumber: data.gidNumber,
objectclass: [ 'posixGroup', 'top' ]
@@ -94,6 +109,7 @@ async function addPosixAccount(client, data){
data.uidNumber = nextPosixId(people, 'uidNumber');
const safeCn = escapeLDAPDNValue(data.cn);
const entry = {
cn: data.cn,
sn: data.sn,
@@ -130,6 +146,10 @@ async function addPosixAccount(client, data){
entry.dateOfBirth = data.dob;
}
if (data.location) {
entry.l = data.location;
}
// userPassword is optional -- a service account with no password set
// simply can't bind (no special enforcement needed, that's the default
// LDAP simple-bind behavior for an entry lacking the attribute).
@@ -143,7 +163,7 @@ async function addPosixAccount(client, data){
entry.manager = [].concat(data.manager);
}
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
await client.add(`cn=${safeCn},${conf.userBase}`, entry);
return data
@@ -171,7 +191,6 @@ async function addLdapUser(client, data){
delete data.userPassword;
}
console.log('addLdapUser', data)
group = await addPosixGroup(client, data);
data = await addPosixAccount(client, group);
@@ -206,6 +225,7 @@ const user_parse = function(data){
data.username = data[conf.userNameAttribute]
data.userPassword = undefined;
}
data.location = data.l ? String(data.l) : '';
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
@@ -504,6 +524,16 @@ User.update = async function(data){
this.dateOfBirth = data.dateOfBirth;
}
if(data.location !== undefined){
await client.modify(this.dn, [
new Change({
operation: 'replace',
modification: new Attribute({ type: 'l', values: [data.location] }),
}),
]);
this.location = data.location;
}
if(data.manager !== undefined){
// Client sends uids; resolve each to a DN before writing --
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
@@ -799,7 +829,7 @@ User.addSSHkey = async function(data) {
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
function personalGroupDN(uid){
return `cn=${uid},${conf.groupBase}`;
return `cn=${escapeLDAPDNValue(uid)},${conf.groupBase}`;
}
User.getPersonalGroupMembers = async function(uid) {
@@ -861,6 +891,12 @@ User.invite = async function(data = {}){
User.login = async function(data){
try{
if (!data.uid && !data.username) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
let user = await this.get(data.uid || data.username);
const loginClient = makeClient();
@@ -873,7 +909,7 @@ User.login = async function(data){
return user;
}catch(error){
console.error("USER LOGIN error:", error);
console.error("USER LOGIN error:", error.name, error.message);
throw error;
}
};
+1609 -350
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -1,7 +1,7 @@
{
"name": "t42-sso-manager",
"version": "1.1.13",
"private": true,
"version": "1.3.1",
"description": "A very simple LDAP management and SSO system",
"author": [
{
"name": "William Mantly",
@@ -23,7 +23,8 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.1.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
@@ -31,18 +32,19 @@
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
"jq-repeat": "^2.1.0",
"jq-repeat": "^2.2.0",
"jquery": "^3.7.1",
"jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.2",
"lru-cache": "^11.5.1",
"marked": "^9.1.6",
"model-redis": "^0.4.0",
"model-redis": "^1.6.0",
"moment": "^2.30.1",
"mustache": "^4.2.0",
"nodemailer": "^9.0.0",
"p2psub": "^0.2.0",
"socket.io": "^4.8.3"
"socket.io": "^4.8.3",
"xss": "^1.0.15"
},
"license": "MIT",
"repository": {
+227
View File
@@ -0,0 +1,227 @@
'use strict';
const router = require('express').Router();
const permission = require('../utils/permission');
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { Group } = require('../models/group_ldap');
// Require the admin group
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
// --- Resources ---
router.get('/resources', async (req, res, next) => {
try {
const resources = await Resource.list();
res.json({ results: resources });
} catch (err) { next(err); }
});
router.post('/resources', async (req, res, next) => {
try {
if (!req.body.hostId && req.body.parentSlug) {
const parents = await Resource.list({ where: { slug: req.body.parentSlug } });
if (parents.length > 0) req.body.hostId = parents[0].id;
}
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
if (req.body.kind === 'service' && !req.body.hostId) {
return res.status(400).json({ error: 'Services must have a parent Host' });
}
if (req.body.kind === 'oauth' && !req.body.hostId) {
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
}
req.body.owner = req.body.owner || req.user.uid;
let r;
if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client');
// Pass created_by explicitly for the wrapper
req.body.created_by = req.body.owner;
// In the UI we might pass slug, but OAuthClient wrapper expects name
r = await OAuthClient.add(req.body);
} else {
r = await Resource.create(req.body);
}
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
}
if (r.kind === 'host' || r.kind === 'service') {
const createGroup = async (suffix, accessLevel) => {
const cn = `${r.slug}_${suffix}`;
try {
await Group.add({
name: cn,
owner: req.user.dn,
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
});
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`Failed to create LDAP group ${cn}:`, err);
}
}
try {
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
} catch(err) { /* ignore duplicate links */ }
};
await createGroup('access', 'member');
await createGroup('admin', 'owner');
}
res.json({ results: r });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A resource with this slug already exists.' });
}
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.put('/resources/:id', async (req, res, next) => {
try {
let r;
if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client');
r = await OAuthClient.get(req.params.id);
} else {
r = await Resource.get(req.params.id);
}
if (!r) return res.status(404).json({ error: 'Not found' });
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
if (req.body.kind === 'service' && !req.body.hostId) {
return res.status(400).json({ error: 'Services must have a parent Host' });
}
if (req.body.kind === 'oauth' && !req.body.hostId) {
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
}
let updated;
if (req.body.kind === 'oauth') {
updated = await r.update(req.body);
} else {
updated = await r.update(req.body);
}
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
for (const e of existingEdges) {
if (e.relation === 'hosts' || e.relation === 'oauth') await e.delete();
}
if (req.body.hostId) {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: updated.kind === 'oauth' ? 'oauth' : 'hosts' });
}
}
res.json({ results: updated });
} catch (err) {
next(err);
}
});
router.post('/resources/:id/rotate-secret', async (req, res, next) => {
try {
const { OAuthClient } = require('../models/oauth_client');
const client = await OAuthClient.get(req.params.id);
const secret = await client.rotateSecret();
res.json({ secret });
} catch (err) {
next(err);
}
});
router.delete('/resources/:id', async (req, res, next) => {
try {
const r = await Resource.get(req.params.id);
if (!r) return res.status(404).json({ error: 'Not found' });
await r.delete();
// Also delete edges and groups involving this resource
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
for (const g of groups) await g.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Edges ---
router.get('/edges', async (req, res, next) => {
try {
const edges = await ResourceEdge.list();
res.json({ results: edges });
} catch (err) { next(err); }
});
router.post('/edges', async (req, res, next) => {
try {
const edge = await ResourceEdge.create(req.body);
res.json({ results: edge });
} catch (err) { next(err); }
});
router.delete('/edges/:id', async (req, res, next) => {
try {
const edge = await ResourceEdge.get(req.params.id);
if (!edge) return res.status(404).json({ error: 'Not found' });
await edge.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Groups ---
router.get('/groups', async (req, res, next) => {
try {
const groups = await ResourceGroup.list();
res.json({ results: groups });
} catch (err) { next(err); }
});
router.post('/groups', async (req, res, next) => {
try {
const g = await ResourceGroup.create(req.body);
res.json({ results: g });
} catch (err) { next(err); }
});
router.delete('/groups/:id', async (req, res, next) => {
try {
const g = await ResourceGroup.get(req.params.id);
if (!g) return res.status(404).json({ error: 'Not found' });
await g.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
router.get('/audit-logs', async (req, res, next) => {
try {
const fs = require('fs');
const { execSync } = require('child_process');
let ldapLogs = '';
let oauthLogs = '';
let auditLogs = '';
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
} catch (err) { next(err); }
});
module.exports = router;
+36
View File
@@ -0,0 +1,36 @@
'use strict';
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
// GET /api/discovery/me
// Returns the list of resources the current user has access to.
router.get('/me', async (req, res, next) => {
try {
const userGroups = req.user.groups || []; // array of LDAP group CNs
const accessibleResourceIds = new Set();
if (req.user.isMachine) {
// Machines only have access to themselves by default
accessibleResourceIds.add(req.resourceId);
} else {
// End users get access via groups
const allGroups = await ResourceGroup.list();
for (const rg of allGroups) {
if (userGroups.includes(rg.groupCn)) {
accessibleResourceIds.add(rg.resourceId);
}
}
}
// Fetch all resources and filter
const allResources = await Resource.list();
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
res.json({ results: accessible });
} catch (err) {
next(err);
}
});
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const router = require('express').Router();
const permission = require('../utils/permission');
const metrics = require('../utils/metrics');
// /api/metrics/executive
router.get('/executive', async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const topIps = await metrics.getTopN('metrics:failed_ips', 7, 5);
const topUsers = await metrics.getTopN('metrics:failed_users', 7, 5);
const topServices = await metrics.getTopN('metrics:service_usage', 7, 5);
res.json({ results: { ips: topIps, users: topUsers, services: topServices } });
} catch(e) {
next(e);
}
});
// /api/metrics/user/:uid
router.get('/user/:uid', async (req, res, next) => {
try {
// Can only view if admin or self
if (req.user.uid !== req.params.uid) {
await permission.byGroup(req.user, ['app_sso_admin']);
}
// Failed logins for user is hard if we didn't track it by user, but wait, we did! metrics:failed_users:YYYY-MM-DD
// However, we didn't track failed IPs per user. We tracked failed_users as a sorted set.
// To get the user's failures, we just query their score from the union.
// Wait, for services we have user_service_usage:<uid>:<date>. No, in metrics.js I wrote:
// `metrics:user_service_usage:${username}:${date}`
const topServices = await metrics.getTopN('metrics:user_service_usage', 7, 5, req.params.uid);
res.json({ results: { services: topServices } });
} catch(e) {
next(e);
}
});
module.exports = router;
+7 -2
View File
@@ -13,6 +13,7 @@ const middleware = require('../middleware/auth');
const rateLimit = require('../middleware/rate_limit');
const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf');
const metrics = require('../utils/metrics');
async function findUserByLogin(login) {
try {
@@ -37,12 +38,16 @@ router.get('/username-suggestions', async function(req, res, next) {
router.post('/login', rateLimit.login, async function(req, res, next){
try{
let auth = await Auth.login(req.body);
metrics.recordServiceUsage('SSO Web UI', req.body.uid);
return res.json({
login: true,
token: auth.token.token,
message:`${req.body.uid} logged in!`,
});
}catch(error){
if (error.name === 'LDAPLoginFailed' || error.status === 401 || error.name === 'UserNotFound') {
metrics.recordFailedLogin(req.ip, req.body.uid);
}
next(error);
}
});
@@ -198,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
const target = await User.get(req.params.uid);
// Clean up any existing impersonation for this target
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
for (const old of existing) {
if (old.is_valid && !old.isExpired) {
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
@@ -232,7 +237,7 @@ router.delete('/impersonate/:uid', middleware.auth, async function(req, res, nex
await permission.byGroup(req.user, ['app_sso_admin']);
const target = await User.get(req.params.uid);
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
let revoked = 0;
for (const token of existing) {
+42
View File
@@ -0,0 +1,42 @@
const express = require('express');
// Parses arguments according to the exposed method config.
// Extended to support { from: 'user' } which injects `req.user.dn` (LDAP integration).
function extractArgs(req, cfg) {
const args = cfg.args;
if (!args) return [];
if (args.from === 'user') return [req.user.dn];
const source = args.from === 'params' ? req.params
: args.from === 'query' ? req.query
: req.body;
if (Array.isArray(args.names)) return args.names.map(name => source[name]);
return [source || {}];
}
// A mini-auto-router that reads `static exposedMethods` from a @simpleworkjs/orm Model
// and maps them directly into Express endpoints.
function autoRouter(Model) {
const router = express.Router();
if (Model.getExposedMethods) {
for (const cfg of Model.getExposedMethods()) {
router[cfg.verb](cfg.routePath, async function(req, res, next) {
try {
// In a full implementation, we'd load the instance if cfg.kind === 'instance'.
// For now, our methods are all static class methods.
const target = Model;
const result = await target[cfg.method](...extractArgs(req, cfg));
res.json(result);
} catch (error) {
next(error);
}
});
}
}
return router;
}
module.exports = autoRouter;
+4
View File
@@ -0,0 +1,4 @@
const autoRouter = require('./autoRouter');
const { Resource } = require('../models/resource');
module.exports = autoRouter(Resource);
+3 -1
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path');
const router = require('express').Router();
const {marked} = require('marked');
const xss = require('xss');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const rateLimit = require('../middleware/rate_limit');
@@ -32,6 +33,7 @@ const DOCS = {
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
@@ -131,7 +133,7 @@ router.get('/:slug', function(req, res, next) {
docs: docList,
currentSlug: req.params.slug,
docTitle: doc.title,
docHtml: fixDocLinks(fixImagePaths(marked(content))),
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
});
} catch (error) {
next(error);
+29 -11
View File
@@ -5,6 +5,7 @@ var express = require('express');
var router = express.Router();
const moment = require('moment');
const {marked} = require('marked');
const xss = require('xss');
const {InviteToken, PasswordResetToken} = require('./../models/token');
const {Tos} = require('../models/tos');
const conf = require('@simpleworkjs/conf');
@@ -46,7 +47,7 @@ router.get('/health', function(req, res) {
router.get('/tos', async function(req, res, next) {
try {
const tos = await Tos.getCurrent();
res.render('tos', {...values, tosHtml: marked(tos.content), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
res.render('tos', {...values, tosHtml: xss(marked(tos.content)), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
} catch (error) {
next(error);
}
@@ -54,27 +55,34 @@ router.get('/tos', async function(req, res, next) {
// Admin dashboard (stats + recent/inactive users) and Notifications
// (broadcast + history) merged into one page.
router.get('/dashboard', function(req, res) {
res.render('dashboard', {...values});
router.get('/executive', function(req, res) {
res.render('executive', {...values});
});
router.get('/admin', (req, res) => res.redirect(301, '/dashboard'));
router.get('/notifications', (req, res) => res.redirect(301, '/dashboard'));
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
router.get('/invites', function(req, res) {
res.render('invites', {...values});
router.get('/directory', function(req, res) {
res.render('directory', {...values});
});
// Route removed since it's now in directory
router.get('/onboarding', async function(req, res, next) {
try {
const tos = await Tos.getCurrent();
res.render('onboarding', {...values, tosHtml: marked(tos.content)});
res.render('onboarding', {...values, tosHtml: xss(marked(tos.content))});
} catch (error) {
next(error);
}
});
router.get('/', async function(req, res, next) {
res.render('landing', {...values});
});
router.get('/profile', async function(req, res, next) {
res.render('profile', {...values});
});
@@ -92,7 +100,14 @@ router.get('/login', async function(req, res, next) {
// hardcoded in a doc, so they're always right for *this* deployment.
router.get('/integrations', function(req, res, next) {
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
const ldapHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
// The public-facing host (from the OAuth issuer). Used for OIDC links.
const issuerHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
// The hostname advertised for direct LDAPS binds may be a separate,
// internal-only name so admins don't have to port-forward 636 publicly.
// Defaults to the issuer host to preserve prior behavior.
const ldapsHost = (conf.ldap && conf.ldap.ldapsHost) || issuerHost;
const ldapsPort = Number((conf.ldap && conf.ldap.ldapsPort) || 636) || 636;
const userBase = (conf.ldap && conf.ldap.userBase) || 'ou=people,dc=example,dc=com';
const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com';
@@ -105,8 +120,9 @@ router.get('/integrations', function(req, res, next) {
...values,
issuer,
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
ldapHost,
ldapsUrl: `ldaps://${ldapHost}:636`,
ldapHost: ldapsHost,
ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
baseDn,
userBase,
groupBase,
@@ -137,6 +153,8 @@ router.get('/token', function(req, res, next) {
});
router.get('/login/resetpassword/:token', async function(req, res, next){
let token = await PasswordResetToken.get(req.params.token);
Binary file not shown.
+4 -1
View File
@@ -45,8 +45,11 @@ router.post('/', async function(req, res, next) {
const client = await OAuthClient.add(req.body);
const result = client.toJSON ? client.toJSON() : { ...client };
result.client_id = client.client_id || client.id;
return res.json({
results: client,
results: result,
client_secret: client._raw_secret,
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
});
+10 -4
View File
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
router.get('/:name', async function(req, res, next){
try{
// ORM models: list() on the redis adapter always returns full rows;
// detail is handled by serialization (isPrivate fields are excluded).
return res.json({
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
results: await tokens[req.params.name].list()
});
}catch(error){
next(error);
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
router.get('/:name/:token', async function(req, res, next){
try{
return res.json({
results: await tokens[req.params.name].get(req.params.token)
});
const result = await tokens[req.params.name].get(req.params.token);
if (!result) {
const error = new Error('Token not found');
error.status = 404;
throw error;
}
return res.json({ results: result });
}catch(error){
next(error);
}
+1 -1
View File
@@ -231,7 +231,7 @@ router.get('/invite', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
const all = await InviteToken.listDetail();
const all = await InviteToken.list();
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
const results = visible.map(t => ({ token: t.token, ...t }));
return res.json({ results });
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const fs = require('fs');
const { spawn } = require('child_process');
const metrics = require('../utils/metrics');
function startLdapMonitor() {
const logFile = '/var/lib/ldap/slapd.log';
if (!fs.existsSync(logFile)) {
setTimeout(startLdapMonitor, 5000);
return;
}
const tail = spawn('tail', ['-F', logFile]);
const connections = {}; // connID -> { ip, uid }
tail.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const connMatch = line.match(/conn=(\d+)/);
if (!connMatch) continue;
const conn = connMatch[1];
if (!connections[conn]) {
connections[conn] = {};
}
const ipMatch = line.match(/ACCEPT from IP=([^:]+)/);
if (ipMatch) {
connections[conn].ip = ipMatch[1];
}
const bindMatch = line.match(/BIND dn="uid=([^,]+)/i) || line.match(/BIND dn="cn=([^,]+)/i);
if (bindMatch) {
connections[conn].uid = bindMatch[1];
}
const resultMatch = line.match(/RESULT tag=\d+ err=(\d+)/);
if (resultMatch) {
const errCode = parseInt(resultMatch[1], 10);
const { ip, uid } = connections[conn];
if (errCode === 0 && uid) {
metrics.recordServiceUsage('LDAP Direct', uid);
} else if (errCode === 49 || errCode === 32) {
metrics.recordFailedLogin(ip, uid);
}
}
if (line.includes('closed') || line.includes('UNBIND')) {
delete connections[conn];
}
}
});
tail.on('error', (err) => {
console.error('Failed to start LDAP monitor', err);
});
}
startLdapMonitor();
+47
View File
@@ -0,0 +1,47 @@
const { init } = require('@simpleworkjs/orm');
const { Resource, ResourceEdge, ResourceGroup } = require('./models/resource');
async function test() {
try {
const models = await init({
conf: {
orm: {
dialect: 'sqlite',
storage: ':memory:', // Test in memory
logging: false
}
},
models: [Resource, ResourceEdge, ResourceGroup]
});
console.log('ORM initialized successfully!');
const r1 = await models.Resource.create({
kind: 'proxmox_node',
name: 'pve1',
slug: 'pve1',
metadata: { ip: '10.0.0.1' }
});
const r2 = await models.Resource.create({
kind: 'container',
name: 'ct101',
slug: 'ct101',
metadata: { ip: '10.0.0.2' }
});
await models.ResourceEdge.create({
parentId: r1.id,
childId: r2.id,
relation: 'hosts'
});
const edges = await models.ResourceEdge.list();
console.log('Edges:', JSON.stringify(edges, null, 2));
} catch (err) {
console.error('Failed:', err);
}
}
test();
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const request = require('supertest');
const app = require('../app');
// Note: To test this properly, valid LDAP credentials are required in setup.js
// Currently tests are skipped or rely on valid auth token to avoid LDAP auth failures
describe.skip('Directory Admin API', () => {
let token;
beforeAll(async () => {
// A valid admin token is required
token = 'placeholder_token';
});
test('POST /api/directory-admin/resources requires hostId for services', async () => {
const res = await request(app)
.post('/api/directory-admin/resources')
.set('auth-token', token)
.send({
name: 'Test Service',
slug: 'app_test_service',
kind: 'service'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('parent Host');
});
test('POST /api/directory-admin/resources creates valid service with parent', async () => {
// This requires a valid host ID to exist first in a real test
const res = await request(app)
.post('/api/directory-admin/resources')
.set('auth-token', token)
.send({
name: 'Test Service',
slug: 'app_test_service',
kind: 'service',
hostId: 'some-uuid-here'
});
// In a fully mocked environment this would be 200
expect(res.status).toBe(200);
});
});
+3 -2
View File
@@ -2,10 +2,11 @@
// Flush all test-prefix Redis keys before each test run so state is always clean.
// Uses model-redis's own bundled redis client since redis is not a top-level dep.
const { createClient } = require('../node_modules/model-redis/node_modules/redis');
const { createClient } = require('redis');
module.exports = async function() {
const client = createClient();
const redisUrl = process.env.REDIS_URL || undefined;
const client = createClient(redisUrl ? { url: redisUrl } : {});
await client.connect();
const keys = await client.keys('sso_manager_test_*');
-112
View File
@@ -1,112 +0,0 @@
'use strict';
const { login, request, app } = require('./setup');
const TEST_CLIENT = {
name: 'Test Client',
description: 'Created by automated tests',
redirect_uris: 'https://test.example.com/callback',
scopes: 'openid profile email',
token_lifetime: { access_token: 3600, refresh_token: 86400 },
};
let token;
let clientId;
let clientSecret;
beforeAll(async () => {
token = await login();
});
afterAll(async () => {
if (clientId) {
await request(app)
.delete(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
}
});
describe('OAuth Clients — POST /api/oauth/client/', () => {
test('creates a new client and returns one-time secret', async () => {
const res = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send(TEST_CLIENT);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results');
expect(res.body).toHaveProperty('client_secret');
expect(res.body.results).toHaveProperty('client_id');
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
expect(res.body.results.client_id.length).toBeGreaterThan(0);
clientId = res.body.results.client_id;
clientSecret = res.body.client_secret;
});
test('requires oauth_admin group — 401 not shown here (see group membership)', () => {
// If test user is not in app_sso_oauth_admin, the test above will fail with 401.
// That itself is the correct behavior to verify.
expect(clientId).toBeDefined();
});
});
describe('OAuth Clients — GET /api/oauth/client/', () => {
test('lists clients including the test client', async () => {
const res = await request(app)
.get('/api/oauth/client/')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
const found = res.body.results.find(c => c.client_id === clientId);
expect(found).toBeDefined();
});
});
describe('OAuth Clients — GET /api/oauth/client/:id', () => {
test('returns the test client by id', async () => {
const res = await request(app)
.get(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toHaveProperty('client_id', clientId);
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
});
test('unknown client_id returns 404 or error', async () => {
const res = await request(app)
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('OAuth Clients — PUT /api/oauth/client/:id', () => {
test('updates the client description', async () => {
const res = await request(app)
.put(`/api/oauth/client/${clientId}`)
.set('auth-token', token)
.send({ description: 'Updated by test' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
});
describe('OAuth Clients — POST /api/oauth/client/:id/rotate', () => {
test('rotates the client secret and returns a new one', async () => {
const res = await request(app)
.post(`/api/oauth/client/${clientId}/rotate`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('client_secret');
expect(typeof res.body.client_secret).toBe('string');
expect(res.body.client_secret).not.toBe(clientSecret);
clientSecret = res.body.client_secret;
});
});
+13
View File
@@ -3,6 +3,19 @@
const crypto = require('crypto');
const request = require('supertest');
const app = require('../app');
const { initORM } = require('../models');
beforeAll(async () => {
await initORM();
const { Token } = require('../models/token');
try {
if (Token.orm) {
await Token.orm.adapter(Token).Table.redisClient.flushDb();
}
} catch (e) {
console.warn('Could not flush Redis:', e.message);
}
});
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
+95
View File
@@ -0,0 +1,95 @@
'use strict';
const { createClient } = require('redis');
const conf = require('@simpleworkjs/conf');
let client;
async function getClient() {
if (!client) {
// conf.redis could be an object or a connection string depending on @simpleworkjs/conf
// But for sso-manager, Redis runs locally or is configured via environment
// The tests use createClient() with no args, so we do the same, allowing env vars to override
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis : (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
client = createClient({ url });
client.on('error', (err) => console.error('Redis metrics error', err));
await client.connect();
}
return client;
}
function getTodayKey() {
return new Date().toISOString().split('T')[0];
}
async function recordFailedLogin(ip, username) {
try {
const c = await getClient();
const date = getTodayKey();
const p = c.multi();
if (ip) {
p.zIncrBy(`metrics:failed_ips:${date}`, 1, ip);
p.expire(`metrics:failed_ips:${date}`, 30 * 86400);
}
if (username) {
p.zIncrBy(`metrics:failed_users:${date}`, 1, username);
p.expire(`metrics:failed_users:${date}`, 30 * 86400);
}
await p.exec();
} catch(e) {
console.error('Failed to record failed login metric', e);
}
}
async function recordServiceUsage(serviceName, username) {
try {
const c = await getClient();
const date = getTodayKey();
const p = c.multi();
if (serviceName) {
p.zIncrBy(`metrics:service_usage:${date}`, 1, serviceName);
p.expire(`metrics:service_usage:${date}`, 30 * 86400);
if (username) {
p.zIncrBy(`metrics:user_service_usage:${username}:${date}`, 1, serviceName);
p.expire(`metrics:user_service_usage:${username}:${date}`, 30 * 86400);
}
}
await p.exec();
} catch(e) {
console.error('Failed to record service usage metric', e);
}
}
// Helper to aggregate the last N days of a metric prefix
async function getTopN(prefix, days, topN, user = null) {
try {
const c = await getClient();
const keys = [];
const today = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
if (user) {
keys.push(`${prefix}:${user}:${dateStr}`);
} else {
keys.push(`${prefix}:${dateStr}`);
}
}
const tempKey = `metrics:temp:union:${Date.now()}:${Math.floor(Math.random() * 1000000)}`;
// ZUNIONSTORE is replaced by ZUNIONSTORE in redis v4 Node client, usually zUnionStore
await c.zUnionStore(tempKey, keys.length, keys);
const results = await c.zRangeWithScores(tempKey, 0, topN - 1, { REV: true });
await c.del(tempKey);
return results.map(r => ({ value: r.value, score: r.score }));
} catch(e) {
console.error('Failed to get top N metrics', e);
return [];
}
}
module.exports = {
recordFailedLogin,
recordServiceUsage,
getTopN
};
-442
View File
@@ -1,442 +0,0 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin('app_sso_admin');
// ── Overview (stats, recent signups, inactive users) ────────────────────
async function loadDashboard() {
try {
const stats = await app.api.get('user/stats');
// Stat cards
document.getElementById('stat-total').textContent = stats.totalUsers;
document.getElementById('stat-active').textContent = stats.activeUsers;
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
document.getElementById('stat-groups').textContent = stats.totalGroups;
// Recent signups
stats.recentSignups.forEach(function(u) {
u.createTimestamp = moment(u.createTimestamp, 'YYYYMMDDHHmmssZ').fromNow();
});
$.scope.recentSignups.push(...stats.recentSignups);
// Inactive users
$.scope.inactiveUsers.push(...stats.inactiveList);
document.getElementById('dashboard-overview').style.display = '';
} catch(e) {
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
location.replace('/');
} else {
console.error('Dashboard load error:', e);
}
}
}
async function activateUser(uid) {
try {
await app.api.put('user/' + uid + '/active', { active: true });
$.scope.inactiveUsers.remove('uid', uid);
document.getElementById('stat-inactive').textContent =
parseInt(document.getElementById('stat-inactive').textContent) - 1;
document.getElementById('stat-active').textContent =
parseInt(document.getElementById('stat-active').textContent) + 1;
} catch(e) {
alert('Failed to activate user.');
}
}
async function exportUsers() {
const resp = await fetch('/api/user/export', {
headers: { 'auth-token': localStorage.getItem('APIToken') }
});
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'users.csv';
a.click();
}
// ── Notifications (compose + history) ────────────────────────────────────
async function loadHistory() {
try {
const data = await app.api.get('notification');
const list = data.results || [];
list.forEach(function(n) {
n.created_on_fmt = moment(n.created_on).fromNow();
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
});
$.scope.notificationHistory.push(...list);
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load notification history:', e);
}
}
function formatFilterLabel(type, value, active_only) {
const suffix = active_only ? ' (active only)' : '';
if (type === 'group') return 'Groups: ' + value + suffix;
if (type === 'users') return 'Specific users';
if (type === 'all_active') return 'All active users';
if (type === 'all') return 'All users';
return type;
}
function toggleFilterInputs() {
// No radio is checked by default (see f-all-active below) — a "send to
// everyone" option must be a deliberate choice, not whatever happens to
// be pre-selected when someone's just trying the form out.
const checked = document.querySelector('input[name="notif-filter"]:checked');
const type = checked ? checked.value : null;
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
}
async function sendNotification() {
const subject = document.getElementById('notif-subject').value.trim();
const message = document.getElementById('notif-message').value.trim();
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
const groupValue = document.getElementById('notif-group').value.trim();
const usersValue = document.getElementById('notif-users').value.trim();
const activeOnly = document.getElementById('notif-active-only').checked;
const msgEl = document.getElementById('notif-result');
const $compose = $('#notif-subject').closest('.card-body');
if (!subject || !message) { alert('Subject and message are required.'); return; }
if (!filterCheck) { alert('Choose who to send this to.'); return; }
const filterType = filterCheck.value;
let filter_value = '';
if (filterType === 'group') filter_value = groupValue;
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
// Broadcasting to everyone is easy to trigger by accident while just
// trying the form out — make it a deliberate, confirmed action.
if (filterType === 'all' || filterType === 'all_active') {
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
if (!confirmed) return;
}
msgEl.className = 'alert alert-info mt-2';
msgEl.textContent = 'Sending…';
msgEl.style.display = '';
try {
const result = await app.api.post('notification', {
subject,
message,
filter_type: filterType,
filter_value,
active_only: activeOnly,
});
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
const n = result.results;
n.created_on_fmt = 'just now';
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
$.scope.notificationHistory.unshift([n]);
// Reset form
document.getElementById('notif-subject').value = '';
document.getElementById('notif-message').value = '';
} catch(e) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
}
}
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
});
</script>
<div class="row mb-3 mt-2">
<div class="col-12">
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Dashboard</h4>
</div>
</div>
<div id="dashboard-overview" class="row" style="display:none">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="fa-solid fa-users"></i> Overview</h5>
<button class="btn btn-outline-secondary shadow" onclick="exportUsers()">
<i class="fa-solid fa-file-csv"></i> Export Users CSV
</button>
</div>
<!-- Stat cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow text-center">
<div class="card-body">
<div class="display-6 fw-bold" id="stat-total">—</div>
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-success">
<div class="card-body">
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-danger">
<div class="card-body">
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-info">
<div class="card-body">
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Recent signups -->
<div class="col-md-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-user-plus"></i> Recent Signups
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr><th>User</th><th>Email</th><th>Joined</th></tr>
</thead>
<tbody jq-repeat="recentSignups">
<tr>
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
<td><small>{{mail}}</small></td>
<td><small class="text-muted">{{createTimestamp}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Inactive users -->
<div class="col-md-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-lock"></i> Inactive Users
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr><th>User</th><th>Email</th><th></th></tr>
</thead>
<tbody jq-repeat="inactiveUsers" jq-index-key="uid">
<tr>
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
<td><small>{{mail}}</small></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-success" onclick="activateUser('{{uid}}')">
<i class="fa-solid fa-lock-open"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h5 class="mb-3"><i class="fa-solid fa-paper-plane"></i> Notifications</h5>
</div>
</div>
<div class="row g-3">
<!-- Compose -->
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-pencil"></i> Compose
</div>
<div class="card-header shadow actionMessage" style="display:none"></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" class="form-control shadow" id="notif-subject" placeholder="Maintenance window tonight" />
</div>
<div class="mb-3">
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
<textarea class="form-control shadow" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Send to</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all-active">All active users</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all">All users (including inactive)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-group">Group members</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-users">Specific users</label>
</div>
</div>
<div id="notif-group-row" class="mb-3" style="display:none">
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
</div>
<div id="notif-users-row" class="mb-3" style="display:none">
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow" id="notif-users" placeholder="wmantly, jsmith" />
</div>
<div id="notif-active-row" class="mb-3" style="display:none">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
<label class="form-check-label" for="notif-active-only">Active members only</label>
</div>
</div>
<button class="btn btn-primary shadow" onclick="sendNotification()">
<i class="fa-solid fa-paper-plane"></i> Send
</button>
<div id="notif-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- History -->
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-clock-rotate-left"></i> History
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Sent</th>
<th>Subject</th>
<th>Filter</th>
<th class="text-center">✓</th>
<th class="text-center">✗</th>
</tr>
</thead>
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
<tr>
<td><small class="text-muted">{{created_on_fmt}}</small></td>
<td><small>{{subject}}</small></td>
<td><small class="text-muted">{{filter_label}}</small></td>
<td class="text-center text-success"><small>{{sent_count}}</small></td>
<td class="text-center text-danger"><small>{{failed_count}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h5 class="mb-3"><i class="fa-solid fa-file-contract"></i> Terms of Service</h5>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-pencil"></i> Editor
<small class="text-muted float-end" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow" id="tos-content" rows="16"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+820
View File
@@ -0,0 +1,820 @@
<%- include('top') %>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-server"></i> Directory Management
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="name">Name (A-Z)</option>
<option value="kind">Kind</option>
<option value="env">Environment</option>
</select>
<div class="btn-group btn-group-sm shadow-sm" role="group">
<input type="radio" class="btn-check" name="viewMode" id="view-list" value="list" autocomplete="off" checked onchange="renderTable()">
<label class="btn btn-outline-secondary" for="view-list"><i class="fa-solid fa-list"></i></label>
<input type="radio" class="btn-check" name="viewMode" id="view-tree" value="tree" autocomplete="off" onchange="renderTable()">
<label class="btn btn-outline-secondary" for="view-tree"><i class="fa-solid fa-folder-tree"></i></label>
</div>
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Resource
</button>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="table-responsive">
<table class="card-body table table-striped mb-0">
<thead>
<tr>
<th class="ps-3">Kind</th>
<th>Name</th>
<th>Env</th>
<th>Host</th>
<th>IP / Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="resources-list" jq-repeat="resources">
<tr>
<td class="ps-3 text-nowrap">
{{{indentHtml}}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td>
<td><strong>{{name}}</strong><br><small class="text-muted">{{slug}}</small></td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
</td>
<td><span class="badge bg-light text-dark border">{{hostName}}</span></td>
<td>
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
</td>
<td>
<button class="btn btn-sm btn-primary" onclick="openEditModal('{{id}}')" title="Edit">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-success" onclick="openAddModal('{{id}}', '{{kind}}')" title="Add Child Resource">
<i class="fa-solid fa-plus"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteResource('{{id}}')">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Resource Modal -->
<div class="modal fade" id="resourceModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header shadow">
<h5 class="modal-title" id="resourceModalTitle">Resource</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="res-id">
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Name</label>
<input type="text" id="res-name" class="form-control shadow-sm">
</div>
<div class="col-6">
<label class="form-label">Slug</label>
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Kind</label>
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
<option value="site">Site</option>
<option value="host">Host</option>
<option value="service">Service (App)</option>
<option value="oauth">OAuth Integration</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
</div>
</div>
<div class="row mb-3" id="site-details-container" style="display: none;">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="res-is-current-site">
<label class="form-check-label" for="res-is-current-site">
Mark as Current Site
</label>
</div>
</div>
</div>
<div class="row mb-3" id="host-parent-container" style="display: none;">
<div class="col-12">
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
<select id="res-host-id" class="form-select shadow-sm border-primary">
<option value="">-- Select Parent --</option>
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">IP Address</label>
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
</div>
<div class="col-6">
<label class="form-label">Host / URI Address</label>
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
</div>
</div>
<div class="row mb-3" id="host-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">VMID</label>
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
</div>
<div class="col-4">
<label class="form-label">MAC Address</label>
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
</div>
<div class="col-4">
<label class="form-label">OS / Kernel</label>
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
</div>
</div>
<div class="row mb-3" id="service-ports-container" style="display: none;">
<div class="col-6">
<label class="form-label">Internal Port</label>
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
</div>
<div class="col-6">
<label class="form-label">External Port</label>
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
<small class="text-muted">Same as Internal if empty</small>
</div>
</div>
<div class="row mb-3" id="service-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">Git Repo</label>
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
</div>
<div class="col-4">
<label class="form-label">Install Path</label>
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
</div>
<div class="col-4">
<label class="form-label">Systemd Service</label>
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
</div>
</div>
<div id="oauth-details-container" style="display: none;">
<hr>
<h5>OAuth Configuration</h5>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
</div>
<div class="col-6">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
</div>
</div>
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
<button class="btn btn-outline-warning" onclick="rotateSecret()">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
</button>
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
</div>
</div>
<div class="row mb-3">
<div class="col-4">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-production">
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
</div>
</div>
<div class="col-4" id="external-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-external">
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
</div>
</div>
<div class="col-4" id="public-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-public">
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
</div>
<hr>
<div id="edit-only-section" style="display: none;">
<h5>Associated LDAP Groups</h5>
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="groups-list" jq-repeat="groups">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
<i class="fa-solid fa-users text-muted me-2"></i>
<strong>{{groupCn}}</strong>
<span class="badge bg-primary ms-2">{{accessLevel}}</span>
</span>
<button class="btn btn-sm btn-outline-danger" onclick="removeGroup('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
</li>
</ul>
<div class="input-group shadow-sm mt-2">
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
<datalist id="ldap-groups-datalist"></datalist>
<select class="form-select" id="new-group-level" style="max-width: 140px;">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
<hr>
<h5>Relationships (Graph Edges)</h5>
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="edges-list" jq-repeat="edges">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>
{{#isParent}}
<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
{{/isParent}}
{{^isParent}}
<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
{{/isParent}}
</span>
<button class="btn btn-sm btn-outline-danger" onclick="removeEdge('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
</li>
</ul>
<div class="input-group shadow-sm mt-2">
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
<option value="parent">Has child</option>
<option value="child">Is child of</option>
</select>
<select class="form-select" id="new-edge-target">
<option value="">-- Select Resource --</option>
</select>
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" onclick="saveResource()">
<i class="fa-solid fa-floppy-disk"></i> Save Resource
</button>
</div>
</div>
</div>
</div>
<script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
var resourceModal = new bootstrap.Modal(document.getElementById('resourceModal'));
var resourcesById = {};
var allGroups = [];
var allEdges = [];
var rawResources = [];
$(document).ready(async function() {
await loadResources();
});
async function loadResources() {
try {
const [resResources, resGroups, resEdges] = await Promise.all([
app.api.get('directory-admin/resources'),
app.api.get('directory-admin/groups'),
app.api.get('directory-admin/edges')
]);
resourcesById = {};
for (const r of resResources.results) {
r.metadata = r.metadata || {};
resourcesById[r.id] = r;
}
allGroups = resGroups.results;
allEdges = resEdges.results;
rawResources = [];
for (const r of resResources.results) {
// Compute hostName from edges
r.hostName = '—';
r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) {
r.parentId = parentEdge.parentId;
const parent = resourcesById[parentEdge.parentId];
if (parent) r.hostName = parent.name;
}
rawResources.push(r);
}
renderTable();
} catch (err) {
console.error(err);
alert('Failed to load data');
}
}
function renderTable() {
const filter = $('#search-filter').val().toLowerCase();
const sort = $('#sort-by').val();
const viewMode = $('input[name="viewMode"]:checked').val();
let filtered = rawResources.filter(r => {
if (!filter) return true;
return (r.name || '').toLowerCase().includes(filter) ||
(r.slug || '').toLowerCase().includes(filter) ||
(r.kind || '').toLowerCase().includes(filter) ||
(r.metadata?.subType || '').toLowerCase().includes(filter) ||
(r.metadata?.ip || '').toLowerCase().includes(filter) ||
(r.hostName || '').toLowerCase().includes(filter);
});
// Sort
filtered.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'kind') return a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
if (sort === 'env') {
const ae = a.metadata?.isProduction ? 0 : 1;
const be = b.metadata?.isProduction ? 0 : 1;
return ae - be || a.name.localeCompare(b.name);
}
return 0;
});
let finalRenderList = [];
if (viewMode === 'tree') {
const map = {};
const roots = [];
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
filtered.forEach(r => {
const node = map[r.id];
if (node.parentId && map[node.parentId]) {
map[node.parentId].children.push(node);
} else {
roots.push(node);
}
});
const flatten = (nodes, depth) => {
nodes.forEach(n => {
let indentHtml = '';
for(let i = 0; i < depth; i++) {
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
}
if (depth > 0) {
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
}
n.indentHtml = indentHtml;
finalRenderList.push(n);
if (n.children.length > 0) {
flatten(n.children, depth + 1);
}
});
};
flatten(roots, 0);
} else {
finalRenderList = filtered.map(r => ({ ...r, indentHtml: '' }));
}
$.scope.resources.empty();
for (const r of finalRenderList) {
$.scope.resources.push(r);
}
}
function toggleFormFields() {
const kind = $('#res-kind').val();
if (kind === 'host') {
$('#host-parent-container').show();
$('#host-details-container').show();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').hide();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').hide();
} else if (kind === 'service') {
$('#host-parent-container').show();
$('#host-details-container').hide();
$('#service-ports-container').show();
$('#service-details-container').show();
$('#oauth-details-container').hide();
$('#external-container').show();
$('#public-container').show();
$('#site-details-container').hide();
} else if (kind === 'oauth') {
$('#host-parent-container').show();
$('#host-details-container').hide();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').show();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').hide();
} else { // site
$('#host-parent-container').hide();
$('#host-details-container').hide();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').hide();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').show();
}
populateHostDropdown($('#res-host-id').val());
}
$('#res-name, #res-kind').on('input change', function() {
const id = $('#res-id').val();
if (!id && $('#res-name').val()) {
const name = $('#res-name').val();
const kind = $('#res-kind').val();
let prefix = '';
if (kind === 'service') prefix = 'app_';
if (kind === 'host') prefix = 'host_';
if (kind === 'site') prefix = 'site_';
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
$('#res-slug').val(slug);
}
});
function openAddModal(parentId, parentKind) {
$('#resourceModalTitle').html('<i class="fa-solid fa-plus"></i> Add Resource');
$('#res-id').val('');
$('#res-name').val('');
$('#res-slug').val('');
let defaultKind = 'service';
if (parentKind === 'site') defaultKind = 'host';
if (parentKind === 'host') defaultKind = 'service';
$('#res-kind').val(defaultKind);
if (!parentId && defaultKind === 'service') {
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (currentSite) parentId = currentSite.id;
}
if (!parentId && defaultKind === 'host') {
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (currentSite) parentId = currentSite.id;
}
$('#res-description').val('');
$('#res-ip').val('');
$('#res-address').val('');
$('#res-subtype').val('');
$('#res-vmid').val('');
$('#res-mac').val('');
$('#res-os').val('');
$('#res-port').val('');
$('#res-external-port').val('');
$('#res-git-repo').val('');
$('#res-install-path').val('');
$('#res-systemd').val('');
$('#res-is-production').prop('checked', false);
$('#res-is-external').prop('checked', false);
$('#res-is-public').prop('checked', false);
$('#res-is-current-site').prop('checked', false);
$('#edit-only-section').hide();
populateHostDropdown(parentId || '');
toggleFormFields();
resourceModal.show();
}
var ldapGroupsCache = null;
async function loadLdapGroups() {
if (ldapGroupsCache) return;
try {
const res = await app.api.get('group');
ldapGroupsCache = res.results;
const $datalist = $('#ldap-groups-datalist');
$datalist.empty();
for (const cn of ldapGroupsCache) {
$datalist.append($('<option>').val(cn));
}
} catch (err) {
console.error('Failed to load LDAP groups', err);
}
}
function populateHostDropdown(selectedId) {
const kind = $('#res-kind').val();
const $target = $('#res-host-id');
$target.empty().append('<option value="">-- Select Parent --</option>');
Object.values(resourcesById).forEach(r => {
if (r.id === $('#res-id').val()) return; // cannot be parent of itself
if (kind === 'host' && (r.kind === 'site' || r.kind === 'host')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
}
});
if (selectedId) $target.val(selectedId);
}
function refreshGroupsUI(resourceId) {
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
$.scope.groups.empty();
for (const g of myGroups) {
$.scope.groups.push(g);
}
}
function refreshEdgesUI(resourceId) {
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
$.scope.edges.empty();
for (const e of myEdges) {
const isParent = e.parentId === resourceId;
const targetId = isParent ? e.childId : e.parentId;
const target = resourcesById[targetId];
if (!target) continue;
$.scope.edges.push({
id: e.id,
isParent: isParent,
relation: e.relation,
targetName: target.name + ' (' + target.slug + ')'
});
}
const $target = $('#new-edge-target');
$target.empty().append('<option value="">-- Select Resource --</option>');
Object.values(resourcesById).forEach(r => {
if (r.id !== resourceId) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
}
});
}
async function openEditModal(id) {
const r = resourcesById[id];
if (!r) return;
$('#resourceModalTitle').html('<i class="fa-solid fa-pen-to-square"></i> Edit Resource');
$('#res-id').val(r.id);
$('#res-name').val(r.name);
$('#res-slug').val(r.slug);
$('#res-kind').val(r.kind);
$('#res-description').val(r.description || '');
$('#res-ip').val(r.metadata.ip || '');
$('#res-address').val(r.metadata.address || '');
$('#res-subtype').val(r.metadata.subType || '');
$('#res-vmid').val(r.metadata.vmid || '');
$('#res-mac').val(r.metadata.macAddress || '');
let osKernel = '';
if (r.metadata.os) osKernel += r.metadata.os;
if (r.metadata.kernel) osKernel += (osKernel ? ' / ' : '') + r.metadata.kernel;
$('#res-os').val(osKernel);
$('#res-port').val(r.metadata.port || '');
$('#res-external-port').val(r.metadata.externalPort || '');
$('#res-git-repo').val(r.metadata.gitRepo || '');
$('#res-install-path').val(r.metadata.installPath || '');
$('#res-systemd').val(r.metadata.systemdService || '');
$('#res-is-production').prop('checked', !!r.metadata.isProduction);
$('#res-is-external').prop('checked', !!r.metadata.isExternalReachable);
$('#res-is-public').prop('checked', !!r.metadata.isPublic);
$('#res-is-current-site').prop('checked', !!r.metadata.isCurrentSite);
$('#res-redirect-uris').val((r.metadata.redirect_uris || []).join('\n'));
$('#res-scopes').val((r.metadata.scopes || []).join(' '));
$('#res-allowed-groups').val((r.metadata.allowed_groups || []).join(' '));
$('#res-access-ttl').val((r.metadata.token_lifetime || {}).access_token || 3600);
$('#res-refresh-ttl').val((r.metadata.token_lifetime || {}).refresh_token || 2592000);
if (r.kind === 'oauth') $('#oauth-rotate-container').show();
else $('#oauth-rotate-container').hide();
// Find parent host
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
toggleFormFields();
$('#edit-only-section').show();
refreshGroupsUI(r.id);
refreshEdgesUI(r.id);
await loadLdapGroups();
resourceModal.show();
}
async function saveResource() {
const id = $('#res-id').val();
const data = {
name: $('#res-name').val(),
slug: $('#res-slug').val(),
kind: $('#res-kind').val(),
hostId: ['host', 'service', 'oauth'].includes($('#res-kind').val()) ? $('#res-host-id').val() : undefined,
description: $('#res-description').val(),
metadata: {
subType: $('#res-subtype').val(),
ip: $('#res-ip').val(),
address: $('#res-address').val(),
vmid: $('#res-vmid').val(),
macAddress: $('#res-mac').val(),
os: $('#res-os').val(),
port: $('#res-port').val(),
externalPort: $('#res-external-port').val() || $('#res-port').val(),
gitRepo: $('#res-git-repo').val(),
installPath: $('#res-install-path').val(),
systemdService: $('#res-systemd').val(),
isProduction: $('#res-is-production').is(':checked'),
isExternalReachable: $('#res-is-external').is(':checked'),
isPublic: $('#res-is-public').is(':checked'),
isCurrentSite: $('#res-is-current-site').is(':checked')
}
};
if (data.kind === 'oauth') {
data.redirect_uris = $('#res-redirect-uris').val().split('\n').map(x => x.trim()).filter(Boolean);
data.scopes = $('#res-scopes').val().split(' ').map(x => x.trim()).filter(Boolean);
data.allowed_groups = $('#res-allowed-groups').val().split(' ').map(x => x.trim()).filter(Boolean);
data.token_lifetime = {
access_token: Number($('#res-access-ttl').val()) || 3600,
refresh_token: Number($('#res-refresh-ttl').val()) || 2592000
};
}
try {
let res;
if (id) {
res = await app.api.put('directory-admin/resources/' + id, data);
} else {
res = await app.api.post('directory-admin/resources', data);
}
resourceModal.hide();
await loadResources();
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
app.util.alert('OAuth Secret', 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>', 'success');
}
} catch (err) {
console.error(err);
alert(err.message || 'Failed to save');
}
}
async function rotateSecret() {
const id = $('#res-id').val();
if (!id) return;
if (!confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.')) return;
try {
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
app.util.alert('Secret Rotated', 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>', 'success');
} catch (err) {
console.error(err);
alert(err.message || 'Failed to rotate secret');
}
}
async function addGroup() {
const resourceId = $('#res-id').val();
const groupCn = $('#new-group-cn').val().trim();
const accessLevel = $('#new-group-level').val();
if (!groupCn) return alert('Group CN is required');
try {
const res = await app.api.post('directory-admin/groups', {
resourceId,
groupCn,
accessLevel
});
allGroups.push(res.results);
refreshGroupsUI(resourceId);
$('#new-group-cn').val('');
} catch (err) {
console.error(err);
alert('Failed to add group');
}
}
async function removeGroup(id) {
try {
await app.api.delete('directory-admin/groups/' + id);
allGroups = allGroups.filter(g => g.id !== id);
refreshGroupsUI($('#res-id').val());
} catch (err) {
console.error(err);
alert('Failed to remove group');
}
}
async function addEdge() {
const resourceId = $('#res-id').val();
const dir = $('#new-edge-dir').val();
const targetId = $('#new-edge-target').val();
const relation = $('#new-edge-relation').val().trim() || 'hosts';
if (!targetId) return alert('Select a target resource');
const data = { relation };
if (dir === 'parent') {
data.parentId = resourceId;
data.childId = targetId;
} else {
data.parentId = targetId;
data.childId = resourceId;
}
try {
const res = await app.api.post('directory-admin/edges', data);
allEdges.push(res.results);
refreshEdgesUI(resourceId);
$('#new-edge-target').val('');
} catch (err) {
console.error(err);
alert('Failed to add edge');
}
}
async function removeEdge(id) {
try {
await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val());
} catch (err) {
console.error(err);
alert('Failed to remove edge');
}
}
async function deleteResource(id) {
if (!confirm('Are you sure you want to delete this resource? All relationships will be destroyed.')) return;
try {
await app.api.delete('directory-admin/resources/' + id);
await loadResources();
} catch (err) {
console.error(err);
alert('Failed to delete');
}
}
</script>
<%- include('bottom') %>
+447
View File
@@ -0,0 +1,447 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin('app_sso_admin');
// ── Overview (stats, recent signups, inactive users) ────────────────────
async function loadDashboard() {
try {
const stats = await app.api.get('user/stats');
// Stat cards
document.getElementById('stat-total').textContent = stats.totalUsers;
document.getElementById('stat-active').textContent = stats.activeUsers;
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
document.getElementById('stat-groups').textContent = stats.totalGroups;
try {
const dirRes = await app.api.get('directory-admin/resources');
const resources = dirRes.results || [];
document.getElementById('stat-hosts').textContent = resources.filter(r => r.kind === 'host').length;
document.getElementById('stat-services').textContent = resources.filter(r => r.kind === 'service').length;
document.getElementById('stat-oauth').textContent = resources.filter(r => r.kind === 'oauth').length;
document.getElementById('directory-stats-row').style.display = '';
} catch (err) {}
document.getElementById('dashboard-overview').style.display = '';
} catch(e) {
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
location.replace('/');
} else {
console.error('Dashboard load error:', e);
}
}
}
async function exportUsers() {
const resp = await fetch('/api/user/export', {
headers: { 'auth-token': localStorage.getItem('APIToken') }
});
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'users.csv';
a.click();
}
async function loadMetrics() {
try {
const data = await app.api.get('metrics/executive');
if (data && data.results) {
const renderList = (items, id) => {
const el = document.getElementById(id);
el.innerHTML = '';
if (!items || items.length === 0) {
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
return;
}
items.forEach(item => {
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
${item.value}
<span class="badge bg-primary rounded-pill">${item.score}</span>
</li>`;
});
};
renderList(data.results.ips, 'metrics-ips');
renderList(data.results.users, 'metrics-users');
renderList(data.results.services, 'metrics-services');
}
} catch (e) {
console.error('Failed to load metrics:', e);
}
}
// ── Notifications (compose + history) ────────────────────────────────────
async function loadHistory() {
try {
const data = await app.api.get('notification');
const list = data.results || [];
list.forEach(function(n) {
n.created_on_fmt = moment(n.created_on).fromNow();
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
});
$.scope.notificationHistory.push(...list);
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load notification history:', e);
}
}
function formatFilterLabel(type, value, active_only) {
const suffix = active_only ? ' (active only)' : '';
if (type === 'group') return 'Groups: ' + value + suffix;
if (type === 'users') return 'Specific users';
if (type === 'all_active') return 'All active users';
if (type === 'all') return 'All users';
return type;
}
function toggleFilterInputs() {
// No radio is checked by default (see f-all-active below) — a "send to
// everyone" option must be a deliberate choice, not whatever happens to
// be pre-selected when someone's just trying the form out.
const checked = document.querySelector('input[name="notif-filter"]:checked');
const type = checked ? checked.value : null;
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
}
async function sendNotification() {
const subject = document.getElementById('notif-subject').value.trim();
const message = document.getElementById('notif-message').value.trim();
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
const groupValue = document.getElementById('notif-group').value.trim();
const usersValue = document.getElementById('notif-users').value.trim();
const activeOnly = document.getElementById('notif-active-only').checked;
const msgEl = document.getElementById('notif-result');
const $compose = $('#notif-subject').closest('.card-body');
if (!subject || !message) { alert('Subject and message are required.'); return; }
if (!filterCheck) { alert('Choose who to send this to.'); return; }
const filterType = filterCheck.value;
let filter_value = '';
if (filterType === 'group') filter_value = groupValue;
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
// Broadcasting to everyone is easy to trigger by accident while just
// trying the form out — make it a deliberate, confirmed action.
if (filterType === 'all' || filterType === 'all_active') {
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
if (!confirmed) return;
}
msgEl.className = 'alert alert-info mt-2';
msgEl.textContent = 'Sending…';
msgEl.style.display = '';
try {
const result = await app.api.post('notification', {
subject,
message,
filter_type: filterType,
filter_value,
active_only: activeOnly,
});
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
const n = result.results;
n.created_on_fmt = 'just now';
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
$.scope.notificationHistory.unshift([n]);
// Reset form
document.getElementById('notif-subject').value = '';
document.getElementById('notif-message').value = '';
} catch(e) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
}
}
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
loadMetrics();
});
</script>
<div class="container mt-4">
<div class="row mb-3">
<div class="col-12">
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
</div>
</div>
<!-- Stats Card -->
<div class="card shadow mb-4" id="dashboard-overview" style="display:none">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-chart-pie"></i> Overview Stats</div>
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="exportUsers()">
<i class="fa-solid fa-file-csv"></i> Export Users CSV
</button>
</div>
<div class="card-body bg-light">
<div class="row g-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center">
<div class="card-body">
<div class="display-6 fw-bold" id="stat-total">—</div>
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center border-success">
<div class="card-body">
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center border-danger">
<div class="card-body">
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center border-info">
<div class="card-body">
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mt-1" id="directory-stats-row" style="display:none">
<div class="col-4">
<div class="card shadow-sm text-center border-secondary">
<div class="card-body">
<div class="display-6 fw-bold text-secondary" id="stat-hosts">—</div>
<div class="text-muted small"><i class="fa-solid fa-server"></i> Hosts</div>
</div>
</div>
</div>
<div class="col-4">
<div class="card shadow-sm text-center border-secondary">
<div class="card-body">
<div class="display-6 fw-bold text-secondary" id="stat-services">—</div>
<div class="text-muted small"><i class="fa-solid fa-layer-group"></i> Services</div>
</div>
</div>
</div>
<div class="col-4">
<div class="card shadow-sm text-center border-secondary">
<div class="card-body">
<div class="display-6 fw-bold text-secondary" id="stat-oauth">—</div>
<div class="text-muted small"><i class="fa-solid fa-plug"></i> OAuth Integrations</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Notifications Card -->
<div class="card shadow mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-paper-plane"></i> Notifications</div>
</div>
<ul class="nav nav-tabs px-3 pt-2 border-bottom-0" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-compose" type="button" role="tab"><i class="fa-solid fa-pencil"></i> Compose</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
</li>
</ul>
<div class="tab-content border-top">
<div class="tab-pane fade show active p-4" id="tab-compose" role="tabpanel">
<div class="row">
<div class="col-lg-8">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" class="form-control shadow-sm" id="notif-subject" placeholder="Maintenance window tonight" />
</div>
<div class="mb-3">
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
<textarea class="form-control shadow-sm" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Send to</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all-active">All active users</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all">All users (including inactive)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-group">Group members</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-users">Specific users</label>
</div>
</div>
<div id="notif-group-row" class="mb-3" style="display:none">
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow-sm" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
</div>
<div id="notif-users-row" class="mb-3" style="display:none">
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow-sm" id="notif-users" placeholder="wmantly, jsmith" />
</div>
<div id="notif-active-row" class="mb-3" style="display:none">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
<label class="form-check-label" for="notif-active-only">Active members only</label>
</div>
</div>
<button class="btn btn-primary shadow-sm" onclick="sendNotification()">
<i class="fa-solid fa-paper-plane"></i> Send
</button>
<div id="notif-result" style="display:none" class="mt-3"></div>
</div>
</div>
</div>
<div class="tab-pane fade" id="tab-history" role="tabpanel">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th class="ps-3">Sent</th>
<th>Subject</th>
<th>Filter</th>
<th class="text-center">✓</th>
<th class="text-center pe-3">✗</th>
</tr>
</thead>
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
<tr>
<td class="ps-3"><small class="text-muted">{{created_on_fmt}}</small></td>
<td><small>{{subject}}</small></td>
<td><small class="text-muted">{{filter_label}}</small></td>
<td class="text-center text-success"><small>{{sent_count}}</small></td>
<td class="text-center text-danger pe-3"><small>{{failed_count}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- TOS Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
<!-- Actionable Metrics Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-chart-line"></i> Actionable Metrics (Last 7 Days)</div>
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="loadMetrics()">
<i class="fa-solid fa-rotate-right"></i> Refresh
</button>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="card shadow-sm h-100 border-danger">
<div class="card-header bg-danger text-white"><i class="fa-solid fa-shield-halved"></i> Top Failed IPs</div>
<ul class="list-group list-group-flush" id="metrics-ips">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm h-100 border-warning">
<div class="card-header bg-warning text-dark"><i class="fa-solid fa-user-xmark"></i> Top Failed Accounts</div>
<ul class="list-group list-group-flush" id="metrics-users">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm h-100 border-success">
<div class="card-header bg-success text-white"><i class="fa-solid fa-plug"></i> Top Services Used</div>
<ul class="list-group list-group-flush" id="metrics-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+1 -1
View File
@@ -121,7 +121,7 @@
tableAJAX();
});
</script>
<div class="row" style="display:none;">
<div class="container mt-4">
<div class="d-flex flex-wrap gap-2 align-items-center">
<div class="input-group" style="flex: 1 1 200px;">
-551
View File
@@ -1,551 +0,0 @@
<%- include('top') %>
<!-- Edit OAuth client modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="card-header actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="edit-client-id">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" id="edit-name" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="edit-description" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes</label>
<div id="edit-scopes"></div>
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
<div id="edit-allowed_groups"></div>
</div>
<div class="row mb-3">
<div class="col">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
</div>
<div class="col">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
</div>
</div>
</div>
</div>
<!-- Secret modal — shared by OAuth client secrets and service account passwords -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="secretModalTitle"><i class="fa-solid fa-key"></i> Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
app.auth.forceLogin(['app_sso_admin', 'app_sso_oauth_admin']);
// The scopes this provider actually understands (see routes/oauth.js discovery).
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
// Widget handles + a lookup of the latest client data (for the edit modal).
var createScopes, createGroups, editScopes, editGroups;
var clientsById = {};
function showSecret(secret, title){
document.getElementById('secretModalTitle').innerHTML = '<i class="fa-solid fa-key"></i> ' + (title || 'Secret');
document.getElementById('secretValue').value = secret;
secretModal.show();
}
function copySecret(){
copyField('secretValue');
}
// Copy the value of an input by id; briefly flips the button icon to a check.
function copyField(id, btn){
var el = document.getElementById(id);
if(!el) return;
el.select();
el.setSelectionRange(0, 99999);
document.execCommand('copy');
if(btn){
var $i = $(btn).find('i');
var prev = $i.attr('class');
$i.attr('class', 'fa-solid fa-check');
setTimeout(function(){ $i.attr('class', prev); }, 1200);
}
}
function fmtTTL(seconds){
if(seconds < 3600) return seconds + 's';
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
return (seconds / 86400).toFixed(1) + 'd';
}
function processClient(client){
clientsById[client.client_id] = client; // keep raw data for the edit modal
client.scopes_display = (client.scopes || []).join(' ');
client.allowed_groups_display = (client.allowed_groups || []).join(', ');
client.has_group_restriction = (client.allowed_groups || []).length > 0;
client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
return client;
}
async function tableAJAX(){
let data = await app.oauthClient.list();
$.scope.oauthClientCard.empty();
$.each(data.results, function(_, client){
$.scope.oauthClientCard.push(processClient(client));
});
}
async function deleteClient(client_id, name, btn){
const $card = $(btn).closest('.card');
$card.addClass('table-warning');
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
$card.removeClass('table-warning');
if (!confirmed) return;
app.api.delete('oauth/client/' + client_id, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
$.scope.oauthClientCard.remove('client_id', client_id);
});
}
async function rotateSecret(client_id, name, btn){
const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
if (!confirmed) return;
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
showSecret(data.client_secret, 'Client Secret');
});
}
// Open the edit modal pre-filled from the client's current values.
function editClient(client_id){
var c = clientsById[client_id];
if(!c) return;
$('#edit-client-id').val(client_id);
$('#edit-name').val(c.name || '');
$('#edit-description').val(c.description || '');
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
// (Re)build the tag widgets fresh each open so they reflect this client.
editScopes = app.ui.tagInput('#edit-scopes', {
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
separator: ' ', placeholder: 'Add a scope…',
});
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
values: c.allowed_groups || [], placeholder: 'Type a group name…',
});
editModal.show();
}
function saveEdit(btn){
var $msg = $('#editModal .actionMessage');
var payload = {
client_id: $('#edit-client-id').val(),
name: $('#edit-name').val(),
description: $('#edit-description').val(),
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
scopes: editScopes.get(),
allowed_groups: editGroups.get(),
token_lifetime: {
access_token: Number($('#edit-access_ttl').val()) || 3600,
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
},
};
app.oauthClient.update(payload, function(error, data){
if(error){
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
return;
}
editModal.hide();
tableAJAX();
});
}
$(document).ready(function(){
tableAJAX();
// Initialise the create-form tag widgets.
createScopes = app.ui.tagInput('#create-scopes', {
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
});
createGroups = app.ui.groupSelect('#create-allowed_groups', {
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
});
// After a successful create, reset the widgets too (form reset ignores them).
$('form[action="oauth/client/"]').attr('evalAJAX',
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
);
});
</script>
<h4><i class="fa-solid fa-plug"></i> Integrations</h4>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-oauth-btn" data-bs-toggle="tab" data-bs-target="#tab-oauth" type="button" role="tab">
<i class="fa-solid fa-key"></i> OAuth Apps
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-ldap-btn" data-bs-toggle="tab" data-bs-target="#tab-ldap" type="button" role="tab">
<i class="fa-solid fa-network-wired"></i> LDAP
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-oauth" role="tabpanel">
<div class="row" style="display:none">
<div class="col-12 mb-3">
<div class="card shadow-sm border-info">
<div class="card-header bg-info bg-opacity-10">
<i class="fa-solid fa-circle-info"></i>
OpenID Connect Endpoints
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-body">
<p class="mb-2 text-muted small">
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
It advertises the authorization, token, and userinfo endpoints automatically.
</p>
<dl class="row mb-0">
<dt class="col-sm-2">Issuer</dt>
<dd class="col-sm-10"><code><%= issuer %></code></dd>
<dt class="col-sm-2">Discovery URL</dt>
<dd class="col-sm-10">
<div class="input-group input-group-sm">
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header">
<i class="fa-solid fa-plus"></i>
Register OAuth Client
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
</div>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label and <code>**</code> matches any
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
covers every host theta42/proxy fronts under example.com without registering
each one individually.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes</label>
<div id="create-scopes"></div>
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
<div id="create-allowed_groups"></div>
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
</div>
<div class="row mb-3">
<div class="col">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
</div>
<div class="col">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
</div>
</div>
<button type="submit" class="btn btn-outline-dark">
<i class="fa-solid fa-plus"></i> Register
</button>
</form>
</div>
</div>
</div>
<div class="col-md-8" style="background-color: initial; border: none">
<div class="card-header actionMessage" style="display:none"></div>
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
<div class="card-header">
<h5>
<i class="fa-solid fa-server"></i>
{{ name }}
</h5>
<small class="text-muted font-monospace">{{ client_id }}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{ #description }}
<p>{{ description }}</p>
{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Client ID</dt>
<dd class="col-sm-9">
<div class="input-group input-group-sm">
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-3">Redirect URIs</dt>
<dd class="col-sm-9">
<ul class="list-unstyled mb-0">
{{ #redirect_uris }}
<li><code>{{ . }}</code></li>
{{ /redirect_uris }}
</ul>
</dd>
<dt class="col-sm-3">Scopes</dt>
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
<dt class="col-sm-3">Access</dt>
<dd class="col-sm-9">
{{ #has_group_restriction }}
<span class="badge bg-warning text-dark"><i class="fa-solid fa-user-lock"></i> Restricted</span>
<code>{{ allowed_groups_display }}</code>
{{ /has_group_restriction }}
{{ ^has_group_restriction }}
<span class="badge bg-secondary"><i class="fa-solid fa-users"></i> Any user</span>
{{ /has_group_restriction }}
</dd>
<dt class="col-sm-3">Access Token</dt>
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
<dt class="col-sm-3">Refresh Token</dt>
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
<dt class="col-sm-3">Created by</dt>
<dd class="col-sm-9">{{ created_by }}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button"
onclick="editClient('{{client_id}}')"
class="btn btn-primary btn-sm">
<i class="fa-solid fa-pen-to-square"></i> Edit
</button>
<button type="button"
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
class="btn btn-warning btn-sm">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
</button>
<button type="button"
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
class="btn btn-danger btn-sm float-end">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="tab-ldap" role="tabpanel">
<p class="text-muted">
Everything a 3rd-party app or host needs to bind this directory, filled in
for <b><%= ssoUrl %></b>.
</p>
<div class="row g-3">
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-circle-info"></i> Connection details
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-body">
<p class="text-muted small">
For a single app's own "LDAP authentication" settings — see
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
</p>
<dl class="row mb-0">
<dt class="col-sm-4">LDAPS URL</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">Base DN</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">User search base</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">Group search base</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">User filter</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">Username attribute</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">Example bind DN</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
<small class="field-help text-muted d-block">
A read-only bind account — create one from
<a href="/users">Users &gt; Service Accounts</a> (don't reuse a real
person's login or the admin DN).
</small>
</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-body">
<p class="text-muted small">
For full host login, SSH keys, and sudo via LDAP (not just one app) —
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
and run this on the host. Fill in a service account's password
(create one from <a href="/users">Users &gt; Service Accounts</a>) and,
if you want this host's access/sudo groups auto-registered, an
<a href="/">API token</a> from your Profile.
</p>
<div class="input-group">
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
</div>
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
<i class="fa-solid fa-copy"></i> Copy
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
(function(){
var lines = [
'git clone https://github.com/theta42/ldap-client.git',
'cd ldap-client',
'cat > ldap.vars << \'EOF\'',
'export ldap_host="<%= ldapHost %>"',
'export ldap_base_dn="<%= baseDn %>"',
'',
'# A read-only service account -- create one under Users > Service',
'# Accounts, then fill in its password below.',
'export ldap_bind_dn="<%= exampleBindDn %>"',
'export ldap_bind_password="CHANGE-ME"',
'',
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
'# Manager. Create a personal access token under Profile > API Tokens',
'# and paste it here; leave blank to skip.',
'export sso_url="<%= ssoUrl %>"',
'export sso_token=""',
'',
'# Optional: set this if you run ldap-client against more than one site.',
'export ldap_location=""',
'',
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
'EOF',
'',
'sudo ./index.sh',
];
document.getElementById('f-bashSnippet').value = lines.join('\n');
})();
</script>
<%- include('bottom') %>
-306
View File
@@ -1,306 +0,0 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['app_sso_admin', 'app_sso_invite']);
let allGroups = [];
let currentUserUid = null;
let isAdmin = false;
async function init() {
const [user, groupData] = await Promise.all([
app.api.get('user/me'),
app.api.get('group/'),
]);
currentUserUid = user.uid;
isAdmin = (user.memberOf || []).some(dn => dn.startsWith('cn=app_sso_admin,'));
allGroups = groupData.results || [];
// Populate create-form group select
populateGroupSelect('create-groups', allGroups, []);
loadInvites();
}
function fuzzyMatch(query, text) {
query = query.toLowerCase();
text = text.toLowerCase();
let qi = 0;
for (let i = 0; i < text.length && qi < query.length; i++) {
if (text[i] === query[qi]) qi++;
}
return qi === query.length;
}
function filterGroups(inputEl, selectId) {
const query = inputEl.value;
[...document.getElementById(selectId).options].forEach(function(opt) {
opt.hidden = query ? !fuzzyMatch(query, opt.value) : false;
});
}
function populateGroupSelect(id, groups, selected) {
const sel = document.getElementById(id);
sel.innerHTML = '';
groups.forEach(function(cn) {
const opt = document.createElement('option');
opt.value = cn;
opt.textContent = cn;
opt.selected = selected.includes(cn);
sel.appendChild(opt);
});
}
function inviteStatus(t) {
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
return { label: 'Pending', cls: 'text-success' };
}
function groupsDisplay(groups) {
try {
const arr = JSON.parse(groups || '[]');
return arr.length ? arr.join(', ') : '—';
} catch(_) { return '—'; }
}
async function loadInvites() {
try {
const data = await app.api.get('user/invite');
const tbody = document.getElementById('invite-tbody');
tbody.innerHTML = '';
(data.results || [])
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
.forEach(function(t) {
const status = inviteStatus(t);
const canEdit = isAdmin || t.created_by === currentUserUid;
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
const tr = document.createElement('tr');
tr.dataset.token = t.token;
tr.innerHTML = `
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
<td><small>${t.created_by}</small></td>
<td><small>${mail}</small></td>
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
<td><small class="${status.cls}">${status.label}</small></td>
<td class="text-nowrap">
${canEdit && isPending ? `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
<i class="fa-solid fa-ban"></i>
</button>
` : ''}
${isPending ? `
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
<i class="fa-solid fa-copy"></i>
</button>
` : ''}
</td>
`;
tbody.appendChild(tr);
});
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load invites:', e);
}
}
async function createInvite() {
const mail = document.getElementById('create-email').value.trim();
const groups = [...document.getElementById('create-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('create-result');
result.style.display = 'none';
try {
const data = await app.api.post('user/invite', { mail, groups });
result.style.display = '';
if (data.mail_sent) {
result.className = 'alert alert-success mt-2';
result.textContent = `Invite sent to ${mail}`;
} else {
result.className = 'alert alert-info mt-2';
result.innerHTML = `Link: <a href="${data.link}" target="_blank">${data.link}</a>`;
}
document.getElementById('create-email').value = '';
[...document.getElementById('create-groups').options].forEach(o => o.selected = false);
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
function openEdit(tokenId) {
// Find cached data from the DOM isn't reliable — re-fetch
app.api.get('user/invite').then(function(data) {
const t = (data.results || []).find(x => x.token === tokenId);
if (!t) return;
document.getElementById('edit-token').value = tokenId;
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
const selected = JSON.parse(t.groups || '[]');
populateGroupSelect('edit-groups', allGroups, selected);
const modal = new bootstrap.Modal(document.getElementById('editModal'));
modal.show();
});
}
async function saveEdit() {
const tokenId = document.getElementById('edit-token').value;
const mail = document.getElementById('edit-email').value.trim();
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('edit-result');
result.style.display = 'none';
try {
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
async function revokeInvite(tokenId, btn) {
$thisRow = $(btn).closest('tr');
$thisRow.addClass('table-warning');
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
if(!confirmation){
$thisRow.removeClass('table-warning');
return;
}
// lets not use `confirm``
// if (!confirm('Revoke this invite token?')) return;
try {
await app.api.delete(`user/invite/${tokenId}`);
loadInvites();
} catch(e) {
alert('Failed to revoke invite.');
}
}
function copyLink(tokenId, btn) {
const link = `${location.origin}/login/invite/${tokenId}`;
navigator.clipboard.writeText(link).then(function() {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = orig, 1500);
});
}
$(document).ready(function() {
init();
});
</script>
<div class="row">
<div class="col-12">
<div class="d-flex align-items-center mb-3 mt-2">
<h4 class="mb-0"><i class="fa-solid fa-envelope-open-text"></i> Invites</h4>
</div>
<div class="row g-3">
<!-- Create invite -->
<div class="col-lg-4">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-plus"></i> Create Invite
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional)</small></label>
<input type="email" id="create-email" class="form-control shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'create-groups')" />
<select id="create-groups" class="form-select shadow" multiple size="5"></select>
</div>
<button class="btn btn-primary shadow" onclick="createInvite()">
<i class="fa-solid fa-paper-plane"></i> Send Invite
</button>
<div id="create-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- Invite list -->
<div class="col-lg-8">
<div class="card shadow-lg">
<div class="card-header shadow d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-list"></i> Invite Tokens</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadInvites()">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
<div class="card-header actionMessage" style="display:none;"></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Created</th>
<th>By</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="invite-tbody">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-token" />
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
</div>
<div class="mb-3">
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
</div>
<div id="edit-result" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+136
View File
@@ -0,0 +1,136 @@
<%- include('top') %>
<style>
/* App Portal styling using Bootstrap defaults */
.portal-banner {
background-color: var(--bs-primary);
color: white;
padding: 3rem 1rem;
margin-bottom: 2rem;
border-radius: .5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.portal-banner h1 {
font-weight: 700;
}
.carousel-container {
display: flex;
overflow-x: auto;
gap: 1.5rem;
padding-bottom: 1.5rem;
scrollbar-width: thin;
}
.service-card {
min-width: 280px;
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
}
.service-card .card-body {
flex: 1;
}
</style>
<div class="container mt-4">
<div class="portal-banner text-center">
<h1>SSO Portal</h1>
<p class="lead">Explore and access all your services in one place.</p>
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
</div>
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
<div class="card shadow-sm service-card border-success">
<div class="card-body">
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
</div>
<div class="card-footer bg-transparent border-top-0 pt-0">
<span class="badge bg-success">Access Granted</span>
</div>
</div>
</a>
</div>
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
<div class="card-body">
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
</div>
<div class="card-footer bg-transparent border-top-0 pt-0">
<span class="badge bg-secondary">Request Access</span>
</div>
</div>
</div>
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
<div class="card shadow-sm service-card" style="min-width: 280px;">
<div class="card-body">
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
</div>
</div>
</div>
</div>
<script type="text/javascript">
app.auth.forceLogin();
$(document).ready(async function() {
try {
let res = await app.api.get('discovery/me');
let allAccessible = res.results || [];
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
let myServices = [];
let otherServices = [];
let hosts = [];
allAccessible.forEach(r => {
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
r.description = r.description || 'No description provided';
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
if (r.kind === 'host') hosts.push(r);
});
if (allRes && allRes.results) {
allRes.results.forEach(r => {
r.description = r.description || 'No description provided';
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
otherServices.push(r);
}
});
}
$.scope.myservices.empty();
$.scope.myservices.push(...myServices);
$.scope.otherservices.empty();
$.scope.otherservices.push(...otherServices);
$.scope.hosts.empty();
$.scope.hosts.push(...hosts);
} catch (e) {
console.error('Failed to load discovery data:', e);
}
});
function requestAccess(id) {
app.util.alert('Access Request', 'This feature is coming soon!', 'info');
}
</script>
+105
View File
@@ -89,6 +89,47 @@
renderPersonalGroupMembers(currentUser);
}
async function renderMyServices(){
try{
let res = await app.api.get('discovery/me');
res.results.forEach(r => {
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || 'N/A';
});
$.scope.myservices.empty();
$.scope.myservices.push(...res.results);
}catch(error){
console.error('renderMyServices error:', error)
}
}
async function renderMyMetrics(){
try{
let uid = currentUser.uid;
let res = await app.api.get(`metrics/user/${uid}`);
if (res && res.results) {
const renderList = (items, id) => {
const el = document.getElementById(id);
if(!el) return;
el.innerHTML = '';
if (!items || items.length === 0) {
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
return;
}
items.forEach(item => {
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
${item.value}
<span class="badge bg-primary rounded-pill">${item.score}</span>
</li>`;
});
};
renderList(res.results.services, 'metrics-user-services');
}
}catch(error){
console.error('renderMyMetrics error:', error)
}
}
async function determinUser(){
if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', '');
@@ -160,6 +201,11 @@
renderProfile(currentUser);
renderUserGroups(currentUser);
renderPersonalGroupMembers(currentUser);
renderMyServices();
if(!isOwnProfile) {
renderMyMetrics();
$('#user-metrics-card').show();
}
$('#personal-group-uid-label').text(currentUser.uid);
addGroupSelect = app.ui.groupSelect('#add-group-select', {
@@ -240,6 +286,7 @@
<i>Phone:</i> <b>{{mobile}}</b>
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
<br />
<i>Location (Site):</i> <b>{{location}} </b><br />
<i>LDAP DN:</i> <b>{{dn}} </b><br />
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
@@ -323,6 +370,10 @@
<label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div>
<div class="mb-3">
<label class="form-label">Location (Site)</label>
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
</div>
<div class="mb-3">
<label class="form-label">Home Directory</label>
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
@@ -392,6 +443,60 @@
</div>
</div>
<div class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fa-solid fa-layer-group"></i>
My Services
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<th>Name</th>
<th>Address / IP</th>
<th>Description</th>
<th>Kind</th>
</thead>
<tbody jq-repeat="myservices">
<tr>
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
<td>{{description}}</td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
<div class="card-header shadow bg-success text-white">
<i class="fa-solid fa-chart-line"></i>
Security & Usage Stats (Last 7 Days)
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body p-0">
<div class="row m-0">
<div class="col-12 p-3">
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fa-solid fa-people-group"></i>
+9 -14
View File
@@ -28,7 +28,7 @@
<body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@@ -44,27 +44,22 @@
Groups
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_oauth_admin">
<a class="nav-link" href="/integrations">
<i class="fa-solid fa-plug"></i>
Integrations
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
<a class="nav-link" href="/invites">
<i class="fa-solid fa-envelope-open-text"></i>
Invites
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_directory_admin">
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i>
Directory
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/dashboard">
<a class="nav-link" href="/executive">
<i class="fa-solid fa-gauge-high"></i>
Dashboard
Executive
</a>
</li>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/" style="display: none;">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
+20 -2
View File
@@ -64,6 +64,7 @@ async function fetchUsernameSuggestions() {
// simply can't bind). Disabling (not just hiding) keeps disabled
// fields out of both form serialization and validation.
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
@@ -136,9 +137,11 @@ async function fetchUsernameSuggestions() {
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
</div>
<div class="mb-3">
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control shadow" name="location" placeholder="Site One" />
</div>
<div class="mb-3">
@@ -161,3 +164,18 @@ async function fetchUsernameSuggestions() {
</div>
<button type="submit" class="btn btn-outline-dark">Add</button>
</form>
<script>
$(document).ready(async function() {
const $loc = $('[name="location"]');
if (!$loc.val()) {
try {
const dirRes = await app.api.get('directory-admin/resources');
const resources = dirRes.results || [];
const site = resources.find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (site) {
$loc.val(site.name);
}
} catch(e) {}
}
});
</script>
+373 -174
View File
@@ -24,6 +24,132 @@
});
}
function inviteStatus(t) {
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
return { label: 'Pending', cls: 'text-success' };
}
function groupsDisplay(groups) {
try {
const arr = JSON.parse(groups || '[]');
return arr.length ? arr.join(', ') : '—';
} catch(_) { return '—'; }
}
async function loadInvites() {
try {
const data = await app.api.get('user/invite');
const tbody = document.getElementById('invite-tbody');
tbody.innerHTML = '';
(data.results || [])
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
.forEach(function(t) {
const status = inviteStatus(t);
const canEdit = true; // all users on this page are admins
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
const tr = document.createElement('tr');
tr.dataset.token = t.token;
tr.innerHTML = `
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
<td><small>${t.created_by}</small></td>
<td><small>${mail}</small></td>
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
<td><small class="${status.cls}">${status.label}</small></td>
<td class="text-nowrap text-end">
${canEdit && isPending ? `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
<i class="fa-solid fa-ban"></i>
</button>
` : ''}
${isPending ? `
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
<i class="fa-solid fa-copy"></i>
</button>
` : ''}
</td>
`;
tbody.appendChild(tr);
});
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load invites:', e);
}
}
function populateGroupSelect(id, groups, selected) {
const sel = document.getElementById(id);
sel.innerHTML = '';
groups.forEach(function(cn) {
const opt = document.createElement('option');
opt.value = cn;
opt.textContent = cn;
opt.selected = selected.includes(cn);
sel.appendChild(opt);
});
}
function openEdit(tokenId) {
app.api.get('user/invite').then(function(data) {
const t = (data.results || []).find(x => x.token === tokenId);
if (!t) return;
document.getElementById('edit-token').value = tokenId;
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
const selected = JSON.parse(t.groups || '[]');
const allGroups = [...document.getElementById('invite-groups').options].map(o => o.value);
populateGroupSelect('edit-groups', allGroups, selected);
const modal = new bootstrap.Modal(document.getElementById('editModal'));
modal.show();
});
}
async function saveEdit() {
const tokenId = document.getElementById('edit-token').value;
const mail = document.getElementById('edit-email').value.trim();
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('edit-result');
result.style.display = 'none';
try {
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
async function revokeInvite(tokenId, btn) {
$thisRow = $(btn).closest('tr');
$thisRow.addClass('table-warning');
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
if(!confirmation){
$thisRow.removeClass('table-warning');
return;
}
try {
await app.api.delete(`user/invite/${tokenId}`);
loadInvites();
} catch(e) {
alert('Failed to revoke invite.');
}
}
function copyLink(tokenId, btn) {
const link = `${location.origin}/login/invite/${tokenId}`;
navigator.clipboard.writeText(link).then(function() {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = orig, 1500);
});
}
async function deleteUser(uid, btn){
const $row = $(btn).closest('tr');
$row.addClass('table-warning');
@@ -84,6 +210,11 @@
}
document.getElementById('invite-email').value = '';
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
loadInvites();
setTimeout(() => {
bootstrap.Modal.getInstance(document.getElementById("inviteUserModal"))?.hide();
result.style.display = 'none';
}, 3000);
}catch(e){
result.style.display = '';
result.className = 'alert alert-danger mt-2';
@@ -97,199 +228,267 @@
$(document).ready(function(){
renderUsers();
loadInviteGroups();
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")')
loadInvites();
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success"); bootstrap.Modal.getInstance(document.getElementById("addUserModal"))?.hide();')
});
})();
</script>
<h4><i class="fa-solid fa-users"></i> Users</h4>
<div class="container mt-4">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
<i class="fa-solid fa-user"></i> People
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
<i class="fa-solid fa-gears"></i> Service Accounts
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
<div class="row" style="display:none">
<div class="col-md-4">
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fas fa-user-plus"></i>
Invite User
<span class="float-end">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i>
</span>
</div>
<div class="card-header shadow actionMessage" style="display: none;"></div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
</div>
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
<i class="fa-solid fa-envelope"></i> Send Invite
</button>
<div id="invite-result" style="display:none" class="mt-2"></div>
</div>
</div>
<div class="card shadow-lg">
<div class="card-header">
<i class="fas fa-user-plus"></i>
Add new user
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<%- include('user_form', {adminMode: true}) %>
</div>
</div>
</div>
<div class="col-md-8">
<div class="row">
<div class="col-md-12">
<div class="card shadow">
<div class="card-header">
<i class="fa-solid fa-users"></i>
User List
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fa-solid fa-users"></i>
User List
</div>
<div>
<button class="btn btn-sm btn-outline-primary me-2 group-required group-required-app_sso_admin" data-bs-toggle="modal" data-bs-target="#inviteUserModal">
<i class="fas fa-envelope"></i> Invite User
</button>
<button class="btn btn-sm btn-primary me-2" data-bs-toggle="modal" data-bs-target="#addUserModal">
<i class="fas fa-user-plus"></i> Add User
</button>
<a href="/docs/accounts" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>ID</th>
<th>Name</th>
<th>eMail</th>
<th>Key</th>
<th>Active</th>
<th>TOS</th>
<th></th>
</thead>
<tbody id="tableAJAX">
<tr jq-repeat="userRow">
<td>
{{ uidNumber }}
</td>
<td>
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
</td>
<td>
{{mail}}
</td>
<td>
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td>
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
<div class="px-3 pt-3 border-bottom">
<ul class="nav nav-tabs border-bottom-0" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
<i class="fa-solid fa-user"></i> People
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
<i class="fa-solid fa-gears"></i> Service Accounts
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-invites-btn" data-bs-toggle="tab" data-bs-target="#tab-invites" type="button" role="tab">
<i class="fa-solid fa-envelope-open-text"></i> Invites
</button>
</li>
</ul>
</div>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>ID</th>
<th>Name</th>
<th>eMail</th>
<th>Key</th>
<th>Active</th>
<th>TOS</th>
<th></th>
</thead>
<tbody id="tableAJAX">
<tr jq-repeat="userRow">
<td>
{{ uidNumber }}
</td>
<td>
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
</td>
<td>
{{mail}}
</td>
<td>
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td>
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add User" form.
</div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Username</th>
<th>Description</th>
<th>Manager(s)</th>
<th>Created</th>
<th>Active</th>
<th></th>
</thead>
<tbody>
<tr jq-repeat="serviceAccountRow">
<td>
<a href='/users/{{uid}}'>{{uid}}</a>
</td>
<td>
{{description}}
</td>
<td>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
</td>
<td>
{{createTimestamp}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-invites" role="tabpanel">
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Created</th>
<th>By</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th></th>
</thead>
<tbody id="invite-tbody">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
<div class="row" style="display:none">
<div class="col-12">
<div class="card shadow">
<div class="card-header">
<i class="fa-solid fa-gears"></i>
Service Accounts
<small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small>
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<!-- Invite User Modal -->
<div class="modal fade" id="inviteUserModal" tabindex="-1" aria-labelledby="inviteUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header shadow">
<h5 class="modal-title" id="inviteUserModalLabel"><i class="fas fa-user-plus"></i> Invite User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
</div>
<div id="invite-result" style="display:none" class="mt-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" onclick="sendInvite()" class="btn btn-primary shadow">
<i class="fa-solid fa-envelope"></i> Send Invite
</button>
</div>
</div>
</div>
</div>
<!-- Add User Modal -->
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header shadow">
<h5 class="modal-title" id="addUserModalLabel">
<i class="fas fa-user-plus"></i> Add new user
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-3">Check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab.</p>
<%- include('user_form', {adminMode: true}) %>
</div>
</div>
</div>
</div>
<!-- Edit Invite Modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header shadow">
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-token" />
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Username</th>
<th>Description</th>
<th>Manager(s)</th>
<th>Created</th>
<th>Active</th>
<th></th>
</thead>
<tbody>
<tr jq-repeat="serviceAccountRow">
<td>
<a href='/users/{{uid}}'>{{uid}}</a>
</td>
<td>
{{description}}
</td>
<td>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
</td>
<td>
{{createTimestamp}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
<div class="mb-3">
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
</div>
<div id="edit-result" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+2 -2
View File
@@ -129,7 +129,7 @@ fi
# ── 3. ppolicy overlay ────────────────────────────────────────────────────────
info "ppolicy overlay"
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then
if config_search -b "$DB_DN" "(objectClass=olcOverlayConfig)" dn | grep -qi "^dn:.*ppolicy"; then
skip "ppolicy overlay already configured on ${DB_DN}"
else
config_add "dn: olcOverlay=ppolicy,${DB_DN}
@@ -280,7 +280,7 @@ info "verifying ppolicy is active on ${DB_DN}"
VERIFY_FAILED=0
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then
if config_search -b "$DB_DN" "(objectClass=olcOverlayConfig)" dn | grep -qi "^dn:.*ppolicy"; then
ok "ppolicy overlay is attached to the user database"
else
warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail"
+7 -3
View File
@@ -1,6 +1,7 @@
[Unit]
Description=SSO NodeJS manager Service
After=network.target
Description=Theta42 SSO Manager
After=network.target slapd.service
Wants=slapd.service
StartLimitIntervalSec=0
[Service]
@@ -8,7 +9,10 @@ Type=simple
Restart=always
RestartSec=1
User=root
ExecStart=/usr/bin/env node /var/www/sso-manager-node/nodejs/bin/www
WorkingDirectory=/opt/theta42/sso-manager/nodejs
Environment="NODE_ENV=production"
Environment="CONF_SECRETS=/etc/sso-manager/secrets.js"
ExecStart=/usr/bin/env node /opt/theta42/sso-manager/nodejs/bin/www
[Install]
WantedBy=multi-user.target
+11 -3
View File
@@ -2,10 +2,14 @@
// Example secrets configuration file (file-based config).
//
// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values.
// Bare-metal: install.sh seeds a filled-in version of this file at
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
// SMTP is left as a placeholder). Only write this one by hand if you're
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
// manually.
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it.
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
//
// Values here override conf/base.js and win over <environment>.js. `app_*` env
// vars (if any are set) override this file too — so the Docker stack passes NO
@@ -26,6 +30,10 @@ module.exports = {
bindPassword: 'your-ldap-password',
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
// direct LDAPS binds on /integrations. Leave empty to derive from the
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
// ldapsPort: 636,
},
smtp: {
host: 'smtp.example.com',
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# seed-test-user.sh — Create the test user in LDAP for the test suite.
#
# The test suite (tests/setup.js) logs in as uid=test / password=MyTestPassword!2.
# This script creates that user in the LDAP directory along with its personal
# posixGroup, and adds it to the app_sso_admin group so admin-gated tests pass.
#
# Environment variables (from docker-compose.test.yml):
# LDAP_HOST — LDAP server hostname (default: ldap)
# LDAP_PORT — LDAP server port (default: 389)
# BIND_DN — LDAP admin bind DN (default: cn=admin,dc=test,dc=local)
# BIND_PW — LDAP admin password (default: secret)
# BASE_DN — LDAP base DN (default: dc=test,dc=local)
set -euo pipefail
LDAP_HOST="${LDAP_HOST:-ldap}"
LDAP_PORT="${LDAP_PORT:-389}"
BIND_DN="${BIND_DN:-cn=admin,dc=test,dc=local}"
BIND_PW="${BIND_PW:-secret}"
BASE_DN="${BASE_DN:-dc=test,dc=local}"
LDAP_URI="ldap://${LDAP_HOST}:${LDAP_PORT}"
USER_UID="test"
USER_PASSWORD="MyTestPassword!2"
info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; }
# ── Wait for LDAP to be reachable ────────────────────────────────────────────
info "Waiting for LDAP at ${LDAP_URI}..."
for i in $(seq 1 30); do
if ldapsearch -x -H "$LDAP_URI" -b '' -s base '(objectClass=*)' >/dev/null 2>&1; then
info "LDAP is reachable"
break
fi
if [ "$i" -eq 30 ]; then
error "LDAP not reachable after 30 attempts"
exit 1
fi
sleep 1
done
# ── Check if the test user already exists ────────────────────────────────────
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' already exists — skipping seed"
exit 0
fi
# ── Generate the SSHA512 password hash ───────────────────────────────────────
# Inline the hash function to avoid requiring the full model chain (which
# tries to connect to Redis/LDAP during module loading and would hang).
info "Generating password hash..."
PASSWORD_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('${USER_PASSWORD}').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
info "Password hash generated"
# ── Create a temporary LDIF file ─────────────────────────────────────────────
TMPFILE=$(mktemp /tmp/seed-test-user.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT
cat > "$TMPFILE" << LDIF
# Personal posixGroup for the test user
dn: cn=${USER_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${USER_UID}
gidNumber: 1500
description: Personal group for test user
# Test user posixAccount
dn: cn=${USER_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${USER_UID}
sn: Test
uid: ${USER_UID}
uidNumber: 1500
gidNumber: 1500
homeDirectory: /home/${USER_UID}
loginShell: /bin/bash
mail: test@test.local
userPassword: ${PASSWORD_HASH}
description: Test user for automated test suite
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${USER_UID}
LDIF
# ── Add the entries to LDAP ──────────────────────────────────────────────────
info "Creating test user '${USER_UID}' in LDAP..."
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' exists or was created"
else
error "Failed to create test user '${USER_UID}'"
exit 1
fi
# ── Add the test user to required SSO groups ─────────────────────────────────
info "Adding test user to SSO admin groups..."
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
ldapmodify -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" << EOF 2>/dev/null || true
dn: cn=${group},ou=groups,${BASE_DN}
changetype: modify
add: member
member: cn=${USER_UID},ou=people,${BASE_DN}
EOF
done
info "Test user added to SSO admin groups"
# ── Create additional users needed by tests ─────────────────────────────────
# wmantly is referenced by OTP (otp.test.js) and impersonation (impersonate.test.js)
# tests as an existing non-admin user.
WMANTLY_UID="wmantly"
if ! ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Creating additional test user '${WMANTLY_UID}'..."
# Generate password hash for wmantly
WMANTLY_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('testpass').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
cat > "$TMPFILE" << LDIF
dn: cn=${WMANTLY_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${WMANTLY_UID}
gidNumber: 1501
dn: cn=${WMANTLY_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${WMANTLY_UID}
sn: Mantly
uid: ${WMANTLY_UID}
uidNumber: 1501
gidNumber: 1501
homeDirectory: /home/${WMANTLY_UID}
loginShell: /bin/bash
mail: wmantly@test.local
userPassword: ${WMANTLY_HASH}
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${WMANTLY_UID}
LDIF
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Additional test user '${WMANTLY_UID}' exists or was created"
else
error "Failed to create additional test user '${WMANTLY_UID}'"
exit 1
fi
fi
info "Seed complete — all test users are ready"
+2
View File
@@ -0,0 +1,2 @@
const { hashPasswordSSHA512 } = require('./nodejs/models/user_ldap.js');
console.log(hashPasswordSSHA512('MyTestPassword!2'));