Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b0418e42e | |||
| 4e3aa082d3 | |||
| 6cb8b259e2 | |||
| 2532c492f1 | |||
| fdc045e166 | |||
| 0c2f38f0fe | |||
| fcba782ac7 | |||
| 6162c6d8a1 | |||
| 3be8c7fde2 | |||
| 3852e9ba62 | |||
| 7f2c71299f | |||
| 18119d54aa | |||
| 487e38f1a4 | |||
| 2e011dd383 | |||
| 3c12ebba16 | |||
| ffb2e99199 | |||
| 9d5f106863 | |||
| 7f00d4c845 | |||
| 1d1d29d287 | |||
| 5665504bc1 | |||
| 2ac1c30112 | |||
| 04c18eaf30 | |||
| 6835074b8b | |||
| 59ae30897b | |||
| 94a7e07410 | |||
| a5de279bb4 | |||
| d8b6f6e7a3 | |||
| 208762f0d1 | |||
| b076498219 | |||
| fc0d9104d0 | |||
| 82da47cef7 | |||
| 39779f51dc | |||
| d9a3cb6044 | |||
| 0ee6825a01 | |||
| 14b6ed5ae0 | |||
| fd98854628 | |||
| 3babf18fe4 | |||
| d78f1dfabf | |||
| c3c206b830 | |||
| 0a21dce0d7 | |||
| 5940880d9b | |||
| 17fcf2fed0 | |||
| ec76054e41 | |||
| 5c0fc4f016 | |||
| 43dae2a3eb | |||
| 20c0a48199 | |||
| 12da7140c2 | |||
| dfd5f46095 | |||
| 5dcc75195c | |||
| 12a99b550c | |||
| 4ce5a5f492 | |||
| c84141b2f3 | |||
| f76d93d840 | |||
| e910a492ba | |||
| 1ef868e23c | |||
| e11e39c23a | |||
| b91089ad4c | |||
| 95ae50a924 | |||
| 0076784fae | |||
| c4d7a1a8e9 | |||
| a100f755ce | |||
| 81ad538e50 | |||
| 6ce36b4a14 | |||
| cda76d3889 | |||
| 2c11226793 | |||
| 80d88b083c | |||
| b4fa824609 | |||
| 5a8030fd7d | |||
| cf80c966eb | |||
| 07819a6254 | |||
| 1b3e842006 | |||
| efe3e514b0 | |||
| 37f2ece172 | |||
| b77704089b | |||
| 114d8c86ca | |||
| 3e87ad86ab | |||
| c5a2c0a71d | |||
| fe23d231be | |||
| 3a612dbed7 | |||
| f154bb8db0 | |||
| 9fc5abda2a | |||
| a6ee985de4 | |||
| 47a9f6c3ec | |||
| f6552cb741 | |||
| 4e7e29b35f | |||
| 4e5a2aa4f9 | |||
| 51784f2f27 | |||
| 4c59b1fabb | |||
| 099638057e | |||
| 077c41844d | |||
| 96adf60cf7 | |||
| 82f703f560 | |||
| 65b107d8ff | |||
| 5d7c0bd594 | |||
| 3ad817767a | |||
| cdc5d1528c |
@@ -20,9 +20,9 @@
|
|||||||
!directory_spec.md
|
!directory_spec.md
|
||||||
!docs/**/*.md
|
!docs/**/*.md
|
||||||
|
|
||||||
# Tests
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
nodejs/tests/
|
# nodejs/tests/
|
||||||
nodejs/*.test.js
|
# nodejs/*.test.js
|
||||||
|
|
||||||
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
|
# 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).
|
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ jobs:
|
|||||||
# directory layout (dc=example,dc=com) -- only the admin password
|
# directory layout (dc=example,dc=com) -- only the admin password
|
||||||
# (normally supplied via a gitignored secrets.js) needs setting.
|
# (normally supplied via a gitignored secrets.js) needs setting.
|
||||||
app_ldap__bindPassword: your-ldap-password
|
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
|
run: npm test
|
||||||
|
|
||||||
test-summary:
|
test-summary:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# SSO Manager API Documentation
|
# SSO Manager API Documentation
|
||||||
|
|
||||||
|
> Looking for a plainer explanation of what API tokens are and when you'd
|
||||||
|
> want one, instead of a full endpoint reference? See
|
||||||
|
> [API Tokens](/docs/api-tokens) (in-app) or
|
||||||
|
> [concepts-api-tokens.md](docs/concepts-api-tokens.md) (repo).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
|
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
|
||||||
|
|||||||
@@ -4,7 +4,226 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
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`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.6.3] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Group membership changes (`PUT`/`DELETE /api/group/:group/:uid`) didn't invalidate the User cache**, so `isServiceAccount` (and anything else derived from `memberOf`) could stay stale for up to 5 minutes after a change. This is what caused a real "lost user" report — the account had landed in `app_sso_service_account` (which `users.ejs`'s People tab filters out entirely) and looked exactly like data loss, though nothing was ever deleted.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **A confirmation before adding anyone to `app_sso_service_account`** via the Groups page — that group's whole purpose is to hide an account from the People tab, and there was no guardrail against doing that to a real person by mistake (which is how the bug above happened). Every other group's add-member flow is unchanged.
|
||||||
|
|
||||||
|
## [1.6.2] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`DELETE /api/oauth/client/:id` 500'd** (`client.remove is not a function`) — `OAuthClient` wraps `@simpleworkjs/orm`'s `Resource` model, whose instance delete method is `.delete()`, not `.remove()`. The Directory Management UI was unaffected (its own delete routes already used `.delete()` correctly); only this legacy/raw API endpoint was broken. Found live against a real deployment's SSO API.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Regression tests**: PUT/DELETE on `/api/oauth/client/:id` now verify persistence with a follow-up GET rather than trusting the mutating response alone (this is what would have caught the bug above). A static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.6.1.
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-07-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Removed every native `alert()`/`confirm()` call**, replacing them with `app.messages.action`/`confirm`/`toast`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of the 1.6.0 `app.messages`/`app.modal` adoption, on `directory.ejs`'s "Rotate Client Secret" — it froze the whole tab). Also deleted `app.user.remove`/`app.oauthClient.remove` in `public/js/app.js`, which had native `confirm()` guards and zero callers anywhere in the app.
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-07-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm`/`alert` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped (the vendored `alert()` this replaces had no escaping), and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched — they're app-specific (dual-mode callback/promise API, `auth-token` header injection) and not something the frontend package's generic `app.js` provides.
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-07-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`PUT /api/user/:uid` 500'd with `ObjectClassViolationError` (LDAP `0x41`) when setting `sshPublicKey`** on any account created before the `ldapPublicKey` auxiliary objectClass was added to new-user creation (e.g. the bootstrap `admin` account). `User.update`'s `sshPublicKey` handling and `User.addSSHkey` (`nodejs/models/user_ldap.js`) now add the `ldapPublicKey` objectClass first (ignoring `TypeOrValueExistsError` if already present), the same pattern already used for `dateOfBirth`/`theta42Person`.
|
||||||
|
- **OAuth Integration parent dropdown was blank.** `populateHostDropdown` in `nodejs/views/directory.ejs` only built options for `kind === 'host'` and `kind === 'service'` — there was no branch for `kind === 'oauth'`, so choosing "OAuth Integration" in the Directory's add-resource modal left the parent-Service picker empty except the placeholder. Added the missing branch.
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-07-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
|
||||||
|
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
|
||||||
|
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
|
||||||
|
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
|
||||||
|
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
|
||||||
|
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
|
||||||
|
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
|
||||||
|
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
|
||||||
|
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
|
||||||
|
|
||||||
|
### Fixed (sso-manager-node)
|
||||||
|
- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.
|
||||||
|
- `public/js/app.js` used `$.isFunction`, removed in jQuery 4.
|
||||||
|
|
||||||
|
### Added (sso-manager-node)
|
||||||
|
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-07-25
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
|
||||||
|
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404 (the autoRouter's `search` supported `?group=`, but the route was effectively unreachable for jump-host's call pattern).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org:
|
||||||
|
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
|
||||||
|
- `@simpleworkjs/ldap` — `models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
|
||||||
|
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
|
||||||
|
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dependency alignment: `ldapts` `^8.1.2` → `^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
|
||||||
|
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
|
||||||
|
|
||||||
|
## [1.3.2] - 2026-07-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **OAuth client management API returned `client_id: undefined` on every GET.** The ORM's `Model.toJSON()` only serializes schema fields, so the mapped `client_id`/`scopes`/`redirect_uris`/… that `OAuthClient.get()` attaches to the wrapped Resource were stripped from `GET /api/oauth/client` and `GET /api/oauth/client/:id` responses. The theta-env bootstrap (which lists clients and rotates by the returned `client_id`) then called `/api/oauth/client/undefined/rotate` and got a 500, aborting stack bring-up when `proxy-secrets.js` had no usable secret. `OAuthClient.get()` now emits an explicit public JSON shape (and deliberately omits `client_secret_hash`, so the secret hash no longer leaks over the API).
|
||||||
|
- `OAuthClient.get()` no longer 500s on an unknown/`undefined` client id: `Resource.get()` returns `null` (it doesn't throw), which was dereferenced as `r.kind`. It now returns a clean 404.
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
|
||||||
|
## [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).
|
||||||
|
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
|
||||||
|
|
||||||
|
## [1.1.9] - 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
|
||||||
|
|
||||||
|
## [1.1.8] - 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The Edit Profile form's Mobile Phone field had a stray `validate=":9"` making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed.
|
||||||
|
- A service account's profile always showed `Name: Service Account` -- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts.
|
||||||
|
- The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via `User.get()` *before* the route handler marks it as a service account (group membership), so the cached copy had `isServiceAccount` stuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking.
|
||||||
|
- A user belonging to exactly one LDAP group had their `memberOf` attribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (`for(let group of user.memberOf)`) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." Normalized `memberOf` to always be an array, same fix already applied to `manager`.
|
||||||
|
|
||||||
|
## [1.1.7] - 2026-07-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Service accounts unified to one kind.** Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its `/api/service-account` routes) -- every service account is now a real Unix/POSIX account with a UID, created from the new **Users → Service Accounts** tab. Email and password are both optional for service accounts; a blank password means no `userPassword` is set at all (the account simply can't bind).
|
||||||
|
- **Added a `manager` field to every account.** Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needing `app_sso_admin`.
|
||||||
|
- `homeDirectory` and `loginShell` are now editable from the Edit Profile form (previously view-only).
|
||||||
|
|
||||||
## [1.1.6] - 2026-07-16
|
## [1.1.6] - 2026-07-16
|
||||||
|
|
||||||
@@ -54,7 +273,16 @@ 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.
|
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
||||||
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...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
|
||||||
|
[1.1.10]: https://github.com/theta42/sso-manager-node/compare/v1.1.9...v1.1.10
|
||||||
|
[1.1.9]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...v1.1.9
|
||||||
|
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
|
||||||
|
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
|
||||||
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
|
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
|
||||||
[1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
|
[1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
|
||||||
[1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4
|
[1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
|
|||||||
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
||||||
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
||||||
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
- 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)
|
### 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 |
|
| `PORT` | `3001` | host port mapped to the UI |
|
||||||
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
|
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
|
||||||
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
|
| `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
|
Any `app_*` var may also be set directly to override any config value (see the
|
||||||
table at the top).
|
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
|
`ldap-certs` volume so it persists across container recreation — clients don't need
|
||||||
to re-trust on every rebuild.
|
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`
|
- **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
|
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:
|
`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)
|
## Method 2: Bare metal (Debian/Ubuntu)
|
||||||
|
|
||||||
`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and
|
`install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
|
||||||
configures OpenLDAP (modules + overlays + custom schema + directory tree +
|
force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
|
||||||
required groups), deploys the app to `/opt/sso-manager`, and creates a systemd
|
config from the repo. Re-run it to update — it prints the version you're
|
||||||
unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based).
|
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
|
### Prerequisites
|
||||||
|
|
||||||
@@ -346,47 +361,50 @@ unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based
|
|||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh \
|
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
|
||||||
-p 'your-ldap-password' \
|
|
||||||
-b 'dc=yourdomain,dc=com' \
|
|
||||||
-n 'Your Org' \
|
|
||||||
-o 3001
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Env var | Description |
|
or, if you already have the repo checked out:
|
||||||
|------|---------|-------------|
|
|
||||||
| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) |
|
```bash
|
||||||
| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) |
|
sudo ./install.sh
|
||||||
| `-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) |
|
| Env var | Description |
|
||||||
| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` |
|
|---------|-------------|
|
||||||
| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) |
|
| `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
|
||||||
| `--skip-app` | `SKIP_APP` | LDAP setup only |
|
| `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
|
||||||
| `--dry-run` | `DRY_RUN` | Show actions without making changes |
|
| `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
|
### Post-install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl enable --now sso-manager
|
sudo systemctl status sso-manager
|
||||||
journalctl -fu sso-manager
|
journalctl -fu sso-manager
|
||||||
curl http://localhost:3001/health # -> {"status":"ok"}
|
curl http://localhost:3001/health # -> {"status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### What `install.sh` does
|
### What `install.sh` does
|
||||||
|
|
||||||
1. Installs Node.js 20.x (NodeSource).
|
1. Installs Node.js 22.x (NodeSource) and Redis.
|
||||||
2. Installs OpenLDAP (`slapd`) with: `pw-sha2`, `ppolicy`, `memberof`, `refint`
|
2. Clones/updates the repo at `/opt/theta42/sso-manager`.
|
||||||
modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes;
|
3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
|
||||||
`ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups.
|
`memberof`, `refint` modules + overlays; the custom `theta42Person` schema
|
||||||
3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`.
|
(`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
|
||||||
4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults).
|
`pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
|
||||||
5. Installs `sso-manager.service` (systemd), enabled on boot.
|
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
|
> For an existing LDAP server, run with `SKIP_LDAP=true` and write
|
||||||
> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use
|
> `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
|
||||||
> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer
|
> starting the service. To (re)configure overlays on an already-installed
|
||||||
> `ops/ldap-setup.sh` (idempotent, auto-detects the user database).
|
> 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
|
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
||||||
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
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
|
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
|
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
|
||||||
use `ldaps://…:636` or StartTLS.
|
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
|
||||||
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
||||||
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
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. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ RUN apk add --no-cache \
|
|||||||
openldap-overlay-ppolicy \
|
openldap-overlay-ppolicy \
|
||||||
openldap-overlay-memberof \
|
openldap-overlay-memberof \
|
||||||
openldap-overlay-refint \
|
openldap-overlay-refint \
|
||||||
|
openldap-overlay-syncprov \
|
||||||
|
openldap-overlay-auditlog \
|
||||||
openldap-passwd-sha2 \
|
openldap-passwd-sha2 \
|
||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
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)
|
# 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
|
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
||||||
# if you want LAN clients to bind without TLS (not recommended).
|
# 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
|
EXPOSE 3001 389 636
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -25,6 +25,10 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [](docs/images/groups.png) | [](docs/images/oauth-clients.png) |
|
| [](docs/images/groups.png) | [](docs/images/oauth-clients.png) |
|
||||||
|
|
||||||
|
| Sites & Replication |
|
||||||
|
| --- |
|
||||||
|
| [](docs/images/sites.png) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
|
- **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
|
- **Web management UI** — manage users, groups, and OAuth clients from a
|
||||||
browser; invite and password-reset flows over email; user self-service for
|
browser; invite and password-reset flows over email; user self-service for
|
||||||
profile and API tokens.
|
profile and API tokens.
|
||||||
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
|
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
|
||||||
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
|
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
|
||||||
directory, so you don't maintain a second user database for them.
|
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
|
- **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.
|
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
|
- **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.
|
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
|
## Why this over the alternatives
|
||||||
|
|
||||||
@@ -107,23 +113,24 @@ vars, LDAPS/TLS, and backups.
|
|||||||
|
|
||||||
### 3. Bare metal on Debian/Ubuntu
|
### 3. Bare metal on Debian/Ubuntu
|
||||||
|
|
||||||
`install.sh` is an idempotent installer: it installs Node.js 20.x and OpenLDAP,
|
An automated installer installs Node.js, Redis, and (on first run) OpenLDAP —
|
||||||
configures the directory (modules, overlays, schema, the SSO groups), deploys
|
configuring the directory (modules, overlays, schema, the SSO groups) and
|
||||||
the app to `/opt/sso-manager`, and creates a systemd unit.
|
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
|
||||||
The only thing it requires is the LDAP admin password; the domain (base DN)
|
systemd service:
|
||||||
defaults to `dc=example,dc=com` if you don't pass one:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com'
|
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
|
||||||
sudo systemctl enable --now sso-manager
|
|
||||||
curl http://localhost:3001/health # -> {"status":"ok"}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `sudo ./install.sh -h` for all flags (`-n` org name, `-o` port, `-j` JWT
|
That's it — LDAP and the app are both live afterward. Edit
|
||||||
secret, `-s` SMTP, `--skip-ldap` to use an existing LDAP, `--dry-run`). Re-run
|
`/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
|
||||||
it to update. Full details in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2:
|
restart the service to customize. It's idempotent and safe to re-run —
|
||||||
Bare metal*.
|
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
|
## Architecture
|
||||||
|
|
||||||
@@ -145,7 +152,7 @@ Bare metal*.
|
|||||||
┌────────────────────────┐
|
┌────────────────────────┐
|
||||||
│ OpenLDAP (slapd) │
|
│ OpenLDAP (slapd) │
|
||||||
│ - users / groups │
|
│ - users / groups │
|
||||||
│ - LDAPS :636 │─── legacy apps bind directly
|
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
|
||||||
│ - StartTLS :389 │
|
│ - StartTLS :389 │
|
||||||
└────────────────────────┘
|
└────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
oidc: {
|
||||||
|
clientId: '',
|
||||||
|
clientSecret: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Home-Lab Directory / Inventory — Design Spec
|
# Home-Lab Directory / Inventory — Design Spec
|
||||||
|
|
||||||
Status: **Draft / agreed direction** (no code yet)
|
Status: **Implemented** (v1.2.1+: model, admin API, UI; v1.3.x: automatic
|
||||||
|
registration from theta-env + ldap-client). §9 adds the planned-consumer
|
||||||
|
readiness review.
|
||||||
Owner: wmantly
|
Owner: wmantly
|
||||||
Last updated: 2026-07-02
|
Last updated: 2026-07-23
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,13 +97,24 @@ common query fields can be promoted to columns later.
|
|||||||
| column | type | notes |
|
| column | type | notes |
|
||||||
|--------------|-------------|-------|
|
|--------------|-------------|-------|
|
||||||
| `id` | uuid / pk | |
|
| `id` | uuid / pk | |
|
||||||
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` |
|
| `kind` | enum | `site` \| `host` \| `service` |
|
||||||
| `name` | text | display name ("Home Assistant", "ct101") |
|
| `name` | text | display name ("Home Assistant", "ct101") |
|
||||||
| `slug` | text unique | url-safe id used by the API |
|
| `slug` | text unique | url-safe id used by the API |
|
||||||
| `description`| text | free text |
|
| `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 | |
|
| `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)
|
### `resource_edge` — directed relationships (the graph)
|
||||||
| column | type | notes |
|
| column | type | notes |
|
||||||
|--------------|--------|-------|
|
|--------------|--------|-------|
|
||||||
@@ -109,7 +122,7 @@ common query fields can be promoted to columns later.
|
|||||||
| `child_id` | fk → resource | |
|
| `child_id` | fk → resource | |
|
||||||
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
|
| `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
|
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
|
||||||
multiple parents/children and multiple relation types.
|
multiple parents/children and multiple relation types.
|
||||||
|
|
||||||
@@ -164,12 +177,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
|
|
||||||
- **Interactive users:** existing session auth — `middleware.auth` validating the
|
- **Interactive users:** existing session auth — `middleware.auth` validating the
|
||||||
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
||||||
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service
|
- **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.
|
||||||
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.
|
|
||||||
- **Read visibility (decision to confirm):** either (a) any authenticated user may
|
- **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
|
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,
|
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
|
||||||
@@ -195,7 +203,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
## 7. Roadmap
|
## 7. Roadmap
|
||||||
|
|
||||||
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
|
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`.
|
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`
|
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
|
||||||
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
||||||
@@ -213,4 +221,149 @@ 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
|
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
|
||||||
for auth, SQL for inventory.
|
for auth, SQL for inventory.
|
||||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Planned consumers — data-model & API readiness
|
||||||
|
|
||||||
|
Five consumers the directory data should be able to power. None are being
|
||||||
|
built yet; this section records what each needs, what already exists, and the
|
||||||
|
gaps to close so the model/API never paints us into a corner.
|
||||||
|
|
||||||
|
The recurring theme: **the graph model itself (Resource / ResourceEdge /
|
||||||
|
ResourceGroup + LDAP groups) is sufficient for all five.** The gaps are
|
||||||
|
(a) one new model (access requests), (b) machine-to-machine auth for the read
|
||||||
|
API, (c) documented metadata conventions instead of new columns, and
|
||||||
|
(d) change detection for the drift/sync consumers.
|
||||||
|
|
||||||
|
### 9.1 End-user exploration ("Netflix-style" catalog + request access)
|
||||||
|
|
||||||
|
A user browses everything that exists — part advertisement, part
|
||||||
|
documentation — sees what they already have, and requests access to the rest.
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- `/api/discovery/me` (`getMyAccess`) — the "My Services" half.
|
||||||
|
- `Resource.owner` + `<slug>_access` / `<slug>_admin` ResourceGroup links —
|
||||||
|
who approves, and which group an approval means joining.
|
||||||
|
- The Notification model — the approval-request delivery mechanism.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Catalog projection with metadata privacy.** `/api/discovery/resources`
|
||||||
|
returns full `metadata` to any authenticated user — including the OAuth
|
||||||
|
kind's `client_secret_hash`, and operator notes that may name internal
|
||||||
|
IPs. Needed: a per-kind public projection (name, description, kind,
|
||||||
|
subType, icon, address, hasAccess, requestable) and a private-key
|
||||||
|
convention for the rest (e.g. only `app_sso_directory_admin` sees full
|
||||||
|
metadata). This is a **fix worth doing before any catalog UI exists**.
|
||||||
|
2. **`AccessRequest` model** — the one genuinely new model:
|
||||||
|
`{id, uid, resourceId, groupCn, status: pending|approved|denied, note,
|
||||||
|
requestedOn, decidedBy, decidedOn}`. Approval = LDAP group add + notify.
|
||||||
|
Endpoints: user POST/GET own; resource owner / directory admin
|
||||||
|
list/approve/deny.
|
||||||
|
3. **Catalog metadata conventions**: `icon`, `tagline` (card-length blurb),
|
||||||
|
`requestable: false` for resources that shouldn't be advertised.
|
||||||
|
|
||||||
|
### 9.2 SSH jump host (`username_-_{hostname-or-ip}@publicHost`)
|
||||||
|
|
||||||
|
A public jump host parses the target out of the SSH username, checks the user
|
||||||
|
may reach that host, and proxies the connection (WinSCP-friendly: one
|
||||||
|
username string, no interactive menu needed — though an interactive picker on
|
||||||
|
plain `username@` login is the same query).
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- Hosts carry `ip` (and `host_<hostname>` slugs to resolve by name).
|
||||||
|
- Access is already group-based (`<slug>_access`), checkable via LDAP alone —
|
||||||
|
the jump host can run entirely off LDAP (SSSD) + one directory query.
|
||||||
|
- User SSH keys are in LDAP (openssh-lpk) — the jump host authenticates the
|
||||||
|
real user without local accounts.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Machine auth for the access query.** The jump host must ask "may user X
|
||||||
|
reach host Y" / "list hosts user X may reach" *about another user*.
|
||||||
|
`getMyAccess` only answers for the calling user. Needed: a
|
||||||
|
service-token-authenticated endpoint (`GET
|
||||||
|
/api/discovery/access/:uid[/:slug]`). `ServiceToken` already exists and
|
||||||
|
is even linked to a resource (`resource_id`) — what's missing is an auth
|
||||||
|
middleware that accepts it and a permission rule ("service tokens may
|
||||||
|
read access info, scoped read-only").
|
||||||
|
2. **Connection metadata conventions** on hosts: `sshPort` (default 22),
|
||||||
|
optional `fqdn` (when IP is dynamic), optional `jumpVia` edge relation if
|
||||||
|
multi-hop topologies ever appear.
|
||||||
|
3. Document the username grammar (`{uid}_-_{host-slug-or-ip}`) here so the
|
||||||
|
seed/ldap-client keep host slugs DNS-safe (they already are: slugify
|
||||||
|
strips everything but `[a-z0-9-]`).
|
||||||
|
|
||||||
|
### 9.3 Firewall port-forward rules (build / update / drift-test)
|
||||||
|
|
||||||
|
An automation renders the public firewall's forwarding table from the
|
||||||
|
directory, applies it, and alerts on drift in either direction.
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- `metadata.port` / `metadata.externalPort` / `metadata.ip` /
|
||||||
|
`metadata.isExternalReachable` — the core mapping data, already seeded for
|
||||||
|
the stack's own services.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Port-mapping convention is too thin for real rules**: no protocol, no
|
||||||
|
multi-port services. Adopt `metadata.portMappings: [{proto: "tcp"|"udp",
|
||||||
|
external: n, internal: n, comment}]` as the authoritative form
|
||||||
|
(`port`/`externalPort` stay as the simple single-mapping case).
|
||||||
|
2. **Drift detection needs cheap change polling**: an `updated_on` timestamp
|
||||||
|
on resources surfaced in the graph API, or a graph-level etag/hash, so
|
||||||
|
the runner can poll without diffing full payloads. (The ORM already
|
||||||
|
publishes create/update events internally — a future push feed can ride
|
||||||
|
that; polling comes first.)
|
||||||
|
3. Same **service-token read auth** as 9.2 — automation must not run on a
|
||||||
|
human's session token.
|
||||||
|
|
||||||
|
### 9.4 Local DNS / mDNS
|
||||||
|
|
||||||
|
A DNS (or mDNS advertiser) zone is generated from the directory: hosts get
|
||||||
|
A records from `metadata.ip`, services get CNAMEs/records from their
|
||||||
|
addresses, sites map to zones.
|
||||||
|
|
||||||
|
Already there:
|
||||||
|
- `host_<hostname>` + `ip` covers A records; `site_<name>` is a natural zone
|
||||||
|
boundary; service `address` yields names.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Name conventions**: `metadata.dnsNames: []` for extra aliases, and a
|
||||||
|
documented rule for which name wins (slug vs `address` hostname). TTL
|
||||||
|
only if someone actually needs per-record TTLs — default is fine.
|
||||||
|
2. Same **change detection** as 9.3 (poll `updated_on` / etag; push later).
|
||||||
|
3. Nothing else — this consumer is nearly free once 9.3's conventions land.
|
||||||
|
|
||||||
|
### 9.5 Access control for hosts
|
||||||
|
|
||||||
|
Who may log in to / sudo on which machine, driven by the directory.
|
||||||
|
|
||||||
|
Already there — this is the original point of the system:
|
||||||
|
- `<slug>_access` / `<slug>_admin` groups are auto-provisioned per host;
|
||||||
|
ldap-client configures SSSD/PAM against the directory; `sudoRole` and
|
||||||
|
openssh-lpk schemas cover sudo and SSH keys.
|
||||||
|
|
||||||
|
Gaps:
|
||||||
|
1. **Close the loop in ldap-client**: joined hosts should set an SSSD access
|
||||||
|
filter (`access_provider = ldap`, filter on `host_<hostname>_access`
|
||||||
|
membership) so directory group membership *is* login permission, not just
|
||||||
|
identity. Today the registration exists but enforcement is host-side
|
||||||
|
convention.
|
||||||
|
2. **`accessLevel` granularity**: ResourceGroup's `member`/`owner` maps to
|
||||||
|
login/admin today; if finer roles emerge (e.g. `login` vs `sudo` vs
|
||||||
|
`admin`), extend the enum — the join-table shape already supports it.
|
||||||
|
|
||||||
|
### 9.6 Consolidated work list (model/API only, no consumers)
|
||||||
|
|
||||||
|
Ordered by how much they unblock:
|
||||||
|
|
||||||
|
1. **Metadata privacy projection** on the read API (blocks 9.1; fixes the
|
||||||
|
`client_secret_hash` exposure regardless of any consumer).
|
||||||
|
2. **Service-token auth for `/api/discovery/*`** + `access/:uid` endpoint
|
||||||
|
(blocks 9.2, 9.3; ServiceToken model already exists).
|
||||||
|
3. **`AccessRequest` model + endpoints** (blocks 9.1's request half).
|
||||||
|
4. **Metadata conventions doc entries** (`sshPort`, `portMappings`,
|
||||||
|
`dnsNames`, `icon`, `tagline`, `requestable`) in `docs/directory.md` —
|
||||||
|
conventions, not schema changes; the json column already holds them.
|
||||||
|
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
||||||
|
freshness; trivial once surfaced).
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
# production, run a dedicated LDAP server and point the app at it via app_*
|
# 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.
|
# 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
|
# The app reads its configuration from conf/base.js + a secrets file, deep-merged
|
||||||
# by @simpleworkjs/conf, with `app_*` environment variables as the
|
# by @simpleworkjs/conf (requires >= 1.2.0, pinned in nodejs/package-lock.json),
|
||||||
# highest-precedence override layer. This entrypoint exports those `app_*`
|
# with `app_*` environment variables as the highest-precedence override layer.
|
||||||
# vars so the app connects to the bundled slapd without any mounted secrets
|
# This entrypoint exports those `app_*` vars so the app connects to the bundled
|
||||||
# file. Any `app_*` var already set in the environment wins (the values below
|
# slapd without any mounted secrets file. Any `app_*` var already set in the
|
||||||
# are defaults/fallbacks only).
|
# environment wins (the values below are defaults/fallbacks only).
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -33,14 +33,15 @@ error() { echo "[ERROR] $*" >&2; }
|
|||||||
# ── Optional: load operational config from a mounted secrets.js ──────────────
|
# ── Optional: load operational config from a mounted secrets.js ──────────────
|
||||||
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and
|
# 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
|
# treats it as the authoritative source for the SSO's config (LDAP base, admin
|
||||||
# password, org name, JWT secret, ...). When present, symlink it into
|
# password, org name, JWT secret, ...). When present, point CONF_SECRETS at it
|
||||||
# /app/conf/secrets.js so @simpleworkjs/conf reads it, and override the
|
# so @simpleworkjs/conf reads it directly (no write access to /app/conf
|
||||||
# env-derived operational vars below with the file's values. When absent
|
# needed), and override the env-derived operational vars below with the
|
||||||
# (standalone / env-var deployments) the env vars set above stay in effect and
|
# file's values. When absent (standalone / env-var deployments) the env vars
|
||||||
# the app_* exports further down are emitted as before.
|
# set above stay in effect and the app_* exports further down are emitted as
|
||||||
|
# before.
|
||||||
SECRETS_JS_MODE=0
|
SECRETS_JS_MODE=0
|
||||||
if [[ -f /config/sso-secrets.js ]]; then
|
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
|
SECRETS_JS_MODE=1
|
||||||
# Pull the entrypoint's operational vars out of secrets.js in one node call.
|
# 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
|
# 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/sudo.schema
|
||||||
include /etc/openldap/schema/openssh-lpk.schema
|
include /etc/openldap/schema/openssh-lpk.schema
|
||||||
|
|
||||||
|
SERVER_ID_PLACEHOLDER
|
||||||
|
|
||||||
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
|
# 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+
|
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
|
||||||
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
|
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
|
||||||
@@ -136,6 +139,8 @@ moduleload pw-sha2
|
|||||||
moduleload ppolicy
|
moduleload ppolicy
|
||||||
moduleload memberof
|
moduleload memberof
|
||||||
moduleload refint
|
moduleload refint
|
||||||
|
moduleload auditlog
|
||||||
|
SYNCPROV_MODULE_PLACEHOLDER
|
||||||
|
|
||||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
# 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
|
# generated/mounted above. We accept clients without their own cert (the common
|
||||||
@@ -183,6 +188,12 @@ memberof-memberof-ad memberOf
|
|||||||
overlay refint
|
overlay refint
|
||||||
refint_attributes memberOf member manager owner
|
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 controls
|
||||||
access to attrs=userPassword
|
access to attrs=userPassword
|
||||||
by dn="BIND_DN_PLACEHOLDER" write
|
by dn="BIND_DN_PLACEHOLDER" write
|
||||||
@@ -210,6 +221,30 @@ else
|
|||||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||||
fi
|
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 ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
|
||||||
chown -R ldap:ldap /var/lib/ldap 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).
|
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
|
||||||
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
||||||
# Alpine and the container only uses simple bind over ldap://localhost:389.
|
# 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=$!
|
SLAPD_PID=$!
|
||||||
|
|
||||||
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ nav:
|
|||||||
- title: LDAP
|
- title: LDAP
|
||||||
page: /ldap.html
|
page: /ldap.html
|
||||||
icon: fa-address-book
|
icon: fa-address-book
|
||||||
|
- title: Directory
|
||||||
|
page: /directory.html
|
||||||
|
icon: fa-server
|
||||||
- title: Changelog
|
- title: Changelog
|
||||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||||
icon: fa-list
|
icon: fa-list
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Accounts, Groups & Managers
|
||||||
|
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Accounts, Groups & Managers
|
||||||
|
|
||||||
|
This page explains the concepts behind the Users and Groups pages in plain
|
||||||
|
language. If you want the technical schema/attribute-level detail instead,
|
||||||
|
see the [LDAP reference](ldap.html).
|
||||||
|
|
||||||
|
## What's an account?
|
||||||
|
|
||||||
|
Every person (or app) that can sign in through this SSO Manager has an
|
||||||
|
**account** — a username, a display name, maybe an email address, and a
|
||||||
|
password (or, for service accounts, no password at all — see below).
|
||||||
|
Accounts live in the directory this app manages, and any other app you've
|
||||||
|
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
|
||||||
|
same accounts instead of keeping its own separate list of users and
|
||||||
|
passwords.
|
||||||
|
|
||||||
|
## Two kinds of account: people and service accounts
|
||||||
|
|
||||||
|
Most accounts belong to an actual person — check **Users → People** to see
|
||||||
|
them. But sometimes you need an account for something that *isn't* a
|
||||||
|
person: a media server, a backup script, a bind account another app uses to
|
||||||
|
look people up. These are **service accounts**, listed separately under
|
||||||
|
**Users → Service Accounts**, and they're different from a person's account
|
||||||
|
in two ways that matter:
|
||||||
|
|
||||||
|
- **No email required.** A service account doesn't need a mailbox, so the
|
||||||
|
form doesn't ask for one.
|
||||||
|
- **A password is optional.** If you leave it blank, nobody can log in as
|
||||||
|
that account — which is exactly what you want for something that only
|
||||||
|
ever gets used programmatically (a script authenticating with an API
|
||||||
|
token, or another app binding with a fixed, separately-configured
|
||||||
|
password you set yourself). Only give it a password if the account
|
||||||
|
genuinely needs to log in or bind somewhere as itself.
|
||||||
|
|
||||||
|
Aside from those two differences, a service account is a completely normal
|
||||||
|
account under the hood — it can belong to groups, have a manager, and so
|
||||||
|
on, just like anyone else's.
|
||||||
|
|
||||||
|
## Groups: who can do what
|
||||||
|
|
||||||
|
A **group** is just a named list of accounts, used to control access. This
|
||||||
|
app has a handful of built-in groups that grant admin powers (e.g. only
|
||||||
|
people in the `app_sso_admin` group can see the Users/Groups/Directory/Executive
|
||||||
|
pages at all), but you can also make your own groups for any app you
|
||||||
|
connect — say, a group listing everyone who should be allowed into your
|
||||||
|
photo server. Once a group exists, add or remove members from the
|
||||||
|
**Groups** page, and point the other app's "who's allowed in" setting at
|
||||||
|
that group's name.
|
||||||
|
|
||||||
|
## Every account's personal group
|
||||||
|
|
||||||
|
Separately from the groups above, every single account — person or
|
||||||
|
service account — automatically gets its own small, personal group when
|
||||||
|
it's created, named after the account itself. Most of the time you'll
|
||||||
|
never think about this; it exists so that, on a Linux system connected to
|
||||||
|
this directory, each account "owns" its own files by default the same way
|
||||||
|
a normal Unix user account would.
|
||||||
|
|
||||||
|
Occasionally you'll want to share that ownership with someone else — for
|
||||||
|
example, letting a second account also have write access to files a
|
||||||
|
service account owns. That's what the **"Members of `<uid>`'s group"**
|
||||||
|
section on a profile page is for: add another account there, and the
|
||||||
|
underlying Linux permissions treat them as if they belong to that same
|
||||||
|
personal group too.
|
||||||
|
|
||||||
|
## What's a "manager"?
|
||||||
|
|
||||||
|
Every account has one or more **managers** — the people allowed to edit
|
||||||
|
that account's profile (phone number, SSH key, home directory, and so on)
|
||||||
|
without needing full admin rights. By default, whoever created an account
|
||||||
|
(the admin who added it, or whoever sent the invite) becomes its first
|
||||||
|
manager, but you can add or remove managers later from the account's Edit
|
||||||
|
form.
|
||||||
|
|
||||||
|
This is useful for service accounts especially: if a service account
|
||||||
|
belongs to a particular project or person, make them its manager so they
|
||||||
|
can maintain it — rotate its SSH key, adjust its description — without
|
||||||
|
needing to be a full SSO administrator.
|
||||||
|
|
||||||
|
## Inviting someone vs. adding them yourself
|
||||||
|
|
||||||
|
From the Users page you can either fill in someone's details yourself
|
||||||
|
("Add new user"), or send them an **invite** — an email (or a link you copy
|
||||||
|
and send however you like) that lets them pick their own username and
|
||||||
|
password. Either way, the resulting account is identical; invites are just
|
||||||
|
a convenience so you don't have to know someone's preferred username or
|
||||||
|
handle their password directly.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page deliberately leaves out LDAP schema names, attribute types, and
|
||||||
|
protocol-level detail. If you're connecting a third-party app directly to
|
||||||
|
the LDAP directory, or you just want to know exactly what's stored where,
|
||||||
|
see the [LDAP reference](ldap.html).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: API Tokens
|
||||||
|
description: A plain-language guide to personal access tokens in SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Tokens
|
||||||
|
|
||||||
|
This page explains what an API token is and when you'd want one. For the
|
||||||
|
full list of API endpoints a token can call, see the
|
||||||
|
[API reference](api.html).
|
||||||
|
|
||||||
|
## What's an API token, in plain terms?
|
||||||
|
|
||||||
|
Normally, you interact with this app by logging in through a web browser.
|
||||||
|
An **API token** (also called a personal access token, or PAT) is an
|
||||||
|
alternative way in — a long, random string that a script, a scheduled job,
|
||||||
|
or another program can use instead of a username and password, to act on
|
||||||
|
your behalf without a human typing a login in each time.
|
||||||
|
|
||||||
|
If you've ever set up a script to talk to GitHub, GitLab, or a similar
|
||||||
|
service using a "token" instead of your real password, this is the same
|
||||||
|
idea.
|
||||||
|
|
||||||
|
## When would you actually need one?
|
||||||
|
|
||||||
|
Most people never need to create one of these — you'll only want a token
|
||||||
|
if you're automating something, for example:
|
||||||
|
|
||||||
|
- A script that syncs users or groups from somewhere else into this SSO
|
||||||
|
Manager on a schedule.
|
||||||
|
- A backup or monitoring job that checks this app's health via its API.
|
||||||
|
- A CI/CD pipeline that needs to register or update an OAuth client
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
If you're not doing any of that, you don't need an API token — just log in
|
||||||
|
normally through the web UI.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Create a token from your Profile page, give it a name so you remember what
|
||||||
|
it's for later, and optionally an expiry. You'll be shown the token's
|
||||||
|
value **exactly once** — copy it somewhere safe immediately, because it
|
||||||
|
can't be viewed again afterward (only revoked or rotated). Whatever script
|
||||||
|
or tool you're using it with sends it along with each request, the same
|
||||||
|
way a browser sends your login session.
|
||||||
|
|
||||||
|
A token acts **as you**, with **your** permissions — if you're not an
|
||||||
|
admin, a token you create can't do admin-only things either. If you ever
|
||||||
|
suspect a token has leaked (ended up somewhere it shouldn't have, like a
|
||||||
|
public script or log file), revoke it immediately from your Profile page;
|
||||||
|
it stops working right away.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page doesn't attempt to list every API endpoint or show request/
|
||||||
|
response examples — for that, see the full [API reference](api.html).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Connecting Apps (Single Sign-On)
|
||||||
|
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Connecting Apps (Single Sign-On)
|
||||||
|
|
||||||
|
This page explains, in plain language, what happens when you "connect" an
|
||||||
|
app to your SSO Manager so people can log into it with their existing
|
||||||
|
account. For the technical endpoint/token detail, see the
|
||||||
|
[OAuth reference](oauth.html).
|
||||||
|
|
||||||
|
## What does "single sign-on" actually mean?
|
||||||
|
|
||||||
|
Instead of every app you run having its own separate list of usernames and
|
||||||
|
passwords, they all check with this SSO Manager instead. You log in once,
|
||||||
|
here, and any connected app trusts that login — no separate password to
|
||||||
|
remember or manage for each one. If you ever need to lock someone out
|
||||||
|
everywhere at once, you do it in one place (deactivate their account here)
|
||||||
|
instead of hunting down every app individually.
|
||||||
|
|
||||||
|
The technology behind this is called **OAuth 2.0** and **OpenID Connect
|
||||||
|
(OIDC)** — you'll see both names used, often together, referring to the
|
||||||
|
same thing. You don't need to understand the protocol to use this page;
|
||||||
|
what matters practically is the handful of concepts below.
|
||||||
|
|
||||||
|
## What's a "client"?
|
||||||
|
|
||||||
|
Every app you connect is registered here as a **client** — a single entry
|
||||||
|
in the Directory representing that one app. Registering a client
|
||||||
|
gives you a **Client ID** and **Client Secret**: think of these like a
|
||||||
|
username and password, but for the *app itself* rather than for a person.
|
||||||
|
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||||
|
screen, along with the discovery URL shown at the top of this page, and
|
||||||
|
that app is now able to ask this SSO Manager to authenticate people on its
|
||||||
|
behalf.
|
||||||
|
|
||||||
|
**Treat the Client Secret like a password** — anyone who has it can
|
||||||
|
impersonate that app when talking to your SSO Manager. If you ever suspect
|
||||||
|
it's leaked, rotate it from the client's card.
|
||||||
|
|
||||||
|
## What are "scopes"?
|
||||||
|
|
||||||
|
**Scopes** control what information a connected app is allowed to ask for
|
||||||
|
about the person logging in — their username, email, group memberships,
|
||||||
|
and so on. Most apps tell you exactly which scopes they need in their own
|
||||||
|
setup instructions; when in doubt, the default set (`openid`, `profile`,
|
||||||
|
`email`, `groups`) covers what nearly every app expects.
|
||||||
|
|
||||||
|
## "Restrict to Groups"
|
||||||
|
|
||||||
|
By default, *any* account with an SSO Manager login can sign into a
|
||||||
|
connected app. If that's not what you want — say, a home automation
|
||||||
|
dashboard that only certain family members should reach — set **Restrict
|
||||||
|
to Groups** on that client to one of your [groups](concepts-accounts.html).
|
||||||
|
Only members of that group will be allowed to log into that particular
|
||||||
|
app; everyone else gets turned away at the login step, even though their
|
||||||
|
SSO Manager account still works everywhere else.
|
||||||
|
|
||||||
|
## Redirect URIs
|
||||||
|
|
||||||
|
A **Redirect URI** is the exact web address the connected app wants people
|
||||||
|
sent back to once they've logged in here — it's a security measure so an
|
||||||
|
attacker can't trick the login flow into redirecting somewhere else. The
|
||||||
|
app's own setup instructions will tell you this value; copy it in exactly
|
||||||
|
as given. If the app is reachable via more than one hostname (for example,
|
||||||
|
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
|
||||||
|
this field supports wildcard patterns — see the inline help under the
|
||||||
|
field itself for the exact syntax.
|
||||||
|
|
||||||
|
## Want more detail?
|
||||||
|
|
||||||
|
This page intentionally skips the protocol-level detail (exact endpoint
|
||||||
|
URLs, token formats, claim names). If you're troubleshooting a connection
|
||||||
|
or building something against the API directly, see the
|
||||||
|
[OAuth reference](oauth.html).
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
@@ -32,6 +32,8 @@ raw strings otherwise.
|
|||||||
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
|
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
|
||||||
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
|
| `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__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__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
|
||||||
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | 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 |
|
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Consumers of the directory
|
||||||
|
|
||||||
|
The inventory graph isn't just documentation — other components read it to make decisions:
|
||||||
|
|
||||||
|
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
|
||||||
|
|
||||||
|
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 221 KiB |
@@ -22,10 +22,11 @@ one command).
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
|
||||||
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
||||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a>
|
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
||||||
|
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
|
||||||
|
|
||||||
*(click any screenshot to view full size)*
|
*(click any screenshot to view full size)*
|
||||||
|
|
||||||
@@ -52,10 +53,13 @@ backend, that's the niche.
|
|||||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||||
invite and password-reset flows over email; self-service profile + API
|
invite and password-reset flows over email; self-service profile + API
|
||||||
tokens.
|
tokens.
|
||||||
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
|
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||||
|
directory.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
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. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
@@ -75,5 +79,7 @@ That's the standalone quick start. For the full set of install options
|
|||||||
|
|
||||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||||
reverse proxy, designed to sit in front of this SSO.
|
reverse proxy, designed to sit in front of this SSO.
|
||||||
|
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
|
||||||
|
uses this SSO's directory to decide who may reach which machine.
|
||||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||||
Manager and the proxy together with one command.
|
Manager and the proxy together with one command.
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
|
|||||||
|
|
||||||
[← Back to Home](index.html)
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
> Looking for a plainer explanation of accounts, groups, and managers
|
||||||
|
> instead of schema/attribute detail? See
|
||||||
|
> [Accounts, Groups & Managers](concepts-accounts.html).
|
||||||
|
|
||||||
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
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)
|
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
|
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||||
directly — Gitea, Emby, the theta42/proxy, etc.
|
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||||
|
theta42/proxy, etc.
|
||||||
|
|
||||||
## Directory layout
|
## Directory layout
|
||||||
|
|
||||||
@@ -35,6 +40,15 @@ User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
|
|||||||
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
|
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
|
||||||
- `theta42Person` (custom auxiliary; `dateOfBirth`).
|
- `theta42Person` (custom auxiliary; `dateOfBirth`).
|
||||||
|
|
||||||
|
Every user (person or service account) also carries a `manager` attribute
|
||||||
|
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
|
||||||
|
the people who created/administer that account. Set automatically to the
|
||||||
|
creator's DN on signup (whoever an admin was logged in as, or whoever sent
|
||||||
|
the invite), and reassignable later from the account's Edit form. Anyone
|
||||||
|
listed as a `manager` can edit that account (same fields an admin can:
|
||||||
|
mobile, description, SSH key, date of birth, home directory, login shell,
|
||||||
|
and the manager list itself) without needing `app_sso_admin`.
|
||||||
|
|
||||||
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
|
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
|
||||||
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
|
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
|
||||||
canonical hasher; if you provision users out-of-band, hash passwords the same
|
canonical hasher; if you provision users out-of-band, hash passwords the same
|
||||||
@@ -48,6 +62,19 @@ membership (`memberOf` on the user); `refint` keeps it consistent on
|
|||||||
add/remove. **Admin permission checks read the group's `member` list**, not
|
add/remove. **Admin permission checks read the group's `member` list**, not
|
||||||
`memberOf` on the user.
|
`memberOf` on the user.
|
||||||
|
|
||||||
|
### Personal groups
|
||||||
|
|
||||||
|
Every user (person or service account) also gets a **personal Unix group**
|
||||||
|
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
|
||||||
|
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
|
||||||
|
different schema than the `groupOfNames` groups above — its membership
|
||||||
|
attribute is `memberUid` (a bare username, not a DN), and unlike
|
||||||
|
`groupOfNames` it's valid with zero members. It's excluded from the
|
||||||
|
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
|
||||||
|
instead from the owning user's own profile page ("Members of `<uid>`'s
|
||||||
|
group", admin-only) — add other accounts as supplementary members, e.g. to
|
||||||
|
share write access to files owned by this group.
|
||||||
|
|
||||||
The SSO requires three groups (seeded automatically by the entrypoint /
|
The SSO requires three groups (seeded automatically by the entrypoint /
|
||||||
`install.sh`):
|
`install.sh`):
|
||||||
|
|
||||||
@@ -92,35 +119,116 @@ volumes:
|
|||||||
|
|
||||||
The entrypoint leaves existing certs untouched (idempotent).
|
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
|
## Service accounts
|
||||||
|
|
||||||
There are two different kinds of "not a real person" account, and which one
|
A service account is a normal `posixAccount` for something that isn't a
|
||||||
you want depends on what's consuming it:
|
person: a media manager, a torrent client, a service like Emby, or a
|
||||||
|
read-only bind account an app uses to look users up — anything that needs a
|
||||||
|
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
|
||||||
|
group for write access (e.g. a `stuff_manager` group granting write rights
|
||||||
|
to a media library). There's only one kind — every account, person or
|
||||||
|
service, is a real `posixAccount` with a UID.
|
||||||
|
|
||||||
**LDAP bind-only** — for an app that just needs to bind LDAP to look users up
|
Create one from the **Users → Service Accounts** tab's "Add new user" form
|
||||||
(its own "LDAP authentication" settings page, or the read-only account
|
with **This is a service account** checked — it skips the birthday/
|
||||||
`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no
|
Terms-of-Service fields a real person's account needs and asks for just an
|
||||||
home directory, can't log into this UI. Create one from the
|
account name. It's flagged (via membership in the `app_sso_service_account`
|
||||||
**Integrations → LDAP** tab's *Service Accounts* section (create, rotate
|
group) so it's listed separately from real people and excluded from "all
|
||||||
password, delete). theta-env's bootstrap creates `cn=ldapclient` this same
|
users" notification broadcasts.
|
||||||
way automatically, and the proxy binds as it — don't reuse the admin DN for
|
|
||||||
this.
|
|
||||||
|
|
||||||
**Unix/POSIX** — for an account something actually *runs as* on a Linux
|
Email and password are both optional for a service account:
|
||||||
host: a media manager, a torrent client, a service like Emby — anything that
|
|
||||||
needs a real `uidNumber`/`gidNumber` to own files or that other accounts join
|
|
||||||
via a group for write access (e.g. a `stuff_manager` group granting write
|
|
||||||
rights to a media library). Create one from the **Users** page's "Add new
|
|
||||||
user" form with **This is a service account** checked — it skips the
|
|
||||||
birthday/Terms-of-Service fields a real person's account needs and asks for
|
|
||||||
just an account name. It's a normal `posixAccount`, just flagged (via
|
|
||||||
membership in the `app_sso_service_account` group) so it's visibly marked in
|
|
||||||
the Users list and excluded from "all users" notification broadcasts.
|
|
||||||
|
|
||||||
Either way: don't reuse the admin DN, and give it only the group memberships
|
- No `mail` is set unless you give it one (it never needs a mailbox).
|
||||||
it actually needs.
|
- Leaving the password blank is fine — no `userPassword` attribute is set at
|
||||||
|
all, and an entry with no `userPassword` simply can't bind with any
|
||||||
|
password (standard LDAP simple-bind behavior). Only set a password if the
|
||||||
|
account actually needs to authenticate as itself (e.g. a bind-only account
|
||||||
|
an app uses to look users up).
|
||||||
|
|
||||||
Example bind test (LDAP bind-only account):
|
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
|
||||||
|
against LDAP (independent of this app), and the proxy binds as it — that
|
||||||
|
account won't show up in the Service Accounts tab since it isn't managed
|
||||||
|
through this app, but it keeps working unchanged.
|
||||||
|
|
||||||
|
Either way: don't reuse the admin DN, and give a service account only the
|
||||||
|
group memberships and `manager`s it actually needs.
|
||||||
|
|
||||||
|
Example bind test (a service account with a password set):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldaps://sso.example.com:636 \
|
ldapsearch -x -H ldaps://sso.example.com:636 \
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery doc
|
|||||||
|
|
||||||
[← Back to Home](index.html)
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
|
||||||
|
> of endpoint-level detail? See
|
||||||
|
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
|
||||||
|
|
||||||
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
|
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
|
||||||
access, refresh, and ID tokens that your apps can consume to authenticate
|
access, refresh, and ID tokens that your apps can consume to authenticate
|
||||||
users and authorize API calls. It also runs a full OpenLDAP directory, so it
|
users and authorize API calls. It also runs a full OpenLDAP directory, so it
|
||||||
@@ -49,20 +53,18 @@ An OAuth client represents an app that authenticates against the SSO. Each has:
|
|||||||
|
|
||||||
### Managing clients
|
### Managing clients
|
||||||
|
|
||||||
Clients are managed from the web UI (as a member of the `app_sso_oauth_admin`
|
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.
|
||||||
group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header
|
|
||||||
from a login):
|
|
||||||
|
|
||||||
| Method | Path | Action |
|
| Action | How to do it |
|
||||||
|--------|------|--------|
|
|--------|--------------|
|
||||||
| `GET` | `/api/oauth/client` | list clients |
|
| **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. |
|
||||||
| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) |
|
| **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. |
|
||||||
| `GET` | `/api/oauth/client/:id` | get one |
|
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
|
||||||
| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups |
|
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
|
||||||
| `DELETE` | `/api/oauth/client/:id` | delete |
|
|
||||||
| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret 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.
|
||||||
|
|
||||||
|
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
|
||||||
|
|
||||||
## Scopes
|
## Scopes
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,719 +1,230 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# install.sh - Idempotent standalone installer for Theta42 SSO Manager
|
|
||||||
# For Debian/Ubuntu systems
|
|
||||||
#
|
#
|
||||||
# This script:
|
# Install / update Theta42 SSO Manager on a fresh or existing host.
|
||||||
# 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
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# This script is idempotent: run it to install, and re-run it to update. It
|
||||||
# sudo ./install.sh [OPTIONS]
|
# 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:
|
# Secrets live at $SECRETS_FILE (/etc/sso-manager/secrets.js by default),
|
||||||
# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env)
|
# outside the repo checkout so they survive the hard reset below. FIRST RUN
|
||||||
# -b, --base-dn DN Base DN (default: dc=example,dc=com)
|
# ONLY (no $SECRETS_FILE yet): installs and configures OpenLDAP (modules,
|
||||||
# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager)
|
# overlays, custom schema, directory tree, required SSO groups -- see
|
||||||
# -o, --port PORT HTTP port for SSO Manager (default: 3001)
|
# ops/ldap-setup.sh), generates an LDAP admin password + JWT secret unless
|
||||||
# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated)
|
# given via env, and seeds $SECRETS_FILE with those values plus SMTP
|
||||||
# -s, --smtp-config CONFIG SMTP config as host:port:user:pass
|
# placeholders. Edit that file (SMTP, org name, ...) and re-run this script to
|
||||||
# --skip-ldap Skip LDAP installation (use existing LDAP)
|
# apply changes -- once it exists it is never touched again, and LDAP is never
|
||||||
# --skip-app Skip application installation (LDAP setup only)
|
# re-bootstrapped.
|
||||||
# --dry-run Show what would be done without making changes
|
|
||||||
# -h, --help Show this help
|
|
||||||
#
|
#
|
||||||
# Environment variables (alternative to flags):
|
# Intended to be driven by CI/CD with no human writes on prod: the checkout is
|
||||||
# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_*
|
# 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
|
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 ──────────────────────────────────────────────────────────────────
|
REPO_URL="${REPO_URL:-https://github.com/theta42/sso-manager-node.git}"
|
||||||
BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
|
REPO_DIR="${REPO_DIR:-/opt/theta42/sso-manager}"
|
||||||
ADMIN_PASS="${LDAP_ADMIN_PASS:-}"
|
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}"
|
ORG_NAME="${ORG_NAME:-SSO Manager}"
|
||||||
PORT="${PORT:-3001}"
|
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_LDAP="${SKIP_LDAP:-false}"
|
||||||
SKIP_APP="${SKIP_APP:-false}"
|
|
||||||
DRY_RUN="${DRY_RUN:-false}"
|
|
||||||
|
|
||||||
INSTALL_DIR="/opt/sso-manager"
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
SYSTEMD_DIR="/etc/systemd/system"
|
echo "This script must be run as root (try: sudo $0)" >&2
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
exit 1
|
||||||
|
|
||||||
# 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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Generate JWT secret if not provided
|
# Symlink $1 -> $2, replacing whatever is already at $2 (idempotent).
|
||||||
if [[ -z "$JWT_SECRET" ]]; then
|
link(){
|
||||||
JWT_SECRET=$(openssl rand -hex 32)
|
ln -sfn "$1" "$2"
|
||||||
info "Generated JWT secret: ${JWT_SECRET:0:8}..."
|
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
|
fi
|
||||||
|
|
||||||
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email
|
# FIRST_RUN gates OpenLDAP bootstrap + secrets seeding below -- both only ever
|
||||||
# sender defaults. Override with LDAP_DOMAIN if set.
|
# happen once, the first time this script runs on a host (i.e. before
|
||||||
if [[ -z "${LDAP_DOMAIN:-}" ]]; then
|
# $SECRETS_FILE exists). Every later run only updates the code.
|
||||||
LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g')
|
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
|
fi
|
||||||
|
|
||||||
# ── System checks ─────────────────────────────────────────────────────────────
|
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
|
||||||
check_root() {
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [ "$FIRST_RUN" -eq 1 ] && [ "$SKIP_LDAP" != "true" ]; then
|
||||||
error "This script must be run as root (sudo)"
|
echo "==> First run: bootstrapping OpenLDAP (base DN: ${LDAP_BASE_DN})"
|
||||||
exit 1
|
LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$(openssl rand -base64 24 | tr -d '=+/')}"
|
||||||
fi
|
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
|
||||||
check_os() {
|
# DN -- "dc=foo,dc=bar" -> "foo.bar". A malformed value here (e.g. the raw
|
||||||
if [[ ! -f /etc/debian_version ]]; then
|
# DN with only the leading "dc=" stripped) makes slapd's postinst hang
|
||||||
error "This script is for Debian/Ubuntu systems only"
|
# indefinitely instead of failing cleanly.
|
||||||
exit 1
|
LDAP_DOMAIN="$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')"
|
||||||
fi
|
|
||||||
info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)"
|
if ! command -v slapd >/dev/null 2>&1; then
|
||||||
}
|
debconf-set-selections <<-EOF
|
||||||
|
slapd slapd/internal/adminpw password ${LDAP_ADMIN_PASS}
|
||||||
# ── Package installation ──────────────────────────────────────────────────────
|
slapd slapd/password1 password ${LDAP_ADMIN_PASS}
|
||||||
install_package() {
|
slapd slapd/password2 password ${LDAP_ADMIN_PASS}
|
||||||
local pkg="$1"
|
slapd slapd/domain string ${LDAP_DOMAIN}
|
||||||
if dpkg -l | grep -q "^ii $pkg "; then
|
slapd shared/organization string ${ORG_NAME}
|
||||||
info "Package $pkg is already installed"
|
slapd slapd/purge_database boolean true
|
||||||
return 0
|
slapd slapd/move_old_database boolean true
|
||||||
fi
|
EOF
|
||||||
dry_run "Would install package: $pkg"
|
apt-get install -y slapd ldap-utils
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
cat > /etc/ldap/ldap.conf <<-EOF
|
||||||
apt-get update -qq
|
BASE ${LDAP_BASE_DN}
|
||||||
apt-get install -y -qq "$pkg"
|
URI ldap://localhost
|
||||||
info "Installed $pkg"
|
EOF
|
||||||
}
|
systemctl enable --now slapd
|
||||||
|
else
|
||||||
install_nodejs() {
|
echo " slapd already installed -- assuming it already serves ${LDAP_BASE_DN}"
|
||||||
if command -v node &>/dev/null && node --version | grep -q "v20"; then
|
fi
|
||||||
info "Node.js 20.x is already installed"
|
|
||||||
return 0
|
echo "==> Directory structure (ou=people, ou=groups)"
|
||||||
fi
|
for ou in people groups; do
|
||||||
dry_run "Would install Node.js 20.x"
|
dn="ou=${ou},${LDAP_BASE_DN}"
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
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"
|
||||||
info "Installing Node.js 20.x..."
|
else
|
||||||
# Use NodeSource repository for Node.js 20.x
|
ldapadd -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost <<-EOF
|
||||||
apt-get update -qq
|
dn: ${dn}
|
||||||
apt-get install -y -qq curl gnupg ca-certificates
|
objectClass: organizationalUnit
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1
|
ou: ${ou}
|
||||||
apt-get install -y -qq nodejs
|
EOF
|
||||||
info "Installed Node.js $(node --version)"
|
echo " ${dn} created"
|
||||||
}
|
fi
|
||||||
|
done
|
||||||
# ── OpenLDAP installation and configuration ───────────────────────────────────
|
|
||||||
install_openldap() {
|
echo "==> LDAP modules, overlays, schema, policy, SSO groups"
|
||||||
if command -v slapd &>/dev/null; then
|
"$REPO_DIR/ops/ldap-setup.sh" -p "$LDAP_ADMIN_PASS" -b "$LDAP_BASE_DN" -D "$BIND_DN"
|
||||||
info "OpenLDAP is already installed"
|
|
||||||
return 0
|
echo "==> Seeding ${SECRETS_FILE}"
|
||||||
fi
|
install -d -m 0750 "$(dirname "$SECRETS_FILE")"
|
||||||
dry_run "Would install OpenLDAP"
|
cat > "$SECRETS_FILE" <<-SECRETSEOF
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
'use strict';
|
||||||
|
|
||||||
info "Installing OpenLDAP..."
|
// 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.
|
||||||
# Pre-seed debconf for non-interactive installation
|
// LDAP admin password + JWT secret below were auto-generated; SMTP is a
|
||||||
debconf-set-selections << EOF
|
// placeholder (email delivery won't work until you fill it in).
|
||||||
slapd slapd/internal/adminpw string $ADMIN_PASS
|
|
||||||
slapd slapd/password1 string $ADMIN_PASS
|
module.exports = {
|
||||||
slapd slapd/password2 string $ADMIN_PASS
|
port: ${PORT},
|
||||||
slapd slapd/domain string ${BASE_DN#dc=}
|
name: '${ORG_NAME}',
|
||||||
slapd slapd/backend string MDB
|
ldap: {
|
||||||
slapd shared/organization string $ORG_NAME
|
url: 'ldap://localhost',
|
||||||
slapd slapd/purge_database boolean true
|
bindDN: '${BIND_DN}',
|
||||||
slapd slapd/move_old_database boolean true
|
bindPassword: '${LDAP_ADMIN_PASS}',
|
||||||
slapd slapd/invalid_config boolean true
|
userBase: 'ou=people,${LDAP_BASE_DN}',
|
||||||
EOF
|
groupBase: 'ou=groups,${LDAP_BASE_DN}',
|
||||||
|
},
|
||||||
apt-get update -qq
|
smtp: {
|
||||||
apt-get install -y -qq slapd ldap-utils
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
# Configure ldap.conf
|
secure: false,
|
||||||
cat > /etc/ldap/ldap.conf << LDAPCONF
|
user: 'noreply@${LDAP_DOMAIN}',
|
||||||
BASE $BASE_DN
|
pass: 'set-me',
|
||||||
URI ldap://localhost
|
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
|
||||||
LDAPCONF
|
},
|
||||||
|
oauth: {
|
||||||
# Set proper permissions
|
issuer: '',
|
||||||
chmod 644 /etc/ldap/ldap.conf
|
jwtSecret: '${JWT_SECRET}',
|
||||||
|
token_lifetime: {
|
||||||
info "OpenLDAP installed"
|
access_token: 3600,
|
||||||
}
|
refresh_token: 2592000,
|
||||||
|
},
|
||||||
configure_openldap() {
|
},
|
||||||
info "Configuring OpenLDAP..."
|
};
|
||||||
dry_run "Would configure OpenLDAP with base DN: $BASE_DN"
|
SECRETSEOF
|
||||||
[[ "$DRY_RUN" == "true" ]] && return 0
|
chmod 600 "$SECRETS_FILE"
|
||||||
|
echo " seeded ${SECRETS_FILE} (LDAP + JWT are live; SMTP is a placeholder)"
|
||||||
# Wait for slapd to be ready
|
echo " \$EDITOR ${SECRETS_FILE}"
|
||||||
for i in {1..10}; do
|
echo " then re-run this script (or: sudo systemctl restart sso-manager)"
|
||||||
if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then
|
elif [ "$FIRST_RUN" -eq 1 ]; then
|
||||||
info "OpenLDAP is ready"
|
echo "==> SKIP_LDAP=true -- not bootstrapping OpenLDAP or seeding ${SECRETS_FILE}"
|
||||||
break
|
echo " Write it yourself (see secrets.js.example) before starting sso-manager."
|
||||||
fi
|
else
|
||||||
sleep 1
|
echo "==> ${SECRETS_FILE} already exists, leaving LDAP + secrets untouched"
|
||||||
done
|
fi
|
||||||
|
|
||||||
# Detect the database DN for our suffix
|
echo "==> Symlink systemd config from the repo"
|
||||||
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
|
link "$REPO_DIR/ops/systemd/sso-manager.service" /etc/systemd/system/sso-manager.service
|
||||||
"(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \
|
|
||||||
| grep "^dn:" | head -1 | sed 's/^dn: //')
|
echo "==> Node dependencies"
|
||||||
|
# Deterministic, production-only install from the lockfile. Falls back to a
|
||||||
if [[ -z "$DB_DN" ]]; then
|
# plain install if the lockfile and manifest are out of step.
|
||||||
# Try to find any database and update its suffix
|
( cd "$REPO_DIR/nodejs" && { npm ci --omit=dev || npm install --omit=dev; } )
|
||||||
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
|
|
||||||
"(objectClass=olcDatabaseConfig)" dn 2>/dev/null \
|
echo "==> Services"
|
||||||
| grep "^dn:" | head -1 | sed 's/^dn: //')
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now sso-manager.service
|
||||||
if [[ -n "$DB_DN" ]]; then
|
systemctl restart sso-manager.service
|
||||||
info "Updating database suffix to $BASE_DN"
|
|
||||||
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
|
echo "==> Done."
|
||||||
dn: $DB_DN
|
if [ -z "$CURRENT_VERSION" ]; then
|
||||||
changetype: modify
|
echo " Installed v${NEW_VERSION}."
|
||||||
replace: olcSuffix
|
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
|
||||||
olcSuffix: $BASE_DN
|
echo " Already up to date (v${NEW_VERSION})."
|
||||||
EOF
|
else
|
||||||
fi
|
echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
|
||||||
fi
|
fi
|
||||||
|
echo " Update later with: sudo BRANCH=${BRANCH} $0"
|
||||||
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
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ app.contoller = require('./controller');
|
|||||||
|
|
||||||
// Background services (self-initializing on require).
|
// Background services (self-initializing on require).
|
||||||
require('./services/update_check');
|
require('./services/update_check');
|
||||||
|
require('./services/ldap_monitor');
|
||||||
|
|
||||||
// Push pubsub over the socket and back.
|
// Push pubsub over the socket and back.
|
||||||
app.onListen.push(function(){
|
app.onListen.push(function(){
|
||||||
@@ -60,6 +61,11 @@ app.set('trust proxy', 1);
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
// Set as an app local so every res.render has it, including routes that don't
|
||||||
|
// spread the routers' `values` object.
|
||||||
|
app.locals.ui = require('./utils/ui');
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// Have express server static content( images, CSS, browser JS) from the public
|
||||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
@@ -82,18 +88,19 @@ app.use('/api/user', middleware.auth, require('./routes/user'));
|
|||||||
app.use('/api/token', middleware.auth, require('./routes/token'));
|
app.use('/api/token', middleware.auth, require('./routes/token'));
|
||||||
|
|
||||||
app.use('/api/group', middleware.auth, require('./routes/group'));
|
app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||||
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
|
|
||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
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/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
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.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
// OAuth 2.0 / OpenID Connect
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
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', middleware.auth, oauthApiRouter);
|
||||||
|
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +113,7 @@ app.use(function(req, res, next) {
|
|||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Error handler. This is where `next()` will go on error
|
// Error handling
|
||||||
app.use(function(err, req, res, next) {
|
app.use(function(err, req, res, next) {
|
||||||
const SILENT_404S = ['/.well-known/'];
|
const SILENT_404S = ['/.well-known/'];
|
||||||
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
|
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
|
||||||
|
|||||||
@@ -25,13 +25,19 @@ var server = http.createServer(app);
|
|||||||
var io = require('socket.io')(server);
|
var io = require('socket.io')(server);
|
||||||
app.io = io;
|
app.io = io;
|
||||||
|
|
||||||
/**
|
const models = require('../models');
|
||||||
* Listen on provided port, on all network interfaces.
|
|
||||||
*/
|
|
||||||
|
|
||||||
server.listen(port);
|
/**
|
||||||
server.on('error', onError);
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
server.on('listening', onListening);
|
*/
|
||||||
|
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.
|
* Normalize a port into a number, string, or false.
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ module.exports = {
|
|||||||
groupBase: 'ou=groups,dc=example,dc=com',
|
groupBase: 'ou=groups,dc=example,dc=com',
|
||||||
userFilter: '(objectClass=posixAccount)',
|
userFilter: '(objectClass=posixAccount)',
|
||||||
userNameAttribute: 'uid',
|
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
|
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||||
|
|||||||
@@ -9,10 +9,23 @@ async function auth(req, res, next){
|
|||||||
// the same /api/* routes the UI uses.
|
// the same /api/* routes the UI uses.
|
||||||
const authz = req.header('authorization') || '';
|
const authz = req.header('authorization') || '';
|
||||||
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
||||||
const user = await Auth.checkApiToken(authz.slice(7));
|
const tokenStr = authz.slice(7);
|
||||||
if(user && user.uid){
|
if (tokenStr.startsWith('sso_')) {
|
||||||
req.user = user;
|
const user = await Auth.checkApiToken(tokenStr);
|
||||||
return next();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Auth.login = async function(data){
|
|||||||
|
|
||||||
return {user, token}
|
return {user, token}
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error("AUTH LOGIN error:", error);
|
console.error("AUTH LOGIN error:", error.name, error.message);
|
||||||
throw this.errors.login();
|
throw this.errors.login();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Mail.sendTemplate = async function(to, template, context, from){
|
|||||||
to,
|
to,
|
||||||
mustache.render(template.subject, context),
|
mustache.render(template.subject, context),
|
||||||
mustache.render(template.message, context),
|
mustache.render(template.message, context),
|
||||||
from || (template.from && mustache.render(template.message, context))
|
from || (template.from && mustache.render(template.from, context))
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,22 @@
|
|||||||
const { Client, Attribute, Change } = require('ldapts');
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
const { LRUCache } = require('lru-cache');
|
const { LRUCache } = require('lru-cache');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
|
||||||
|
// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
|
||||||
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
|
const escapeLDAPDNValue = escapeDN;
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroups(client, member){
|
async function getGroups(client, member){
|
||||||
let memberFilter = member ? `(member=${member})`: ''
|
let memberFilter = member ? `(member=${escapeLDAPSearchValue(member)})`: ''
|
||||||
|
|
||||||
let groups = (await client.search(conf.groupBase, {
|
let groups = (await client.search(conf.groupBase, {
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
@@ -35,7 +34,8 @@ async function getGroups(client, member){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addGroup(client, data){
|
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,
|
cn: data.name,
|
||||||
member: data.owner,
|
member: data.owner,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -139,9 +139,10 @@ Group.get = async function(data){
|
|||||||
}
|
}
|
||||||
|
|
||||||
return withClient(async (client) => {
|
return withClient(async (client) => {
|
||||||
|
const safeName = escapeLDAPSearchValue(data.name);
|
||||||
let group = (await client.search(conf.groupBase, {
|
let group = (await client.search(conf.groupBase, {
|
||||||
scope: 'sub',
|
scope: 'sub',
|
||||||
filter: `(&(objectClass=groupOfNames)(cn=${data.name}))`,
|
filter: `(&(objectClass=groupOfNames)(cn=${safeName}))`,
|
||||||
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
|
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
|
||||||
})).searchEntries[0];
|
})).searchEntries[0];
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
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);
|
const Table = setUpTable(conf.redis);
|
||||||
|
|
||||||
module.exports = Table;
|
module.exports = Table;
|
||||||
|
|
||||||
require('./token');
|
const { Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken } = require('./token');
|
||||||
require('./verification');
|
require('./verification');
|
||||||
require('./oauth_client');
|
|
||||||
require('./oauth_code');
|
require('./oauth_code');
|
||||||
require('./api_token');
|
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;
|
||||||
|
|||||||
@@ -1,50 +1,137 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
const { Resource } = require('./resource');
|
||||||
const bcrypt = require('bcrypt');
|
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 conf = require('@simpleworkjs/conf');
|
||||||
|
const UUID = () => crypto.randomUUID();
|
||||||
|
|
||||||
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
||||||
access_token: 3600,
|
access_token: 3600,
|
||||||
refresh_token: 2592000
|
refresh_token: 2592000
|
||||||
};
|
};
|
||||||
|
|
||||||
class OAuthClient extends Table {
|
class OAuthClient {
|
||||||
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'},
|
|
||||||
}
|
|
||||||
|
|
||||||
static async add(data) {
|
static async add(data) {
|
||||||
const raw_secret = UUID();
|
const raw_secret = crypto.randomUUID();
|
||||||
data.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
const client_id = crypto.randomUUID();
|
||||||
data.client_id = UUID();
|
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||||
const client = await this.create(data);
|
|
||||||
client._raw_secret = raw_secret;
|
// Generate a unique slug from the client name
|
||||||
return client;
|
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) {
|
||||||
|
const notFound = () => {
|
||||||
|
const e = new Error('OAuthClient not found');
|
||||||
|
e.status = 404;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await Resource.get(client_id);
|
||||||
|
} catch (_) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
// Resource.get() returns null (does not throw) for a missing id —
|
||||||
|
// guard it so a bad/undefined client_id is a clean 404, not a
|
||||||
|
// "Cannot read properties of null (reading 'kind')" 500.
|
||||||
|
if (!r || r.kind !== 'oauth') throw notFound();
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The ORM Model.toJSON() only serializes schema fields, so the mapped
|
||||||
|
// properties above (client_id, scopes, redirect_uris, …) would be
|
||||||
|
// stripped from any res.json() — that's why GET /api/oauth/client
|
||||||
|
// returned client_id: undefined and the bootstrap's rotate blew up.
|
||||||
|
// Emit the public shape explicitly. client_secret_hash is deliberately
|
||||||
|
// omitted so it never leaks over the API.
|
||||||
|
r.toJSON = function () {
|
||||||
|
return {
|
||||||
|
client_id: r.id,
|
||||||
|
id: r.id,
|
||||||
|
kind: r.kind,
|
||||||
|
name: r.name,
|
||||||
|
slug: r.slug,
|
||||||
|
owner: r.owner,
|
||||||
|
description: r.description,
|
||||||
|
redirect_uris: r.redirect_uris,
|
||||||
|
scopes: r.scopes,
|
||||||
|
allowed_groups: r.allowed_groups,
|
||||||
|
token_lifetime: r.token_lifetime,
|
||||||
|
is_valid: r.is_valid,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) {
|
static async list() {
|
||||||
return bcrypt.compare(secret, this.client_secret_hash);
|
const resources = await Resource.list({ where: { kind: 'oauth' } });
|
||||||
|
return Promise.all(resources.map(r => this.get(r.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async rotateSecret() {
|
static async listDetail() {
|
||||||
const raw_secret = UUID();
|
return this.list();
|
||||||
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) });
|
}
|
||||||
return raw_secret;
|
|
||||||
|
static async verifySecret(client_id, secret) {
|
||||||
|
const client = await this.get(client_id);
|
||||||
|
return client.verifySecret(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OAuthClient.register();
|
|
||||||
|
|
||||||
module.exports = { OAuthClient };
|
module.exports = { OAuthClient };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
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
|
// Shared base keyMap matching Token's schema so these behave as tokens
|
||||||
const tokenKeyMap = {
|
const tokenKeyMap = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Non-person "service" accounts under ou=people -- bind-only LDAP identities
|
|
||||||
// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's
|
|
||||||
// direct-LDAP bind account) or any other app/host that needs its own
|
|
||||||
// dedicated read-only credential, as opposed to a real user who logs into
|
|
||||||
// the web UI.
|
|
||||||
//
|
|
||||||
// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) --
|
|
||||||
// these can't log into the SSO Manager UI or get a home directory/uidNumber.
|
|
||||||
// objectClass matches exactly what theta-env's bootstrap.js already creates
|
|
||||||
// for cn=ldapclient, so this model recognizes and manages that account too,
|
|
||||||
// not just ones created through this UI.
|
|
||||||
|
|
||||||
const { Client, Attribute, Change } = require('ldapts');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
|
||||||
|
|
||||||
function hashPasswordSSHA512(password) {
|
|
||||||
const salt = crypto.randomBytes(8);
|
|
||||||
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
|
|
||||||
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClient() {
|
|
||||||
return new Client({ url: conf.url });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withClient(fn) {
|
|
||||||
const client = makeClient();
|
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))';
|
|
||||||
const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/;
|
|
||||||
|
|
||||||
var ServiceAccount = {};
|
|
||||||
|
|
||||||
ServiceAccount.list = async function(){
|
|
||||||
return withClient(async (client) => {
|
|
||||||
const res = await client.search(conf.userBase, {
|
|
||||||
scope: 'sub',
|
|
||||||
filter: FILTER,
|
|
||||||
attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'],
|
|
||||||
});
|
|
||||||
return res.searchEntries.map((entry) => ({
|
|
||||||
cn: entry.cn,
|
|
||||||
dn: `cn=${entry.cn},${conf.userBase}`,
|
|
||||||
description: entry.description || '',
|
|
||||||
created_on: entry.createTimestamp || null,
|
|
||||||
modified_on: entry.modifyTimestamp || null,
|
|
||||||
})).sort((a, b) => a.cn.localeCompare(b.cn));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.create = async function({cn, description}){
|
|
||||||
if(!cn || !CN_RE.test(cn)){
|
|
||||||
throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'});
|
|
||||||
}
|
|
||||||
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
const password = crypto.randomBytes(24).toString('base64url');
|
|
||||||
|
|
||||||
await withClient(async (client) => {
|
|
||||||
let existing = true;
|
|
||||||
try{
|
|
||||||
const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']});
|
|
||||||
existing = res.searchEntries.length > 0;
|
|
||||||
}catch(error){ existing = false; }
|
|
||||||
if(existing){
|
|
||||||
throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`});
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.add(dn, {
|
|
||||||
objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'],
|
|
||||||
cn,
|
|
||||||
description: description || '',
|
|
||||||
userPassword: hashPasswordSSHA512(password),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {cn, dn, description: description || '', password};
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.setPassword = async function(cn, password){
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
const newPassword = password || crypto.randomBytes(24).toString('base64url');
|
|
||||||
|
|
||||||
await withClient(async (client) => {
|
|
||||||
await client.modify(dn, [
|
|
||||||
new Change({
|
|
||||||
operation: 'replace',
|
|
||||||
modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {cn, dn, password: newPassword};
|
|
||||||
};
|
|
||||||
|
|
||||||
ServiceAccount.remove = async function(cn){
|
|
||||||
const dn = `cn=${cn},${conf.userBase}`;
|
|
||||||
await withClient(async (client) => {
|
|
||||||
await client.del(dn);
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {ServiceAccount};
|
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
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();
|
||||||
|
|
||||||
|
class Token extends Model {
|
||||||
class Token extends Table{
|
static adapterName = 'redis';
|
||||||
static _key = 'token';
|
static fields = {
|
||||||
static _keyMap = {
|
token: { type: 'string', primaryKey: true, default: UUID, isPrivate: true, min: 36, max: 36 },
|
||||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
created_by: { isRequired: true, type: 'string', min: 3, max: 500 },
|
||||||
'created_on': {default: function(){return (new Date).getTime()}},
|
created_on: { type: 'integer', default: function(){return (new Date).getTime()} },
|
||||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
updated_on: { type: 'integer', 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' }
|
||||||
'is_valid': {default: true, type: 'boolean'},
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(...args){
|
|
||||||
super(...args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async check(){
|
async check(){
|
||||||
@@ -27,12 +23,10 @@ class Token extends Table{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Token.register();
|
|
||||||
|
|
||||||
class AuthToken extends Token{
|
class AuthToken extends Token{
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
user: {model: 'User', type: 'hasOne', localKey: 'created_by'},
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(data){
|
static async create(data){
|
||||||
@@ -41,11 +35,10 @@ class AuthToken extends Token{
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthToken.register();
|
|
||||||
|
|
||||||
class InviteToken extends Token{
|
class InviteToken extends Token{
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
||||||
mail: {default: '__NONE__', type: 'string'},
|
mail: {default: '__NONE__', type: 'string'},
|
||||||
mail_token: {default: '__NONE__', type: 'string'},
|
mail_token: {default: '__NONE__', type: 'string'},
|
||||||
@@ -67,14 +60,13 @@ class InviteToken extends Token{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InviteToken.register();
|
|
||||||
|
|
||||||
class ImpersonationToken extends Token {
|
class ImpersonationToken extends Token {
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
||||||
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
|
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() {
|
get isExpired() {
|
||||||
@@ -86,42 +78,48 @@ class ImpersonationToken extends Token {
|
|||||||
return this.create(data);
|
return this.create(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImpersonationToken.register();
|
|
||||||
|
|
||||||
class PasswordResetToken extends Token {}
|
class PasswordResetToken extends Token {}
|
||||||
PasswordResetToken.register();
|
|
||||||
|
|
||||||
class OtpToken extends Token {
|
class OtpToken extends Token {
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...Token._keyMap,
|
...Token.fields,
|
||||||
uid: {isRequired: true, type: 'string'},
|
uid: {isRequired: true, type: 'string'},
|
||||||
code: {isRequired: true, type: 'string'},
|
code: {isRequired: true, type: 'string'},
|
||||||
method: {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() {
|
get isExpired() {
|
||||||
return (new Date).getTime() > this.expires_at;
|
return (new Date).getTime() > this.expires_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
|
|
||||||
static async issue(uid, method) {
|
static async issue(uid, method) {
|
||||||
const existing = await this.listDetail({uid});
|
const existing = await this.list({where: {uid}});
|
||||||
for (const t of existing) {
|
for (const t of existing) {
|
||||||
if (t.is_valid) await t.update({is_valid: false});
|
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});
|
return this.create({uid, code, method, created_by: uid});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async verify(uid, code) {
|
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);
|
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
await match.update({is_valid: false});
|
await match.update({is_valid: false});
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OtpToken.register();
|
class ServiceToken extends Token {
|
||||||
|
static fields = {
|
||||||
|
...Token.fields,
|
||||||
|
resource_id: {isRequired: true, type: 'string'}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issue(resource_id, created_by) {
|
||||||
|
return this.create({resource_id, created_by});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken};
|
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken};
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
|
|||||||
const {Group} = require('./group_ldap');
|
const {Group} = require('./group_ldap');
|
||||||
const {UserVerification} = require('./verification');
|
const {UserVerification} = require('./verification');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
|
||||||
|
// wrappers below preserve this file's no-arg call signatures (makeClient() /
|
||||||
|
// withClient(fn)) so no call site changes; sso's makeClient passes no
|
||||||
|
// tlsOptions, which the shared client forwards as undefined — identical to the
|
||||||
|
// previous `new Client({ url: conf.url })`.
|
||||||
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
|
const escapeLDAPDNValue = escapeDN;
|
||||||
|
|
||||||
function hashPasswordSSHA512(password) {
|
function hashPasswordSSHA512(password) {
|
||||||
const salt = crypto.randomBytes(8);
|
const salt = crypto.randomBytes(8);
|
||||||
@@ -23,26 +31,11 @@ const cache = new LRUCache({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to escape LDAP filter values (crucial for security)
|
|
||||||
function escapeLDAPSearchValue(val) {
|
|
||||||
return val.replace(/\\/g, '\\5c')
|
|
||||||
.replace(/\*/g, '\\2a')
|
|
||||||
.replace(/\(/g, '\\28')
|
|
||||||
.replace(/\)/g, '\\29')
|
|
||||||
.replace(/\0/g, '\\00');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute the next available uid/gidNumber: the highest existing value below
|
// Compute the next available uid/gidNumber: the highest existing value below
|
||||||
@@ -72,7 +65,8 @@ async function addPosixGroup(client, data){
|
|||||||
|
|
||||||
data.gidNumber = nextPosixId(groups, 'gidNumber');
|
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,
|
cn: data.cn,
|
||||||
gidNumber: data.gidNumber,
|
gidNumber: data.gidNumber,
|
||||||
objectclass: [ 'posixGroup', 'top' ]
|
objectclass: [ 'posixGroup', 'top' ]
|
||||||
@@ -94,6 +88,7 @@ async function addPosixAccount(client, data){
|
|||||||
|
|
||||||
data.uidNumber = nextPosixId(people, 'uidNumber');
|
data.uidNumber = nextPosixId(people, 'uidNumber');
|
||||||
|
|
||||||
|
const safeCn = escapeLDAPDNValue(data.cn);
|
||||||
const entry = {
|
const entry = {
|
||||||
cn: data.cn,
|
cn: data.cn,
|
||||||
sn: data.sn,
|
sn: data.sn,
|
||||||
@@ -103,7 +98,6 @@ async function addPosixAccount(client, data){
|
|||||||
givenName: data.givenName,
|
givenName: data.givenName,
|
||||||
loginShell: data.loginShell,
|
loginShell: data.loginShell,
|
||||||
homeDirectory: data.homeDirectory,
|
homeDirectory: data.homeDirectory,
|
||||||
userPassword: data.userPassword,
|
|
||||||
description: data.description || ' ',
|
description: data.description || ' ',
|
||||||
sudoHost: 'ALL',
|
sudoHost: 'ALL',
|
||||||
sudoCommand: 'ALL',
|
sudoCommand: 'ALL',
|
||||||
@@ -131,7 +125,24 @@ async function addPosixAccount(client, data){
|
|||||||
entry.dateOfBirth = data.dob;
|
entry.dateOfBirth = data.dob;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.add(`cn=${data.cn},${conf.userBase}`, entry);
|
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).
|
||||||
|
if (data.userPassword) {
|
||||||
|
entry.userPassword = data.userPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
|
||||||
|
// every account gets at least the DN of whoever created it.
|
||||||
|
if (data.manager && [].concat(data.manager).length) {
|
||||||
|
entry.manager = [].concat(data.manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.add(`cn=${safeCn},${conf.userBase}`, entry);
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -151,11 +162,14 @@ async function addLdapUser(client, data){
|
|||||||
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
|
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
|
||||||
}
|
}
|
||||||
data.cn = data.uid;
|
data.cn = data.uid;
|
||||||
data.loginShell = '/bin/bash';
|
data.loginShell = data.loginShell || '/bin/bash';
|
||||||
data.homeDirectory= `/home/${data.uid}`;
|
data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
|
||||||
data.userPassword = hashPasswordSSHA512(data.userPassword);
|
if (data.userPassword) {
|
||||||
|
data.userPassword = hashPasswordSSHA512(data.userPassword);
|
||||||
|
} else {
|
||||||
|
delete data.userPassword;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('addLdapUser', data)
|
|
||||||
group = await addPosixGroup(client, data);
|
group = await addPosixGroup(client, data);
|
||||||
data = await addPosixAccount(client, group);
|
data = await addPosixAccount(client, group);
|
||||||
|
|
||||||
@@ -190,10 +204,19 @@ const user_parse = function(data){
|
|||||||
data.username = data[conf.userNameAttribute]
|
data.username = data[conf.userNameAttribute]
|
||||||
data.userPassword = undefined;
|
data.userPassword = undefined;
|
||||||
}
|
}
|
||||||
|
data.location = data.l ? String(data.l) : '';
|
||||||
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
|
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
|
||||||
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
||||||
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
||||||
|
|
||||||
|
// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
|
||||||
|
// overlay) are both multi-valued; ldapts returns a bare string for a
|
||||||
|
// single value and an array for multiple -- normalize both to always be
|
||||||
|
// an array, or app-base.js's `for(let group of user.memberOf)` silently
|
||||||
|
// iterates a single DN string character-by-character instead of once.
|
||||||
|
data.manager = [].concat(data.manager || []).filter(Boolean);
|
||||||
|
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +265,8 @@ User.listDetail = async function(){
|
|||||||
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
||||||
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
|
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
|
||||||
|
|
||||||
|
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
|
||||||
|
|
||||||
const users = await Promise.all(searchEntries.map(async (entry) => {
|
const users = await Promise.all(searchEntries.map(async (entry) => {
|
||||||
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
|
||||||
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
|
||||||
@@ -269,6 +294,7 @@ User.listDetail = async function(){
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
|
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}));
|
}));
|
||||||
@@ -324,6 +350,13 @@ User.get = async function(data, key) {
|
|||||||
|
|
||||||
const verif = await UserVerification.getOrCreate(obj.uid);
|
const verif = await UserVerification.getOrCreate(obj.uid);
|
||||||
|
|
||||||
|
// Same membership check as User.listDetail() -- see the comment there.
|
||||||
|
try{
|
||||||
|
const svcGroup = await Group.get('app_sso_service_account');
|
||||||
|
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
|
||||||
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
|
}catch(error){ obj.isServiceAccount = ''; }
|
||||||
|
|
||||||
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
|
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
|
||||||
if (isLegacyMD5 && !verif.password_must_change) {
|
if (isLegacyMD5 && !verif.password_must_change) {
|
||||||
await verif.update({ password_must_change: true });
|
await verif.update({ password_must_change: true });
|
||||||
@@ -421,7 +454,7 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let editableFeilds = ['mobile', 'description'];
|
let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];
|
||||||
|
|
||||||
await withClient(async (client) => {
|
await withClient(async (client) => {
|
||||||
for(let field of editableFeilds){
|
for(let field of editableFeilds){
|
||||||
@@ -440,6 +473,19 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(data.sshPublicKey){
|
if(data.sshPublicKey){
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if(e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'replace',
|
operation: 'replace',
|
||||||
@@ -469,6 +515,31 @@ User.update = async function(data){
|
|||||||
]);
|
]);
|
||||||
this.dateOfBirth = data.dateOfBirth;
|
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.
|
||||||
|
const uids = [].concat(data.manager || []).filter(Boolean);
|
||||||
|
const managers = await Promise.all(uids.map(uid => User.get(uid)));
|
||||||
|
const dns = managers.map(u => u.dn);
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'replace',
|
||||||
|
modification: new Attribute({ type: 'manager', values: dns }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.manager = dns;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
|
||||||
@@ -537,6 +608,12 @@ User.addByInvite = async function(data){
|
|||||||
|
|
||||||
data.mail = token.mail;
|
data.mail = token.mail;
|
||||||
|
|
||||||
|
// Default manager: whoever sent the invite.
|
||||||
|
try {
|
||||||
|
const inviter = await this.get(token.created_by);
|
||||||
|
data.manager = [inviter.dn];
|
||||||
|
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
|
||||||
|
|
||||||
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
|
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
|
||||||
if (!data.uid || !suggestions.includes(data.uid)) {
|
if (!data.uid || !suggestions.includes(data.uid)) {
|
||||||
const err = new Error('Invalid username selection');
|
const err = new Error('Invalid username selection');
|
||||||
@@ -720,6 +797,19 @@ User.addSSHkey = async function(data) {
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
await withClient(async (client) => {
|
await withClient(async (client) => {
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(user.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if (e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(user.dn, [
|
await client.modify(user.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'add',
|
operation: 'add',
|
||||||
@@ -739,6 +829,53 @@ User.addSSHkey = async function(data) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every user gets a personal Unix group of the same name at creation (see
|
||||||
|
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
|
||||||
|
// 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=${escapeLDAPDNValue(uid)},${conf.groupBase}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
User.getPersonalGroupMembers = async function(uid) {
|
||||||
|
try {
|
||||||
|
return await withClient(async (client) => {
|
||||||
|
const res = await client.search(personalGroupDN(uid), {
|
||||||
|
scope: 'base',
|
||||||
|
filter: '(objectClass=posixGroup)',
|
||||||
|
attributes: ['memberUid'],
|
||||||
|
});
|
||||||
|
const entry = res.searchEntries[0];
|
||||||
|
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
|
||||||
|
});
|
||||||
|
} catch(error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
User.addPersonalGroupMember = async function(uid, memberUid) {
|
||||||
|
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.modify(personalGroupDN(uid), [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
User.removePersonalGroupMember = async function(uid, memberUid) {
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.modify(personalGroupDN(uid), [
|
||||||
|
new Change({
|
||||||
|
operation: 'delete',
|
||||||
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
User.invite = async function(data = {}){
|
User.invite = async function(data = {}){
|
||||||
try{
|
try{
|
||||||
let token = await InviteToken.create({
|
let token = await InviteToken.create({
|
||||||
@@ -759,6 +896,12 @@ User.invite = async function(data = {}){
|
|||||||
|
|
||||||
User.login = async function(data){
|
User.login = async function(data){
|
||||||
try{
|
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);
|
let user = await this.get(data.uid || data.username);
|
||||||
|
|
||||||
const loginClient = makeClient();
|
const loginClient = makeClient();
|
||||||
@@ -771,7 +914,7 @@ User.login = async function(data){
|
|||||||
return user;
|
return user;
|
||||||
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error("USER LOGIN error:", error);
|
console.error("USER LOGIN error:", error.name, error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.6",
|
"version": "1.6.3",
|
||||||
"private": true,
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -23,7 +23,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.1.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
|
"@simpleworkjs/frontend": "^0.2.5",
|
||||||
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
@@ -31,18 +36,19 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.1.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^0.4.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3",
|
||||||
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -67,13 +67,6 @@ app.user = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(args, callack){
|
|
||||||
if(!confirm('Delete '+ args.uid+ 'user?')) return false;
|
|
||||||
app.api.delete('user/'+ args.uid, function(error, data){
|
|
||||||
callack(error, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePassword(args, callack){
|
function changePassword(args, callack){
|
||||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -102,7 +95,15 @@ app.user = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, remove, createInvite, setActive};
|
// A user DN's cn is always their uid (see models/user_ldap.js addLdapUser,
|
||||||
|
// `data.cn = data.uid`) -- pulling it straight out of the DN avoids an
|
||||||
|
// extra lookup just to display a manager list.
|
||||||
|
function dnToUid(dn){
|
||||||
|
var m = /^cn=([^,]+)/i.exec(dn || '');
|
||||||
|
return m ? m[1] : dn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, createInvite, setActive, dnToUid};
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -149,6 +150,21 @@ app.ui = (function(app){
|
|||||||
// Drop the cache (e.g. after a group is created) so the next selector refetches.
|
// Drop the cache (e.g. after a group is created) so the next selector refetches.
|
||||||
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
|
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
|
||||||
|
|
||||||
|
// All usernames, fetched once and shared across every user selector (e.g. manager pickers).
|
||||||
|
var _usersPromise = null;
|
||||||
|
function loadUsers(){
|
||||||
|
if(!_usersPromise){
|
||||||
|
_usersPromise = new Promise(function(resolve){
|
||||||
|
app.user.list(function(error, data){
|
||||||
|
if(error || !data || !data.results){ resolve([]); return; }
|
||||||
|
resolve(data.results.map(function(u){ return u.uid; }).filter(Boolean).sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _usersPromise;
|
||||||
|
}
|
||||||
|
function refreshUsers(){ _usersPromise = null; return loadUsers(); }
|
||||||
|
|
||||||
// opts: { values, options, freeSolo, placeholder, name, separator }
|
// opts: { values, options, freeSolo, placeholder, name, separator }
|
||||||
// Returns a handle: { get, set, add, clear, setOptions, element }.
|
// Returns a handle: { get, set, add, clear, setOptions, element }.
|
||||||
function tagInput(mount, opts){
|
function tagInput(mount, opts){
|
||||||
@@ -249,7 +265,25 @@ app.ui = (function(app){
|
|||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups };
|
// Universal user selector (e.g. picking managers). Preloads all usernames.
|
||||||
|
function userSelect(mount, opts){
|
||||||
|
opts = opts || {};
|
||||||
|
var handle = tagInput(mount, {
|
||||||
|
name: opts.name || 'manager',
|
||||||
|
values: opts.values || [],
|
||||||
|
options: [],
|
||||||
|
freeSolo: opts.freeSolo !== false,
|
||||||
|
separator: opts.separator != null ? opts.separator : '\n',
|
||||||
|
placeholder: opts.placeholder || 'Type a username…',
|
||||||
|
});
|
||||||
|
loadUsers().then(function(users){ handle.setOptions(users); });
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups,
|
||||||
|
userSelect: userSelect, loadUsers: loadUsers, refreshUsers: refreshUsers,
|
||||||
|
};
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.oauthClient = (function(app){
|
app.oauthClient = (function(app){
|
||||||
@@ -265,13 +299,6 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(args, callack){
|
|
||||||
if(!confirm('Delete OAuth client "' + args.client_id + '"?')) return false;
|
|
||||||
app.api.delete('oauth/client/' + args.client_id, function(error, data){
|
|
||||||
callack(error, data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(args, callack){
|
function update(args, callack){
|
||||||
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -284,7 +311,7 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { list, add, remove, update, rotateSecret };
|
return { list, add, update, rotateSecret };
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.tos = (function(app){
|
app.tos = (function(app){
|
||||||
@@ -355,7 +382,7 @@ app.impersonate = (function(app){
|
|||||||
|
|
||||||
app.token = (function(app){
|
app.token = (function(app){
|
||||||
function list(name, callack){
|
function list(name, callack){
|
||||||
if($.isFunction(name)){
|
if(typeof name === 'function'){
|
||||||
callack = name;
|
callack = name;
|
||||||
name = '';
|
name = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// Shared client framework for the theta42 apps.
|
||||||
|
//
|
||||||
|
// This file is byte-identical across sso-manager-node, proxy and jump-host —
|
||||||
|
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
|
||||||
|
// the /api/user/me response), never from edits to this file. Edit all three
|
||||||
|
// copies together.
|
||||||
|
//
|
||||||
|
// jQuery 4 safe: no $.isFunction, no $.holdReady.
|
||||||
|
|
||||||
var app = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -45,7 +54,7 @@ app.pubsub = (function(){
|
|||||||
app.socket = (function(app){
|
app.socket = (function(app){
|
||||||
// $.getScript('/socket.io/socket.io.js')
|
// $.getScript('/socket.io/socket.io.js')
|
||||||
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
// <script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
var socket;
|
var socket;
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
socket = io({
|
socket = io({
|
||||||
@@ -75,11 +84,17 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if (!$.isFunction(callback)) {
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||||
return new Promise((resolve, reject) => {
|
// with the parsed body and rejects with the error body. get/options return
|
||||||
|
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
|
||||||
|
|
||||||
|
function body(method, url, data, callback){
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST', url: baseURL+url,
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
@@ -88,9 +103,11 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
@@ -104,40 +121,27 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if (!$.isFunction(callback)) {
|
return body('PUT', url, data, callback);
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
$.ajax({
|
|
||||||
type: 'PUT', url: baseURL+url,
|
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: 'application/json; charset=utf-8',
|
|
||||||
dataType: 'json',
|
|
||||||
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return $.ajax({
|
|
||||||
type: 'PUT',
|
|
||||||
url: baseURL+url,
|
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
complete: function(res, text){
|
|
||||||
callback(
|
|
||||||
text !== 'success' ? res.statusText : null,
|
|
||||||
JSON.parse(res.responseText),
|
|
||||||
res.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if (!$.isFunction(callback)) {
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
return new Promise((resolve, reject) => {
|
// No request body is sent either way.
|
||||||
|
function remove(url, data, callback){
|
||||||
|
if(typeof data === 'function'){
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'DELETE', url: baseURL+url,
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@@ -147,7 +151,9 @@ app.api = (function(app){
|
|||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
@@ -202,7 +208,10 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(app){
|
app.auth = (function(app){
|
||||||
var user = {};
|
// One in-flight/cached GET /api/user/me per page load. Every gating
|
||||||
|
// decision (nav items, per-view forceLogin, group-required elements) reads
|
||||||
|
// this same promise instead of re-fetching.
|
||||||
|
var userPromise = null;
|
||||||
|
|
||||||
function setToken(token){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
@@ -216,35 +225,70 @@ app.auth = (function(app){
|
|||||||
try{
|
try{
|
||||||
return await app.api.get('user/me');
|
return await app.api.get('user/me');
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(error?.status === 401) return null;
|
if(error && error.status === 401) return null;
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cached current user, or false when there's no token at all. Callers that
|
||||||
|
// need a fresh copy (after a login or a profile change) pass force.
|
||||||
|
function loadUser(force){
|
||||||
|
if(force || !userPromise){
|
||||||
|
userPromise = getToken() ? getUser() : Promise.resolve(null);
|
||||||
|
userPromise = userPromise.then(function(user){
|
||||||
|
app.auth.user = app.auth.perms = user || null;
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return userPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The apps report group membership two ways: sso-manager-node returns LDAP
|
||||||
|
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
|
||||||
|
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
|
||||||
|
// is exposed as a synthetic `admin` group so one gating model covers both.
|
||||||
|
function groupCNs(user){
|
||||||
|
var raw = (user && (user.memberOf || user.groups)) || [];
|
||||||
|
if(!Array.isArray(raw)) raw = [raw];
|
||||||
|
var names = raw.map(function(group){
|
||||||
|
return String(group).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
async function memberOf(groupNameToFind, user){
|
async function memberOf(groupNameToFind, user){
|
||||||
try{
|
user = user || await loadUser();
|
||||||
user = user || await app.auth.asyncUser;
|
if(!user) return false;
|
||||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||||
|
|
||||||
for(let group of user.memberOf){
|
return groupCNs(user).some(function(group){
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
return groupNameToFind.includes(group);
|
||||||
if(groupNameToFind.includes(group)) return true;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}catch(error){
|
|
||||||
throw(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLoggedIn(){
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||||
if(getToken()){
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||||
user = await app.auth.asyncUser;
|
function isAdmin(){
|
||||||
return user;
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||||
}else{
|
}
|
||||||
return false;
|
|
||||||
|
// Dual-mode: returns a Promise resolving to the user (or false), and calls
|
||||||
|
// an optional node-style callback with the same result.
|
||||||
|
function isLoggedIn(callback){
|
||||||
|
var promise = loadUser().then(function(user){
|
||||||
|
return user || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(typeof callback === 'function'){
|
||||||
|
promise.then(function(user){
|
||||||
|
callback(null, user);
|
||||||
|
}, function(error){
|
||||||
|
callback(error, false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logIn(args, callback){
|
function logIn(args, callback){
|
||||||
@@ -252,62 +296,125 @@ app.auth = (function(app){
|
|||||||
if(data.login){
|
if(data.login){
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
}
|
}
|
||||||
|
loadUser(true);
|
||||||
callback(error, !!data.token);
|
callback(error, !!data.token);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clears the session only — the caller decides where to go next (the nav's
|
||||||
|
// Log Out button uses ui.logoutRedirect).
|
||||||
function logOut(callback){
|
function logOut(callback){
|
||||||
localStorage.removeItem('APIToken');
|
localStorage.removeItem('APIToken');
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
userPromise = null;
|
||||||
callback();
|
app.auth.user = app.auth.perms = null;
|
||||||
|
if(typeof callback === 'function') callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an app token handed back by the OIDC callback via the URL
|
||||||
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||||
|
// forwards to the intended page. Returns true if a token was consumed.
|
||||||
|
function consumeTokenFragment(){
|
||||||
|
if(!location.hash) return false;
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
var token = params.get('token');
|
||||||
|
if(!token) return false;
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
// redirect comes from the URL fragment (attacker-controllable); only
|
||||||
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||||
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||||
|
// Drop the token from the address bar before navigating on.
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
window.location.href = redirect;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
|
// rather than by pausing document ready.
|
||||||
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
async function forceLogin(requiredGroups){
|
async function forceLogin(requiredGroups){
|
||||||
$.holdReady(true);
|
var user = await loadUser();
|
||||||
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
|
|
||||||
|
if(!user){
|
||||||
|
logOut(function(){});
|
||||||
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
location.replace('/onboarding');
|
location.replace('/onboarding');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(!await memberOf(requiredGroups)){
|
app.messages.action(
|
||||||
console.log("Does not have permission!!!")
|
`<h1>
|
||||||
app.util.actionMessage(
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
`<h1>
|
<b>You do not have permission to be here.</b>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
</h1>`,
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
$('#spa-shell'),
|
||||||
</h1>`,
|
'danger',
|
||||||
$('#spa-shell'),
|
);
|
||||||
'danger',
|
throw new Error("User does not have permission");
|
||||||
);
|
|
||||||
throw new Error("User does not have permission");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.holdReady(false);
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where to go after a successful login: the ?redirect= query param, or the
|
||||||
|
// legacy /login/<path> suffix form, constrained to a same-origin path. The
|
||||||
|
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
|
||||||
|
// is how the OIDC provider sends an unauthenticated user through login.
|
||||||
function logInRedirect(){
|
function logInRedirect(){
|
||||||
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
|
var params = new URLSearchParams(location.search);
|
||||||
|
var target = params.get('redirect')
|
||||||
|
|| location.href.replace(location.origin + '/login', '')
|
||||||
|
|| '/';
|
||||||
|
window.location.href = safeInternalPath(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
|
getUser: getUser,
|
||||||
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
|
isAdmin: isAdmin,
|
||||||
isLoggedIn: isLoggedIn,
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
logInRedirect,
|
logInRedirect,
|
||||||
getUser,
|
|
||||||
memberOf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
app.auth.asyncUser = app.auth.getUser();
|
|
||||||
|
|
||||||
|
// Back-compat alias for views that awaited the cached user directly.
|
||||||
|
Object.defineProperty(app.auth, 'asyncUser', {
|
||||||
|
get: function(){ return app.auth.loadUser(); },
|
||||||
|
});
|
||||||
|
|
||||||
app.user = (function(app){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
@@ -338,6 +445,72 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||||
|
// these endpoints; the calls are inert elsewhere.
|
||||||
|
app.permission = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('permission/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjects(callback){
|
||||||
|
app.api.get('permission/subjects', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('permission/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, callback){
|
||||||
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, subjects, add, remove};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.group = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('group/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('group/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(name, username, callback){
|
||||||
|
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(name, username, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, add, remove, addMember, removeMember};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
app.util = (function(app){
|
app.util = (function(app){
|
||||||
|
|
||||||
function getUrlParameter(name){
|
function getUrlParameter(name){
|
||||||
@@ -347,65 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $targetPassed, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
|
function escapeHtml(s){
|
||||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
return String(s == null ? '' : s)
|
||||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
type = type || 'info';
|
.replace(/>/g, '>')
|
||||||
callback = callback || function(){};
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
if($target.html() === message) return;
|
|
||||||
|
|
||||||
if($target.html()){
|
|
||||||
$target.slideUp('fast', function(){
|
|
||||||
$target.html('')
|
|
||||||
$target.removeClass (function(index, className){
|
|
||||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
|
||||||
});
|
|
||||||
if(message) return actionMessage(message, $target, type, callback);
|
|
||||||
$target.hide()
|
|
||||||
})
|
|
||||||
}else{
|
|
||||||
if(type) $target.addClass('bg-' + type);
|
|
||||||
|
|
||||||
if(!message.includes('<button')) message += `
|
|
||||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
$target.html(message).slideDown('fast');
|
|
||||||
}
|
|
||||||
setTimeout(callback,10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionConfirm(message, $target, type, callback){
|
|
||||||
return new Promise((resolve, reject) =>{
|
|
||||||
let id = crypto.randomUUID();
|
|
||||||
message = `
|
|
||||||
<h4 class"align-middle" >
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
<b>${message}</b>
|
|
||||||
<span class="float-end">
|
|
||||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
|
||||||
<i class="fa-solid fa-circle-check"></i>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger confirm-${id}">
|
|
||||||
<i class="fa-solid fa-circle-stop"></i>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
`
|
|
||||||
actionMessage(message, $target, type);
|
|
||||||
$("body").on('click', `.confirm-${id}`, function(){
|
|
||||||
actionMessage('', $target, type);
|
|
||||||
resolve(!!$(this).data('confirm'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -415,8 +538,11 @@ app.util = (function(app){
|
|||||||
for (let {name, value} of $(this).serializeArray()) {
|
for (let {name, value} of $(this).serializeArray()) {
|
||||||
console.log(name, value)
|
console.log(name, value)
|
||||||
if (obj[name] === undefined) {
|
if (obj[name] === undefined) {
|
||||||
if (!value
|
if (!value
|
||||||
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||||
|
// Keep empty <textarea>s so a cleared field is submitted (and
|
||||||
|
// can reset a list, e.g. the per-host IP/header controls).
|
||||||
|
&& !$(this).filter(`textarea[name="${name}"]`).length
|
||||||
){
|
){
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -461,26 +587,40 @@ app.util = (function(app){
|
|||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage,
|
escapeHtml: escapeHtml,
|
||||||
actionConfirm,
|
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
$( document ).ready(async function(){
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
app.auth.applyGroupVisibility = function(user){
|
||||||
|
var groups = app.auth.groupCNs(user);
|
||||||
|
if(!groups.length) return;
|
||||||
|
|
||||||
// Show content if the user has the correct group
|
var style = document.getElementById('group-required-rules');
|
||||||
for(let group of (await app.auth.asyncUser)?.memberOf || []){
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
try{
|
try{
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
const sheet = document.styleSheets[0];
|
style.sheet.cssRules.length
|
||||||
const selector = `.group-required-${group}`;
|
);
|
||||||
const cssText = `${selector} { display: revert !important; }`;
|
|
||||||
sheet.insertRule(cssText, sheet.cssRules.length);
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$( document ).ready(async function(){
|
||||||
|
|
||||||
|
// Show content the user's groups entitle them to.
|
||||||
|
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
||||||
|
|
||||||
$('div.row').fadeIn('slow'); //show the page
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
@@ -502,9 +642,9 @@ $( document ).ready(async function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -535,11 +675,11 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
app.messages.action(
|
||||||
`<div class="spinner-border" role="status">
|
`<div class="spinner-border" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>`,
|
</div>`,
|
||||||
@@ -548,7 +688,7 @@ function formAJAX(btn){
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
$form.validateClear();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
@@ -556,7 +696,7 @@ function formAJAX(btn){
|
|||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
console.log('formAJAX res error', error, data)
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
}
|
}
|
||||||
if(data && data.keys){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
console.log('form key errors', data.keys)
|
||||||
@@ -567,4 +707,3 @@ function formAJAX(btn){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
( function( $ ) {
|
|
||||||
var settings = {
|
|
||||||
rule: {
|
|
||||||
eq: function(value, options){
|
|
||||||
var compare = $('[name=' + options + ']').val();
|
|
||||||
|
|
||||||
if ( value != compare ) {
|
|
||||||
return "Miss-match";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validate = function(event) {
|
|
||||||
// let thisSettings = $.extend(true, settings, settingsObj);
|
|
||||||
let hasErrors = false;
|
|
||||||
|
|
||||||
if(this.is('[validate]')) return this.validateField(event);
|
|
||||||
|
|
||||||
if(!this.attr('isValid')){
|
|
||||||
console.log('adding reset event')
|
|
||||||
this.on('reset', function(){
|
|
||||||
$(this).attr('isValid', false);
|
|
||||||
$(this).validateClear();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.find('[validate]').each(function(){
|
|
||||||
if(!$(this).validateField()) hasErrors = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.attr('isValid', !hasErrors);
|
|
||||||
|
|
||||||
if(hasErrors && event) event.preventDefault();
|
|
||||||
|
|
||||||
return !hasErrors;
|
|
||||||
};
|
|
||||||
|
|
||||||
$.fn.validateClear = function(){
|
|
||||||
$(this).find('input').each(function(){
|
|
||||||
$(this).removeClass('is-invalid');
|
|
||||||
$(this).removeClass('is-valid');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateField = function(){
|
|
||||||
var attr = this.attr('validate').split(':'); //array of params
|
|
||||||
var rule = attr[0];
|
|
||||||
var options = attr[1];
|
|
||||||
var value = this.val(); //link to input value
|
|
||||||
var message;
|
|
||||||
|
|
||||||
if(this.prop('disabled')) return true;
|
|
||||||
|
|
||||||
|
|
||||||
//checks if field is required, and length
|
|
||||||
if(!isNaN(options) && value.length < options){
|
|
||||||
message = `Must be ${options} characters`;
|
|
||||||
}
|
|
||||||
|
|
||||||
//checks if empty to stop processing
|
|
||||||
if(!isNaN(options) && value.length === 0) {
|
|
||||||
}else if(rule in settings.rule){
|
|
||||||
let message = settings.rule[rule].apply(this, [value, options]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.validateMessage(message)
|
|
||||||
return !message;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.fn.validateMessage = function(message){
|
|
||||||
if(message && message !== true){
|
|
||||||
this.closest('.form-group').find('b.invalid-feedback').html(message);
|
|
||||||
this.addClass('is-invalid');
|
|
||||||
}else{
|
|
||||||
this.removeClass('is-invalid');
|
|
||||||
this.addClass('is-valid');
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
jQuery.extend({
|
|
||||||
validateSettings: function( settingsObj ) {
|
|
||||||
$.extend( true, settings, settingsObj );
|
|
||||||
},
|
|
||||||
|
|
||||||
validateInit: function( ettingsObj ) {
|
|
||||||
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
|
|
||||||
$( this ).validate( settingsObj, event );
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}( jQuery ));
|
|
||||||
|
|
||||||
$.validateSettings({
|
|
||||||
rule:{
|
|
||||||
ip: function( value ) {
|
|
||||||
value = value.split( '.' );
|
|
||||||
|
|
||||||
if ( value.length != 4 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
|
|
||||||
$.each( value, function( key, value ) {
|
|
||||||
if( value > 255 || value < 0 ) {
|
|
||||||
return "Malformed IP";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
host: function( value ) {
|
|
||||||
var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
user: function( value ) {
|
|
||||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Invalid";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
password: function( value ) {
|
|
||||||
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
|
|
||||||
if ( reg.test( value ) === false ) {
|
|
||||||
return "Weak password, Try again";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
'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');
|
||||||
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
|
// the wire; projectResources strips it unconditionally.
|
||||||
|
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||||
|
} 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;
|
||||||
@@ -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;
|
||||||
@@ -13,6 +13,7 @@ const middleware = require('../middleware/auth');
|
|||||||
const rateLimit = require('../middleware/rate_limit');
|
const rateLimit = require('../middleware/rate_limit');
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const metrics = require('../utils/metrics');
|
||||||
|
|
||||||
async function findUserByLogin(login) {
|
async function findUserByLogin(login) {
|
||||||
try {
|
try {
|
||||||
@@ -37,12 +38,16 @@ router.get('/username-suggestions', async function(req, res, next) {
|
|||||||
router.post('/login', rateLimit.login, async function(req, res, next){
|
router.post('/login', rateLimit.login, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let auth = await Auth.login(req.body);
|
let auth = await Auth.login(req.body);
|
||||||
|
metrics.recordServiceUsage('SSO Web UI', req.body.uid);
|
||||||
return res.json({
|
return res.json({
|
||||||
login: true,
|
login: true,
|
||||||
token: auth.token.token,
|
token: auth.token.token,
|
||||||
message:`${req.body.uid} logged in!`,
|
message:`${req.body.uid} logged in!`,
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
if (error.name === 'LDAPLoginFailed' || error.status === 401 || error.name === 'UserNotFound') {
|
||||||
|
metrics.recordFailedLogin(req.ip, req.body.uid);
|
||||||
|
}
|
||||||
next(error);
|
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);
|
const target = await User.get(req.params.uid);
|
||||||
|
|
||||||
// Clean up any existing impersonation for this target
|
// 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) {
|
for (const old of existing) {
|
||||||
if (old.is_valid && !old.isExpired) {
|
if (old.is_valid && !old.isExpired) {
|
||||||
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
|
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']);
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
|
||||||
const target = await User.get(req.params.uid);
|
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;
|
let revoked = 0;
|
||||||
for (const token of existing) {
|
for (const token of existing) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Public directory discovery API. Mounted at /api/discovery (app.js, before
|
||||||
|
// the 404 catcher). Every response uses the `{ results }` envelope and the
|
||||||
|
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
|
||||||
|
// OAuth client's client_secret_hash) never leave the server and non-admins only
|
||||||
|
// see the public metadata allowlist.
|
||||||
|
//
|
||||||
|
// This replaces the autoRouter mount (which returned bare arrays — the shape
|
||||||
|
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
|
||||||
|
// bridge) and absorbs the dead /me handler that used to live in
|
||||||
|
// routes/api_discovery.js (mounted after the 404, so unreachable).
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
|
const {
|
||||||
|
envelope,
|
||||||
|
projectResource,
|
||||||
|
projectResources,
|
||||||
|
isDirectoryAdmin,
|
||||||
|
} = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
// GET /api/discovery/resources[?kind=&group=&parent=]
|
||||||
|
router.get('/resources', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resources = await Resource.search(req.query);
|
||||||
|
res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) })));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/discovery/resources/:slug
|
||||||
|
router.get('/resources/:slug', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.getBySlug(req.params.slug);
|
||||||
|
// parents/children are edges (no secrets); project only the resource body.
|
||||||
|
const projected = projectResource(resource, { fullMetadata: isDirectoryAdmin(req.user) });
|
||||||
|
projected.parents = resource.parents;
|
||||||
|
projected.children = resource.children;
|
||||||
|
res.json(envelope(projected));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/discovery/graph
|
||||||
|
router.get('/graph', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const graph = await Resource.getGraph();
|
||||||
|
res.json(envelope({
|
||||||
|
resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }),
|
||||||
|
edges: graph.edges,
|
||||||
|
}));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/discovery/me
|
||||||
|
// Returns the resources the current caller can reach. Machines see only their
|
||||||
|
// own resource; humans get the union of their LDAP groups' resources plus
|
||||||
|
// anything flagged isPublic. Uses req.user.groups (populated by the auth
|
||||||
|
// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it
|
||||||
|
// works for every auth transport without assuming a .dn is present.
|
||||||
|
router.get('/me', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let accessible;
|
||||||
|
if (req.user && req.user.isMachine) {
|
||||||
|
accessible = await Resource.list({ where: { id: req.resourceId } });
|
||||||
|
} else {
|
||||||
|
const userGroups = (req.user && req.user.groups) || [];
|
||||||
|
const ids = new Set();
|
||||||
|
if (userGroups.length) {
|
||||||
|
const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } });
|
||||||
|
for (const rg of rgs) ids.add(rg.resourceId);
|
||||||
|
}
|
||||||
|
const all = await Resource.list();
|
||||||
|
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
|
||||||
|
}
|
||||||
|
res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) })));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const {marked} = require('marked');
|
const {marked} = require('marked');
|
||||||
|
const xss = require('xss');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
const rateLimit = require('../middleware/rate_limit');
|
const rateLimit = require('../middleware/rate_limit');
|
||||||
@@ -25,6 +26,15 @@ const values = {
|
|||||||
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
|
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
|
||||||
// already covered by the "deployment" entry.
|
// already covered by the "deployment" entry.
|
||||||
const DOCS = {
|
const DOCS = {
|
||||||
|
// Plain-language "what is this and why would I use it" guides -- linked
|
||||||
|
// directly from the relevant card in the UI (see the help icon on each
|
||||||
|
// card). Each links onward to the deeper technical doc below for readers
|
||||||
|
// who want the schema/protocol-level detail.
|
||||||
|
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')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
|
||||||
@@ -46,24 +56,84 @@ function fixImagePaths(html) {
|
|||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docs cross-link each other as "<slug>.html" (correct for the Jekyll/GitHub
|
||||||
|
// Pages build, which is what these same .md files also feed) and
|
||||||
|
// "index.html" for the docs home -- neither resolves here, where a doc lives
|
||||||
|
// at /docs/<slug> with no .html suffix. Rewrite known doc links to the
|
||||||
|
// in-app route, same idea as fixImagePaths() above. Only touches slugs that
|
||||||
|
// actually exist, so an unrelated "foo.html" link is left alone.
|
||||||
|
// Docs are also linked by their real filename stem (e.g. "concepts-accounts.html"
|
||||||
|
// for docs/concepts-accounts.md) -- the correct, working link on the Jekyll/
|
||||||
|
// GitHub Pages build, where the URL IS the filename stem. That doesn't match
|
||||||
|
// this viewer's own short slugs (DOCS keys, e.g. "accounts"), so also resolve
|
||||||
|
// by filename as a fallback -- one link written in a doc works correctly on
|
||||||
|
// both targets, rather than needing two different link forms.
|
||||||
|
const slugByFilename = Object.fromEntries(
|
||||||
|
Object.entries(DOCS).map(([slug, d]) => [path.basename(d.file, '.md'), slug])
|
||||||
|
);
|
||||||
|
function fixDocLinks(html) {
|
||||||
|
return html
|
||||||
|
.replace(/href="index\.html"/g, 'href="/docs"')
|
||||||
|
.replace(/href="([a-z0-9-]+)\.html"/g, (match, name) => {
|
||||||
|
const slug = DOCS[name] ? name : slugByFilename[name];
|
||||||
|
return slug ? `href="/docs/${slug}"` : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// docs/*.md files (not the repo-root README/CHANGELOG/API.md) carry Jekyll
|
||||||
|
// front matter for the GitHub Pages build and a "← Back to Home" link back
|
||||||
|
// to that site's index -- both meaningless here (this viewer has its own
|
||||||
|
// doc-list sidebar, docs_page.ejs) and, worse, marked() doesn't know front
|
||||||
|
// matter isn't regular markdown: it rendered as a garbled heading + stray
|
||||||
|
// <hr> at the top of every page. Strip both before rendering.
|
||||||
|
function stripJekyllCruft(content) {
|
||||||
|
return content
|
||||||
|
.replace(/^---\n[\s\S]*?\n---\n/, '')
|
||||||
|
.replace(/^\s*\[← Back to Home\]\([^)]*\)\s*\n/m, '');
|
||||||
|
}
|
||||||
|
|
||||||
router.use(rateLimit.docs);
|
router.use(rateLimit.docs);
|
||||||
|
|
||||||
router.get('/', function(req, res) {
|
router.get('/', function(req, res) {
|
||||||
res.render('docs_index', {...values, docs: docList});
|
res.render('docs_index', {...values, docs: docList});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Plain, dependency-free line-substring search over the same allowlisted
|
||||||
|
// doc set -- no separate index to build/maintain, no new dependency, and it
|
||||||
|
// keeps working with no internet access (same reasoning as the rest of this
|
||||||
|
// route). Must be registered before the /:slug catch-all below, or "search"
|
||||||
|
// would be treated as a (nonexistent) doc slug and 404.
|
||||||
|
router.get('/search', function(req, res) {
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
if (!q) return res.json({results: []});
|
||||||
|
const qLower = q.toLowerCase();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [slug, doc] of Object.entries(DOCS)) {
|
||||||
|
try {
|
||||||
|
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
|
||||||
|
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
|
||||||
|
if (matchLine) {
|
||||||
|
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
|
||||||
|
}
|
||||||
|
} catch (error) { /* unreadable doc file -- skip it */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({results});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:slug', function(req, res, next) {
|
router.get('/:slug', function(req, res, next) {
|
||||||
const doc = DOCS[req.params.slug];
|
const doc = DOCS[req.params.slug];
|
||||||
if (!doc) return next({status: 404, message: 'Doc not found'});
|
if (!doc) return next({status: 404, message: 'Doc not found'});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(doc.file, 'utf8');
|
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
|
||||||
res.render('docs_page', {
|
res.render('docs_page', {
|
||||||
...values,
|
...values,
|
||||||
docs: docList,
|
docs: docList,
|
||||||
currentSlug: req.params.slug,
|
currentSlug: req.params.slug,
|
||||||
docTitle: doc.title,
|
docTitle: doc.title,
|
||||||
docHtml: fixImagePaths(marked(content)),
|
docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -82,8 +82,13 @@ router.put('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
var user = await User.get(req.params.uid);
|
||||||
|
const results = await group.addMember(user);
|
||||||
|
// Group membership feeds directly into cached-User-derived state
|
||||||
|
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
|
||||||
|
// a membership change here is invisible for up to the cache's TTL.
|
||||||
|
User.clearCache();
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await group.addMember(user),
|
results,
|
||||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
@@ -98,8 +103,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
var user = await User.get(req.params.uid);
|
||||||
|
const results = await group.removeMember(user);
|
||||||
|
User.clearCache();
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await group.removeMember(user),
|
results,
|
||||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ var express = require('express');
|
|||||||
var router = express.Router();
|
var router = express.Router();
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
const {marked} = require('marked');
|
const {marked} = require('marked');
|
||||||
|
const xss = require('xss');
|
||||||
const {InviteToken, PasswordResetToken} = require('./../models/token');
|
const {InviteToken, PasswordResetToken} = require('./../models/token');
|
||||||
const {Tos} = require('../models/tos');
|
const {Tos} = require('../models/tos');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
@@ -19,24 +21,16 @@ const values ={
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List of front end node modules to be served
|
// List of front end node modules to be served
|
||||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
|
|
||||||
'moment', '@popper', 'jq-repeat',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Server front end modules
|
|
||||||
// https://stackoverflow.com/a/55700773/3140931
|
|
||||||
// Vendor libraries only change when package versions are bumped (a rebuild),
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
||||||
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
||||||
// still cover that rare case with a cheap 304 instead of a stale asset.
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
||||||
frontEndModules.forEach(dep => {
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
||||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
// every deploy and isn't cache-busted/fingerprinted.
|
||||||
|
mountStaticModules(router, {
|
||||||
|
root: path.join(__dirname, '..'),
|
||||||
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
|
||||||
// local folder. Shorter maxAge than /static-modules since this is the app's
|
|
||||||
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
|
|
||||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
|
|
||||||
|
|
||||||
// Public health endpoint for container/orchestration healthchecks.
|
// Public health endpoint for container/orchestration healthchecks.
|
||||||
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
||||||
router.get('/health', function(req, res) {
|
router.get('/health', function(req, res) {
|
||||||
@@ -46,7 +40,7 @@ router.get('/health', function(req, res) {
|
|||||||
router.get('/tos', async function(req, res, next) {
|
router.get('/tos', async function(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const tos = await Tos.getCurrent();
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -54,27 +48,34 @@ router.get('/tos', async function(req, res, next) {
|
|||||||
|
|
||||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||||
// (broadcast + history) merged into one page.
|
// (broadcast + history) merged into one page.
|
||||||
router.get('/dashboard', function(req, res) {
|
router.get('/executive', function(req, res) {
|
||||||
res.render('dashboard', {...values});
|
res.render('executive', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/admin', (req, res) => res.redirect(301, '/dashboard'));
|
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
||||||
router.get('/notifications', (req, res) => res.redirect(301, '/dashboard'));
|
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
||||||
|
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
||||||
|
|
||||||
router.get('/invites', function(req, res) {
|
router.get('/directory', function(req, res) {
|
||||||
res.render('invites', {...values});
|
res.render('directory', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route removed since it's now in directory
|
||||||
|
|
||||||
router.get('/onboarding', async function(req, res, next) {
|
router.get('/onboarding', async function(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const tos = await Tos.getCurrent();
|
const tos = await Tos.getCurrent();
|
||||||
res.render('onboarding', {...values, tosHtml: marked(tos.content)});
|
res.render('onboarding', {...values, tosHtml: xss(marked(tos.content))});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/', async function(req, res, next) {
|
router.get('/', async function(req, res, next) {
|
||||||
|
res.render('landing', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/profile', async function(req, res, next) {
|
||||||
res.render('profile', {...values});
|
res.render('profile', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +93,14 @@ router.get('/login', async function(req, res, next) {
|
|||||||
// hardcoded in a doc, so they're always right for *this* deployment.
|
// hardcoded in a doc, so they're always right for *this* deployment.
|
||||||
router.get('/integrations', function(req, res, next) {
|
router.get('/integrations', function(req, res, next) {
|
||||||
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
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 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';
|
const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com';
|
||||||
@@ -105,8 +113,9 @@ router.get('/integrations', function(req, res, next) {
|
|||||||
...values,
|
...values,
|
||||||
issuer,
|
issuer,
|
||||||
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
||||||
ldapHost,
|
ldapHost: ldapsHost,
|
||||||
ldapsUrl: `ldaps://${ldapHost}:636`,
|
ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
|
||||||
|
ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
|
||||||
baseDn,
|
baseDn,
|
||||||
userBase,
|
userBase,
|
||||||
groupBase,
|
groupBase,
|
||||||
@@ -136,6 +145,8 @@ router.get('/token', function(req, res, next) {
|
|||||||
res.render('token', {...values});
|
res.render('token', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
router.get('/login/resetpassword/:token', async function(req, res, next){
|
router.get('/login/resetpassword/:token', async function(req, res, next){
|
||||||
let token = await PasswordResetToken.get(req.params.token);
|
let token = await PasswordResetToken.get(req.params.token);
|
||||||
|
|||||||
@@ -45,8 +45,11 @@ router.post('/', async function(req, res, next) {
|
|||||||
|
|
||||||
const client = await OAuthClient.add(req.body);
|
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({
|
return res.json({
|
||||||
results: client,
|
results: result,
|
||||||
client_secret: client._raw_secret,
|
client_secret: client._raw_secret,
|
||||||
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
|
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
|
||||||
});
|
});
|
||||||
@@ -94,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
|
|||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
|
||||||
const client = await OAuthClient.get(req.params.client_id);
|
const client = await OAuthClient.get(req.params.client_id);
|
||||||
await client.remove();
|
await client.delete();
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
client_id: req.params.client_id,
|
client_id: req.params.client_id,
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const {ServiceAccount} = require('../models/service_account');
|
|
||||||
const permission = require('../utils/permission');
|
|
||||||
|
|
||||||
const ADMIN_GROUP = 'app_sso_admin';
|
|
||||||
|
|
||||||
router.get('/', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
return res.json({results: await ServiceAccount.list()});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
const result = await ServiceAccount.create({cn: req.body.cn, description: req.body.description});
|
|
||||||
return res.json({
|
|
||||||
results: result,
|
|
||||||
message: `Service account "${result.cn}" created. Save the password now — it will not be shown again.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:cn/password', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
const result = await ServiceAccount.setPassword(req.params.cn, req.body.password);
|
|
||||||
return res.json({
|
|
||||||
results: result,
|
|
||||||
message: `Password rotated for "${req.params.cn}". Save it now — it will not be shown again.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:cn', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
await ServiceAccount.remove(req.params.cn);
|
|
||||||
return res.json({message: `Service account "${req.params.cn}" deleted.`});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/:name', async function(req, res, next){
|
router.get('/:name', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
// ORM models: list() on the redis adapter always returns full rows;
|
||||||
|
// detail is handled by serialization (isPrivate fields are excluded).
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
|
results: await tokens[req.params.name].list()
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/:name/:token', async function(req, res, next){
|
router.get('/:name/:token', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
return res.json({
|
const result = await tokens[req.params.name].get(req.params.token);
|
||||||
results: 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){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ router.post('/', async function(req, res, next){
|
|||||||
await permission.byGroup(req.user, ['app_sso_admin'])
|
await permission.byGroup(req.user, ['app_sso_admin'])
|
||||||
|
|
||||||
req.body.created_by = req.user.uid
|
req.body.created_by = req.user.uid
|
||||||
|
req.body.manager = [req.user.dn];
|
||||||
|
|
||||||
const user = await User.add(req.body);
|
let user = await User.add(req.body);
|
||||||
const verif = await UserVerification.getOrCreate(user.uid);
|
const verif = await UserVerification.getOrCreate(user.uid);
|
||||||
const updates = { password_must_change: true };
|
const updates = { password_must_change: true };
|
||||||
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
|
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
|
||||||
@@ -37,6 +38,12 @@ router.post('/', async function(req, res, next){
|
|||||||
try {
|
try {
|
||||||
const group = await Group.get('app_sso_service_account');
|
const group = await Group.get('app_sso_service_account');
|
||||||
await group.addMember(user);
|
await group.addMember(user);
|
||||||
|
// User.add() already cached `user` (via its own internal
|
||||||
|
// User.get()) before this group membership existed, so the
|
||||||
|
// cached isServiceAccount would be stuck wrong for 5 minutes
|
||||||
|
// (the cache TTL) without this -- re-fetch after clearing.
|
||||||
|
User.clearCache();
|
||||||
|
user = await User.get(user.uid);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
|
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
|
||||||
}
|
}
|
||||||
@@ -67,7 +74,17 @@ router.delete('/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/me', async function(req, res, next){
|
router.get('/me', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
return res.json(await User.get({uid: req.user.uid}));
|
const user = JSON.parse(JSON.stringify(await User.get({uid: req.user.uid})));
|
||||||
|
|
||||||
|
// The shared client framework gates the UI on a single effective-rights
|
||||||
|
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||||
|
// membership in app_sso_admin; group-level gating still reads memberOf.
|
||||||
|
const groups = (user.memberOf || []).map(function(dn){
|
||||||
|
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
user.isAdmin = groups.includes('app_sso_admin');
|
||||||
|
|
||||||
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -137,6 +154,41 @@ router.put('/:uid/active', async function(req, res, next){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:uid/group-members', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
|
||||||
|
return res.json({
|
||||||
|
results: true,
|
||||||
|
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
|
||||||
|
return res.json({
|
||||||
|
results: true,
|
||||||
|
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/:uid', async function(req, res, next){
|
router.put('/:uid', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let user;
|
let user;
|
||||||
@@ -145,7 +197,15 @@ router.put('/:uid', async function(req, res, next){
|
|||||||
user = req.user;
|
user = req.user;
|
||||||
}else{
|
}else{
|
||||||
user = await User.get(req.params.uid);
|
user = await User.get(req.params.uid);
|
||||||
await permission.byGroup(req.user, ['app_sso_admin'])
|
const isManager = (user.manager || []).includes(req.user.dn);
|
||||||
|
if(!isManager) await permission.byGroup(req.user, ['app_sso_admin'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The manager picker is a tag widget backed by a single newline-separated
|
||||||
|
// hidden input (see public/js/app.js app.ui.userSelect), same convention
|
||||||
|
// as oauth_client.js's allowed_groups.
|
||||||
|
if (typeof req.body.manager === 'string') {
|
||||||
|
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -181,7 +241,7 @@ router.get('/invite', async function(req, res, next){
|
|||||||
try{
|
try{
|
||||||
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
|
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 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 visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
|
||||||
const results = visible.map(t => ({ token: t.token, ...t }));
|
const results = visible.map(t => ({ token: t.token, ...t }));
|
||||||
return res.json({ results });
|
return res.json({ results });
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Directory discovery API — security + contract regression coverage.
|
||||||
|
//
|
||||||
|
// These tests run under the jest + docker harness (redis + the test seed).
|
||||||
|
// They lock in the two fixes from the @simpleworkjs/directory-schema release:
|
||||||
|
// 1. /api/discovery/* returns the { results } envelope (not a bare array —
|
||||||
|
// the drift that made jump-host's `data.results || []` collapse to []).
|
||||||
|
// 2. No response path leaks secret metadata (e.g. an OAuth client's
|
||||||
|
// client_secret_hash), regardless of caller.
|
||||||
|
//
|
||||||
|
// The core assertions hold for any authenticated caller. The admin-projection
|
||||||
|
// assertion (fullMetadata for directory admins) additionally requires the `test`
|
||||||
|
// seed user to be a member of app_sso_directory_admin — see setup.js.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertNoSecrets(results, path) {
|
||||||
|
for (const r of results || []) {
|
||||||
|
// toBeUndefined() in this jest version takes no message arg, so assert
|
||||||
|
// manually and throw with context — this also surfaces the leaked value
|
||||||
|
// if the projection ever regresses.
|
||||||
|
const secretHash = r.metadata && r.metadata.client_secret_hash;
|
||||||
|
if (secretHash !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`client_secret_hash leaked from ${path} on ${r.slug || r.id} (value: ${JSON.stringify(secretHash)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (r.metadata) {
|
||||||
|
for (const k of Object.keys(r.metadata)) {
|
||||||
|
if (/secret|password|privatekey/i.test(k)) {
|
||||||
|
throw new Error(`secret-ish key "${k}" leaked from ${path} on ${r.slug || r.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Discovery — envelope + security', () => {
|
||||||
|
test('GET /api/discovery/resources returns 200 with { results } (not a bare array)', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
expect(Array.isArray(res.body)).toBe(false); // never a bare array
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources never leaks client_secret_hash', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
assertNoSecrets(res.body.results, '/resources');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources?group= returns 200 (regression: was 404)', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/discovery/resources?group=host_web01_access')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/graph returns { results: { resources, edges } } and strips secrets', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/graph').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(Array.isArray(res.body.results.resources)).toBe(true);
|
||||||
|
assertNoSecrets(res.body.results.resources, '/graph');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/me returns 200 with { results } and strips secrets', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body.results)).toBe(true);
|
||||||
|
assertNoSecrets(res.body.results, '/me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/discovery/resources/:slug returns 200 + { results } for a known slug', async () => {
|
||||||
|
// Seed-dependent: pick the first slug from the list, then fetch it.
|
||||||
|
const list = await request(app).get('/api/discovery/resources').set('auth-token', token);
|
||||||
|
const slug = list.body.results[0] && list.body.results[0].slug;
|
||||||
|
if (!slug) return; // empty seed — skip rather than fail
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/discovery/resources/${encodeURIComponent(slug)}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(res.body.results.slug).toBe(slug);
|
||||||
|
assertNoSecrets([res.body.results], '/resources/:slug');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Discovery — admin projection (requires test user in app_sso_directory_admin)', () => {
|
||||||
|
// If the seed `test` user is a directory admin, /resources should keep
|
||||||
|
// admin-only (non-secret) metadata like redirect_uris/token_lifetime for
|
||||||
|
// them. If not, this assertion is skipped — the no-secrets assertion above
|
||||||
|
// already covers the security guarantee for every caller.
|
||||||
|
test('admin callers keep token_lifetime / redirect_uris (non-secret admin keys)', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/resources?kind=oauth').set('auth-token', token);
|
||||||
|
const oauth = (res.body.results || []).find(r => r.kind === 'oauth');
|
||||||
|
if (!oauth) return; // no oauth resource seeded
|
||||||
|
// Only meaningful if the caller is an admin; non-admins correctly get
|
||||||
|
// the public allowlist (no redirect_uris). We assert the absence of
|
||||||
|
// secrets regardless, and skip the positive admin check without a known
|
||||||
|
// admin seed.
|
||||||
|
expect(oauth.metadata && oauth.metadata.client_secret_hash).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
// Flush all test-prefix Redis keys before each test run so state is always clean.
|
// 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.
|
// 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() {
|
module.exports = async function() {
|
||||||
const client = createClient();
|
const redisUrl = process.env.REDIS_URL || undefined;
|
||||||
|
const client = createClient(redisUrl ? { url: redisUrl } : {});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const keys = await client.keys('sso_manager_test_*');
|
const keys = await client.keys('sso_manager_test_*');
|
||||||
|
|||||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
|||||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||||
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: adding/removing a member here didn't clear User's LRU
|
||||||
|
// cache (ttl 5 minutes), so isServiceAccount -- derived from
|
||||||
|
// app_sso_service_account membership at GET /api/user/:uid time -- could
|
||||||
|
// stay wrong for up to 5 minutes after the group change. In production
|
||||||
|
// this hid a real person's account from the Users page's "People" tab
|
||||||
|
// (it filters out anything with isServiceAccount) for however long the
|
||||||
|
// stale cache entry lived, which looked exactly like the account had
|
||||||
|
// vanished.
|
||||||
|
test('PUT app_sso_service_account/:uid immediately flips isServiceAccount (no stale cache)', async () => {
|
||||||
|
const added = await request(app)
|
||||||
|
.put(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
|
||||||
|
const afterAdd = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterAdd.body.results.isServiceAccount).toBeTruthy();
|
||||||
|
|
||||||
|
const removed = await request(app)
|
||||||
|
.delete(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(removed.status).toBe(200);
|
||||||
|
|
||||||
|
const afterRemove = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterRemove.body.results.isServiceAccount).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Groups — owner management', () => {
|
describe('Groups — owner management', () => {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||||
|
// browser events on the page (found live, mid browser-automation testing, on
|
||||||
|
// directory.ejs's "Rotate Client Secret" — it froze the tab entirely) and are
|
||||||
|
// visually inconsistent with the rest of the UI. Every call site was removed
|
||||||
|
// in favor of app.messages.action/confirm/toast and app.modal.open; this test
|
||||||
|
// keeps it that way.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', d));
|
||||||
|
|
||||||
|
// Matches a bare alert(/confirm(/prompt( call, but not app.messages.*,
|
||||||
|
// app.modal.*, or identifiers merely containing these words (e.g.
|
||||||
|
// "confirmation", ".confirmed").
|
||||||
|
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||||
|
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
let m;
|
||||||
|
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||||
|
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||||
|
const line = src.slice(0, m.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(path.join(__dirname, '..'), file)}:${line} — ${m[2]}(`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
@@ -43,6 +43,114 @@ afterAll(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OAuth client management API — /api/oauth/client', () => {
|
||||||
|
// Regression: the ORM Model.toJSON() strips non-schema fields, so the
|
||||||
|
// mapped client_id/scopes/etc. used to vanish from GET responses —
|
||||||
|
// client_id came back undefined and the theta-env bootstrap's rotate
|
||||||
|
// crashed with a 500. GET must expose client_id (and never the secret hash).
|
||||||
|
test('GET / list exposes client_id and hides client_secret_hash', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/oauth/client/')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const mine = res.body.results.find((c) => c.client_id === clientId);
|
||||||
|
expect(mine).toBeDefined();
|
||||||
|
expect(mine.client_id).toBe(clientId);
|
||||||
|
expect(mine).toHaveProperty('scopes');
|
||||||
|
expect(mine).not.toHaveProperty('client_secret_hash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:id exposes client_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.client_id).toBe(clientId);
|
||||||
|
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT persists — a changed name survives a fresh GET', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const updated = await request(app)
|
||||||
|
.put(`/api/oauth/client/${id}`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test-renamed' });
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(fetched.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: this route called client.remove(), but OAuthClient wraps
|
||||||
|
// @simpleworkjs/orm's Resource model, whose instance method is .delete()
|
||||||
|
// — .remove() doesn't exist on it (unlike the model-redis Tables
|
||||||
|
// elsewhere in this app, e.g. api_token.js, which really do have
|
||||||
|
// .remove()). The route's try/catch turned the resulting TypeError into
|
||||||
|
// a plain 500 JSON response rather than a thrown exception, so every
|
||||||
|
// prior DELETE call in this file's cleanup hooks silently "succeeded"
|
||||||
|
// from Jest's point of view while leaving the client un-deleted.
|
||||||
|
test('DELETE persists — the client is actually gone, not just a 200', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'delete-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const deleted = await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
||||||
|
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
||||||
|
// find by name, rotate by the client_id from the list response. Uses a
|
||||||
|
// throwaway client so the shared flow client's secret is untouched.
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'rotate-regression', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
|
||||||
|
const list = await request(app).get('/api/oauth/client/').set('auth-token', token);
|
||||||
|
const found = list.body.results.find((c) => c.name === 'rotate-regression');
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found.client_id).toBeTruthy(); // was undefined before the fix
|
||||||
|
|
||||||
|
const rotated = await request(app)
|
||||||
|
.post(`/api/oauth/client/${found.client_id}/rotate`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(rotated.status).toBe(200);
|
||||||
|
expect(rotated.body.client_secret).toBeTruthy();
|
||||||
|
|
||||||
|
const deleted = await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const afterDelete = await request(app).get(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(afterDelete.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:id unknown id returns 404, not 500', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(res.status).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('OIDC Discovery', () => {
|
describe('OIDC Discovery', () => {
|
||||||
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
||||||
const res = await request(app).get('/.well-known/openid-configuration');
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,19 @@
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const app = require('../app');
|
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' };
|
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||||
|
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
|
||||||
|
// (../ from here in utils/), matching the Dockerfile.openldap gitinfo stage;
|
||||||
|
// cwd is utils/ for the bare-metal git fallback.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||||
const { version: buildVersion } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
module.exports = createBuildInfo({
|
||||||
// stage in Dockerfile.openldap) -- the final image has no git binary and no
|
version,
|
||||||
// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||||
// runs have no baked file, so they fall back to asking git directly.
|
cwd: __dirname,
|
||||||
function readBuildHash() {
|
});
|
||||||
try {
|
|
||||||
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
|
|
||||||
if (baked) return baked;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
|
|
||||||
} catch (_) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildVersion,
|
|
||||||
buildHash: readBuildHash(),
|
|
||||||
buildYear: new Date().getFullYear(),
|
|
||||||
};
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
//
|
||||||
|
// Those two partials are byte-identical across sso-manager-node, proxy and
|
||||||
|
// jump-host — everything that differs between the apps lives here and is
|
||||||
|
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
|
||||||
|
// in sync across the three apps; a missing key is a render-time ReferenceError,
|
||||||
|
// not a silent fallback.
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// --- footer -------------------------------------------------------------
|
||||||
|
repoUrl: 'https://github.com/theta42/sso-manager-node',
|
||||||
|
licenseUrl: 'https://github.com/theta42/sso-manager-node/blob/master/LICENSE',
|
||||||
|
// In-app docs route (routes/docs.js). Apps without one point at the
|
||||||
|
// published docs site and set docsExternal.
|
||||||
|
docsUrl: '/docs',
|
||||||
|
docsExternal: false,
|
||||||
|
// Only sso-manager-node serves a Terms of Service page; null hides the link.
|
||||||
|
tosUrl: '/tos',
|
||||||
|
|
||||||
|
// --- header / nav -------------------------------------------------------
|
||||||
|
faviconUrl: conf.logo,
|
||||||
|
// Where the current-user chip links. null renders it as a plain span (for
|
||||||
|
// apps with no profile page).
|
||||||
|
profileUrl: '/profile',
|
||||||
|
// Where "Log Out" lands.
|
||||||
|
logoutRedirect: '/',
|
||||||
|
// Admin-only "a newer release is available" banner, backed by
|
||||||
|
// GET /api/update-check. Apps without that endpoint set false.
|
||||||
|
updateCheck: true,
|
||||||
|
updateLabel: 'SSO Manager',
|
||||||
|
|
||||||
|
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
|
||||||
|
// item; an empty list means "always visible". Gating is done client-side by
|
||||||
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
|
nav: [
|
||||||
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
|
||||||
|
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
|
||||||
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
|
||||||
|
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
</div><!-- end spa-shell -->
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
@@ -7,19 +10,21 @@
|
|||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</a>
|
</a>
|
||||||
© <%- buildYear %> theta42 ·
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="d-flex align-items-center gap-3">
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="/docs" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</a>
|
</a>
|
||||||
<a href="/tos" class="text-light text-decoration-none">Terms of Service</a>
|
<% if(ui.tosUrl){ %>
|
||||||
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
|
<% } %>
|
||||||
</span>
|
</span>
|
||||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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') %>
|
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
<%- 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);
|
||||||
|
app.messages.toast('Failed to load data', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + ')'));
|
||||||
|
} else if (kind === 'oauth' && 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.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
app.messages.action(err.message || 'Failed to save', $('#resourceModal'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateSecret() {
|
||||||
|
const id = $('#res-id').val();
|
||||||
|
if (!id) return;
|
||||||
|
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', $('#resourceModal'), 'warning');
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
|
||||||
|
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
app.messages.action(err.message || 'Failed to rotate secret', $('#resourceModal'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGroup() {
|
||||||
|
const resourceId = $('#res-id').val();
|
||||||
|
const groupCn = $('#new-group-cn').val().trim();
|
||||||
|
const accessLevel = $('#new-group-level').val();
|
||||||
|
|
||||||
|
if (!groupCn) return app.messages.action('Group CN is required', $('#resourceModal'), 'danger');
|
||||||
|
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);
|
||||||
|
app.messages.action('Failed to add group', $('#resourceModal'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
app.messages.action('Failed to remove group', $('#resourceModal'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 app.messages.action('Select a target resource', $('#resourceModal'), 'danger');
|
||||||
|
|
||||||
|
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);
|
||||||
|
app.messages.action('Failed to add edge', $('#resourceModal'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
app.messages.action('Failed to remove edge', $('#resourceModal'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteResource(id) {
|
||||||
|
// Called from the outer table's row button, not from inside
|
||||||
|
// #resourceModal — target the page's own card so the confirm/error
|
||||||
|
// renders somewhere actually visible.
|
||||||
|
const $target = $('#resources-list');
|
||||||
|
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await app.api.delete('directory-admin/resources/' + id);
|
||||||
|
await loadResources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
app.messages.action('Failed to delete', $target, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -10,7 +10,12 @@
|
|||||||
A local copy of this project's documentation, readable from the
|
A local copy of this project's documentation, readable from the
|
||||||
running app -- no internet access required.
|
running app -- no internet access required.
|
||||||
</p>
|
</p>
|
||||||
<ul class="list-group">
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||||
|
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
|
||||||
|
</div>
|
||||||
|
<div id="docs-search-results" style="display:none"></div>
|
||||||
|
<ul id="docs-list" class="list-group">
|
||||||
<% docs.forEach(function(doc){ %>
|
<% docs.forEach(function(doc){ %>
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
||||||
@@ -21,4 +26,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var docsSearchTimer;
|
||||||
|
function docsSearch(q){
|
||||||
|
clearTimeout(docsSearchTimer);
|
||||||
|
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
|
||||||
|
}
|
||||||
|
function docsSearchRun(q){
|
||||||
|
q = (q || '').trim();
|
||||||
|
var $results = $('#docs-search-results');
|
||||||
|
var $list = $('#docs-list');
|
||||||
|
if(!q){
|
||||||
|
$results.hide().empty();
|
||||||
|
$list.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
|
||||||
|
// not under /api, unlike the rest of this app's endpoints.
|
||||||
|
$.getJSON('/docs/search', {q: q}, function(data){
|
||||||
|
$list.hide();
|
||||||
|
$results.empty().show();
|
||||||
|
var hits = (data && data.results) || [];
|
||||||
|
if(!hits.length){
|
||||||
|
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var $ul = $('<ul class="list-group"></ul>');
|
||||||
|
hits.forEach(function(hit){
|
||||||
|
var $li = $('<li class="list-group-item"></li>');
|
||||||
|
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
|
||||||
|
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
|
||||||
|
$ul.append($li);
|
||||||
|
});
|
||||||
|
$results.append($ul);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<%- 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) { app.messages.action('Subject and message are required.', $compose, 'danger'); return; }
|
||||||
|
if (!filterCheck) { app.messages.action('Choose who to send this to.', $compose, 'danger'); 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.messages.confirm(`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) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Terms of Service text cannot be empty.';
|
||||||
|
msgEl.style.display = '';
|
||||||
|
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') %>
|
||||||
@@ -32,10 +32,37 @@
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// app_sso_service_account is a marker group: membership hides an account
|
||||||
|
// from the Users page's People tab entirely (see users.ejs), which is
|
||||||
|
// exactly right for a non-person account but has silently made a real
|
||||||
|
// person's account look "gone" before (nothing else about it changes).
|
||||||
|
// Everywhere else in this dropdown just fires the PUT directly; only
|
||||||
|
// this one group gets a confirmation first.
|
||||||
|
function addMemberClick(event, groupCN, uid, el){
|
||||||
|
event.preventDefault();
|
||||||
|
const $el = $(el);
|
||||||
|
(async function(){
|
||||||
|
if (groupCN === 'app_sso_service_account') {
|
||||||
|
const ok = await app.messages.confirm(
|
||||||
|
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
||||||
|
$el.closest('.card'), 'warning'
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
||||||
|
await addedUser(data.message, groupCN, uid, $el);
|
||||||
|
} catch(e) {
|
||||||
|
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function addedUser(message, group, user, $form){
|
async function addedUser(message, group, user, $form){
|
||||||
let data = await app.group.get(group);
|
let data = await app.group.get(group);
|
||||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
||||||
app.util.actionMessage(message, $("#group-card-"+group), 'success');
|
app.messages.action(message, $("#group-card-"+group), 'success');
|
||||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
||||||
setTimeout(function(group){
|
setTimeout(function(group){
|
||||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
||||||
@@ -73,44 +100,44 @@
|
|||||||
async function removeMember(groupCN, uid, btn) {
|
async function removeMember(groupCN, uid, btn) {
|
||||||
const $item = $(btn).closest('li');
|
const $item = $(btn).closest('li');
|
||||||
$item.addClass('list-group-item-warning');
|
$item.addClass('list-group-item-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||||
try {
|
try {
|
||||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
||||||
const groupData = await app.group.get(groupCN);
|
const groupData = await app.group.get(groupCN);
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
$item.removeClass('list-group-item-warning');
|
$item.removeClass('list-group-item-warning');
|
||||||
app.util.actionMessage(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeOwner(groupCN, uid, btn) {
|
async function removeOwner(groupCN, uid, btn) {
|
||||||
const $item = $(btn).closest('li');
|
const $item = $(btn).closest('li');
|
||||||
$item.addClass('list-group-item-warning');
|
$item.addClass('list-group-item-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||||
try {
|
try {
|
||||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
||||||
const groupData = await app.group.get(groupCN);
|
const groupData = await app.group.get(groupCN);
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
$item.removeClass('list-group-item-warning');
|
$item.removeClass('list-group-item-warning');
|
||||||
app.util.actionMessage(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteGroup(cn, btn) {
|
async function deleteGroup(cn, btn) {
|
||||||
const $card = $(btn).closest('.card');
|
const $card = $(btn).closest('.card');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete group "${cn}"?`, $card, 'danger');
|
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
await app.api.delete(`group/${cn}`);
|
await app.api.delete(`group/${cn}`);
|
||||||
$.scope.groupCard.remove('cn', cn);
|
$.scope.groupCard.remove('cn', cn);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
app.util.actionMessage(e.message || 'Failed to delete group', $card, 'danger');
|
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +148,7 @@
|
|||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
</script>
|
</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="d-flex flex-wrap gap-2 align-items-center">
|
||||||
<div class="input-group" style="flex: 1 1 200px;">
|
<div class="input-group" style="flex: 1 1 200px;">
|
||||||
@@ -142,6 +169,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<i class="fa-solid fa-object-group"></i>
|
<i class="fa-solid fa-object-group"></i>
|
||||||
Add new group
|
Add new group
|
||||||
|
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -167,6 +195,7 @@
|
|||||||
<h5>
|
<h5>
|
||||||
<i class="fa-solid fa-arrows-down-to-people"></i>
|
<i class="fa-solid fa-arrows-down-to-people"></i>
|
||||||
Group: {{ cn }}
|
Group: {{ cn }}
|
||||||
|
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
</h5>
|
</h5>
|
||||||
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -212,7 +241,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||||
{{ #toAdd }}{{#.}}
|
{{ #toAdd }}{{#.}}
|
||||||
<a class="dropdown-item" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
|
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
<i class="fa-solid fa-user"></i> {{uid}}
|
||||||
</a>
|
</a>
|
||||||
{{/.}}{{ /toAdd }}
|
{{/.}}{{ /toAdd }}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
function startImpersonate(uid){
|
function startImpersonate(uid){
|
||||||
app.impersonate.create(uid, function(error, data){
|
app.impersonate.create(uid, function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
alert('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'));
|
app.messages.toast('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#impersonateModalTitle').text(data.uid);
|
$('#impersonateModalTitle').text(data.uid);
|
||||||
@@ -79,7 +79,7 @@ function startImpersonate(uid){
|
|||||||
$('#impersonateStopBtn').off('click').on('click', function(){
|
$('#impersonateStopBtn').off('click').on('click', function(){
|
||||||
app.impersonate.revoke(data.uid, function(err){
|
app.impersonate.revoke(data.uid, function(err){
|
||||||
$('#impersonateModal').modal('hide');
|
$('#impersonateModal').modal('hide');
|
||||||
if(!err) app.util.actionMessage('Impersonation ended for ' + data.uid, $('body'), 'success');
|
if(!err) app.messages.action('Impersonation ended for ' + data.uid, $('body'), 'success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,639 +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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Service accounts ──────────────────────────────────────────────────
|
|
||||||
async function svcTableAJAX(){
|
|
||||||
let data = await app.api.get('service-account');
|
|
||||||
$.scope.serviceAccountCard.empty();
|
|
||||||
$.each(data.results, function(_, acct){
|
|
||||||
$.scope.serviceAccountCard.push(acct);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateServiceAccountPassword(cn, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
const confirmed = await app.util.actionConfirm('Rotate the password for "' + cn + '"? Anything still using the old password will stop working immediately.', $card, 'warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.api.put('service-account/' + encodeURIComponent(cn) + '/password', {}, function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
|
|
||||||
showSecret(data.results.password, 'Password for ' + cn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteServiceAccount(cn, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
$card.addClass('table-warning');
|
|
||||||
const confirmed = await app.util.actionConfirm('Delete service account "' + cn + '"? Anything binding as it will stop working immediately.', $card, 'warning');
|
|
||||||
$card.removeClass('table-warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.api.delete('service-account/' + encodeURIComponent(cn), function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
|
|
||||||
$.scope.serviceAccountCard.remove('cn', cn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function(){
|
|
||||||
tableAJAX();
|
|
||||||
svcTableAJAX();
|
|
||||||
|
|
||||||
// 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();'
|
|
||||||
);
|
|
||||||
$('form[action="service-account/"]').attr('evalAJAX',
|
|
||||||
'showSecret(data.password, "Password for " + data.cn); svcTableAJAX(); $form.trigger("reset");'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</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
|
|
||||||
</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
|
|
||||||
</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
|
|
||||||
</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 below under
|
|
||||||
<b>Service Accounts</b> (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)
|
|
||||||
</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 below) 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 class="col-12">
|
|
||||||
<div class="card shadow-sm border-info">
|
|
||||||
<div class="card-header bg-info bg-opacity-10">
|
|
||||||
<i class="fa-solid fa-user-gear"></i> Service Accounts
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small mb-3">
|
|
||||||
Bind-only LDAP identities for apps and hosts — not real people, can't log
|
|
||||||
into this UI, no home directory. theta-env's <code>cn=ldapclient</code>
|
|
||||||
bootstrap account (used by theta42/proxy) shows up here too, since it's
|
|
||||||
the same kind of account.
|
|
||||||
<br>
|
|
||||||
Need an account something actually <i>runs as</i> on a Linux host instead
|
|
||||||
(a media manager, a torrent client, ...) — with a real <code>uidNumber</code>
|
|
||||||
and a group other accounts join for write access? That's a Unix account, not
|
|
||||||
a bind-only one — create it from <a href="/users">Users</a> with
|
|
||||||
<b>This is a service account</b> checked.
|
|
||||||
</p>
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form action="service-account/" method="post" onsubmit="formAJAX(this)">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="cn" placeholder="ldapclient" validate=":1">
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">Description <small class="text-muted">(optional)</small></label>
|
|
||||||
<input type="text" class="form-control shadow" name="description" placeholder="Bind account for gitea.example.com">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-outline-dark btn-sm">
|
|
||||||
<i class="fa-solid fa-plus"></i> Create
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead><tr><th>Name</th><th>Description</th><th></th></tr></thead>
|
|
||||||
<tbody jq-repeat="serviceAccountCard">
|
|
||||||
<tr>
|
|
||||||
<td><code>cn={{cn}},<%= userBase %></code></td>
|
|
||||||
<td>{{description}}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-warning" title="Rotate password" onclick="rotateServiceAccountPassword('{{cn}}', this)">
|
|
||||||
<i class="fa-solid fa-key"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete" onclick="deleteServiceAccount('{{cn}}', this)">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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 Service Accounts',
|
|
||||||
'# above, 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') %>
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function tableAJAX(message){
|
function tableAJAX(message){
|
||||||
app.util.actionMessage(message);
|
app.messages.action(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
|
|||||||
@@ -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') %>
|
|
||||||
@@ -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.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
app.api.post('oauth/authorize', oauthParams, function(error, data){
|
app.api.post('oauth/authorize', oauthParams, function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
|
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
|
||||||
app.util.actionMessage(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
app.messages.action(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = data.redirect_url;
|
window.location.href = data.redirect_url;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
async function acceptTos() {
|
async function acceptTos() {
|
||||||
var checkbox = document.getElementById('tosCheckbox');
|
var checkbox = document.getElementById('tosCheckbox');
|
||||||
if (!checkbox.checked) {
|
if (!checkbox.checked) {
|
||||||
alert('Please read and check the box to accept the Terms of Service.');
|
app.messages.toast('Please read and check the box to accept the Terms of Service.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -50,14 +50,14 @@
|
|||||||
document.getElementById('section-tos').style.display = 'none';
|
document.getElementById('section-tos').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not save TOS acceptance. Please try again.');
|
app.messages.toast('Could not save TOS acceptance. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDob() {
|
async function saveDob() {
|
||||||
var dob = document.getElementById('dobInput').value;
|
var dob = document.getElementById('dobInput').value;
|
||||||
if (!dob) {
|
if (!dob) {
|
||||||
alert('Please enter your date of birth.');
|
app.messages.toast('Please enter your date of birth.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
document.getElementById('section-dob').style.display = 'none';
|
document.getElementById('section-dob').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not save date of birth. Please try again.');
|
app.messages.toast('Could not save date of birth. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +81,11 @@
|
|||||||
var pw = document.getElementById('pwInput').value;
|
var pw = document.getElementById('pwInput').value;
|
||||||
var pw2 = document.getElementById('pwInput2').value;
|
var pw2 = document.getElementById('pwInput2').value;
|
||||||
if (!pw || pw.length < 5) {
|
if (!pw || pw.length < 5) {
|
||||||
alert('Password must be at least 5 characters.');
|
app.messages.toast('Password must be at least 5 characters.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pw !== pw2) {
|
if (pw !== pw2) {
|
||||||
alert('Passwords do not match.');
|
app.messages.toast('Passwords do not match.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
document.getElementById('section-password').style.display = 'none';
|
document.getElementById('section-password').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not change password. Please try again.');
|
app.messages.toast('Could not change password. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
|
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
|
||||||
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
|
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
||||||
|
|
||||||
$.scope.user.update(user);
|
$.scope.user.update(user);
|
||||||
$.scope.passwordReset.update(user);
|
$.scope.passwordReset.update(user);
|
||||||
@@ -17,12 +18,118 @@
|
|||||||
async function renderUserGroups(user){
|
async function renderUserGroups(user){
|
||||||
try{
|
try{
|
||||||
let res = await app.api.get('group/?detail=true&member='+user.uid);
|
let res = await app.api.get('group/?detail=true&member='+user.uid);
|
||||||
|
$.scope.mygroups.empty();
|
||||||
$.scope.mygroups.push(...res.results);
|
$.scope.mygroups.push(...res.results);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error('renderUserGroups error:', error)
|
console.error('renderUserGroups error:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeFromGroup(cn, btn){
|
||||||
|
const $row = $(btn).closest('tr');
|
||||||
|
const confirmed = await app.messages.confirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
|
||||||
|
if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||||
|
$.scope.mygroups.remove('cn', cn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var addGroupSelect;
|
||||||
|
async function addToGroups(btn){
|
||||||
|
const cns = addGroupSelect.get();
|
||||||
|
if(!cns.length) return;
|
||||||
|
const $card = $(btn).closest('.card-body');
|
||||||
|
for(const cn of cns){
|
||||||
|
await new Promise(function(resolve){
|
||||||
|
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
|
||||||
|
if(error) app.messages.action((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addGroupSelect.clear();
|
||||||
|
renderUserGroups(currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPersonalGroupMembers(user){
|
||||||
|
try{
|
||||||
|
let res = await app.api.get('user/' + user.uid + '/group-members');
|
||||||
|
$.scope.personalGroupMembers.empty();
|
||||||
|
$.scope.personalGroupMembers.push(...(res.results || []).map(uid => ({uid})));
|
||||||
|
}catch(error){
|
||||||
|
console.error('renderPersonalGroupMembers error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePersonalGroupMember(memberUid, btn){
|
||||||
|
const $row = $(btn).closest('tr');
|
||||||
|
const confirmed = await app.messages.confirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
|
||||||
|
if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||||
|
$.scope.personalGroupMembers.remove('uid', memberUid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var addPersonalGroupMemberSelect;
|
||||||
|
async function addPersonalGroupMembers(btn){
|
||||||
|
const uids = addPersonalGroupMemberSelect.get();
|
||||||
|
if(!uids.length) return;
|
||||||
|
const $card = $(btn).closest('.card-body');
|
||||||
|
for(const uid of uids){
|
||||||
|
await new Promise(function(resolve){
|
||||||
|
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
|
||||||
|
if(error) app.messages.action((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addPersonalGroupMemberSelect.clear();
|
||||||
|
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(){
|
async function determinUser(){
|
||||||
if(location.pathname.includes('/users/')){
|
if(location.pathname.includes('/users/')){
|
||||||
let uid = location.pathname.replace('/users/', '');
|
let uid = location.pathname.replace('/users/', '');
|
||||||
@@ -42,8 +149,15 @@
|
|||||||
$.scope.editProfile.update(user);
|
$.scope.editProfile.update(user);
|
||||||
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
|
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
|
||||||
// wait for the throttle tick to land before sliding the updated card
|
// wait for the throttle tick to land before sliding the updated card
|
||||||
// into view, or it can briefly show stale/empty data.
|
// into view, or it can briefly show stale/empty data. The manager
|
||||||
|
// picker is a JS widget, not a mustache-bound input, so it also has to
|
||||||
|
// wait for update() to (re-)render its empty mount div before attaching.
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
|
app.ui.userSelect('#edit-manager', {
|
||||||
|
name: 'manager',
|
||||||
|
values: user.managerUids || [],
|
||||||
|
placeholder: 'Type a username…',
|
||||||
|
});
|
||||||
$profileCard.slideUp();
|
$profileCard.slideUp();
|
||||||
$editCard.slideDown();
|
$editCard.slideDown();
|
||||||
}, 60);
|
}, 60);
|
||||||
@@ -62,7 +176,7 @@
|
|||||||
|
|
||||||
async function toggleActive(uid, active){
|
async function toggleActive(uid, active){
|
||||||
app.user.setActive(uid, active, async function(error, data){
|
app.user.setActive(uid, active, async function(error, data){
|
||||||
if(error) return alert('Failed to update user status');
|
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
||||||
currentUser = await determinUser();
|
currentUser = await determinUser();
|
||||||
renderProfile(currentUser);
|
renderProfile(currentUser);
|
||||||
});
|
});
|
||||||
@@ -70,11 +184,11 @@
|
|||||||
|
|
||||||
async function deleteUser(uid, btn){
|
async function deleteUser(uid, btn){
|
||||||
const $card = $(btn).closest('.card');
|
const $card = $(btn).closest('.card');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $card, 'warning');
|
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $card, 'warning');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
app.api.delete('user/' + uid, function(error, data){
|
app.api.delete('user/' + uid, function(error, data){
|
||||||
if (error) {
|
if (error) {
|
||||||
app.util.actionMessage(data.message || 'Failed to delete user', $card, 'danger');
|
app.messages.action(data.message || 'Failed to delete user', $card, 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = '/users';
|
window.location.href = '/users';
|
||||||
@@ -86,6 +200,20 @@
|
|||||||
|
|
||||||
renderProfile(currentUser);
|
renderProfile(currentUser);
|
||||||
renderUserGroups(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', {
|
||||||
|
name: 'groups', values: [], placeholder: 'Type a group name…',
|
||||||
|
});
|
||||||
|
addPersonalGroupMemberSelect = app.ui.userSelect('#add-personal-group-member-select', {
|
||||||
|
name: 'members', values: [], placeholder: 'Type a username…',
|
||||||
|
});
|
||||||
|
|
||||||
// API Tokens are self-service only — never shown when an admin is
|
// API Tokens are self-service only — never shown when an admin is
|
||||||
// viewing someone else's profile via /users/:uid.
|
// viewing someone else's profile via /users/:uid.
|
||||||
@@ -151,16 +279,20 @@
|
|||||||
<div class="profile-body" jq-repeat="user">
|
<div class="profile-body" jq-repeat="user">
|
||||||
<div class="card-body profile-body-{{uid}}">
|
<div class="card-body profile-body-{{uid}}">
|
||||||
<h2><i>User Name:</i> <b>{{uid}}</b></h2>
|
<h2><i>User Name:</i> <b>{{uid}}</b></h2>
|
||||||
<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />
|
{{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}}
|
||||||
<i>Email:</i> <b>{{mail}}</b>
|
<i>Email:</i> <b>{{mail}}</b>
|
||||||
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
|
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
|
||||||
<br />
|
<br />
|
||||||
<i>Phone:</i> <b>{{mobile}}</b>
|
<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}}
|
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
|
||||||
<br />
|
<br />
|
||||||
|
<i>Location (Site):</i> <b>{{location}} </b><br />
|
||||||
<i>LDAP DN:</i> <b>{{dn}} </b><br />
|
<i>LDAP DN:</i> <b>{{dn}} </b><br />
|
||||||
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
|
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
|
||||||
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
|
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
|
||||||
|
<i>Manager(s):</i>
|
||||||
|
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||||
|
<br />
|
||||||
<i>Status:</i>
|
<i>Status:</i>
|
||||||
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
|
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
|
||||||
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
|
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
|
||||||
@@ -236,7 +368,23 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Mobile Phone</label>
|
<label class="form-label">Mobile Phone</label>
|
||||||
<input type="text" class="form-control" name="mobile" placeholder="9175551234" validate=":9" value="{{mobile}}" />
|
<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}}" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Login Shell</label>
|
||||||
|
<input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Manager(s)</label>
|
||||||
|
<div id="edit-manager"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">User Description (Optional)</label>
|
<label class="form-label">User Description (Optional)</label>
|
||||||
@@ -254,12 +402,13 @@
|
|||||||
<i class="fa-solid fa-users-viewfinder"></i>
|
<i class="fa-solid fa-users-viewfinder"></i>
|
||||||
My groups
|
My groups
|
||||||
<div class="float-end">
|
<div 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>
|
<i class="fa-solid fa-arrows-up-down"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header shadow actionMessage" style="display:none">
|
<div class="card-header shadow actionMessage" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body" style="padding-bottom:0">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -269,19 +418,132 @@
|
|||||||
<th>
|
<th>
|
||||||
Description
|
Description
|
||||||
</th>
|
</th>
|
||||||
|
<th class="group-required group-required-app_sso_admin"></th>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody jq-repeat="mygroups">
|
<tbody jq-repeat="mygroups">
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{cn}}</td>
|
<td>{{cn}}</td>
|
||||||
<td>{{description}}</td>
|
<td>{{description}}</td>
|
||||||
|
<td class="text-end group-required group-required-app_sso_admin">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="group-required group-required-app_sso_admin">
|
||||||
|
<label class="form-label small">Add to group</label>
|
||||||
|
<div class="d-flex gap-2 align-items-start">
|
||||||
|
<div id="add-group-select" class="flex-grow-1"></div>
|
||||||
|
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
Members of <span id="personal-group-uid-label"></span>'s group
|
||||||
|
<div 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header shadow actionMessage" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">
|
||||||
|
Every account gets a personal Unix group (its primary GID) — add
|
||||||
|
other accounts here as supplementary members (e.g. to share write
|
||||||
|
access to files owned by this group).
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<th>
|
||||||
|
Username
|
||||||
|
</th>
|
||||||
|
<th class="text-end"></th>
|
||||||
|
</thead>
|
||||||
|
<tbody jq-repeat="personalGroupMembers">
|
||||||
|
<tr>
|
||||||
|
<td>{{uid}}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<label class="form-label small">Add member</label>
|
||||||
|
<div class="d-flex gap-2 align-items-start">
|
||||||
|
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
|
||||||
|
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Token modal (shown once on create/rotate) -->
|
<!-- Token modal (shown once on create/rotate) -->
|
||||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||||
@@ -400,21 +662,21 @@
|
|||||||
async function revokeToken(id, name, btn){
|
async function revokeToken(id, name, btn){
|
||||||
var $card = $(btn).closest('.card');
|
var $card = $(btn).closest('.card');
|
||||||
$card.addClass('table-warning');
|
$card.addClass('table-warning');
|
||||||
var confirmed = await app.util.actionConfirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
|
var confirmed = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
|
||||||
$card.removeClass('table-warning');
|
$card.removeClass('table-warning');
|
||||||
if(!confirmed) return;
|
if(!confirmed) return;
|
||||||
app.apiToken.remove({id: id}, function(error, data){
|
app.apiToken.remove({id: id}, function(error, data){
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||||
$.scope.apiTokenCard.remove('id', id);
|
$.scope.apiTokenCard.remove('id', id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateToken(id, name, btn){
|
async function rotateToken(id, name, btn){
|
||||||
var $card = $(btn).closest('.card');
|
var $card = $(btn).closest('.card');
|
||||||
var confirmed = await app.util.actionConfirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
|
var confirmed = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
|
||||||
if(!confirmed) return;
|
if(!confirmed) return;
|
||||||
app.apiToken.rotate({id: id}, function(error, data){
|
app.apiToken.rotate({id: id}, function(error, data){
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||||
showSecret(data.token);
|
showSecret(data.token);
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
@@ -438,7 +700,7 @@
|
|||||||
expires_in_days: $('#edit-expires_in_days').val(),
|
expires_in_days: $('#edit-expires_in_days').val(),
|
||||||
};
|
};
|
||||||
app.apiToken.update(payload, function(error, data){
|
app.apiToken.update(payload, function(error, data){
|
||||||
if(error){ app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
if(error){ app.messages.action((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
||||||
editModal.hide();
|
editModal.hide();
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
@@ -455,7 +717,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</div>
|
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
|
||||||
|
<a href="/docs/api-tokens" 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-header actionMessage" style="display:none"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
|
<p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
|
||||||
|
|||||||
@@ -1,143 +1,151 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%- name %> <%- title %></title>
|
<title><%- name %> <%- title %></title>
|
||||||
<!-- Favicon -->
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
<link rel="icon" type="image/svg+xml" href="<%- logo %>">
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||||
<!-- CSS are placed here -->
|
via app.locals in app.js). Edit all three copies together. -->
|
||||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
<!-- Favicon -->
|
||||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||||
|
<!-- CSS are placed here -->
|
||||||
|
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
|
|
||||||
<link rel='stylesheet' href='/static/css/styles.css' />
|
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||||
<!-- Scripts are placed here -->
|
<!-- Scripts are placed here -->
|
||||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||||
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<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">
|
<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>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav top-nav">
|
<ul class="navbar-nav top-nav">
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
Users
|
<% for(const item of ui.nav){ %>
|
||||||
</a>
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
</li>
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%- item.label %>
|
||||||
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i>
|
</a>
|
||||||
Groups
|
</li>
|
||||||
</a>
|
<% } %>
|
||||||
</li>
|
</ul>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_oauth_admin">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<a class="nav-link" href="/integrations">
|
<% if(ui.profileUrl){ %>
|
||||||
<i class="fa-solid fa-plug"></i>
|
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
Integrations
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
<% } else { %>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
|
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
|
||||||
<a class="nav-link" href="/invites">
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
<i class="fa-solid fa-envelope-open-text"></i>
|
</span>
|
||||||
Invites
|
<% } %>
|
||||||
</a>
|
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
||||||
</li>
|
<i class="fas fa-sign-in"></i>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
Login
|
||||||
<a class="nav-link" href="/dashboard">
|
</a>
|
||||||
<i class="fa-solid fa-gauge-high"></i>
|
|
||||||
Dashboard
|
|
||||||
</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;">
|
|
||||||
<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;">
|
|
||||||
<i class="fas fa-sign-out"></i>
|
|
||||||
Login
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
|
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
|
||||||
<i class="fas fa-sign-out"></i>
|
<i class="fas fa-sign-out"></i>
|
||||||
Log Out
|
Log Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
<% if(ui.updateCheck){ %>
|
||||||
<span id="update-banner-text"></span>
|
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||||
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||||
</div>
|
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
|
||||||
|
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
|
||||||
|
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
|
||||||
|
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
|
||||||
|
<span id="update-banner-text"></span>
|
||||||
|
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function showUpdateBanner(){
|
function showUpdateBanner(){
|
||||||
let $nav = $('nav.fixed-top');
|
let $nav = $('nav.fixed-top');
|
||||||
let $banner = $('#update-banner');
|
let $banner = $('#update-banner');
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
||||||
}
|
}
|
||||||
function dismissUpdateBanner(){
|
|
||||||
$('#update-banner').hide();
|
|
||||||
$('#spa-shell').css('margin-top', '');
|
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(async function(){
|
function dismissUpdateBanner(){
|
||||||
|
$('#update-banner').hide();
|
||||||
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
|
}
|
||||||
|
|
||||||
// Set the correct link to active in the top nav bar
|
function checkForUpdate(){
|
||||||
$('.top-nav a').each(function(index){
|
if(sessionStorage.getItem('update-banner-dismissed')) return;
|
||||||
let $this = $(this);
|
app.api.get('update-check', function(error, info){
|
||||||
$this.removeClass('active');
|
if(error || !info || !info.updateAvailable) return;
|
||||||
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
$('#update-banner-text').html(
|
||||||
$this.addClass('active')
|
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
|
||||||
}
|
'(running v' + info.currentVersion + ') — ' +
|
||||||
})
|
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
||||||
|
);
|
||||||
|
showUpdateBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
// Set the correct login/logout button, and reveal the current user's
|
<script type="text/javascript">
|
||||||
// name (linking to their profile) once we know who they are.
|
$(document).ready(function(){
|
||||||
var me = await app.auth.isLoggedIn();
|
|
||||||
if(me){
|
|
||||||
$('#cl-logout-button').show();
|
|
||||||
if(me.uid){
|
|
||||||
$('#cl-username-text').text(me.uid);
|
|
||||||
$('#cl-username').css('display', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){
|
// Set the correct link to active in the top nav bar
|
||||||
app.api.get('update-check', function(error, info){
|
$('.top-nav a').each(function(index){
|
||||||
if(error || !info || !info.updateAvailable) return;
|
let $this = $(this);
|
||||||
$('#update-banner-text').html(
|
$this.removeClass('active');
|
||||||
'A newer version of SSO Manager is available: <b>v' + info.latestVersion + '</b> ' +
|
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||||
'(running v' + info.currentVersion + ') — ' +
|
$this.addClass('active')
|
||||||
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
|
}
|
||||||
);
|
})
|
||||||
showUpdateBanner();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}else{
|
|
||||||
$('#cl-login-button').show();
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
// Set the correct login/logout button, and reveal the current user's
|
||||||
</script>
|
// name once we know who they are. Group-gated nav items are revealed
|
||||||
|
// by app-base.js off the same cached user/me.
|
||||||
|
app.auth.isLoggedIn(function(error, me){
|
||||||
|
if(me){
|
||||||
|
$('#cl-logout-button').show();
|
||||||
|
let username = me.uid || me.username;
|
||||||
|
if(username){
|
||||||
|
$('#cl-username-text').text(username);
|
||||||
|
$('#cl-username').css('display', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
<% if(ui.updateCheck){ %>
|
||||||
|
if(me.isAdmin) checkForUpdate();
|
||||||
|
<% } %>
|
||||||
|
}else{
|
||||||
|
$('#cl-login-button').show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<!-- Container -->
|
<!-- Container -->
|
||||||
<div id="spa-shell" class="container-fluid">
|
<div id="spa-shell" class="container-fluid">
|
||||||
<div class="actionMessage" style="display:none;"></div>
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ async function fetchUsernameSuggestions() {
|
|||||||
$form.find('#personNameFields').toggle(!checked);
|
$form.find('#personNameFields').toggle(!checked);
|
||||||
$form.find('#serviceAccountNameField').toggle(checked);
|
$form.find('#serviceAccountNameField').toggle(checked);
|
||||||
|
|
||||||
|
// Service accounts aren't a person with a mailbox, and a blank
|
||||||
|
// password is fine (no userPassword attribute set -- the account
|
||||||
|
// 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);
|
||||||
|
|
||||||
if(checked){
|
if(checked){
|
||||||
// Filler values so the LDAP schema (inetOrgPerson requires sn) is
|
// Filler values so the LDAP schema (inetOrgPerson requires sn) is
|
||||||
// satisfied; not shown anywhere, the account name is what matters.
|
// satisfied; not shown anywhere, the account name is what matters.
|
||||||
@@ -128,9 +137,11 @@ async function fetchUsernameSuggestions() {
|
|||||||
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
|
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
|
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
|
||||||
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
|
<input type="text" class="form-control shadow" name="location" placeholder="Site One" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -153,3 +164,18 @@ async function fetchUsernameSuggestions() {
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
<button type="submit" class="btn btn-outline-dark">Add</button>
|
||||||
</form>
|
</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>
|
||||||
|
|||||||
@@ -3,34 +3,162 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function renderUsers(actionMessage, type){
|
function renderUsers(){
|
||||||
|
|
||||||
app.user.list(function(error, data){
|
app.user.list(function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
app.util.actionMessage(data.message, $target, 'danger');
|
app.messages.action(data.message, $('#tab-people'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$.scope.userRow.push(...data.results);
|
$.scope.userRow.empty();
|
||||||
|
$.scope.serviceAccountRow.empty();
|
||||||
|
const results = data.results || [];
|
||||||
|
$.scope.userRow.push(...results.filter(u => !u.isServiceAccount));
|
||||||
|
$.scope.serviceAccountRow.push(...results.filter(u => u.isServiceAccount));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleActive(uid, active){
|
function toggleActive(uid, active){
|
||||||
app.user.setActive(uid, active, function(error, data){
|
app.user.setActive(uid, active, function(error, data){
|
||||||
if(error) return alert('Failed to update user status');
|
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
||||||
renderUsers();
|
renderUsers();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.messages.confirm('Revoke selected invite token?', $thisRow, 'warning');
|
||||||
|
if(!confirmation){
|
||||||
|
$thisRow.removeClass('table-warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await app.api.delete(`user/invite/${tokenId}`);
|
||||||
|
loadInvites();
|
||||||
|
} catch(e) {
|
||||||
|
app.messages.action('Failed to revoke invite.', $thisRow, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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){
|
async function deleteUser(uid, btn){
|
||||||
const $row = $(btn).closest('tr');
|
const $row = $(btn).closest('tr');
|
||||||
$row.addClass('table-warning');
|
$row.addClass('table-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning');
|
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $row, 'warning');
|
||||||
$row.removeClass('table-warning');
|
$row.removeClass('table-warning');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
app.api.delete('user/' + uid, function(error, data){
|
app.api.delete('user/' + uid, function(error, data){
|
||||||
if (error) {
|
if (error) {
|
||||||
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger');
|
app.messages.action(data.message || 'Failed to delete user', $row, 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderUsers();
|
renderUsers();
|
||||||
@@ -82,6 +210,11 @@
|
|||||||
}
|
}
|
||||||
document.getElementById('invite-email').value = '';
|
document.getElementById('invite-email').value = '';
|
||||||
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
|
[...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){
|
}catch(e){
|
||||||
result.style.display = '';
|
result.style.display = '';
|
||||||
result.className = 'alert alert-danger mt-2';
|
result.className = 'alert alert-danger mt-2';
|
||||||
@@ -95,111 +228,263 @@
|
|||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
renderUsers();
|
renderUsers();
|
||||||
loadInviteGroups();
|
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>
|
</script>
|
||||||
<div class="row" style="display:none">
|
<div class="container mt-4">
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
|
<div class="row">
|
||||||
<div class="card-header shadow">
|
<div class="col-md-12">
|
||||||
<i class="fas fa-user-plus"></i>
|
<div class="card shadow">
|
||||||
Invite User
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span class="float-end">
|
<div>
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
<i class="fa-solid fa-users"></i>
|
||||||
</span>
|
User List
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header shadow actionMessage" style="display: none;"></div>
|
<div>
|
||||||
<div class="card-body">
|
<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">
|
||||||
<div class="mb-2">
|
<i class="fas fa-envelope"></i> Invite User
|
||||||
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
</button>
|
||||||
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
<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>
|
||||||
<div class="mb-2">
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<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')" />
|
<div class="px-3 pt-3 border-bottom">
|
||||||
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
<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>
|
||||||
<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
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<%- include('user_form', {adminMode: true}) %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
</div>
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header">
|
<!-- Invite User Modal -->
|
||||||
<i class="fa-solid fa-users"></i>
|
<div class="modal fade" id="inviteUserModal" tabindex="-1" aria-labelledby="inviteUserModalLabel" aria-hidden="true">
|
||||||
User List
|
<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>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="modal-body">
|
||||||
<div class="table-responsive">
|
<input type="hidden" id="edit-token" />
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<div class="mb-3">
|
||||||
<thead>
|
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
|
||||||
<th>ID</th>
|
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
|
||||||
<th>Name</th>
|
</div>
|
||||||
<th>eMail</th>
|
<div class="mb-3">
|
||||||
<th>Key</th>
|
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
||||||
<th>Active</th>
|
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
|
||||||
<th>TOS</th>
|
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
|
||||||
<th></th>
|
</div>
|
||||||
</thead>
|
<div id="edit-result" style="display:none"></div>
|
||||||
<tbody id="tableAJAX">
|
</div>
|
||||||
<tr jq-repeat="userRow">
|
<div class="modal-footer">
|
||||||
<td>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
{{ uidNumber }}
|
<button type="button" class="btn btn-primary" onclick="saveEdit()">
|
||||||
</td>
|
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||||
<td>
|
</button>
|
||||||
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
|
||||||
{{#isServiceAccount}}<span class="badge bg-secondary" title="Service account — not a person"><i class="fa-solid fa-gears"></i> service</span>{{/isServiceAccount}}
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fi
|
|||||||
# ── 3. ppolicy overlay ────────────────────────────────────────────────────────
|
# ── 3. ppolicy overlay ────────────────────────────────────────────────────────
|
||||||
info "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}"
|
skip "ppolicy overlay already configured on ${DB_DN}"
|
||||||
else
|
else
|
||||||
config_add "dn: olcOverlay=ppolicy,${DB_DN}
|
config_add "dn: olcOverlay=ppolicy,${DB_DN}
|
||||||
@@ -280,7 +280,7 @@ info "verifying ppolicy is active on ${DB_DN}"
|
|||||||
|
|
||||||
VERIFY_FAILED=0
|
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"
|
ok "ppolicy overlay is attached to the user database"
|
||||||
else
|
else
|
||||||
warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail"
|
warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=SSO NodeJS manager Service
|
Description=Theta42 SSO Manager
|
||||||
After=network.target
|
After=network.target slapd.service
|
||||||
|
Wants=slapd.service
|
||||||
StartLimitIntervalSec=0
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -8,7 +9,10 @@ Type=simple
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=1
|
RestartSec=1
|
||||||
User=root
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
// Example secrets configuration file (file-based config).
|
// 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
|
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
|
||||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks
|
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
|
||||||
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it.
|
// 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
|
// 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
|
// 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',
|
bindPassword: 'your-ldap-password',
|
||||||
userBase: 'ou=people,dc=example,dc=com',
|
userBase: 'ou=people,dc=example,dc=com',
|
||||||
groupBase: 'ou=groups,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: {
|
smtp: {
|
||||||
host: 'smtp.example.com',
|
host: 'smtp.example.com',
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
const { hashPasswordSSHA512 } = require('./nodejs/models/user_ldap.js');
|
||||||
|
console.log(hashPasswordSSHA512('MyTestPassword!2'));
|
||||||