Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b54da5c64c | |||
| 782ef69fb8 | |||
| 0e955abc73 | |||
| 69883836e1 | |||
| 17df21041a | |||
| c19fffe3c9 | |||
| b6abfe8f03 | |||
| 420ccfab3b | |||
| 8ed4505dc0 | |||
| 451054f0c2 | |||
| 3a46680c8b | |||
| 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 |
@@ -20,9 +20,9 @@
|
||||
!directory_spec.md
|
||||
!docs/**/*.md
|
||||
|
||||
# Tests
|
||||
nodejs/tests/
|
||||
nodejs/*.test.js
|
||||
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||
# nodejs/tests/
|
||||
# nodejs/*.test.js
|
||||
|
||||
# Host dependency tree — let the image run a clean `npm ci`. Also avoids
|
||||
# copying platform-wrong native modules (e.g. bcrypt built for the host OS).
|
||||
|
||||
@@ -4,7 +4,171 @@ All notable changes to this project are documented here. Format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||
|
||||
## [Unreleased]
|
||||
## [1.8.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — `loadLdapGroups()`'s fetch-once cache guard (`if (ldapGroupsCache) return;`) also skipped repopulating the `<datalist>` on every call after the first, but the modal body (including that `<datalist>`) is rebuilt fresh and empty on every `app.modal.open()`. Now the fetch is still cached, but the datalist is always repopulated.
|
||||
|
||||
## [1.8.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
|
||||
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
|
||||
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
|
||||
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
|
||||
|
||||
### Fixed
|
||||
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
|
||||
|
||||
### Operational note
|
||||
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
|
||||
|
||||
## [1.7.0] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **`formAJAX`'s loading indicator showed literal HTML** ("<div class=..."), not a spinner — it passed raw markup to `app.messages.action`, which HTML-escapes its message by design. Replaced with plain text.
|
||||
- **`POST /api/user/` (create) and `PUT /api/user/password` had no `message` field** in their response, so the success notification rendered empty. Added messages matching every other route's convention.
|
||||
- **The user landing on `/login` with a `?redirect=` had no explanation why** — happens whenever another app's "Log in with SSO" bounces an unauthenticated user through `/oauth/authorize`. Now shows a contextual banner explaining what's happening.
|
||||
|
||||
### Changed
|
||||
- **Directory: tree view is now the only view** (the list/tree toggle is gone) — simpler, one code path.
|
||||
- **Directory: clicking a resource's name opens its detail modal**, not just the pencil/edit icon.
|
||||
|
||||
Found via a fresh production install's feedback — see the [theta-env v1.13.0 release](https://github.com/theta42/theta-env/releases) for the full cross-repo summary.
|
||||
|
||||
## [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
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
|
||||
- Health check: `http://localhost:3001/health` → `{"status":"ok"}`
|
||||
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
|
||||
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
|
||||
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS)
|
||||
- LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
|
||||
|
||||
### API tokens (personal access tokens)
|
||||
|
||||
@@ -483,8 +483,8 @@ netstat -tlnp | grep 389
|
||||
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
|
||||
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
|
||||
StartTLS); port 389 is not mapped to the host by default so LAN clients can't
|
||||
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should
|
||||
use `ldaps://…:636` or StartTLS.
|
||||
bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
|
||||
`theta42/proxy`) should use `ldaps://…:636` or StartTLS.
|
||||
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
|
||||
set `JWT_SECRET`, issued tokens invalidate on container recreation.
|
||||
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
|
||||
|
||||
@@ -56,6 +56,7 @@ RUN apk add --no-cache \
|
||||
openldap-overlay-memberof \
|
||||
openldap-overlay-refint \
|
||||
openldap-overlay-syncprov \
|
||||
openldap-overlay-auditlog \
|
||||
openldap-passwd-sha2 \
|
||||
dumb-init \
|
||||
bash \
|
||||
@@ -130,7 +131,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
|
||||
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
|
||||
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
|
||||
# if you want LAN clients to bind without TLS (not recommended).
|
||||
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS)
|
||||
# 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
|
||||
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
|
||||
EXPOSE 3001 389 636
|
||||
|
||||
# Health check
|
||||
|
||||
@@ -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) |
|
||||
|
||||
| Sites & Replication |
|
||||
| --- |
|
||||
| [](docs/images/sites.png) |
|
||||
|
||||
## Features
|
||||
|
||||
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
|
||||
@@ -37,9 +41,10 @@ phone-home, no hosted control plane, and no per-user pricing.
|
||||
- **Web management UI** — manage users, groups, and OAuth clients from a
|
||||
browser; invite and password-reset flows over email; user self-service for
|
||||
profile and API tokens.
|
||||
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and
|
||||
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same
|
||||
directory, so you don't maintain a second user database for them.
|
||||
- **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
|
||||
rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
|
||||
and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
|
||||
same directory, so you don't maintain a second user database for them.
|
||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||
drive the management API from scripts or CI, scoped to their own permissions.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
||||
@@ -147,7 +152,7 @@ details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||
┌────────────────────────┐
|
||||
│ OpenLDAP (slapd) │
|
||||
│ - users / groups │
|
||||
│ - LDAPS :636 │─── legacy apps bind directly
|
||||
│ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
|
||||
│ - 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
|
||||
|
||||
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
|
||||
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 |
|
||||
|--------------|-------------|-------|
|
||||
| `id` | uuid / pk | |
|
||||
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` |
|
||||
| `kind` | enum | `site` \| `host` \| `service` |
|
||||
| `name` | text | display name ("Home Assistant", "ct101") |
|
||||
| `slug` | text unique | url-safe id used by the API |
|
||||
| `description`| text | free text |
|
||||
| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` |
|
||||
| `metadata` | jsonb | `{ subType, ip, macAddress, address, vmid, port, externalPort, gitRepo, installPath, systemdService, os, kernel, isProduction, isExternalReachable, isPublic }` |
|
||||
| `created_at` / `updated_at` | timestamptz | |
|
||||
|
||||
**Parent Enforcement Rules:**
|
||||
- A **Host** MUST have a parent **Site** or **Host**.
|
||||
- A **Service** MUST have a parent **Host**.
|
||||
- An **OAuth Integration** MUST have a parent **Service**.
|
||||
|
||||
**LDAP Group Auto-Creation:**
|
||||
When a Host or Service is created, the system will automatically create two LDAP groups in the directory (if they do not already exist):
|
||||
- `<slug>_access` (for standard user access)
|
||||
- `<slug>_admin` (for administrative access)
|
||||
Additional groups can still be linked manually.
|
||||
|
||||
### `resource_edge` — directed relationships (the graph)
|
||||
| column | type | notes |
|
||||
|--------------|--------|-------|
|
||||
@@ -109,7 +122,7 @@ common query fields can be promoted to columns later.
|
||||
| `child_id` | fk → resource | |
|
||||
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
|
||||
|
||||
Represents host←container←service (`hosts`/`runs_on`) and service→service
|
||||
Represents site←host←service (`hosts`/`runs_on`) and service→service
|
||||
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
|
||||
multiple parents/children and multiple relation types.
|
||||
|
||||
@@ -164,12 +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
|
||||
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
||||
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service
|
||||
token — `AuthToken` is session-oriented. **Proposed small addition:** a
|
||||
`ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`),
|
||||
long-lived, read-only, passed in the same `auth-token` header. Track as its own
|
||||
task; the discovery API should assume it exists but degrade to normal auth tokens
|
||||
until then.
|
||||
- **CI/CD (machine) access:** scripts and external integrations (like jump hosts) will use the existing `ApiToken` system (Personal Access Tokens) passed in the `Authorization: Bearer sso_...` header. The `ApiToken` inherits the exact LDAP group permissions of the user who created it, seamlessly mapping to existing access controls.
|
||||
- **Read visibility (decision to confirm):** either (a) any authenticated user may
|
||||
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
|
||||
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
|
||||
@@ -195,7 +203,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
||||
## 7. Roadmap
|
||||
|
||||
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
|
||||
`/api/discovery/*` endpoints, `ServiceToken` for CI/CD.
|
||||
`/api/discovery/*` endpoints, `ApiToken` for CI/CD.
|
||||
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
|
||||
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
|
||||
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
||||
@@ -213,4 +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
|
||||
for auth, SQL for inventory.
|
||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
||||
5. **Service token scope:** read-only globally, or per-token resource/kind scoping?
|
||||
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
|
||||
|
||||
---
|
||||
|
||||
## 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,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:
|
||||
@@ -139,6 +139,7 @@ moduleload pw-sha2
|
||||
moduleload ppolicy
|
||||
moduleload memberof
|
||||
moduleload refint
|
||||
moduleload auditlog
|
||||
SYNCPROV_MODULE_PLACEHOLDER
|
||||
|
||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||
@@ -187,6 +188,10 @@ memberof-memberof-ad memberOf
|
||||
overlay refint
|
||||
refint_attributes memberOf member manager owner
|
||||
|
||||
# auditlog overlay (LDIF audit trail of all changes)
|
||||
overlay auditlog
|
||||
auditlog /var/lib/ldap/auditlog.ldif
|
||||
|
||||
REPLICATION_BLOCK_PLACEHOLDER
|
||||
|
||||
# Access controls
|
||||
@@ -249,7 +254,7 @@ info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
|
||||
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
|
||||
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
||||
# Alpine and the container only uses simple bind over ldap://localhost:389.
|
||||
slapd -d 0 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" &
|
||||
slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
|
||||
SLAPD_PID=$!
|
||||
|
||||
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
||||
|
||||
@@ -31,6 +31,9 @@ nav:
|
||||
- title: LDAP
|
||||
page: /ldap.html
|
||||
icon: fa-address-book
|
||||
- title: Directory
|
||||
page: /directory.html
|
||||
icon: fa-server
|
||||
- title: Changelog
|
||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||
icon: fa-list
|
||||
|
||||
@@ -46,7 +46,7 @@ on, just like anyone else's.
|
||||
|
||||
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/Integrations
|
||||
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
|
||||
|
||||
@@ -28,7 +28,7 @@ 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
|
||||
on the Integrations page representing that one app. Registering a client
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
<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/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)*
|
||||
|
||||
@@ -52,11 +53,13 @@ backend, that's the niche.
|
||||
- **Web management UI** — users, groups, and OAuth clients from a browser;
|
||||
invite and password-reset flows over email; self-service profile + API
|
||||
tokens.
|
||||
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea,
|
||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
||||
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||
directory.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||
run the pieces separately via `app_*` env config.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -76,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
|
||||
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
|
||||
Manager and the proxy together with one command.
|
||||
|
||||
@@ -14,8 +14,9 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
|
||||
|
||||
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
||||
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP
|
||||
directly — Gitea, Emby, the theta42/proxy, etc.
|
||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||
theta42/proxy, etc.
|
||||
|
||||
## Directory layout
|
||||
|
||||
|
||||
@@ -53,20 +53,18 @@ An OAuth client represents an app that authenticates against the SSO. Each has:
|
||||
|
||||
### Managing clients
|
||||
|
||||
Clients are managed from the web UI (as a member of the `app_sso_oauth_admin`
|
||||
group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header
|
||||
from a login):
|
||||
Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
|
||||
|
||||
| Method | Path | Action |
|
||||
|--------|------|--------|
|
||||
| `GET` | `/api/oauth/client` | list clients |
|
||||
| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) |
|
||||
| `GET` | `/api/oauth/client/:id` | get one |
|
||||
| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups |
|
||||
| `DELETE` | `/api/oauth/client/:id` | delete |
|
||||
| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret once) |
|
||||
| Action | How to do it |
|
||||
|--------|--------------|
|
||||
| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
|
||||
| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
|
||||
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
|
||||
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
|
||||
|
||||
> All client-management endpoints are gated by the `app_sso_oauth_admin` group.
|
||||
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
|
||||
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
|
||||
|
||||
## Scopes
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ app.contoller = require('./controller');
|
||||
|
||||
// Background services (self-initializing on require).
|
||||
require('./services/update_check');
|
||||
require('./services/ldap_monitor');
|
||||
|
||||
// Push pubsub over the socket and back.
|
||||
app.onListen.push(function(){
|
||||
@@ -60,6 +61,11 @@ app.set('trust proxy', 1);
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
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
|
||||
// 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.
|
||||
@@ -83,16 +89,18 @@ app.use('/api/token', middleware.auth, require('./routes/token'));
|
||||
|
||||
app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||
|
||||
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||
|
||||
// OAuth 2.0 / OpenID Connect
|
||||
app.use('/oauth', oauthRouter);
|
||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||
app.get('/.well-known/openid-configuration', discovery);
|
||||
|
||||
|
||||
@@ -105,7 +113,7 @@ app.use(function(req, res, next) {
|
||||
next(err);
|
||||
});
|
||||
|
||||
// Error handler. This is where `next()` will go on error
|
||||
// Error handling
|
||||
app.use(function(err, req, res, next) {
|
||||
const SILENT_404S = ['/.well-known/'];
|
||||
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
|
||||
|
||||
@@ -25,13 +25,19 @@ var server = http.createServer(app);
|
||||
var io = require('socket.io')(server);
|
||||
app.io = io;
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
const models = require('../models');
|
||||
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
/**
|
||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
models.initORM().then(() => {
|
||||
server.listen(port);
|
||||
server.on('error', onError);
|
||||
server.on('listening', onListening);
|
||||
}).catch(err => {
|
||||
console.error('Failed to initialize ORM:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalize a port into a number, string, or false.
|
||||
|
||||
@@ -9,10 +9,23 @@ async function auth(req, res, next){
|
||||
// the same /api/* routes the UI uses.
|
||||
const authz = req.header('authorization') || '';
|
||||
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
||||
const user = await Auth.checkApiToken(authz.slice(7));
|
||||
if(user && user.uid){
|
||||
req.user = user;
|
||||
return next();
|
||||
const tokenStr = authz.slice(7);
|
||||
if (tokenStr.startsWith('sso_')) {
|
||||
const user = await Auth.checkApiToken(tokenStr);
|
||||
if(user && user.uid){
|
||||
req.user = user;
|
||||
return next();
|
||||
}
|
||||
} else {
|
||||
// Machine token (ServiceToken)
|
||||
const { ServiceToken } = require('../models/token');
|
||||
let svcToken;
|
||||
try { svcToken = await ServiceToken.get(tokenStr); } catch(e) {}
|
||||
if (svcToken && svcToken.is_valid) {
|
||||
req.user = { uid: svcToken.resource_id, isMachine: true, name: 'Machine Account' };
|
||||
req.resourceId = svcToken.resource_id;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,44 +3,18 @@
|
||||
const { Client, Attribute, Change } = require('ldapts');
|
||||
const { LRUCache } = require('lru-cache');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
|
||||
// Escape a value used inside an LDAP search filter (RFC 4515).
|
||||
function escapeLDAPSearchValue(val) {
|
||||
return String(val)
|
||||
.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\0/g, '\\00');
|
||||
}
|
||||
|
||||
// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns
|
||||
// are normally alphanumeric, but this prevents metacharacter injection.
|
||||
function escapeLDAPDNValue(val) {
|
||||
return String(val)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/</g, '\\<')
|
||||
.replace(/>/g, '\\>')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
||||
}
|
||||
// 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() {
|
||||
return new Client({ url: conf.url });
|
||||
return _makeClient(conf);
|
||||
}
|
||||
|
||||
async function withClient(fn) {
|
||||
const client = makeClient();
|
||||
try {
|
||||
await client.bind(conf.bindDN, conf.bindPassword);
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.unbind().catch(() => {});
|
||||
}
|
||||
return _withClient(conf, fn);
|
||||
}
|
||||
|
||||
async function getGroups(client, member){
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const {setUpTable} = require('model-redis');
|
||||
const { setUpTable } = require('model-redis');
|
||||
|
||||
// Keep model-redis for the ones not yet ported
|
||||
const Table = setUpTable(conf.redis);
|
||||
|
||||
module.exports = Table;
|
||||
|
||||
require('./token');
|
||||
const { Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken } = require('./token');
|
||||
require('./verification');
|
||||
require('./oauth_client');
|
||||
require('./oauth_code');
|
||||
require('./api_token');
|
||||
|
||||
const { init } = require('@simpleworkjs/orm');
|
||||
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
storage: './config/inventory.sqlite',
|
||||
logging: false
|
||||
};
|
||||
ormConf.redis = conf.redis;
|
||||
|
||||
console.log('[initORM] Starting ORM initialization...');
|
||||
try {
|
||||
await init({
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
console.log('[initORM] ORM initialized successfully');
|
||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||
} catch (err) {
|
||||
console.error('[initORM] ORM initialization failed:', err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports.initORM = initORM;
|
||||
|
||||
@@ -1,51 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const { Resource } = require('./resource');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const UUID = () => crypto.randomUUID();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const UUID = () => crypto.randomUUID();
|
||||
|
||||
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
||||
access_token: 3600,
|
||||
refresh_token: 2592000
|
||||
};
|
||||
|
||||
class OAuthClient extends Table {
|
||||
static _key = 'client_id';
|
||||
static _keyMap = {
|
||||
'client_id': {default: UUID, type: 'string'},
|
||||
'client_secret_hash': {isRequired: true, type: 'string', isPrivate: true},
|
||||
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
||||
'description': {default: '', type: 'string'},
|
||||
'redirect_uris': {default: [], type: 'object'},
|
||||
'scopes': {default: ['openid', 'profile', 'email', 'groups'], type: 'object'},
|
||||
'allowed_groups': {default: [], type: 'object'},
|
||||
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
|
||||
'created_by': {isRequired: true, type: 'string'},
|
||||
'created_on': {default: function(){ return (new Date).getTime() }},
|
||||
'is_valid': {default: true, type: 'boolean'},
|
||||
}
|
||||
|
||||
class OAuthClient {
|
||||
static async add(data) {
|
||||
const raw_secret = UUID();
|
||||
data.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||
data.client_id = UUID();
|
||||
const client = await this.create(data);
|
||||
client._raw_secret = raw_secret;
|
||||
return client;
|
||||
const raw_secret = crypto.randomUUID();
|
||||
const client_id = crypto.randomUUID();
|
||||
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||
|
||||
// Generate a unique slug from the client name
|
||||
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
|
||||
// Ensure uniqueness by appending a suffix if needed
|
||||
const existing = await Resource.list({ where: { slug } });
|
||||
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
|
||||
|
||||
const r = await Resource.create({
|
||||
id: client_id,
|
||||
kind: 'oauth',
|
||||
name: data.name,
|
||||
slug: slug,
|
||||
description: data.description || '',
|
||||
owner: data.created_by,
|
||||
metadata: {
|
||||
client_secret_hash,
|
||||
redirect_uris: data.redirect_uris || [],
|
||||
scopes: data.scopes || ['openid', 'profile', 'email', 'groups'],
|
||||
allowed_groups: data.allowed_groups || [],
|
||||
token_lifetime: data.token_lifetime || { ...defaultLifetime }
|
||||
}
|
||||
});
|
||||
|
||||
r._raw_secret = raw_secret;
|
||||
r.client_id = client_id;
|
||||
return r;
|
||||
}
|
||||
static async get(client_id) {
|
||||
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) {
|
||||
return bcrypt.compare(secret, this.client_secret_hash);
|
||||
static async list() {
|
||||
const resources = await Resource.list({ where: { kind: 'oauth' } });
|
||||
return Promise.all(resources.map(r => this.get(r.id)));
|
||||
}
|
||||
|
||||
async rotateSecret() {
|
||||
const raw_secret = UUID();
|
||||
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) });
|
||||
return raw_secret;
|
||||
static async listDetail() {
|
||||
return this.list();
|
||||
}
|
||||
|
||||
static async verifySecret(client_id, secret) {
|
||||
const client = await this.get(client_id);
|
||||
return client.verifySecret(secret);
|
||||
}
|
||||
}
|
||||
OAuthClient.register();
|
||||
|
||||
module.exports = { OAuthClient };
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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: {} },
|
||||
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||
// are set explicitly by the route handler on every create/update (see
|
||||
// routes/api_directory_admin.js). Existing rows predating this change
|
||||
// simply read back undefined -- callers must render a fallback.
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
updated_by: { type: 'string' },
|
||||
updated_on: { type: 'integer' },
|
||||
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||
};
|
||||
|
||||
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||
// top-level resource with no site parent keeps its unprefixed group name).
|
||||
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||
if (visited.has(resourceId)) return null;
|
||||
visited.add(resourceId);
|
||||
|
||||
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||
for (const edge of parentEdges) {
|
||||
const parent = await this.get(edge.parentId);
|
||||
if (!parent) continue;
|
||||
if (parent.kind === 'site') return parent.slug;
|
||||
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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,22 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
const Table = require('.');
|
||||
const { Model } = require('@simpleworkjs/orm');
|
||||
const crypto = require('crypto');
|
||||
const UUID = () => crypto.randomUUID();
|
||||
|
||||
|
||||
class Token extends Table{
|
||||
static _key = 'token';
|
||||
static _keyMap = {
|
||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
||||
'created_on': {default: function(){return (new Date).getTime()}},
|
||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
||||
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
|
||||
'is_valid': {default: true, type: 'boolean'},
|
||||
}
|
||||
|
||||
constructor(...args){
|
||||
super(...args);
|
||||
class Token extends Model {
|
||||
static adapterName = 'redis';
|
||||
static fields = {
|
||||
token: { type: 'string', primaryKey: true, default: UUID, isPrivate: true, min: 36, max: 36 },
|
||||
created_by: { isRequired: true, type: 'string', min: 3, max: 500 },
|
||||
created_on: { type: 'integer', default: function(){return (new Date).getTime()} },
|
||||
updated_on: { type: 'integer', default: function(){return (new Date).getTime()}, always: true },
|
||||
is_valid: { default: true, type: 'boolean' }
|
||||
}
|
||||
|
||||
async check(){
|
||||
@@ -28,12 +23,10 @@ class Token extends Table{
|
||||
}
|
||||
}
|
||||
|
||||
Token.register();
|
||||
|
||||
class AuthToken extends Token{
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
||||
static fields = {
|
||||
...Token.fields,
|
||||
user: {model: 'User', type: 'hasOne', localKey: 'created_by'},
|
||||
}
|
||||
|
||||
static async create(data){
|
||||
@@ -42,11 +35,10 @@ class AuthToken extends Token{
|
||||
|
||||
}
|
||||
}
|
||||
AuthToken.register();
|
||||
|
||||
class InviteToken extends Token{
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
static fields = {
|
||||
...Token.fields,
|
||||
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
||||
mail: {default: '__NONE__', type: 'string'},
|
||||
mail_token: {default: '__NONE__', type: 'string'},
|
||||
@@ -68,14 +60,13 @@ class InviteToken extends Token{
|
||||
}
|
||||
}
|
||||
}
|
||||
InviteToken.register();
|
||||
|
||||
class ImpersonationToken extends Token {
|
||||
static _keyMap = {
|
||||
...super._keyMap,
|
||||
static fields = {
|
||||
...Token.fields,
|
||||
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
||||
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
|
||||
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'number'},
|
||||
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'integer'},
|
||||
}
|
||||
|
||||
get isExpired() {
|
||||
@@ -87,27 +78,24 @@ class ImpersonationToken extends Token {
|
||||
return this.create(data);
|
||||
}
|
||||
}
|
||||
ImpersonationToken.register();
|
||||
|
||||
class PasswordResetToken extends Token {}
|
||||
PasswordResetToken.register();
|
||||
|
||||
class OtpToken extends Token {
|
||||
static _keyMap = {
|
||||
...Token._keyMap,
|
||||
static fields = {
|
||||
...Token.fields,
|
||||
uid: {isRequired: true, type: 'string'},
|
||||
code: {isRequired: true, type: 'string'},
|
||||
method: {isRequired: true, type: 'string'},
|
||||
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'number'},
|
||||
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'integer'},
|
||||
};
|
||||
|
||||
get isExpired() {
|
||||
return (new Date).getTime() > this.expires_at;
|
||||
}
|
||||
|
||||
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
|
||||
static async issue(uid, method) {
|
||||
const existing = await this.listDetail({uid});
|
||||
const existing = await this.list({where: {uid}});
|
||||
for (const t of existing) {
|
||||
if (t.is_valid) await t.update({is_valid: false});
|
||||
}
|
||||
@@ -116,13 +104,22 @@ class OtpToken extends Token {
|
||||
}
|
||||
|
||||
static async verify(uid, code) {
|
||||
const tokens = await this.listDetail({uid});
|
||||
const tokens = await this.list({where: {uid}});
|
||||
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
|
||||
if (!match) return null;
|
||||
await match.update({is_valid: false});
|
||||
return match;
|
||||
}
|
||||
}
|
||||
OtpToken.register();
|
||||
class ServiceToken extends Token {
|
||||
static fields = {
|
||||
...Token.fields,
|
||||
resource_id: {isRequired: true, type: 'string'}
|
||||
}
|
||||
|
||||
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken};
|
||||
static async issue(resource_id, created_by) {
|
||||
return this.create({resource_id, created_by});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken};
|
||||
|
||||
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
|
||||
const {Group} = require('./group_ldap');
|
||||
const {UserVerification} = require('./verification');
|
||||
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) {
|
||||
const salt = crypto.randomBytes(8);
|
||||
@@ -23,40 +31,11 @@ const cache = new LRUCache({
|
||||
});
|
||||
|
||||
function makeClient() {
|
||||
return new Client({ url: conf.url });
|
||||
return _makeClient(conf);
|
||||
}
|
||||
|
||||
async function withClient(fn) {
|
||||
const client = makeClient();
|
||||
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');
|
||||
}
|
||||
|
||||
// Escape a value used in an LDAP DN (RFC 4514).
|
||||
function escapeLDAPDNValue(val) {
|
||||
return String(val)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/</g, '\\<')
|
||||
.replace(/>/g, '\\>')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/=/g, '\\=')
|
||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
||||
return _withClient(conf, fn);
|
||||
}
|
||||
|
||||
// Compute the next available uid/gidNumber: the highest existing value below
|
||||
@@ -494,6 +473,19 @@ User.update = async function(data){
|
||||
}
|
||||
|
||||
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, [
|
||||
new Change({
|
||||
operation: 'replace',
|
||||
@@ -805,6 +797,19 @@ User.addSSHkey = async function(data) {
|
||||
let result;
|
||||
try {
|
||||
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, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
@@ -891,6 +896,12 @@ User.invite = async function(data = {}){
|
||||
|
||||
User.login = async function(data){
|
||||
try{
|
||||
if (!data.uid && !data.username) {
|
||||
let error = new Error('Invalid Credentials, login failed.');
|
||||
error.name = 'LDAPLoginFailed';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
let user = await this.get(data.uid || data.username);
|
||||
|
||||
const loginClient = makeClient();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.1.17",
|
||||
"version": "1.8.1",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
@@ -22,7 +23,12 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.6",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"compression": "^1.8.1",
|
||||
@@ -31,12 +37,12 @@
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"extend": "^3.0.2",
|
||||
"jq-repeat": "^2.2.0",
|
||||
"jquery": "^3.7.1",
|
||||
"jquery": "^4.0.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"ldapts": "^8.1.2",
|
||||
"ldapts": "^8.1.8",
|
||||
"lru-cache": "^11.5.1",
|
||||
"marked": "^9.1.6",
|
||||
"model-redis": "^0.4.0",
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"nodemailer": "^9.0.0",
|
||||
|
||||
@@ -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){
|
||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||
callack(error, data);
|
||||
@@ -110,7 +103,7 @@ app.user = (function(app){
|
||||
return m ? m[1] : dn;
|
||||
}
|
||||
|
||||
return {list, remove, createInvite, setActive, dnToUid};
|
||||
return {list, createInvite, setActive, dnToUid};
|
||||
|
||||
})(app);
|
||||
|
||||
@@ -306,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){
|
||||
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
||||
callack(error, data);
|
||||
@@ -325,7 +311,7 @@ app.oauthClient = (function(app){
|
||||
});
|
||||
}
|
||||
|
||||
return { list, add, remove, update, rotateSecret };
|
||||
return { list, add, update, rotateSecret };
|
||||
})(app);
|
||||
|
||||
app.tos = (function(app){
|
||||
@@ -396,7 +382,7 @@ app.impersonate = (function(app){
|
||||
|
||||
app.token = (function(app){
|
||||
function list(name, callack){
|
||||
if($.isFunction(name)){
|
||||
if(typeof name === 'function'){
|
||||
callack = 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 = {};
|
||||
|
||||
app.pubsub = (function(){
|
||||
@@ -75,11 +84,17 @@ app.socket = (function(app){
|
||||
app.api = (function(app){
|
||||
var baseURL = '/api/'
|
||||
|
||||
function post(url, data, callback){
|
||||
if (!$.isFunction(callback)) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||
// 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({
|
||||
type: 'POST', url: baseURL+url,
|
||||
type: method,
|
||||
url: baseURL+url,
|
||||
headers: { 'auth-token': app.auth.getToken() },
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
@@ -88,9 +103,11 @@ app.api = (function(app){
|
||||
});
|
||||
}
|
||||
return $.ajax({
|
||||
type: 'POST',
|
||||
type: method,
|
||||
url: baseURL+url,
|
||||
headers:{ 'auth-token': app.auth.getToken() },
|
||||
headers:{
|
||||
'auth-token': app.auth.getToken()
|
||||
},
|
||||
data: JSON.stringify(data),
|
||||
contentType: "application/json; charset=utf-8",
|
||||
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){
|
||||
if (!$.isFunction(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
|
||||
);
|
||||
}
|
||||
});
|
||||
return body('PUT', url, data, callback);
|
||||
}
|
||||
|
||||
function remove(url, callback){
|
||||
if (!$.isFunction(callback)) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||
// the serialized form as the second argument — as (url, data, callback).
|
||||
// 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({
|
||||
type: 'DELETE', url: baseURL+url,
|
||||
type: 'DELETE',
|
||||
url: baseURL+url,
|
||||
headers: { 'auth-token': app.auth.getToken() },
|
||||
contentType: 'application/json; charset=utf-8',
|
||||
dataType: 'json',
|
||||
@@ -147,7 +151,9 @@ app.api = (function(app){
|
||||
return $.ajax({
|
||||
type: 'DELETE',
|
||||
url: baseURL+url,
|
||||
headers:{ 'auth-token': app.auth.getToken() },
|
||||
headers:{
|
||||
'auth-token': app.auth.getToken()
|
||||
},
|
||||
contentType: "application/json; charset=utf-8",
|
||||
dataType: "json",
|
||||
complete: function(res, text){
|
||||
@@ -202,7 +208,10 @@ app.api = (function(app){
|
||||
})(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){
|
||||
localStorage.setItem('APIToken', token);
|
||||
@@ -216,35 +225,70 @@ app.auth = (function(app){
|
||||
try{
|
||||
return await app.api.get('user/me');
|
||||
}catch(error){
|
||||
if(error?.status === 401) return null;
|
||||
throw error
|
||||
if(error && error.status === 401) return null;
|
||||
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){
|
||||
try{
|
||||
user = user || await app.auth.asyncUser;
|
||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
|
||||
user = user || await loadUser();
|
||||
if(!user) return false;
|
||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||
|
||||
for(let group of user.memberOf){
|
||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
||||
if(groupNameToFind.includes(group)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
}catch(error){
|
||||
throw(error);
|
||||
}
|
||||
return groupCNs(user).some(function(group){
|
||||
return groupNameToFind.includes(group);
|
||||
});
|
||||
}
|
||||
|
||||
async function isLoggedIn(){
|
||||
if(getToken()){
|
||||
user = await app.auth.asyncUser;
|
||||
return user;
|
||||
}else{
|
||||
return false;
|
||||
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||
function isAdmin(){
|
||||
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||
}
|
||||
|
||||
// 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){
|
||||
@@ -252,62 +296,125 @@ app.auth = (function(app){
|
||||
if(data.login){
|
||||
setToken(data.token);
|
||||
}
|
||||
loadUser(true);
|
||||
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){
|
||||
localStorage.removeItem('APIToken');
|
||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
||||
callback();
|
||||
userPromise = null;
|
||||
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){
|
||||
$.holdReady(true);
|
||||
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
|
||||
var user = await loadUser();
|
||||
|
||||
if(!user){
|
||||
logOut(function(){});
|
||||
location.replace('/login?redirect=' + encodeURIComponent(
|
||||
location.pathname + location.search
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||
location.replace('/onboarding');
|
||||
return false;
|
||||
}
|
||||
|
||||
if(requiredGroups){
|
||||
if(!await memberOf(requiredGroups)){
|
||||
console.log("Does not have permission!!!")
|
||||
app.util.actionMessage(
|
||||
`<h1>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>You do not have permission to be here.</b>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</h1>`,
|
||||
$('#spa-shell'),
|
||||
'danger',
|
||||
);
|
||||
throw new Error("User does not have permission");
|
||||
}
|
||||
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||
app.messages.action(
|
||||
`<h1>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>You do not have permission to be here.</b>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
</h1>`,
|
||||
$('#spa-shell'),
|
||||
'danger',
|
||||
);
|
||||
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(){
|
||||
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 {
|
||||
getToken: getToken,
|
||||
setToken: setToken,
|
||||
getUser: getUser,
|
||||
loadUser: loadUser,
|
||||
groupCNs: groupCNs,
|
||||
memberOf: memberOf,
|
||||
isAdmin: isAdmin,
|
||||
isLoggedIn: isLoggedIn,
|
||||
safeInternalPath: safeInternalPath,
|
||||
consumeTokenFragment: consumeTokenFragment,
|
||||
user: null,
|
||||
perms: null,
|
||||
logIn: logIn,
|
||||
logOut: logOut,
|
||||
forceLogin,
|
||||
logInRedirect,
|
||||
getUser,
|
||||
memberOf,
|
||||
}
|
||||
|
||||
})(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){
|
||||
function list(callback){
|
||||
@@ -338,6 +445,72 @@ app.user = (function(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){
|
||||
|
||||
function getUrlParameter(name){
|
||||
@@ -347,65 +520,15 @@ app.util = (function(app){
|
||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||
};
|
||||
|
||||
function actionMessage(message, $targetPassed, type, callback){
|
||||
message = message || '';
|
||||
|
||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
||||
|
||||
type = type || 'info';
|
||||
callback = callback || function(){};
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||
// app.util.escapeHtml and app.messages.action/confirm.
|
||||
function escapeHtml(s){
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
$.fn.serializeObject = function() {
|
||||
@@ -417,6 +540,9 @@ app.util = (function(app){
|
||||
if (obj[name] === undefined) {
|
||||
if (!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;
|
||||
}
|
||||
@@ -461,26 +587,40 @@ app.util = (function(app){
|
||||
return {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
actionMessage: actionMessage,
|
||||
actionConfirm,
|
||||
escapeHtml: escapeHtml,
|
||||
}
|
||||
})(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
|
||||
for(let group of (await app.auth.asyncUser)?.memberOf || []){
|
||||
var style = document.getElementById('group-required-rules');
|
||||
if(!style){
|
||||
style = document.createElement('style');
|
||||
style.id = 'group-required-rules';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
for(var group of groups){
|
||||
try{
|
||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
||||
|
||||
const sheet = document.styleSheets[0];
|
||||
const selector = `.group-required-${group}`;
|
||||
const cssText = `${selector} { display: revert !important; }`;
|
||||
sheet.insertRule(cssText, sheet.cssRules.length);
|
||||
style.sheet.insertRule(
|
||||
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||
style.sheet.cssRules.length
|
||||
);
|
||||
}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
|
||||
|
||||
@@ -502,9 +642,9 @@ $( document ).ready(async function(){
|
||||
$(this).closest('.card').slideUp('fast');
|
||||
});
|
||||
|
||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
||||
app.util.actionMessage(null, $(this));
|
||||
});
|
||||
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||
// app.messages.js (delegated on document, so it also covers messages
|
||||
// rendered after this ready handler runs).
|
||||
|
||||
setInterval(()=>{
|
||||
$('.momentFromNow').each((idx, el)=>{
|
||||
@@ -535,20 +675,17 @@ function formAJAX(btn){
|
||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
app.util.actionMessage(
|
||||
`<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>`,
|
||||
$form,
|
||||
'info'
|
||||
);
|
||||
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||
// render literally instead of as an element.
|
||||
app.messages.action('Saving…', $form, 'info');
|
||||
|
||||
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();
|
||||
if(!error){
|
||||
$form.trigger("reset");
|
||||
@@ -556,7 +693,7 @@ function formAJAX(btn){
|
||||
}else{
|
||||
console.log('formAJAX res error', error, data)
|
||||
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){
|
||||
console.log('form key errors', data.keys)
|
||||
@@ -567,4 +704,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,241 @@
|
||||
'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;
|
||||
|
||||
const now = Date.now();
|
||||
req.body.created_by = req.body.created_by || req.user.uid;
|
||||
req.body.created_on = now;
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = now;
|
||||
|
||||
let r;
|
||||
if (req.body.kind === 'oauth') {
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
// Pass created_by explicitly for the wrapper (overrides the generic
|
||||
// assignment above -- this is OAuthClient-wrapper-specific behavior).
|
||||
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 siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||
const createGroup = async (suffix, accessLevel) => {
|
||||
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${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' });
|
||||
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = Date.now();
|
||||
|
||||
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 permission = require('../utils/permission');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const metrics = require('../utils/metrics');
|
||||
|
||||
async function findUserByLogin(login) {
|
||||
try {
|
||||
@@ -37,12 +38,16 @@ router.get('/username-suggestions', async function(req, res, next) {
|
||||
router.post('/login', rateLimit.login, async function(req, res, next){
|
||||
try{
|
||||
let auth = await Auth.login(req.body);
|
||||
metrics.recordServiceUsage('SSO Web UI', req.body.uid);
|
||||
return res.json({
|
||||
login: true,
|
||||
token: auth.token.token,
|
||||
message:`${req.body.uid} logged in!`,
|
||||
});
|
||||
}catch(error){
|
||||
if (error.name === 'LDAPLoginFailed' || error.status === 401 || error.name === 'UserNotFound') {
|
||||
metrics.recordFailedLogin(req.ip, req.body.uid);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
@@ -198,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
|
||||
const target = await User.get(req.params.uid);
|
||||
|
||||
// Clean up any existing impersonation for this target
|
||||
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
|
||||
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
|
||||
for (const old of existing) {
|
||||
if (old.is_valid && !old.isExpired) {
|
||||
try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
|
||||
@@ -232,7 +237,7 @@ router.delete('/impersonate/:uid', middleware.auth, async function(req, res, nex
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
|
||||
const target = await User.get(req.params.uid);
|
||||
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid });
|
||||
const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
|
||||
|
||||
let revoked = 0;
|
||||
for (const token of existing) {
|
||||
|
||||
@@ -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;
|
||||
@@ -33,6 +33,7 @@ const DOCS = {
|
||||
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
|
||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
|
||||
@@ -82,8 +82,13 @@ router.put('/:group/:uid', async function(req, res, next){
|
||||
|
||||
var group = await Group.get(req.params.group);
|
||||
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({
|
||||
results: await group.addMember(user),
|
||||
results,
|
||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
@@ -98,8 +103,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
||||
|
||||
var group = await Group.get(req.params.group);
|
||||
var user = await User.get(req.params.uid);
|
||||
const results = await group.removeMember(user);
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results: await group.removeMember(user),
|
||||
results,
|
||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
|
||||
@@ -10,6 +10,7 @@ const {InviteToken, PasswordResetToken} = require('./../models/token');
|
||||
const {Tos} = require('../models/tos');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('../utils/build_info');
|
||||
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||
|
||||
const values ={
|
||||
title: conf.environment !== 'production' ? `dev` : '',
|
||||
@@ -20,24 +21,16 @@ const values ={
|
||||
}
|
||||
|
||||
// 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),
|
||||
// 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.
|
||||
frontEndModules.forEach(dep => {
|
||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
||||
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
||||
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
||||
// 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.
|
||||
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
||||
router.get('/health', function(req, res) {
|
||||
@@ -55,17 +48,28 @@ router.get('/tos', async function(req, res, next) {
|
||||
|
||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||
// (broadcast + history) merged into one page.
|
||||
router.get('/dashboard', function(req, res) {
|
||||
res.render('dashboard', {...values});
|
||||
router.get('/executive', function(req, res) {
|
||||
res.render('executive', {...values});
|
||||
});
|
||||
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/dashboard'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/dashboard'));
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
||||
|
||||
router.get('/invites', function(req, res) {
|
||||
res.render('invites', {...values});
|
||||
router.get('/directory', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||
// the matching resource's modal once the page's own data has loaded.
|
||||
router.get('/directory/:slug', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Route removed since it's now in directory
|
||||
|
||||
router.get('/onboarding', async function(req, res, next) {
|
||||
try {
|
||||
const tos = await Tos.getCurrent();
|
||||
@@ -76,6 +80,10 @@ router.get('/onboarding', 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});
|
||||
});
|
||||
|
||||
@@ -145,44 +153,7 @@ router.get('/token', function(req, res, next) {
|
||||
res.render('token', {...values});
|
||||
});
|
||||
|
||||
router.get('/sites', async function(req, res, next) {
|
||||
const net = require('net');
|
||||
const url = require('url');
|
||||
|
||||
const myId = process.env.LDAP_SERVER_ID || 'Standalone';
|
||||
const hostsStr = process.env.LDAP_REPLICATION_HOSTS || '';
|
||||
const hosts = hostsStr.split(' ').filter(h => h);
|
||||
|
||||
const sites = await Promise.all(hosts.map(hostUrl => {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const u = new url.URL(hostUrl);
|
||||
const port = u.port || (u.protocol === 'ldaps:' ? 636 : 389);
|
||||
const hostname = u.hostname;
|
||||
const socket = new net.Socket();
|
||||
socket.setTimeout(2000);
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.destroy();
|
||||
resolve({ url: hostUrl, status: 'Online' });
|
||||
});
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
resolve({ url: hostUrl, status: 'Offline (Timeout)' });
|
||||
});
|
||||
socket.on('error', (err) => {
|
||||
socket.destroy();
|
||||
resolve({ url: hostUrl, status: 'Offline (' + err.code + ')' });
|
||||
});
|
||||
socket.connect(port, hostname);
|
||||
} catch (e) {
|
||||
resolve({ url: hostUrl, status: 'Invalid URL' });
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
res.render('sites', { ...values, myId, sites });
|
||||
});
|
||||
|
||||
|
||||
router.get('/login/resetpassword/:token', async function(req, res, next){
|
||||
|
||||
@@ -45,8 +45,11 @@ router.post('/', async function(req, res, next) {
|
||||
|
||||
const client = await OAuthClient.add(req.body);
|
||||
|
||||
const result = client.toJSON ? client.toJSON() : { ...client };
|
||||
result.client_id = client.client_id || client.id;
|
||||
|
||||
return res.json({
|
||||
results: client,
|
||||
results: result,
|
||||
client_secret: client._raw_secret,
|
||||
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
|
||||
});
|
||||
@@ -94,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
|
||||
const client = await OAuthClient.get(req.params.client_id);
|
||||
await client.remove();
|
||||
await client.delete();
|
||||
|
||||
return res.json({
|
||||
client_id: req.params.client_id,
|
||||
|
||||
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
|
||||
|
||||
router.get('/:name', async function(req, res, next){
|
||||
try{
|
||||
// ORM models: list() on the redis adapter always returns full rows;
|
||||
// detail is handled by serialization (isPrivate fields are excluded).
|
||||
return res.json({
|
||||
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]()
|
||||
results: await tokens[req.params.name].list()
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
|
||||
|
||||
router.get('/:name/:token', async function(req, res, next){
|
||||
try{
|
||||
return res.json({
|
||||
results: await tokens[req.params.name].get(req.params.token)
|
||||
});
|
||||
const result = await tokens[req.params.name].get(req.params.token);
|
||||
if (!result) {
|
||||
const error = new Error('Token not found');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return res.json({ results: result });
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({results: user});
|
||||
return res.json({results: user, message: `User ${user.uid} created.`});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
@@ -74,7 +74,17 @@ router.delete('/:uid', async function(req, res, next){
|
||||
|
||||
router.get('/me', async function(req, res, next){
|
||||
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){
|
||||
next(error);
|
||||
}
|
||||
@@ -97,7 +107,7 @@ router.put('/password', async function(req, res, next){
|
||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||
await verif.update({ password_must_change: false });
|
||||
User.clearCache();
|
||||
return res.json({results: result});
|
||||
return res.json({results: result, message: 'Password changed.'});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
@@ -231,7 +241,7 @@ router.get('/invite', async function(req, res, next){
|
||||
try{
|
||||
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
|
||||
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
|
||||
const all = await InviteToken.listDetail();
|
||||
const all = await InviteToken.list();
|
||||
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
|
||||
const results = visible.map(t => ({ token: t.token, ...t }));
|
||||
return res.json({ results });
|
||||
|
||||
@@ -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.
|
||||
// Uses model-redis's own bundled redis client since redis is not a top-level dep.
|
||||
const { createClient } = require('../node_modules/model-redis/node_modules/redis');
|
||||
const { createClient } = require('redis');
|
||||
|
||||
module.exports = async function() {
|
||||
const client = createClient();
|
||||
const redisUrl = process.env.REDIS_URL || undefined;
|
||||
const client = createClient(redisUrl ? { url: redisUrl } : {});
|
||||
await client.connect();
|
||||
|
||||
const keys = await client.keys('sso_manager_test_*');
|
||||
|
||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||
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', () => {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
const ORIG_LDAP = { ...conf.ldap };
|
||||
const ORIG_OAUTH = { ...(conf.oauth || {}) };
|
||||
|
||||
function restoreConf() {
|
||||
conf.ldap = { ...conf.ldap, ...ORIG_LDAP };
|
||||
conf.oauth = { ...(conf.oauth || {}), ...ORIG_OAUTH };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Start each test from a known state; the local secrets.js may set an issuer.
|
||||
conf.ldap = { ...conf.ldap, ldapsHost: '', ldapsPort: 636 };
|
||||
if (conf.oauth) conf.oauth.issuer = '';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreConf();
|
||||
});
|
||||
|
||||
describe('GET /integrations', () => {
|
||||
test('renders and derives LDAPS URL from the request host by default', async () => {
|
||||
const res = await request(app)
|
||||
.get('/integrations')
|
||||
.set('Host', 'sso.example.com');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('ldaps://sso.example.com:636');
|
||||
});
|
||||
|
||||
test('uses conf.ldap.ldapsHost when set', async () => {
|
||||
conf.ldap = { ...conf.ldap, ldapsHost: 'ldap.internal.example.com', ldapsPort: 1636 };
|
||||
|
||||
const res = await request(app)
|
||||
.get('/integrations')
|
||||
.set('Host', 'public.example.com');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('ldaps://ldap.internal.example.com:1636');
|
||||
expect(res.text).not.toContain('ldaps://public.example.com:636');
|
||||
expect(res.text).toContain('Custom <code>conf.ldap.ldapsHost</code>');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
// findAncestorSiteSlug has no LDAP dependency (unlike most of this test
|
||||
// suite, which needs a live LDAP server) -- it's pure Resource/ResourceEdge
|
||||
// graph traversal against the ORM, so it's tested directly here rather than
|
||||
// through the (LDAP-gated) directory-admin HTTP routes.
|
||||
|
||||
const { initORM } = require('../models');
|
||||
const { Resource, ResourceEdge } = require('../models/resource');
|
||||
|
||||
const marker = 'test_site_slug_' + Date.now();
|
||||
const created = [];
|
||||
|
||||
async function makeResource(kind, name) {
|
||||
const r = await Resource.create({ kind, name, slug: `${marker}_${name}` });
|
||||
created.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initORM();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const r of created) {
|
||||
try { await r.delete(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Resource.findAncestorSiteSlug', () => {
|
||||
test('returns the direct parent site\'s slug', async () => {
|
||||
const site = await makeResource('site', 'site-direct');
|
||||
const host = await makeResource('host', 'host-direct');
|
||||
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBe(site.slug);
|
||||
});
|
||||
|
||||
test('walks up through an intermediate host to find the owning site', async () => {
|
||||
const site = await makeResource('site', 'site-nested');
|
||||
const host = await makeResource('host', 'host-nested');
|
||||
const service = await makeResource('service', 'service-nested');
|
||||
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||
await ResourceEdge.create({ parentId: host.id, childId: service.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(service.id)).resolves.toBe(site.slug);
|
||||
});
|
||||
|
||||
test('returns null for a top-level resource with no site ancestor', async () => {
|
||||
const host = await makeResource('host', 'host-orphan');
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('does not loop forever on a cyclic parent chain', async () => {
|
||||
const a = await makeResource('host', 'host-cycle-a');
|
||||
const b = await makeResource('host', 'host-cycle-b');
|
||||
await ResourceEdge.create({ parentId: a.id, childId: b.id, relation: 'hosts' });
|
||||
await ResourceEdge.create({ parentId: b.id, childId: a.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(a.id)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,19 @@
|
||||
const crypto = require('crypto');
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
const { initORM } = require('../models');
|
||||
|
||||
beforeAll(async () => {
|
||||
await initORM();
|
||||
const { Token } = require('../models/token');
|
||||
try {
|
||||
if (Token.orm) {
|
||||
await Token.orm.adapter(Token).Table.redisClient.flushDb();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not flush Redis:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
|
||||
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
'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 { execSync } = require('child_process');
|
||||
const { version: buildVersion } = require('../package.json');
|
||||
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
||||
// stage in Dockerfile.openldap) -- the final image has no git binary and no
|
||||
// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
||||
// runs have no baked file, so they fall back to asking git directly.
|
||||
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(),
|
||||
};
|
||||
module.exports = createBuildInfo({
|
||||
version,
|
||||
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||
cwd: __dirname,
|
||||
});
|
||||
@@ -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 -->
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
@@ -7,19 +10,21 @@
|
||||
<img width="64" src="/static/img/theta42.svg"/>
|
||||
</a>
|
||||
© <%- 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 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
|
||||
</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
|
||||
</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>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</body>
|
||||
</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,874 @@
|
||||
<%- 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>
|
||||
<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>
|
||||
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
|
||||
<strong>{{name}}</strong>
|
||||
</a>
|
||||
<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>
|
||||
|
||||
<script>
|
||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
|
||||
|
||||
// --- Resource modal tab content, built once. Populated via .val() in
|
||||
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the
|
||||
// DOM from these strings -- order matters, unlike the old always-in-DOM
|
||||
// static modal. ---
|
||||
|
||||
var generalTabHtml = `
|
||||
<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">
|
||||
<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">
|
||||
<div class="col-6">
|
||||
<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>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
var detailsTabHtml = `
|
||||
<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" 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-6" 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-6" 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>
|
||||
`;
|
||||
|
||||
var groupsTabHtml = `
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="groups-list"></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>
|
||||
`;
|
||||
|
||||
var childrenTabHtml = `
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
|
||||
<button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
|
||||
<i class="fa-solid fa-plus"></i> Add Child Resource
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6 class="text-muted">Other Relationships (advanced)</h6>
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="edges-list"></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>
|
||||
`;
|
||||
|
||||
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
|
||||
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
|
||||
// the Groups/Children tabs in add-mode (no resource id to scope them to).
|
||||
function openResourceModal(title, id) {
|
||||
app.modal.open({
|
||||
title: title,
|
||||
size: 'lg',
|
||||
tabs: [
|
||||
{id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
|
||||
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
|
||||
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
||||
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
||||
],
|
||||
footer: {
|
||||
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
||||
buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
|
||||
},
|
||||
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
|
||||
});
|
||||
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
|
||||
}
|
||||
|
||||
function refreshChildrenUI(resourceId) {
|
||||
const kids = allEdges.filter(e => e.parentId === resourceId)
|
||||
.map(e => resourcesById[e.childId]).filter(Boolean);
|
||||
const $list = $('#children-list').empty();
|
||||
if (!kids.length) {
|
||||
$list.append('<li class="list-group-item text-muted">No child resources.</li>');
|
||||
return;
|
||||
}
|
||||
for (const kid of kids) {
|
||||
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||
$('<a href="#" class="text-reset text-decoration-none"></a>')
|
||||
.text(kid.name + ' (' + kid.slug + ')')
|
||||
.on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
|
||||
.appendTo($li);
|
||||
$('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
|
||||
$list.append($li);
|
||||
}
|
||||
}
|
||||
|
||||
var resourcesById = {};
|
||||
var allGroups = [];
|
||||
var allEdges = [];
|
||||
var rawResources = [];
|
||||
|
||||
$(document).ready(async function() {
|
||||
await loadResources();
|
||||
const slug = app.modal.deepLinkSlug('/directory');
|
||||
if (slug) {
|
||||
const match = Object.values(resourcesById).find(r => r.slug === slug);
|
||||
if (match) openEditModal(match.id);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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 = [];
|
||||
|
||||
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);
|
||||
|
||||
$.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());
|
||||
}
|
||||
|
||||
// Delegated (not direct) since app.modal.open() rebuilds the modal body
|
||||
// from scratch every call -- a direct .on() bind here would silently stop
|
||||
// firing after the first Add/Edit.
|
||||
app.modal.on('input change', '#res-name, #res-kind', 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) {
|
||||
openResourceModal('Add Resource', null);
|
||||
$('#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);
|
||||
|
||||
populateHostDropdown(parentId || '');
|
||||
toggleFormFields();
|
||||
}
|
||||
|
||||
var ldapGroupsCache = null;
|
||||
async function loadLdapGroups() {
|
||||
try {
|
||||
if (!ldapGroupsCache) {
|
||||
const res = await app.api.get('group');
|
||||
ldapGroupsCache = res.results;
|
||||
}
|
||||
// Re-populate every call, not just the first -- #ldap-groups-datalist
|
||||
// is rebuilt fresh (empty) on every app.modal.open(), so returning
|
||||
// early here on a cache hit left the second and later modal opens
|
||||
// with no autocomplete options at all.
|
||||
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);
|
||||
}
|
||||
|
||||
// Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
|
||||
// the modal body on every app.modal.open() call, and jq-repeat's
|
||||
// MutationObserver-based (re)registration for a same-id element that's
|
||||
// destroyed and recreated runs asynchronously -- populating it
|
||||
// synchronously right after open() (as this function is called) would race
|
||||
// that and silently lose the pushed data on the second and later opens.
|
||||
function refreshGroupsUI(resourceId) {
|
||||
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
||||
const $list = $('#groups-list').empty();
|
||||
if (!myGroups.length) {
|
||||
$list.append('<li class="list-group-item text-muted">No associated groups.</li>');
|
||||
return;
|
||||
}
|
||||
for (const g of myGroups) {
|
||||
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||
const $span = $('<span></span>');
|
||||
$span.append('<i class="fa-solid fa-users text-muted me-2"></i>');
|
||||
$('<strong></strong>').text(g.groupCn).appendTo($span);
|
||||
$('<span class="badge bg-primary ms-2"></span>').text(g.accessLevel).appendTo($span);
|
||||
$li.append($span);
|
||||
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
||||
.on('click', function(){ removeGroup(g.id); })
|
||||
.appendTo($li);
|
||||
$list.append($li);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshEdgesUI(resourceId) {
|
||||
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
||||
const $list = $('#edges-list').empty();
|
||||
if (!myEdges.length) {
|
||||
$list.append('<li class="list-group-item text-muted">No other relationships.</li>');
|
||||
}
|
||||
for (const e of myEdges) {
|
||||
const isParent = e.parentId === resourceId;
|
||||
const targetId = isParent ? e.childId : e.parentId;
|
||||
const target = resourcesById[targetId];
|
||||
if (!target) continue;
|
||||
|
||||
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||
const $span = $('<span></span>');
|
||||
$span.append(isParent
|
||||
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
|
||||
: '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
|
||||
$('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
|
||||
$('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
|
||||
$li.append($span);
|
||||
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
||||
.on('click', function(){ removeEdge(e.id); })
|
||||
.appendTo($li);
|
||||
$list.append($li);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
openResourceModal('Edit Resource', id);
|
||||
$('#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();
|
||||
|
||||
refreshGroupsUI(r.id);
|
||||
refreshEdgesUI(r.id);
|
||||
refreshChildrenUI(r.id);
|
||||
await loadLdapGroups();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
app.modal.close();
|
||||
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', app.modal.body(), '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.', app.modal.body(), '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', app.modal.body(), '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', app.modal.body(), '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', app.modal.body(), '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', app.modal.body(), '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', app.modal.body(), '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', app.modal.body(), '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', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResource(id) {
|
||||
// Called from the outer table's row button, not from inside the resource
|
||||
// modal — 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') %>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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){
|
||||
let data = await app.group.get(group);
|
||||
$.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');
|
||||
setTimeout(function(group){
|
||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
||||
@@ -73,44 +100,44 @@
|
||||
async function removeMember(groupCN, uid, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$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; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.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) {
|
||||
$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) {
|
||||
const $item = $(btn).closest('li');
|
||||
$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; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.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) {
|
||||
$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) {
|
||||
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;
|
||||
try {
|
||||
await app.api.delete(`group/${cn}`);
|
||||
$.scope.groupCard.remove('cn', cn);
|
||||
} 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();
|
||||
});
|
||||
</script>
|
||||
<div class="row" style="display:none;">
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<div class="input-group" style="flex: 1 1 200px;">
|
||||
@@ -214,7 +241,7 @@
|
||||
</button>
|
||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||
{{ #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}}
|
||||
</a>
|
||||
{{/.}}{{ /toAdd }}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
function startImpersonate(uid){
|
||||
app.impersonate.create(uid, function(error, data){
|
||||
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;
|
||||
}
|
||||
$('#impersonateModalTitle').text(data.uid);
|
||||
@@ -79,7 +79,7 @@ function startImpersonate(uid){
|
||||
$('#impersonateStopBtn').off('click').on('click', function(){
|
||||
app.impersonate.revoke(data.uid, function(err){
|
||||
$('#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,598 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<!-- Edit OAuth client modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
||||
<input type="hidden" id="edit-client-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" id="edit-name" class="form-control shadow">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" id="edit-description" class="form-control shadow">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
|
||||
<small class="field-help text-muted d-block">
|
||||
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
||||
</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scopes</label>
|
||||
<div id="edit-scopes"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||
<div id="edit-allowed_groups"></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret modal — shared by OAuth client secrets and service account passwords -->
|
||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="secretModalTitle"><i class="fa-solid fa-key"></i> Secret</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this now — it will <strong>not</strong> be shown again.</p>
|
||||
<div class="input-group">
|
||||
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
|
||||
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_oauth_admin']);
|
||||
|
||||
// The scopes this provider actually understands (see routes/oauth.js discovery).
|
||||
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||
|
||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
|
||||
// Widget handles + a lookup of the latest client data (for the edit modal).
|
||||
var createScopes, createGroups, editScopes, editGroups;
|
||||
var clientsById = {};
|
||||
|
||||
function showSecret(secret, title){
|
||||
document.getElementById('secretModalTitle').innerHTML = '<i class="fa-solid fa-key"></i> ' + (title || 'Secret');
|
||||
document.getElementById('secretValue').value = secret;
|
||||
secretModal.show();
|
||||
}
|
||||
|
||||
function copySecret(){
|
||||
copyField('secretValue');
|
||||
}
|
||||
|
||||
// Copy the value of an input by id; briefly flips the button icon to a check.
|
||||
function copyField(id, btn){
|
||||
var el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
el.select();
|
||||
el.setSelectionRange(0, 99999);
|
||||
document.execCommand('copy');
|
||||
if(btn){
|
||||
var $i = $(btn).find('i');
|
||||
var prev = $i.attr('class');
|
||||
$i.attr('class', 'fa-solid fa-check');
|
||||
setTimeout(function(){ $i.attr('class', prev); }, 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTTL(seconds){
|
||||
if(seconds < 3600) return seconds + 's';
|
||||
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
||||
return (seconds / 86400).toFixed(1) + 'd';
|
||||
}
|
||||
|
||||
function processClient(client){
|
||||
clientsById[client.client_id] = client; // keep raw data for the edit modal
|
||||
client.scopes_display = (client.scopes || []).join(' ');
|
||||
client.allowed_groups_display = (client.allowed_groups || []).join(', ');
|
||||
client.has_group_restriction = (client.allowed_groups || []).length > 0;
|
||||
client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
|
||||
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
|
||||
return client;
|
||||
}
|
||||
|
||||
async function tableAJAX(){
|
||||
let data = await app.oauthClient.list();
|
||||
$.scope.oauthClientCard.empty();
|
||||
$.each(data.results, function(_, client){
|
||||
$.scope.oauthClientCard.push(processClient(client));
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteClient(client_id, name, btn){
|
||||
const $card = $(btn).closest('.card');
|
||||
$card.addClass('table-warning');
|
||||
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
|
||||
$card.removeClass('table-warning');
|
||||
if (!confirmed) return;
|
||||
app.api.delete('oauth/client/' + client_id, function(error, data){
|
||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||
$.scope.oauthClientCard.remove('client_id', client_id);
|
||||
});
|
||||
}
|
||||
|
||||
async function rotateSecret(client_id, name, btn){
|
||||
const $card = $(btn).closest('.card');
|
||||
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
|
||||
if (!confirmed) return;
|
||||
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
|
||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||
showSecret(data.client_secret, 'Client Secret');
|
||||
});
|
||||
}
|
||||
|
||||
// Open the edit modal pre-filled from the client's current values.
|
||||
function editClient(client_id){
|
||||
var c = clientsById[client_id];
|
||||
if(!c) return;
|
||||
$('#edit-client-id').val(client_id);
|
||||
$('#edit-name').val(c.name || '');
|
||||
$('#edit-description').val(c.description || '');
|
||||
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
|
||||
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
|
||||
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
|
||||
|
||||
// (Re)build the tag widgets fresh each open so they reflect this client.
|
||||
editScopes = app.ui.tagInput('#edit-scopes', {
|
||||
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
|
||||
separator: ' ', placeholder: 'Add a scope…',
|
||||
});
|
||||
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
|
||||
values: c.allowed_groups || [], placeholder: 'Type a group name…',
|
||||
});
|
||||
editModal.show();
|
||||
}
|
||||
|
||||
function saveEdit(btn){
|
||||
var $msg = $('#editModal .actionMessage');
|
||||
var payload = {
|
||||
client_id: $('#edit-client-id').val(),
|
||||
name: $('#edit-name').val(),
|
||||
description: $('#edit-description').val(),
|
||||
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
|
||||
scopes: editScopes.get(),
|
||||
allowed_groups: editGroups.get(),
|
||||
token_lifetime: {
|
||||
access_token: Number($('#edit-access_ttl').val()) || 3600,
|
||||
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
|
||||
},
|
||||
};
|
||||
app.oauthClient.update(payload, function(error, data){
|
||||
if(error){
|
||||
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
|
||||
return;
|
||||
}
|
||||
editModal.hide();
|
||||
tableAJAX();
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
tableAJAX();
|
||||
|
||||
// Initialise the create-form tag widgets.
|
||||
createScopes = app.ui.tagInput('#create-scopes', {
|
||||
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
|
||||
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
|
||||
});
|
||||
createGroups = app.ui.groupSelect('#create-allowed_groups', {
|
||||
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
|
||||
});
|
||||
|
||||
// After a successful create, reset the widgets too (form reset ignores them).
|
||||
$('form[action="oauth/client/"]').attr('evalAJAX',
|
||||
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<h4><i class="fa-solid fa-plug"></i> Integrations</h4>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-oauth-btn" data-bs-toggle="tab" data-bs-target="#tab-oauth" type="button" role="tab">
|
||||
<i class="fa-solid fa-key"></i> OAuth Apps
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-ldap-btn" data-bs-toggle="tab" data-bs-target="#tab-ldap" type="button" role="tab">
|
||||
<i class="fa-solid fa-network-wired"></i> LDAP
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-oauth" role="tabpanel">
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card shadow-sm border-info">
|
||||
<div class="card-header bg-info bg-opacity-10">
|
||||
<i class="fa-solid fa-circle-info"></i>
|
||||
OpenID Connect Endpoints
|
||||
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2 text-muted small">
|
||||
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
|
||||
It advertises the authorization, token, and userinfo endpoints automatically.
|
||||
</p>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-2">Issuer</dt>
|
||||
<dd class="col-sm-10"><code><%= issuer %></code></dd>
|
||||
<dt class="col-sm-2">Discovery URL</dt>
|
||||
<dd class="col-sm-10">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
|
||||
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Register OAuth Client
|
||||
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
|
||||
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
|
||||
<small class="field-help text-muted d-block">
|
||||
<code>*</code> matches one hostname label and <code>**</code> matches any
|
||||
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
|
||||
covers every host theta42/proxy fronts under example.com without registering
|
||||
each one individually.
|
||||
</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scopes</label>
|
||||
<div id="create-scopes"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||
<div id="create-allowed_groups"></div>
|
||||
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-dark">
|
||||
<i class="fa-solid fa-plus"></i> Register
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8" style="background-color: initial; border: none">
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
|
||||
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
|
||||
<div class="card-header">
|
||||
<h5>
|
||||
<i class="fa-solid fa-server"></i>
|
||||
{{ name }}
|
||||
</h5>
|
||||
<small class="text-muted font-monospace">{{ client_id }}</small>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
{{ #description }}
|
||||
<p>{{ description }}</p>
|
||||
{{ /description }}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Client ID</dt>
|
||||
<dd class="col-sm-9">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
<dt class="col-sm-3">Redirect URIs</dt>
|
||||
<dd class="col-sm-9">
|
||||
<ul class="list-unstyled mb-0">
|
||||
{{ #redirect_uris }}
|
||||
<li><code>{{ . }}</code></li>
|
||||
{{ /redirect_uris }}
|
||||
</ul>
|
||||
</dd>
|
||||
<dt class="col-sm-3">Scopes</dt>
|
||||
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
|
||||
<dt class="col-sm-3">Access</dt>
|
||||
<dd class="col-sm-9">
|
||||
{{ #has_group_restriction }}
|
||||
<span class="badge bg-warning text-dark"><i class="fa-solid fa-user-lock"></i> Restricted</span>
|
||||
<code>{{ allowed_groups_display }}</code>
|
||||
{{ /has_group_restriction }}
|
||||
{{ ^has_group_restriction }}
|
||||
<span class="badge bg-secondary"><i class="fa-solid fa-users"></i> Any user</span>
|
||||
{{ /has_group_restriction }}
|
||||
</dd>
|
||||
<dt class="col-sm-3">Access Token</dt>
|
||||
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
|
||||
<dt class="col-sm-3">Refresh Token</dt>
|
||||
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
|
||||
<dt class="col-sm-3">Created by</dt>
|
||||
<dd class="col-sm-9">{{ created_by }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button"
|
||||
onclick="editClient('{{client_id}}')"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-pen-to-square"></i> Edit
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
|
||||
class="btn btn-warning btn-sm">
|
||||
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
|
||||
class="btn btn-danger btn-sm float-end">
|
||||
<i class="fa-solid fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-ldap" role="tabpanel">
|
||||
<p class="text-muted">
|
||||
Everything a 3rd-party app or host needs to bind this directory, filled in
|
||||
for <b><%= ssoUrl %></b>.
|
||||
</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-warning">
|
||||
<div class="card-header bg-warning bg-opacity-10">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
LDAPS hostname: keep LDAP binds off the public internet
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">
|
||||
LDAPS requires a <strong>hostname</strong>, not a bare IP address, because
|
||||
the TLS client verifies the server name against the certificate.
|
||||
The URL below <% if (ldapsHostExplicit) { %>is set to <code><%= ldapHost %></code> from
|
||||
<code>conf.ldap.ldapsHost</code>.<% } else { %>currently matches the public
|
||||
OAuth issuer host — convenient, but that implies clients reach it through
|
||||
your router on port 636. <strong>Do not port-forward 636 to the internet</strong>
|
||||
for LDAP simple binds; instead pick an internal-only hostname and set
|
||||
<code>conf.ldap.ldapsHost</code>.<% } %>
|
||||
</p>
|
||||
<ul class="small mb-2">
|
||||
<li><strong>Same Docker/network host (recommended for the proxy or apps on this machine):</strong>
|
||||
use <code>ldaps://sso-manager:636</code> (the internal service name).
|
||||
Set <code>conf.ldap.ldapsHost = 'sso-manager'</code>.</li>
|
||||
<li><strong>LAN host:</strong> create an internal DNS record like
|
||||
<code>ldap.internal.example.com</code> → the local IP, get or generate a cert
|
||||
whose SAN matches that name, and set <code>conf.ldap.ldapsHost</code>.
|
||||
A wildcard for <code>*.internal.example.com</code> works well.</li>
|
||||
<li><strong>Public hostname:</strong> only acceptable behind a VPN or firewall
|
||||
lockdown — never exposed to the open internet.</li>
|
||||
</ul>
|
||||
<p class="small mb-0">
|
||||
<b>Trusting the cert:</b> The bundled slapd uses a self-signed cert unless you
|
||||
mount your own at <code>/etc/openldap/certs</code>. Clients must either trust
|
||||
that cert, or set <code>TLS_REQCERT never</code> / <code>rejectUnauthorized: false</code>
|
||||
for LAN-only use. See <a href="/docs/ldap">LDAP docs</a> for the full
|
||||
runbook, including how to set <code>ldapsHost</code> in
|
||||
<code>conf/secrets.js</code> or via <code>app_ldap__ldapsHost=...</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-circle-info"></i> Connection details
|
||||
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
For a single app's own "LDAP authentication" settings — see
|
||||
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
|
||||
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
|
||||
</p>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">LDAPS URL</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
<% if (ldapsHostExplicit) { %>
|
||||
<small class="field-help text-muted d-block">
|
||||
Custom <code>conf.ldap.ldapsHost</code> — override in your secrets file if this name doesn't resolve from the client.
|
||||
</small>
|
||||
<% } %>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Base DN</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">User search base</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Group search base</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">User filter</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Username attribute</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Example bind DN</dt>
|
||||
<dd class="col-sm-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
<small class="field-help text-muted d-block">
|
||||
A read-only bind account — create one from
|
||||
<a href="/users">Users > Service Accounts</a> (don't reuse a real
|
||||
person's login or the admin DN).
|
||||
</small>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
|
||||
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
For full host login, SSH keys, and sudo via LDAP (not just one app) —
|
||||
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
|
||||
and run this on the host. Fill in a service account's password
|
||||
(create one from <a href="/users">Users > Service Accounts</a>) and,
|
||||
if you want this host's access/sudo groups auto-registered, an
|
||||
<a href="/">API token</a> from your Profile.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
|
||||
<i class="fa-solid fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function(){
|
||||
var lines = [
|
||||
'git clone https://github.com/theta42/ldap-client.git',
|
||||
'cd ldap-client',
|
||||
'cat > ldap.vars << \'EOF\'',
|
||||
'# LDAPS host advertised on the Integrations page. If this is an internal-only',
|
||||
'# hostname, make sure it resolves from this host and the cert SAN matches it.',
|
||||
'export ldap_host="<%= ldapHost %>"',
|
||||
'export ldap_base_dn="<%= baseDn %>"',
|
||||
'',
|
||||
'# A read-only service account -- create one under Users > Service',
|
||||
'# Accounts, then fill in its password below.',
|
||||
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
||||
'export ldap_bind_password="CHANGE-ME"',
|
||||
'',
|
||||
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
|
||||
'# Manager. Create a personal access token under Profile > API Tokens',
|
||||
'# and paste it here; leave blank to skip.',
|
||||
'export sso_url="<%= ssoUrl %>"',
|
||||
'export sso_token=""',
|
||||
'',
|
||||
'# Optional: set this if you run ldap-client against more than one site.',
|
||||
'export ldap_location=""',
|
||||
'',
|
||||
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
|
||||
'EOF',
|
||||
'',
|
||||
'sudo ./index.sh',
|
||||
];
|
||||
document.getElementById('f-bashSnippet').value = lines.join('\n');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">
|
||||
function tableAJAX(message){
|
||||
app.util.actionMessage(message);
|
||||
app.messages.action(message);
|
||||
}
|
||||
|
||||
$(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>
|
||||
@@ -7,6 +7,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Landing here with no explanation ("why am I on the SSO login page?") is
|
||||
// exactly what happens when another app's "Log in with SSO" button sends
|
||||
// an unauthenticated user through /oauth/authorize, which bounces them
|
||||
// here with ?redirect=. Tell them what's happening instead of leaving it
|
||||
// a mystery.
|
||||
$(document).ready(function(){
|
||||
var redirect = <%- JSON.stringify(redirect || '') %>;
|
||||
if(redirect){
|
||||
var isOauth = /\/oauth\/authorize/.test(redirect);
|
||||
var message = isOauth
|
||||
? 'Log in to continue — an application is requesting access to your account.'
|
||||
: "Log in to continue to what you were doing — you'll be sent back afterward.";
|
||||
app.messages.action(message, $('.card').first(), 'info');
|
||||
}
|
||||
});
|
||||
|
||||
function setOtpMethod(method) {
|
||||
$('#otpMethodInput').val(method);
|
||||
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
app.api.post('oauth/authorize', oauthParams, function(error, data){
|
||||
if(error){
|
||||
$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;
|
||||
}
|
||||
window.location.href = data.redirect_url;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
async function acceptTos() {
|
||||
var checkbox = document.getElementById('tosCheckbox');
|
||||
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;
|
||||
}
|
||||
try {
|
||||
@@ -50,14 +50,14 @@
|
||||
document.getElementById('section-tos').style.display = 'none';
|
||||
checkAllDone();
|
||||
} 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() {
|
||||
var dob = document.getElementById('dobInput').value;
|
||||
if (!dob) {
|
||||
alert('Please enter your date of birth.');
|
||||
app.messages.toast('Please enter your date of birth.', 'danger');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -73,7 +73,7 @@
|
||||
document.getElementById('section-dob').style.display = 'none';
|
||||
checkAllDone();
|
||||
} 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 pw2 = document.getElementById('pwInput2').value;
|
||||
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;
|
||||
}
|
||||
if (pw !== pw2) {
|
||||
alert('Passwords do not match.');
|
||||
app.messages.toast('Passwords do not match.', 'danger');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -101,7 +101,7 @@
|
||||
document.getElementById('section-password').style.display = 'none';
|
||||
checkAllDone();
|
||||
} catch(e) {
|
||||
alert('Could not change password. Please try again.');
|
||||
app.messages.toast('Could not change password. Please try again.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
|
||||
async function removeFromGroup(cn, btn){
|
||||
const $row = $(btn).closest('tr');
|
||||
const confirmed = await app.util.actionConfirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
|
||||
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.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
$.scope.mygroups.remove('cn', cn);
|
||||
});
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
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.util.actionMessage((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
|
||||
if(error) app.messages.action((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -64,10 +64,10 @@
|
||||
|
||||
async function removePersonalGroupMember(memberUid, btn){
|
||||
const $row = $(btn).closest('tr');
|
||||
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
|
||||
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.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
$.scope.personalGroupMembers.remove('uid', memberUid);
|
||||
});
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
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.util.actionMessage((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
|
||||
if(error) app.messages.action((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,47 @@
|
||||
renderPersonalGroupMembers(currentUser);
|
||||
}
|
||||
|
||||
async function renderMyServices(){
|
||||
try{
|
||||
let res = await app.api.get('discovery/me');
|
||||
res.results.forEach(r => {
|
||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || 'N/A';
|
||||
});
|
||||
$.scope.myservices.empty();
|
||||
$.scope.myservices.push(...res.results);
|
||||
}catch(error){
|
||||
console.error('renderMyServices error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMyMetrics(){
|
||||
try{
|
||||
let uid = currentUser.uid;
|
||||
let res = await app.api.get(`metrics/user/${uid}`);
|
||||
if (res && res.results) {
|
||||
const renderList = (items, id) => {
|
||||
const el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
el.innerHTML = '';
|
||||
if (!items || items.length === 0) {
|
||||
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
||||
return;
|
||||
}
|
||||
items.forEach(item => {
|
||||
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
${item.value}
|
||||
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
||||
</li>`;
|
||||
});
|
||||
};
|
||||
renderList(res.results.services, 'metrics-user-services');
|
||||
}
|
||||
}catch(error){
|
||||
console.error('renderMyMetrics error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function determinUser(){
|
||||
if(location.pathname.includes('/users/')){
|
||||
let uid = location.pathname.replace('/users/', '');
|
||||
@@ -135,7 +176,7 @@
|
||||
|
||||
async function toggleActive(uid, active){
|
||||
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();
|
||||
renderProfile(currentUser);
|
||||
});
|
||||
@@ -143,11 +184,11 @@
|
||||
|
||||
async function deleteUser(uid, btn){
|
||||
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;
|
||||
app.api.delete('user/' + uid, function(error, data){
|
||||
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;
|
||||
}
|
||||
window.location.href = '/users';
|
||||
@@ -160,6 +201,11 @@
|
||||
renderProfile(currentUser);
|
||||
renderUserGroups(currentUser);
|
||||
renderPersonalGroupMembers(currentUser);
|
||||
renderMyServices();
|
||||
if(!isOwnProfile) {
|
||||
renderMyMetrics();
|
||||
$('#user-metrics-card').show();
|
||||
}
|
||||
$('#personal-group-uid-label').text(currentUser.uid);
|
||||
|
||||
addGroupSelect = app.ui.groupSelect('#add-group-select', {
|
||||
@@ -397,6 +443,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shadow-lg card card-default mb-8">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
My Services
|
||||
<div class="float-end">
|
||||
<i class="fa-solid fa-arrows-up-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th>Name</th>
|
||||
<th>Address / IP</th>
|
||||
<th>Description</th>
|
||||
<th>Kind</th>
|
||||
</thead>
|
||||
<tbody jq-repeat="myservices">
|
||||
<tr>
|
||||
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
|
||||
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
|
||||
<td>{{description}}</td>
|
||||
<td>
|
||||
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
|
||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
|
||||
<div class="card-header shadow bg-success text-white">
|
||||
<i class="fa-solid fa-chart-line"></i>
|
||||
Security & Usage Stats (Last 7 Days)
|
||||
<div class="float-end">
|
||||
<i class="fa-solid fa-arrows-up-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="row m-0">
|
||||
<div class="col-12 p-3">
|
||||
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
|
||||
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
|
||||
<li class="list-group-item text-muted">Loading...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
|
||||
<div class="card-header shadow">
|
||||
<i class="fa-solid fa-people-group"></i>
|
||||
@@ -562,21 +662,21 @@
|
||||
async function revokeToken(id, name, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
$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');
|
||||
if(!confirmed) return;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async function rotateToken(id, name, btn){
|
||||
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;
|
||||
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);
|
||||
tableAJAX();
|
||||
});
|
||||
@@ -600,7 +700,7 @@
|
||||
expires_in_days: $('#edit-expires_in_days').val(),
|
||||
};
|
||||
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();
|
||||
tableAJAX();
|
||||
});
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<%- include('header') %>
|
||||
|
||||
<div class="container-fluid" style="margin-top: 80px;">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card shadow-lg mb-4">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-network-wired"></i> Sites & Replication
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-4">
|
||||
This page shows the status of Multi-Master LDAP replication peers.
|
||||
<br/>Your Server ID: <strong><%= myId %></strong>
|
||||
</p>
|
||||
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Site LDAP URL</th>
|
||||
<th>Replication Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (sites.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="2" class="text-center text-muted">No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).</td>
|
||||
</tr>
|
||||
<% } else { %>
|
||||
<% sites.forEach(function(site) { %>
|
||||
<tr>
|
||||
<td class="align-middle"><strong><%= site.url %></strong></td>
|
||||
<td class="align-middle">
|
||||
<% if (site.status === 'Online') { %>
|
||||
<span class="badge bg-success"><i class="fa-solid fa-circle-check"></i> Online</span>
|
||||
<% } else { %>
|
||||
<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark"></i> <%= site.status %></span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('footer') %>
|
||||
@@ -1,149 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title><%- name %> <%- title %></title>
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="<%- logo %>">
|
||||
<!-- 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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title><%- name %> <%- title %></title>
|
||||
<!-- 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. -->
|
||||
<!-- Favicon -->
|
||||
<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' />
|
||||
<!-- Scripts are placed here -->
|
||||
<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/lib/js/popper-1.16.0.min.js"></script> -->
|
||||
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.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/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.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/val.js'></script>
|
||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||
<!-- Scripts are placed here -->
|
||||
<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/bootstrap/dist/js/bootstrap.bundle.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/mustache/mustache.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/moment/moment.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.messages.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav top-nav">
|
||||
<li class="nav-item group-required group-required-app_sso_admin">
|
||||
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin">
|
||||
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i>
|
||||
Groups
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_oauth_admin">
|
||||
<a class="nav-link" href="/integrations">
|
||||
<i class="fa-solid fa-plug"></i>
|
||||
Integrations
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
|
||||
<a class="nav-link" href="/invites">
|
||||
<i class="fa-solid fa-envelope-open-text"></i>
|
||||
Invites
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin">
|
||||
<a class="nav-link" href="/sites">
|
||||
<i class="fa-solid fa-network-wired"></i>
|
||||
Sites
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item group-required group-required-app_sso_admin">
|
||||
<a class="nav-link" href="/dashboard">
|
||||
<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>
|
||||
<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>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav top-nav">
|
||||
<%# Items gated on a group start hidden (.group-required) and are
|
||||
revealed by app-base.js for the groups the user is in. %>
|
||||
<% for(const item of ui.nav){ %>
|
||||
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||
<%- item.label %>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<% if(ui.profileUrl){ %>
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</span>
|
||||
<% } %>
|
||||
<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-in"></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;">
|
||||
<i class="fas fa-sign-out"></i>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<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>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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;">
|
||||
<span id="update-banner-text"></span>
|
||||
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
|
||||
</div>
|
||||
<% if(ui.updateCheck){ %>
|
||||
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
|
||||
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
|
||||
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">
|
||||
function showUpdateBanner(){
|
||||
let $nav = $('nav.fixed-top');
|
||||
let $banner = $('#update-banner');
|
||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||
$('#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');
|
||||
}
|
||||
<script type="text/javascript">
|
||||
function showUpdateBanner(){
|
||||
let $nav = $('nav.fixed-top');
|
||||
let $banner = $('#update-banner');
|
||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
||||
}
|
||||
|
||||
$(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
|
||||
$('.top-nav a').each(function(index){
|
||||
let $this = $(this);
|
||||
$this.removeClass('active');
|
||||
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||
$this.addClass('active')
|
||||
}
|
||||
})
|
||||
function checkForUpdate(){
|
||||
if(sessionStorage.getItem('update-banner-dismissed')) return;
|
||||
app.api.get('update-check', function(error, info){
|
||||
if(error || !info || !info.updateAvailable) return;
|
||||
$('#update-banner-text').html(
|
||||
'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
|
||||
// name (linking to their profile) once we know who they are.
|
||||
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', '');
|
||||
}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function(){
|
||||
|
||||
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){
|
||||
app.api.get('update-check', function(error, info){
|
||||
if(error || !info || !info.updateAvailable) return;
|
||||
$('#update-banner-text').html(
|
||||
'A newer version of SSO Manager 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();
|
||||
});
|
||||
}
|
||||
}else{
|
||||
$('#cl-login-button').show();
|
||||
}
|
||||
// Set the correct link to active in the top nav bar
|
||||
$('.top-nav a').each(function(index){
|
||||
let $this = $(this);
|
||||
$this.removeClass('active');
|
||||
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
|
||||
$this.addClass('active')
|
||||
}
|
||||
})
|
||||
|
||||
});
|
||||
</script>
|
||||
// Set the correct login/logout button, and reveal the current user's
|
||||
// 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 -->
|
||||
<div id="spa-shell" class="container-fluid">
|
||||
<div class="actionMessage" style="display:none;"></div>
|
||||
<!-- Container -->
|
||||
<div id="spa-shell" class="container-fluid">
|
||||
<div class="actionMessage" style="display:none;"></div>
|
||||
|
||||
@@ -64,6 +64,7 @@ async function fetchUsernameSuggestions() {
|
||||
// simply can't bind). Disabling (not just hiding) keeps disabled
|
||||
// fields out of both form serialization and validation.
|
||||
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
|
||||
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||
|
||||
@@ -136,10 +137,7 @@ async function fetchUsernameSuggestions() {
|
||||
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
|
||||
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
|
||||
@@ -166,3 +164,18 @@ async function fetchUsernameSuggestions() {
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
||||
</form>
|
||||
<script>
|
||||
$(document).ready(async function() {
|
||||
const $loc = $('[name="location"]');
|
||||
if (!$loc.val()) {
|
||||
try {
|
||||
const dirRes = await app.api.get('directory-admin/resources');
|
||||
const resources = dirRes.results || [];
|
||||
const site = resources.find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
||||
if (site) {
|
||||
$loc.val(site.name);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
function renderUsers(){
|
||||
app.user.list(function(error, data){
|
||||
if(error){
|
||||
app.util.actionMessage(data.message, $('#tab-people'), 'danger');
|
||||
app.messages.action(data.message, $('#tab-people'), 'danger');
|
||||
return;
|
||||
}
|
||||
$.scope.userRow.empty();
|
||||
@@ -19,20 +19,146 @@
|
||||
|
||||
function toggleActive(uid, active){
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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){
|
||||
const $row = $(btn).closest('tr');
|
||||
$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');
|
||||
if (!confirmed) return;
|
||||
app.api.delete('user/' + uid, function(error, data){
|
||||
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;
|
||||
}
|
||||
renderUsers();
|
||||
@@ -84,6 +210,11 @@
|
||||
}
|
||||
document.getElementById('invite-email').value = '';
|
||||
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
|
||||
loadInvites();
|
||||
setTimeout(() => {
|
||||
bootstrap.Modal.getInstance(document.getElementById("inviteUserModal"))?.hide();
|
||||
result.style.display = 'none';
|
||||
}, 3000);
|
||||
}catch(e){
|
||||
result.style.display = '';
|
||||
result.className = 'alert alert-danger mt-2';
|
||||
@@ -97,199 +228,267 @@
|
||||
$(document).ready(function(){
|
||||
renderUsers();
|
||||
loadInviteGroups();
|
||||
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")')
|
||||
loadInvites();
|
||||
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success"); bootstrap.Modal.getInstance(document.getElementById("addUserModal"))?.hide();')
|
||||
});
|
||||
})();
|
||||
|
||||
</script>
|
||||
<h4><i class="fa-solid fa-users"></i> Users</h4>
|
||||
<div class="container mt-4">
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
|
||||
<i class="fa-solid fa-user"></i> People
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
|
||||
<i class="fa-solid fa-gears"></i> Service Accounts
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-md-4">
|
||||
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
|
||||
<div class="card-header shadow">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
Invite User
|
||||
<span class="float-end">
|
||||
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<i class="fa-solid fa-arrows-up-down"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-header shadow actionMessage" style="display: none;"></div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
||||
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
||||
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
||||
</div>
|
||||
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
|
||||
<i class="fa-solid fa-envelope"></i> Send Invite
|
||||
</button>
|
||||
<div id="invite-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
Add new user
|
||||
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<%- include('user_form', {adminMode: true}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
User List
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa-solid fa-users"></i>
|
||||
User List
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-2 group-required group-required-app_sso_admin" data-bs-toggle="modal" data-bs-target="#inviteUserModal">
|
||||
<i class="fas fa-envelope"></i> Invite User
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary me-2" data-bs-toggle="modal" data-bs-target="#addUserModal">
|
||||
<i class="fas fa-user-plus"></i> Add User
|
||||
</button>
|
||||
<a href="/docs/accounts" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>eMail</th>
|
||||
<th>Key</th>
|
||||
<th>Active</th>
|
||||
<th>TOS</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody id="tableAJAX">
|
||||
<tr jq-repeat="userRow">
|
||||
<td>
|
||||
{{ uidNumber }}
|
||||
</td>
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{mail}}
|
||||
</td>
|
||||
<td>
|
||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td>
|
||||
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
|
||||
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
|
||||
<i class="fa-solid fa-user-secret"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="px-3 pt-3 border-bottom">
|
||||
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
|
||||
<i class="fa-solid fa-user"></i> People
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
|
||||
<i class="fa-solid fa-gears"></i> Service Accounts
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-invites-btn" data-bs-toggle="tab" data-bs-target="#tab-invites" type="button" role="tab">
|
||||
<i class="fa-solid fa-envelope-open-text"></i> Invites
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>eMail</th>
|
||||
<th>Key</th>
|
||||
<th>Active</th>
|
||||
<th>TOS</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody id="tableAJAX">
|
||||
<tr jq-repeat="userRow">
|
||||
<td>
|
||||
{{ uidNumber }}
|
||||
</td>
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{mail}}
|
||||
</td>
|
||||
<td>
|
||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td>
|
||||
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
|
||||
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
|
||||
<i class="fa-solid fa-user-secret"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
|
||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||
<i class="fa-solid fa-circle-info"></i> Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add User" form.
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Username</th>
|
||||
<th>Description</th>
|
||||
<th>Manager(s)</th>
|
||||
<th>Created</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr jq-repeat="serviceAccountRow">
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{uid}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{description}}
|
||||
</td>
|
||||
<td>
|
||||
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||
</td>
|
||||
<td>
|
||||
{{createTimestamp}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-invites" role="tabpanel">
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Created</th>
|
||||
<th>By</th>
|
||||
<th>Email</th>
|
||||
<th>Groups</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody id="invite-tbody">
|
||||
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
|
||||
<div class="row" style="display:none">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-gears"></i>
|
||||
Service Accounts
|
||||
<small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small>
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
|
||||
<!-- Invite User Modal -->
|
||||
<div class="modal fade" id="inviteUserModal" tabindex="-1" aria-labelledby="inviteUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header shadow">
|
||||
<h5 class="modal-title" id="inviteUserModalLabel"><i class="fas fa-user-plus"></i> Invite User</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
||||
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
||||
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
||||
</div>
|
||||
<div id="invite-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" onclick="sendInvite()" class="btn btn-primary shadow">
|
||||
<i class="fa-solid fa-envelope"></i> Send Invite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add User Modal -->
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header shadow">
|
||||
<h5 class="modal-title" id="addUserModalLabel">
|
||||
<i class="fas fa-user-plus"></i> Add new user
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-muted mb-3">Check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab.</p>
|
||||
<%- include('user_form', {adminMode: true}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Invite Modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header shadow">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="edit-token" />
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
|
||||
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||
<thead>
|
||||
<th>Username</th>
|
||||
<th>Description</th>
|
||||
<th>Manager(s)</th>
|
||||
<th>Created</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr jq-repeat="serviceAccountRow">
|
||||
<td>
|
||||
<a href='/users/{{uid}}'>{{uid}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{description}}
|
||||
</td>
|
||||
<td>
|
||||
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||
</td>
|
||||
<td>
|
||||
{{createTimestamp}}
|
||||
</td>
|
||||
<td>
|
||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{#isActive}}
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i>
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
|
||||
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
|
||||
</div>
|
||||
<div id="edit-result" style="display:none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEdit()">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('impersonate_modal') %>
|
||||
<%- include('bottom') %>
|
||||
|
||||
@@ -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'));
|
||||