Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -4,6 +4,120 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [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
|
## [1.3.1] - 2026-07-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# Home-Lab Directory / Inventory — Design Spec
|
# Home-Lab Directory / Inventory — Design Spec
|
||||||
|
|
||||||
Status: **Draft / agreed direction** (no code yet)
|
Status: **Implemented** (v1.2.1+: model, admin API, UI; v1.3.x: automatic
|
||||||
|
registration from theta-env + ldap-client). §9 adds the planned-consumer
|
||||||
|
readiness review.
|
||||||
Owner: wmantly
|
Owner: wmantly
|
||||||
Last updated: 2026-07-02
|
Last updated: 2026-07-23
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -220,3 +222,148 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
for auth, SQL for inventory.
|
for auth, SQL for inventory.
|
||||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
||||||
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
|
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).
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ on, just like anyone else's.
|
|||||||
|
|
||||||
A **group** is just a named list of accounts, used to control access. This
|
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
|
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
|
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
|
connect — say, a group listing everyone who should be allowed into your
|
||||||
photo server. Once a group exists, add or remove members from the
|
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"?
|
## What's a "client"?
|
||||||
|
|
||||||
Every app you connect is registered here as a **client** — a single entry
|
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
|
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.
|
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
|
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
|||||||
|
|
||||||
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.
|
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
|
## Slug conventions
|
||||||
|
|
||||||
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||||
@@ -87,6 +89,14 @@ The seed is idempotent and non-destructive: a resource whose slug already exists
|
|||||||
|
|
||||||
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.
|
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
|
## API
|
||||||
|
|
||||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 221 KiB |
@@ -22,10 +22,11 @@ one command).
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
|
||||||
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
||||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a>
|
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
||||||
|
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
|
||||||
|
|
||||||
*(click any screenshot to view full size)*
|
*(click any screenshot to view full size)*
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ backend, that's the niche.
|
|||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
run the pieces separately via `app_*` env config.
|
||||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
- **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.
|
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
@@ -78,5 +79,7 @@ That's the standalone quick start. For the full set of install options
|
|||||||
|
|
||||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||||
reverse proxy, designed to sit in front of this SSO.
|
reverse proxy, designed to sit in front of this SSO.
|
||||||
|
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
|
||||||
|
uses this SSO's directory to decide who may reach which machine.
|
||||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||||
Manager and the proxy together with one command.
|
Manager and the proxy together with one command.
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ Clients are managed directly from the **Directory** tab in the web UI. They are
|
|||||||
|
|
||||||
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
|
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
|
||||||
|
|
||||||
|
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
|
||||||
|
|
||||||
## Scopes
|
## Scopes
|
||||||
|
|
||||||
| Scope | Claims / access |
|
| Scope | Claims / access |
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ app.set('trust proxy', 1);
|
|||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
// Set as an app local so every res.render has it, including routes that don't
|
||||||
|
// spread the routers' `values` object.
|
||||||
|
app.locals.ui = require('./utils/ui');
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// Have express server static content( images, CSS, browser JS) from the public
|
||||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
@@ -108,9 +113,6 @@ app.use(function(req, res, next) {
|
|||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Discovery API
|
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/api_discovery'));
|
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(function(err, req, res, next) {
|
app.use(function(err, req, res, next) {
|
||||||
const SILENT_404S = ['/.well-known/'];
|
const SILENT_404S = ['/.well-known/'];
|
||||||
|
|||||||
@@ -3,44 +3,18 @@
|
|||||||
const { Client, Attribute, Change } = require('ldapts');
|
const { Client, Attribute, Change } = require('ldapts');
|
||||||
const { LRUCache } = require('lru-cache');
|
const { LRUCache } = require('lru-cache');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
|
||||||
// Escape a value used inside an LDAP search filter (RFC 4515).
|
// wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
|
||||||
function escapeLDAPSearchValue(val) {
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
return String(val)
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
.replace(/\\/g, '\\5c')
|
const escapeLDAPDNValue = escapeDN;
|
||||||
.replace(/\*/g, '\\2a')
|
|
||||||
.replace(/\(/g, '\\28')
|
|
||||||
.replace(/\)/g, '\\29')
|
|
||||||
.replace(/\0/g, '\\00');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns
|
|
||||||
// are normally alphanumeric, but this prevents metacharacter injection.
|
|
||||||
function escapeLDAPDNValue(val) {
|
|
||||||
return String(val)
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/,/g, '\\,')
|
|
||||||
.replace(/\+/g, '\\+')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/</g, '\\<')
|
|
||||||
.replace(/>/g, '\\>')
|
|
||||||
.replace(/;/g, '\\;')
|
|
||||||
.replace(/=/g, '\\=')
|
|
||||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroups(client, member){
|
async function getGroups(client, member){
|
||||||
|
|||||||
@@ -44,13 +44,21 @@ class OAuthClient {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
static async get(client_id) {
|
static async get(client_id) {
|
||||||
|
const notFound = () => {
|
||||||
|
const e = new Error('OAuthClient not found');
|
||||||
|
e.status = 404;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = await Resource.get(client_id);
|
r = await Resource.get(client_id);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error('OAuthClient not found');
|
throw notFound();
|
||||||
}
|
}
|
||||||
if (r.kind !== 'oauth') throw new Error('OAuthClient not found');
|
// 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
|
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
||||||
r.client_id = r.id;
|
r.client_id = r.id;
|
||||||
r.client_secret_hash = r.metadata.client_secret_hash;
|
r.client_secret_hash = r.metadata.client_secret_hash;
|
||||||
@@ -69,6 +77,29 @@ class OAuthClient {
|
|||||||
return raw_secret;
|
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
|
// proxy update to handle metadata correctly
|
||||||
const originalUpdate = r.update.bind(r);
|
const originalUpdate = r.update.bind(r);
|
||||||
r.update = async (data) => {
|
r.update = async (data) => {
|
||||||
|
|||||||
@@ -152,10 +152,36 @@ class Resource extends Model {
|
|||||||
owner: { type: 'string' },
|
owner: { type: 'string' },
|
||||||
description: { type: 'text' },
|
description: { type: 'text' },
|
||||||
metadata: { type: 'json', default: {} },
|
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' },
|
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
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 {
|
class ResourceEdge extends Model {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
|
|||||||
const {Group} = require('./group_ldap');
|
const {Group} = require('./group_ldap');
|
||||||
const {UserVerification} = require('./verification');
|
const {UserVerification} = require('./verification');
|
||||||
const conf = require('@simpleworkjs/conf').ldap;
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
|
||||||
|
// wrappers below preserve this file's no-arg call signatures (makeClient() /
|
||||||
|
// withClient(fn)) so no call site changes; sso's makeClient passes no
|
||||||
|
// tlsOptions, which the shared client forwards as undefined — identical to the
|
||||||
|
// previous `new Client({ url: conf.url })`.
|
||||||
|
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
|
||||||
|
const escapeLDAPSearchValue = escapeFilter;
|
||||||
|
const escapeLDAPDNValue = escapeDN;
|
||||||
|
|
||||||
function hashPasswordSSHA512(password) {
|
function hashPasswordSSHA512(password) {
|
||||||
const salt = crypto.randomBytes(8);
|
const salt = crypto.randomBytes(8);
|
||||||
@@ -23,40 +31,11 @@ const cache = new LRUCache({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
return new Client({ url: conf.url });
|
return _makeClient(conf);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withClient(fn) {
|
async function withClient(fn) {
|
||||||
const client = makeClient();
|
return _withClient(conf, fn);
|
||||||
try {
|
|
||||||
await client.bind(conf.bindDN, conf.bindPassword);
|
|
||||||
return await fn(client);
|
|
||||||
} finally {
|
|
||||||
await client.unbind().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to escape LDAP filter values (crucial for security)
|
|
||||||
function escapeLDAPSearchValue(val) {
|
|
||||||
return val.replace(/\\/g, '\\5c')
|
|
||||||
.replace(/\*/g, '\\2a')
|
|
||||||
.replace(/\(/g, '\\28')
|
|
||||||
.replace(/\)/g, '\\29')
|
|
||||||
.replace(/\0/g, '\\00');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape a value used in an LDAP DN (RFC 4514).
|
|
||||||
function escapeLDAPDNValue(val) {
|
|
||||||
return String(val)
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/,/g, '\\,')
|
|
||||||
.replace(/\+/g, '\\+')
|
|
||||||
.replace(/"/g, '\\"')
|
|
||||||
.replace(/</g, '\\<')
|
|
||||||
.replace(/>/g, '\\>')
|
|
||||||
.replace(/;/g, '\\;')
|
|
||||||
.replace(/=/g, '\\=')
|
|
||||||
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute the next available uid/gidNumber: the highest existing value below
|
// Compute the next available uid/gidNumber: the highest existing value below
|
||||||
@@ -494,6 +473,19 @@ User.update = async function(data){
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(data.sshPublicKey){
|
if(data.sshPublicKey){
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if(e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'replace',
|
operation: 'replace',
|
||||||
@@ -805,6 +797,19 @@ User.addSSHkey = async function(data) {
|
|||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
await withClient(async (client) => {
|
await withClient(async (client) => {
|
||||||
|
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||||
|
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||||
|
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||||
|
try {
|
||||||
|
await client.modify(user.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch(e) {
|
||||||
|
if (e.name !== 'TypeOrValueExistsError') throw e;
|
||||||
|
}
|
||||||
await client.modify(user.dn, [
|
await client.modify(user.dn, [
|
||||||
new Change({
|
new Change({
|
||||||
operation: 'add',
|
operation: 'add',
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.1",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.1",
|
"version": "1.7.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.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",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -21,9 +25,9 @@
|
|||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
@@ -1242,6 +1246,18 @@
|
|||||||
"@redis/client": "^6.1.0"
|
"@redis/client": "^6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/app-stack": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/app-stack/-/app-stack-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Hg/mouA87WruKeZqhqtJgAaLabjHY8Z9POO6U+DB7sGGDhy1jgZXT31hyxLUDV+InByOPhz48NIkGiWNwoesXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -1254,6 +1270,36 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/directory-schema": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@simpleworkjs/ldap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ldapts": "^8.1.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/orm": {
|
"node_modules/@simpleworkjs/orm": {
|
||||||
"version": "0.2.8",
|
"version": "0.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz",
|
||||||
@@ -4817,9 +4863,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jquery": {
|
"node_modules/jquery": {
|
||||||
"version": "3.7.1",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
|
||||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
"integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.3.1",
|
"version": "1.8.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -23,7 +23,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.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",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -33,9 +37,9 @@
|
|||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^3.7.1",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"ldapts": "^8.1.2",
|
"ldapts": "^8.1.8",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"marked": "^9.1.6",
|
"marked": "^9.1.6",
|
||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.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){
|
function changePassword(args, callack){
|
||||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -110,7 +103,7 @@ app.user = (function(app){
|
|||||||
return m ? m[1] : dn;
|
return m ? m[1] : dn;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {list, remove, createInvite, setActive, dnToUid};
|
return {list, createInvite, setActive, dnToUid};
|
||||||
|
|
||||||
})(app);
|
})(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){
|
function update(args, callack){
|
||||||
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
||||||
callack(error, data);
|
callack(error, data);
|
||||||
@@ -325,7 +311,7 @@ app.oauthClient = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { list, add, remove, update, rotateSecret };
|
return { list, add, update, rotateSecret };
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
app.tos = (function(app){
|
app.tos = (function(app){
|
||||||
@@ -396,7 +382,7 @@ app.impersonate = (function(app){
|
|||||||
|
|
||||||
app.token = (function(app){
|
app.token = (function(app){
|
||||||
function list(name, callack){
|
function list(name, callack){
|
||||||
if($.isFunction(name)){
|
if(typeof name === 'function'){
|
||||||
callack = name;
|
callack = name;
|
||||||
name = '';
|
name = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// Shared client framework for the theta42 apps.
|
||||||
|
//
|
||||||
|
// This file is byte-identical across sso-manager-node, proxy and jump-host —
|
||||||
|
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
|
||||||
|
// the /api/user/me response), never from edits to this file. Edit all three
|
||||||
|
// copies together.
|
||||||
|
//
|
||||||
|
// jQuery 4 safe: no $.isFunction, no $.holdReady.
|
||||||
|
|
||||||
var app = {};
|
var app = {};
|
||||||
|
|
||||||
app.pubsub = (function(){
|
app.pubsub = (function(){
|
||||||
@@ -75,11 +84,17 @@ app.socket = (function(app){
|
|||||||
app.api = (function(app){
|
app.api = (function(app){
|
||||||
var baseURL = '/api/'
|
var baseURL = '/api/'
|
||||||
|
|
||||||
function post(url, data, callback){
|
// post/put/delete are dual-mode: pass a callback for the node-style
|
||||||
if (!$.isFunction(callback)) {
|
// (error, data, status) form, or omit it to get a Promise that resolves
|
||||||
return new Promise((resolve, reject) => {
|
// with the parsed body and rejects with the error body. get/options return
|
||||||
|
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
|
||||||
|
|
||||||
|
function body(method, url, data, callback){
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST', url: baseURL+url,
|
type: method,
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
@@ -88,9 +103,11 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'POST',
|
type: method,
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
@@ -104,40 +121,27 @@ app.api = (function(app){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(url, data, callback){
|
||||||
|
return body('POST', url, data, callback);
|
||||||
|
}
|
||||||
|
|
||||||
function put(url, data, callback){
|
function put(url, data, callback){
|
||||||
if (!$.isFunction(callback)) {
|
return body('PUT', url, data, callback);
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
$.ajax({
|
|
||||||
type: 'PUT', url: baseURL+url,
|
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: 'application/json; charset=utf-8',
|
|
||||||
dataType: 'json',
|
|
||||||
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return $.ajax({
|
|
||||||
type: 'PUT',
|
|
||||||
url: baseURL+url,
|
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
|
||||||
data: JSON.stringify(data),
|
|
||||||
contentType: "application/json; charset=utf-8",
|
|
||||||
dataType: "json",
|
|
||||||
complete: function(res, text){
|
|
||||||
callback(
|
|
||||||
text !== 'success' ? res.statusText : null,
|
|
||||||
JSON.parse(res.responseText),
|
|
||||||
res.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(url, callback){
|
// Called both as (url, callback) and — from formAJAX, which always passes
|
||||||
if (!$.isFunction(callback)) {
|
// the serialized form as the second argument — as (url, data, callback).
|
||||||
return new Promise((resolve, reject) => {
|
// No request body is sent either way.
|
||||||
|
function remove(url, data, callback){
|
||||||
|
if(typeof data === 'function'){
|
||||||
|
callback = data;
|
||||||
|
data = undefined;
|
||||||
|
}
|
||||||
|
if(typeof callback !== 'function'){
|
||||||
|
return new Promise(function(resolve, reject){
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'DELETE', url: baseURL+url,
|
type: 'DELETE',
|
||||||
|
url: baseURL+url,
|
||||||
headers: { 'auth-token': app.auth.getToken() },
|
headers: { 'auth-token': app.auth.getToken() },
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
@@ -147,7 +151,9 @@ app.api = (function(app){
|
|||||||
return $.ajax({
|
return $.ajax({
|
||||||
type: 'DELETE',
|
type: 'DELETE',
|
||||||
url: baseURL+url,
|
url: baseURL+url,
|
||||||
headers:{ 'auth-token': app.auth.getToken() },
|
headers:{
|
||||||
|
'auth-token': app.auth.getToken()
|
||||||
|
},
|
||||||
contentType: "application/json; charset=utf-8",
|
contentType: "application/json; charset=utf-8",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
complete: function(res, text){
|
complete: function(res, text){
|
||||||
@@ -202,7 +208,10 @@ app.api = (function(app){
|
|||||||
})(app)
|
})(app)
|
||||||
|
|
||||||
app.auth = (function(app){
|
app.auth = (function(app){
|
||||||
var user = {};
|
// One in-flight/cached GET /api/user/me per page load. Every gating
|
||||||
|
// decision (nav items, per-view forceLogin, group-required elements) reads
|
||||||
|
// this same promise instead of re-fetching.
|
||||||
|
var userPromise = null;
|
||||||
|
|
||||||
function setToken(token){
|
function setToken(token){
|
||||||
localStorage.setItem('APIToken', token);
|
localStorage.setItem('APIToken', token);
|
||||||
@@ -216,35 +225,70 @@ app.auth = (function(app){
|
|||||||
try{
|
try{
|
||||||
return await app.api.get('user/me');
|
return await app.api.get('user/me');
|
||||||
}catch(error){
|
}catch(error){
|
||||||
if(error?.status === 401) return null;
|
if(error && error.status === 401) return null;
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cached current user, or false when there's no token at all. Callers that
|
||||||
|
// need a fresh copy (after a login or a profile change) pass force.
|
||||||
|
function loadUser(force){
|
||||||
|
if(force || !userPromise){
|
||||||
|
userPromise = getToken() ? getUser() : Promise.resolve(null);
|
||||||
|
userPromise = userPromise.then(function(user){
|
||||||
|
app.auth.user = app.auth.perms = user || null;
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return userPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The apps report group membership two ways: sso-manager-node returns LDAP
|
||||||
|
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
|
||||||
|
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
|
||||||
|
// is exposed as a synthetic `admin` group so one gating model covers both.
|
||||||
|
function groupCNs(user){
|
||||||
|
var raw = (user && (user.memberOf || user.groups)) || [];
|
||||||
|
if(!Array.isArray(raw)) raw = [raw];
|
||||||
|
var names = raw.map(function(group){
|
||||||
|
return String(group).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
async function memberOf(groupNameToFind, user){
|
async function memberOf(groupNameToFind, user){
|
||||||
try{
|
user = user || await loadUser();
|
||||||
user = user || await app.auth.asyncUser;
|
if(!user) return false;
|
||||||
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind]
|
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
|
||||||
|
|
||||||
for(let group of user.memberOf){
|
return groupCNs(user).some(function(group){
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
return groupNameToFind.includes(group);
|
||||||
if(groupNameToFind.includes(group)) return true;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
|
|
||||||
}catch(error){
|
|
||||||
throw(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLoggedIn(){
|
// True when the logged-in user is a global admin (per user/me). Sync — only
|
||||||
if(getToken()){
|
// meaningful once isLoggedIn/forceLogin has resolved.
|
||||||
user = await app.auth.asyncUser;
|
function isAdmin(){
|
||||||
return user;
|
return !!(app.auth.perms && app.auth.perms.isAdmin);
|
||||||
}else{
|
}
|
||||||
return false;
|
|
||||||
|
// Dual-mode: returns a Promise resolving to the user (or false), and calls
|
||||||
|
// an optional node-style callback with the same result.
|
||||||
|
function isLoggedIn(callback){
|
||||||
|
var promise = loadUser().then(function(user){
|
||||||
|
return user || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if(typeof callback === 'function'){
|
||||||
|
promise.then(function(user){
|
||||||
|
callback(null, user);
|
||||||
|
}, function(error){
|
||||||
|
callback(error, false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function logIn(args, callback){
|
function logIn(args, callback){
|
||||||
@@ -252,62 +296,125 @@ app.auth = (function(app){
|
|||||||
if(data.login){
|
if(data.login){
|
||||||
setToken(data.token);
|
setToken(data.token);
|
||||||
}
|
}
|
||||||
|
loadUser(true);
|
||||||
callback(error, !!data.token);
|
callback(error, !!data.token);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clears the session only — the caller decides where to go next (the nav's
|
||||||
|
// Log Out button uses ui.logoutRedirect).
|
||||||
function logOut(callback){
|
function logOut(callback){
|
||||||
localStorage.removeItem('APIToken');
|
localStorage.removeItem('APIToken');
|
||||||
location.replace(`/login${location.href.replace(location.origin, '')}`);
|
userPromise = null;
|
||||||
callback();
|
app.auth.user = app.auth.perms = null;
|
||||||
|
if(typeof callback === 'function') callback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constrain a redirect target to a same-origin absolute path. Rejects
|
||||||
|
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
|
||||||
|
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
|
||||||
|
function safeInternalPath(path){
|
||||||
|
if(typeof path !== 'string' || path.charAt(0) !== '/'
|
||||||
|
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume an app token handed back by the OIDC callback via the URL
|
||||||
|
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
|
||||||
|
// forwards to the intended page. Returns true if a token was consumed.
|
||||||
|
function consumeTokenFragment(){
|
||||||
|
if(!location.hash) return false;
|
||||||
|
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
var token = params.get('token');
|
||||||
|
if(!token) return false;
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
// redirect comes from the URL fragment (attacker-controllable); only
|
||||||
|
// allow a same-origin path so it can't become an open redirect / XSS.
|
||||||
|
var redirect = safeInternalPath(params.get('redirect') || '/');
|
||||||
|
// Drop the token from the address bar before navigating on.
|
||||||
|
history.replaceState(null, '', location.pathname + location.search);
|
||||||
|
window.location.href = redirect;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
|
||||||
|
// unauthorised user is kept off the page by a redirect / an error panel
|
||||||
|
// rather than by pausing document ready.
|
||||||
|
//
|
||||||
|
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
|
||||||
|
// `admin` group covers the OIDC clients' isAdmin flag.
|
||||||
async function forceLogin(requiredGroups){
|
async function forceLogin(requiredGroups){
|
||||||
$.holdReady(true);
|
var user = await loadUser();
|
||||||
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
|
|
||||||
|
if(!user){
|
||||||
|
logOut(function(){});
|
||||||
|
location.replace('/login?redirect=' + encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
if(user.onboardingRequired && location.pathname !== '/onboarding'){
|
||||||
location.replace('/onboarding');
|
location.replace('/onboarding');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(requiredGroups){
|
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||||
if(!await memberOf(requiredGroups)){
|
app.messages.action(
|
||||||
console.log("Does not have permission!!!")
|
`<h1>
|
||||||
app.util.actionMessage(
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
`<h1>
|
<b>You do not have permission to be here.</b>
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
<b>You do not have permission to be here.</b>
|
</h1>`,
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
$('#spa-shell'),
|
||||||
</h1>`,
|
'danger',
|
||||||
$('#spa-shell'),
|
);
|
||||||
'danger',
|
throw new Error("User does not have permission");
|
||||||
);
|
|
||||||
throw new Error("User does not have permission");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.holdReady(false);
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where to go after a successful login: the ?redirect= query param, or the
|
||||||
|
// legacy /login/<path> suffix form, constrained to a same-origin path. The
|
||||||
|
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
|
||||||
|
// is how the OIDC provider sends an unauthenticated user through login.
|
||||||
function logInRedirect(){
|
function logInRedirect(){
|
||||||
window.location.href = location.href.replace(location.origin+'/login', '') || '/'
|
var params = new URLSearchParams(location.search);
|
||||||
|
var target = params.get('redirect')
|
||||||
|
|| location.href.replace(location.origin + '/login', '')
|
||||||
|
|| '/';
|
||||||
|
window.location.href = safeInternalPath(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: getToken,
|
getToken: getToken,
|
||||||
setToken: setToken,
|
setToken: setToken,
|
||||||
|
getUser: getUser,
|
||||||
|
loadUser: loadUser,
|
||||||
|
groupCNs: groupCNs,
|
||||||
|
memberOf: memberOf,
|
||||||
|
isAdmin: isAdmin,
|
||||||
isLoggedIn: isLoggedIn,
|
isLoggedIn: isLoggedIn,
|
||||||
|
safeInternalPath: safeInternalPath,
|
||||||
|
consumeTokenFragment: consumeTokenFragment,
|
||||||
|
user: null,
|
||||||
|
perms: null,
|
||||||
logIn: logIn,
|
logIn: logIn,
|
||||||
logOut: logOut,
|
logOut: logOut,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
logInRedirect,
|
logInRedirect,
|
||||||
getUser,
|
|
||||||
memberOf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
app.auth.asyncUser = app.auth.getUser();
|
|
||||||
|
|
||||||
|
// Back-compat alias for views that awaited the cached user directly.
|
||||||
|
Object.defineProperty(app.auth, 'asyncUser', {
|
||||||
|
get: function(){ return app.auth.loadUser(); },
|
||||||
|
});
|
||||||
|
|
||||||
app.user = (function(app){
|
app.user = (function(app){
|
||||||
function list(callback){
|
function list(callback){
|
||||||
@@ -338,6 +445,72 @@ app.user = (function(app){
|
|||||||
|
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
|
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
|
||||||
|
// these endpoints; the calls are inert elsewhere.
|
||||||
|
app.permission = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('permission/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function subjects(callback){
|
||||||
|
app.api.get('permission/subjects', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('permission/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, callback){
|
||||||
|
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, subjects, add, remove};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
|
app.group = (function(app){
|
||||||
|
function list(callback){
|
||||||
|
app.api.get('group/', function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(args, callback){
|
||||||
|
app.api.post('group/', args, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(name, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMember(name, username, callback){
|
||||||
|
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMember(name, username, callback){
|
||||||
|
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
|
||||||
|
callback(error, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {list, add, remove, addMember, removeMember};
|
||||||
|
|
||||||
|
})(app);
|
||||||
|
|
||||||
app.util = (function(app){
|
app.util = (function(app){
|
||||||
|
|
||||||
function getUrlParameter(name){
|
function getUrlParameter(name){
|
||||||
@@ -347,65 +520,15 @@ app.util = (function(app){
|
|||||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionMessage(message, $targetPassed, type, callback){
|
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||||
message = message || '';
|
// app.util.escapeHtml and app.messages.action/confirm.
|
||||||
|
function escapeHtml(s){
|
||||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
return String(s == null ? '' : s)
|
||||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
type = type || 'info';
|
.replace(/>/g, '>')
|
||||||
callback = callback || function(){};
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
if($target.html() === message) return;
|
|
||||||
|
|
||||||
if($target.html()){
|
|
||||||
$target.slideUp('fast', function(){
|
|
||||||
$target.html('')
|
|
||||||
$target.removeClass (function(index, className){
|
|
||||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
|
||||||
});
|
|
||||||
if(message) return actionMessage(message, $target, type, callback);
|
|
||||||
$target.hide()
|
|
||||||
})
|
|
||||||
}else{
|
|
||||||
if(type) $target.addClass('bg-' + type);
|
|
||||||
|
|
||||||
if(!message.includes('<button')) message += `
|
|
||||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
$target.html(message).slideDown('fast');
|
|
||||||
}
|
|
||||||
setTimeout(callback,10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionConfirm(message, $target, type, callback){
|
|
||||||
return new Promise((resolve, reject) =>{
|
|
||||||
let id = crypto.randomUUID();
|
|
||||||
message = `
|
|
||||||
<h4 class"align-middle" >
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
<b>${message}</b>
|
|
||||||
<span class="float-end">
|
|
||||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
|
||||||
<i class="fa-solid fa-circle-check"></i>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger confirm-${id}">
|
|
||||||
<i class="fa-solid fa-circle-stop"></i>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
`
|
|
||||||
actionMessage(message, $target, type);
|
|
||||||
$("body").on('click', `.confirm-${id}`, function(){
|
|
||||||
actionMessage('', $target, type);
|
|
||||||
resolve(!!$(this).data('confirm'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$.fn.serializeObject = function() {
|
$.fn.serializeObject = function() {
|
||||||
@@ -417,6 +540,9 @@ app.util = (function(app){
|
|||||||
if (obj[name] === undefined) {
|
if (obj[name] === undefined) {
|
||||||
if (!value
|
if (!value
|
||||||
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||||
|
// Keep empty <textarea>s so a cleared field is submitted (and
|
||||||
|
// can reset a list, e.g. the per-host IP/header controls).
|
||||||
|
&& !$(this).filter(`textarea[name="${name}"]`).length
|
||||||
){
|
){
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -461,26 +587,40 @@ app.util = (function(app){
|
|||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
actionMessage: actionMessage,
|
escapeHtml: escapeHtml,
|
||||||
actionConfirm,
|
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
$( document ).ready(async function(){
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
app.auth.applyGroupVisibility = function(user){
|
||||||
|
var groups = app.auth.groupCNs(user);
|
||||||
|
if(!groups.length) return;
|
||||||
|
|
||||||
// Show content if the user has the correct group
|
var style = document.getElementById('group-required-rules');
|
||||||
for(let group of (await app.auth.asyncUser)?.memberOf || []){
|
if(!style){
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = 'group-required-rules';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(var group of groups){
|
||||||
try{
|
try{
|
||||||
group = group.split(',ou=groups')[0].replace('cn=', '');
|
style.sheet.insertRule(
|
||||||
|
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
|
||||||
const sheet = document.styleSheets[0];
|
style.sheet.cssRules.length
|
||||||
const selector = `.group-required-${group}`;
|
);
|
||||||
const cssText = `${selector} { display: revert !important; }`;
|
|
||||||
sheet.insertRule(cssText, sheet.cssRules.length);
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$( document ).ready(async function(){
|
||||||
|
|
||||||
|
// Show content the user's groups entitle them to.
|
||||||
|
app.auth.applyGroupVisibility(await app.auth.loadUser());
|
||||||
|
|
||||||
$('div.row').fadeIn('slow'); //show the page
|
$('div.row').fadeIn('slow'); //show the page
|
||||||
|
|
||||||
@@ -502,9 +642,9 @@ $( document ).ready(async function(){
|
|||||||
$(this).closest('.card').slideUp('fast');
|
$(this).closest('.card').slideUp('fast');
|
||||||
});
|
});
|
||||||
|
|
||||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||||
app.util.actionMessage(null, $(this));
|
// app.messages.js (delegated on document, so it also covers messages
|
||||||
});
|
// rendered after this ready handler runs).
|
||||||
|
|
||||||
setInterval(()=>{
|
setInterval(()=>{
|
||||||
$('.momentFromNow').each((idx, el)=>{
|
$('.momentFromNow').each((idx, el)=>{
|
||||||
@@ -535,20 +675,17 @@ function formAJAX(btn){
|
|||||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||||
|
|
||||||
if($form.validate && !$form.validate()){
|
if($form.validate && !$form.validate()){
|
||||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.util.actionMessage(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
$form.validateClear();
|
$form.validateClear();
|
||||||
if(!error){
|
if(!error){
|
||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
@@ -556,7 +693,7 @@ function formAJAX(btn){
|
|||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
console.log('formAJAX res error', error, data)
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
}
|
}
|
||||||
if(data && data.keys){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
console.log('form key errors', data.keys)
|
||||||
@@ -567,4 +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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,7 @@ const router = require('express').Router();
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
const { Group } = require('../models/group_ldap');
|
const { Group } = require('../models/group_ldap');
|
||||||
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
// Require the admin group
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
@@ -18,7 +19,9 @@ router.use(async (req, res, next) => {
|
|||||||
router.get('/resources', async (req, res, next) => {
|
router.get('/resources', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const resources = await Resource.list();
|
const resources = await Resource.list();
|
||||||
res.json({ results: resources });
|
// 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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,10 +44,17 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
|
|
||||||
req.body.owner = req.body.owner || req.user.uid;
|
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;
|
let r;
|
||||||
if (req.body.kind === 'oauth') {
|
if (req.body.kind === 'oauth') {
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
// Pass created_by explicitly for the wrapper
|
// 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;
|
req.body.created_by = req.body.owner;
|
||||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||||
r = await OAuthClient.add(req.body);
|
r = await OAuthClient.add(req.body);
|
||||||
@@ -57,8 +67,9 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
if (r.kind === 'host' || r.kind === 'service') {
|
||||||
|
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
const createGroup = async (suffix, accessLevel) => {
|
||||||
const cn = `${r.slug}_${suffix}`;
|
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
|
||||||
try {
|
try {
|
||||||
await Group.add({
|
await Group.add({
|
||||||
name: cn,
|
name: cn,
|
||||||
@@ -101,6 +112,9 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
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) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const { Resource, ResourceGroup } = require('../models/resource');
|
|
||||||
|
|
||||||
// GET /api/discovery/me
|
|
||||||
// Returns the list of resources the current user has access to.
|
|
||||||
router.get('/me', async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const userGroups = req.user.groups || []; // array of LDAP group CNs
|
|
||||||
const accessibleResourceIds = new Set();
|
|
||||||
|
|
||||||
if (req.user.isMachine) {
|
|
||||||
// Machines only have access to themselves by default
|
|
||||||
accessibleResourceIds.add(req.resourceId);
|
|
||||||
} else {
|
|
||||||
// End users get access via groups
|
|
||||||
const allGroups = await ResourceGroup.list();
|
|
||||||
for (const rg of allGroups) {
|
|
||||||
if (userGroups.includes(rg.groupCn)) {
|
|
||||||
accessibleResourceIds.add(rg.resourceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch all resources and filter
|
|
||||||
const allResources = await Resource.list();
|
|
||||||
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
|
|
||||||
|
|
||||||
res.json({ results: accessible });
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,4 +1,79 @@
|
|||||||
const autoRouter = require('./autoRouter');
|
'use strict';
|
||||||
const { Resource } = require('../models/resource');
|
|
||||||
|
|
||||||
module.exports = autoRouter(Resource);
|
// 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;
|
||||||
@@ -82,8 +82,13 @@ router.put('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
var user = await User.get(req.params.uid);
|
||||||
|
const results = await group.addMember(user);
|
||||||
|
// Group membership feeds directly into cached-User-derived state
|
||||||
|
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
|
||||||
|
// a membership change here is invisible for up to the cache's TTL.
|
||||||
|
User.clearCache();
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await group.addMember(user),
|
results,
|
||||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
@@ -98,8 +103,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
var group = await Group.get(req.params.group);
|
var group = await Group.get(req.params.group);
|
||||||
var user = await User.get(req.params.uid);
|
var user = await User.get(req.params.uid);
|
||||||
|
const results = await group.removeMember(user);
|
||||||
|
User.clearCache();
|
||||||
return res.json({
|
return res.json({
|
||||||
results: await group.removeMember(user),
|
results,
|
||||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const {InviteToken, PasswordResetToken} = require('./../models/token');
|
|||||||
const {Tos} = require('../models/tos');
|
const {Tos} = require('../models/tos');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
@@ -20,24 +21,16 @@ const values ={
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List of front end node modules to be served
|
// List of front end node modules to be served
|
||||||
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
|
|
||||||
'moment', '@popper', 'jq-repeat',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Server front end modules
|
|
||||||
// https://stackoverflow.com/a/55700773/3140931
|
|
||||||
// Vendor libraries only change when package versions are bumped (a rebuild),
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
||||||
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
||||||
// still cover that rare case with a cheap 304 instead of a stale asset.
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
||||||
frontEndModules.forEach(dep => {
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
||||||
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'}))
|
// every deploy and isn't cache-busted/fingerprinted.
|
||||||
|
mountStaticModules(router, {
|
||||||
|
root: path.join(__dirname, '..'),
|
||||||
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Have express server static content( images, CSS, browser JS) from the public
|
|
||||||
// local folder. Shorter maxAge than /static-modules since this is the app's
|
|
||||||
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
|
|
||||||
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
|
|
||||||
|
|
||||||
// Public health endpoint for container/orchestration healthchecks.
|
// Public health endpoint for container/orchestration healthchecks.
|
||||||
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
||||||
router.get('/health', function(req, res) {
|
router.get('/health', function(req, res) {
|
||||||
@@ -67,6 +60,14 @@ router.get('/directory', function(req, res) {
|
|||||||
res.render('directory', {...values});
|
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
|
// Route removed since it's now in directory
|
||||||
|
|
||||||
router.get('/onboarding', async function(req, res, next) {
|
router.get('/onboarding', async function(req, res, next) {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
|
|||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
|
||||||
const client = await OAuthClient.get(req.params.client_id);
|
const client = await OAuthClient.get(req.params.client_id);
|
||||||
await client.remove();
|
await client.delete();
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
client_id: req.params.client_id,
|
client_id: req.params.client_id,
|
||||||
|
|||||||
@@ -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){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,17 @@ router.delete('/:uid', async function(req, res, next){
|
|||||||
|
|
||||||
router.get('/me', async function(req, res, next){
|
router.get('/me', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
return res.json(await User.get({uid: req.user.uid}));
|
const user = JSON.parse(JSON.stringify(await User.get({uid: req.user.uid})));
|
||||||
|
|
||||||
|
// The shared client framework gates the UI on a single effective-rights
|
||||||
|
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||||
|
// membership in app_sso_admin; group-level gating still reads memberOf.
|
||||||
|
const groups = (user.memberOf || []).map(function(dn){
|
||||||
|
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
});
|
||||||
|
user.isAdmin = groups.includes('app_sso_admin');
|
||||||
|
|
||||||
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -97,7 +107,7 @@ router.put('/password', async function(req, res, next){
|
|||||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||||
await verif.update({ password_must_change: false });
|
await verif.update({ password_must_change: false });
|
||||||
User.clearCache();
|
User.clearCache();
|
||||||
return res.json({results: result});
|
return res.json({results: result, message: 'Password changed.'});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
|||||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||||
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: adding/removing a member here didn't clear User's LRU
|
||||||
|
// cache (ttl 5 minutes), so isServiceAccount -- derived from
|
||||||
|
// app_sso_service_account membership at GET /api/user/:uid time -- could
|
||||||
|
// stay wrong for up to 5 minutes after the group change. In production
|
||||||
|
// this hid a real person's account from the Users page's "People" tab
|
||||||
|
// (it filters out anything with isServiceAccount) for however long the
|
||||||
|
// stale cache entry lived, which looked exactly like the account had
|
||||||
|
// vanished.
|
||||||
|
test('PUT app_sso_service_account/:uid immediately flips isServiceAccount (no stale cache)', async () => {
|
||||||
|
const added = await request(app)
|
||||||
|
.put(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
|
||||||
|
const afterAdd = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterAdd.body.results.isServiceAccount).toBeTruthy();
|
||||||
|
|
||||||
|
const removed = await request(app)
|
||||||
|
.delete(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(removed.status).toBe(200);
|
||||||
|
|
||||||
|
const afterRemove = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterRemove.body.results.isServiceAccount).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Groups — owner management', () => {
|
describe('Groups — owner management', () => {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||||
|
// browser events on the page (found live, mid browser-automation testing, on
|
||||||
|
// directory.ejs's "Rotate Client Secret" — it froze the tab entirely) and are
|
||||||
|
// visually inconsistent with the rest of the UI. Every call site was removed
|
||||||
|
// in favor of app.messages.action/confirm/toast and app.modal.open; this test
|
||||||
|
// keeps it that way.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', d));
|
||||||
|
|
||||||
|
// Matches a bare alert(/confirm(/prompt( call, but not app.messages.*,
|
||||||
|
// app.modal.*, or identifiers merely containing these words (e.g.
|
||||||
|
// "confirmation", ".confirmed").
|
||||||
|
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
let files = [];
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||||
|
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const root of ROOTS) {
|
||||||
|
for (const file of walk(root)) {
|
||||||
|
const src = fs.readFileSync(file, 'utf8');
|
||||||
|
let m;
|
||||||
|
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||||
|
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||||
|
const line = src.slice(0, m.index).split('\n').length;
|
||||||
|
offenders.push(`${path.relative(path.join(__dirname, '..'), file)}:${line} — ${m[2]}(`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
@@ -43,6 +43,114 @@ afterAll(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OAuth client management API — /api/oauth/client', () => {
|
||||||
|
// Regression: the ORM Model.toJSON() strips non-schema fields, so the
|
||||||
|
// mapped client_id/scopes/etc. used to vanish from GET responses —
|
||||||
|
// client_id came back undefined and the theta-env bootstrap's rotate
|
||||||
|
// crashed with a 500. GET must expose client_id (and never the secret hash).
|
||||||
|
test('GET / list exposes client_id and hides client_secret_hash', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/oauth/client/')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const mine = res.body.results.find((c) => c.client_id === clientId);
|
||||||
|
expect(mine).toBeDefined();
|
||||||
|
expect(mine.client_id).toBe(clientId);
|
||||||
|
expect(mine).toHaveProperty('scopes');
|
||||||
|
expect(mine).not.toHaveProperty('client_secret_hash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:id exposes client_id', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/oauth/client/${clientId}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.client_id).toBe(clientId);
|
||||||
|
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT persists — a changed name survives a fresh GET', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const updated = await request(app)
|
||||||
|
.put(`/api/oauth/client/${id}`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'put-persist-test-renamed' });
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(fetched.body.results.name).toBe('put-persist-test-renamed');
|
||||||
|
|
||||||
|
await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: this route called client.remove(), but OAuthClient wraps
|
||||||
|
// @simpleworkjs/orm's Resource model, whose instance method is .delete()
|
||||||
|
// — .remove() doesn't exist on it (unlike the model-redis Tables
|
||||||
|
// elsewhere in this app, e.g. api_token.js, which really do have
|
||||||
|
// .remove()). The route's try/catch turned the resulting TypeError into
|
||||||
|
// a plain 500 JSON response rather than a thrown exception, so every
|
||||||
|
// prior DELETE call in this file's cleanup hooks silently "succeeded"
|
||||||
|
// from Jest's point of view while leaving the client un-deleted.
|
||||||
|
test('DELETE persists — the client is actually gone, not just a 200', async () => {
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'delete-persist-test', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
const id = created.body.results.client_id;
|
||||||
|
|
||||||
|
const deleted = await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||||
|
expect(fetched.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
||||||
|
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
||||||
|
// find by name, rotate by the client_id from the list response. Uses a
|
||||||
|
// throwaway client so the shared flow client's secret is untouched.
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/oauth/client/')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: 'rotate-regression', redirect_uris: REDIRECT_URI });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
|
||||||
|
const list = await request(app).get('/api/oauth/client/').set('auth-token', token);
|
||||||
|
const found = list.body.results.find((c) => c.name === 'rotate-regression');
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found.client_id).toBeTruthy(); // was undefined before the fix
|
||||||
|
|
||||||
|
const rotated = await request(app)
|
||||||
|
.post(`/api/oauth/client/${found.client_id}/rotate`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(rotated.status).toBe(200);
|
||||||
|
expect(rotated.body.client_secret).toBeTruthy();
|
||||||
|
|
||||||
|
const deleted = await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(deleted.status).toBe(200);
|
||||||
|
|
||||||
|
const afterDelete = await request(app).get(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||||
|
expect(afterDelete.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:id unknown id returns 404, not 500', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(res.status).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('OIDC Discovery', () => {
|
describe('OIDC Discovery', () => {
|
||||||
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
test('GET /.well-known/openid-configuration returns required fields', async () => {
|
||||||
const res = await request(app).get('/.well-known/openid-configuration');
|
const res = await request(app).get('/.well-known/openid-configuration');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,16 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
// Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
|
||||||
|
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
|
||||||
|
// (../ from here in utils/), matching the Dockerfile.openldap gitinfo stage;
|
||||||
|
// cwd is utils/ for the bare-metal git fallback.
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execSync } = require('child_process');
|
const { createBuildInfo } = require('@simpleworkjs/app-stack');
|
||||||
const { version: buildVersion } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo
|
module.exports = createBuildInfo({
|
||||||
// stage in Dockerfile.openldap) -- the final image has no git binary and no
|
version,
|
||||||
// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev
|
buildCommitPath: path.join(__dirname, '../.build_commit'),
|
||||||
// runs have no baked file, so they fall back to asking git directly.
|
cwd: __dirname,
|
||||||
function readBuildHash() {
|
});
|
||||||
try {
|
|
||||||
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
|
|
||||||
if (baked) return baked;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
|
|
||||||
} catch (_) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildVersion,
|
|
||||||
buildHash: readBuildHash(),
|
|
||||||
buildYear: new Date().getFullYear(),
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
|
||||||
|
//
|
||||||
|
// Those two partials are byte-identical across sso-manager-node, proxy and
|
||||||
|
// jump-host — everything that differs between the apps lives here and is
|
||||||
|
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
|
||||||
|
// in sync across the three apps; a missing key is a render-time ReferenceError,
|
||||||
|
// not a silent fallback.
|
||||||
|
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// --- footer -------------------------------------------------------------
|
||||||
|
repoUrl: 'https://github.com/theta42/sso-manager-node',
|
||||||
|
licenseUrl: 'https://github.com/theta42/sso-manager-node/blob/master/LICENSE',
|
||||||
|
// In-app docs route (routes/docs.js). Apps without one point at the
|
||||||
|
// published docs site and set docsExternal.
|
||||||
|
docsUrl: '/docs',
|
||||||
|
docsExternal: false,
|
||||||
|
// Only sso-manager-node serves a Terms of Service page; null hides the link.
|
||||||
|
tosUrl: '/tos',
|
||||||
|
|
||||||
|
// --- header / nav -------------------------------------------------------
|
||||||
|
faviconUrl: conf.logo,
|
||||||
|
// Where the current-user chip links. null renders it as a plain span (for
|
||||||
|
// apps with no profile page).
|
||||||
|
profileUrl: '/profile',
|
||||||
|
// Where "Log Out" lands.
|
||||||
|
logoutRedirect: '/',
|
||||||
|
// Admin-only "a newer release is available" banner, backed by
|
||||||
|
// GET /api/update-check. Apps without that endpoint set false.
|
||||||
|
updateCheck: true,
|
||||||
|
updateLabel: 'SSO Manager',
|
||||||
|
|
||||||
|
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
|
||||||
|
// item; an empty list means "always visible". Gating is done client-side by
|
||||||
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
|
nav: [
|
||||||
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
|
||||||
|
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
|
||||||
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
|
||||||
|
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
</div><!-- end spa-shell -->
|
</div><!-- end spa-shell -->
|
||||||
|
|
||||||
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
|
||||||
|
app.locals in app.js). Edit all three copies together. -->
|
||||||
<footer class="py-2 bg-dark text-light mt-4">
|
<footer class="py-2 bg-dark text-light mt-4">
|
||||||
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
|
||||||
<span class="d-flex align-items-center gap-2">
|
<span class="d-flex align-items-center gap-2">
|
||||||
@@ -7,19 +10,21 @@
|
|||||||
<img width="64" src="/static/img/theta42.svg"/>
|
<img width="64" src="/static/img/theta42.svg"/>
|
||||||
</a>
|
</a>
|
||||||
© <%- buildYear %> theta42 ·
|
© <%- buildYear %> theta42 ·
|
||||||
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a>
|
<a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="d-flex align-items-center gap-3">
|
<span class="d-flex align-items-center gap-3">
|
||||||
<a href="/docs" class="text-light text-decoration-none">
|
<a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
|
||||||
<i class="fa-solid fa-book"></i> Docs
|
<i class="fa-solid fa-book"></i> Docs
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none">
|
<a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
|
||||||
<i class="fa-brands fa-github"></i> GitHub
|
<i class="fa-brands fa-github"></i> GitHub
|
||||||
</a>
|
</a>
|
||||||
<a href="/tos" class="text-light text-decoration-none">Terms of Service</a>
|
<% if(ui.tosUrl){ %>
|
||||||
|
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
|
||||||
|
<% } %>
|
||||||
</span>
|
</span>
|
||||||
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
<span>v<%- buildVersion %> (<%- buildHash %>)</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,12 +15,6 @@
|
|||||||
<option value="kind">Kind</option>
|
<option value="kind">Kind</option>
|
||||||
<option value="env">Environment</option>
|
<option value="env">Environment</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="btn-group btn-group-sm shadow-sm" role="group">
|
|
||||||
<input type="radio" class="btn-check" name="viewMode" id="view-list" value="list" autocomplete="off" checked onchange="renderTable()">
|
|
||||||
<label class="btn btn-outline-secondary" for="view-list"><i class="fa-solid fa-list"></i></label>
|
|
||||||
<input type="radio" class="btn-check" name="viewMode" id="view-tree" value="tree" autocomplete="off" onchange="renderTable()">
|
|
||||||
<label class="btn btn-outline-secondary" for="view-tree"><i class="fa-solid fa-folder-tree"></i></label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
||||||
<i class="fas fa-plus"></i> Add Resource
|
<i class="fas fa-plus"></i> Add Resource
|
||||||
</button>
|
</button>
|
||||||
@@ -49,7 +43,12 @@
|
|||||||
{{{indentHtml}}}
|
{{{indentHtml}}}
|
||||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td><strong>{{name}}</strong><br><small class="text-muted">{{slug}}</small></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>
|
<td>
|
||||||
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
||||||
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
||||||
@@ -79,252 +78,269 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Resource Modal -->
|
|
||||||
<div class="modal fade" id="resourceModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header shadow">
|
|
||||||
<h5 class="modal-title" id="resourceModalTitle">Resource</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="actionMessage mb-3" style="display:none"></div>
|
|
||||||
<input type="hidden" id="res-id">
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" id="res-name" class="form-control shadow-sm">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Slug</label>
|
|
||||||
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Kind</label>
|
|
||||||
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
|
|
||||||
<option value="site">Site</option>
|
|
||||||
<option value="host">Host</option>
|
|
||||||
<option value="service">Service (App)</option>
|
|
||||||
<option value="oauth">OAuth Integration</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Sub Type</label>
|
|
||||||
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="site-details-container" style="display: none;">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-current-site">
|
|
||||||
<label class="form-check-label" for="res-is-current-site">
|
|
||||||
Mark as Current Site
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="host-parent-container" style="display: none;">
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
|
|
||||||
<select id="res-host-id" class="form-select shadow-sm border-primary">
|
|
||||||
<option value="">-- Select Parent --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">IP Address</label>
|
|
||||||
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Host / URI Address</label>
|
|
||||||
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="host-details-container" style="display: none;">
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">VMID</label>
|
|
||||||
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">MAC Address</label>
|
|
||||||
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">OS / Kernel</label>
|
|
||||||
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="service-ports-container" style="display: none;">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Internal Port</label>
|
|
||||||
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">External Port</label>
|
|
||||||
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
|
|
||||||
<small class="text-muted">Same as Internal if empty</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="service-details-container" style="display: none;">
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">Git Repo</label>
|
|
||||||
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">Install Path</label>
|
|
||||||
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">Systemd Service</label>
|
|
||||||
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="oauth-details-container" style="display: none;">
|
|
||||||
<hr>
|
|
||||||
<h5>OAuth Configuration</h5>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
||||||
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
|
|
||||||
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
|
|
||||||
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
|
|
||||||
<button class="btn btn-outline-warning" onclick="rotateSecret()">
|
|
||||||
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
|
|
||||||
</button>
|
|
||||||
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-production">
|
|
||||||
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-4" id="external-container" style="display: none;">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-external">
|
|
||||||
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-4" id="public-container" style="display: none;">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-public">
|
|
||||||
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div id="edit-only-section" style="display: none;">
|
|
||||||
<h5>Associated LDAP Groups</h5>
|
|
||||||
<div class="mb-3">
|
|
||||||
<ul class="list-group mb-2 shadow-sm" id="groups-list" jq-repeat="groups">
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span>
|
|
||||||
<i class="fa-solid fa-users text-muted me-2"></i>
|
|
||||||
<strong>{{groupCn}}</strong>
|
|
||||||
<span class="badge bg-primary ms-2">{{accessLevel}}</span>
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="removeGroup('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="input-group shadow-sm mt-2">
|
|
||||||
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
|
|
||||||
<datalist id="ldap-groups-datalist"></datalist>
|
|
||||||
<select class="form-select" id="new-group-level" style="max-width: 140px;">
|
|
||||||
<option value="member">Member</option>
|
|
||||||
<option value="owner">Owner</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h5>Relationships (Graph Edges)</h5>
|
|
||||||
<div class="mb-3">
|
|
||||||
<ul class="list-group mb-2 shadow-sm" id="edges-list" jq-repeat="edges">
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span>
|
|
||||||
{{#isParent}}
|
|
||||||
<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
|
||||||
{{/isParent}}
|
|
||||||
{{^isParent}}
|
|
||||||
<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
|
||||||
{{/isParent}}
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="removeEdge('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="input-group shadow-sm mt-2">
|
|
||||||
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
|
|
||||||
<option value="parent">Has child</option>
|
|
||||||
<option value="child">Is child of</option>
|
|
||||||
</select>
|
|
||||||
<select class="form-select" id="new-edge-target">
|
|
||||||
<option value="">-- Select Resource --</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
|
|
||||||
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveResource()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save Resource
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
|
||||||
|
|
||||||
var resourceModal = new bootstrap.Modal(document.getElementById('resourceModal'));
|
// --- 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 resourcesById = {};
|
||||||
var allGroups = [];
|
var allGroups = [];
|
||||||
var allEdges = [];
|
var allEdges = [];
|
||||||
@@ -332,6 +348,11 @@
|
|||||||
|
|
||||||
$(document).ready(async function() {
|
$(document).ready(async function() {
|
||||||
await loadResources();
|
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() {
|
async function loadResources() {
|
||||||
@@ -369,14 +390,13 @@
|
|||||||
renderTable();
|
renderTable();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('Failed to load data');
|
app.messages.toast('Failed to load data', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
const filter = $('#search-filter').val().toLowerCase();
|
const filter = $('#search-filter').val().toLowerCase();
|
||||||
const sort = $('#sort-by').val();
|
const sort = $('#sort-by').val();
|
||||||
const viewMode = $('input[name="viewMode"]:checked').val();
|
|
||||||
|
|
||||||
let filtered = rawResources.filter(r => {
|
let filtered = rawResources.filter(r => {
|
||||||
if (!filter) return true;
|
if (!filter) return true;
|
||||||
@@ -402,41 +422,37 @@
|
|||||||
|
|
||||||
let finalRenderList = [];
|
let finalRenderList = [];
|
||||||
|
|
||||||
if (viewMode === 'tree') {
|
const map = {};
|
||||||
const map = {};
|
const roots = [];
|
||||||
const roots = [];
|
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
|
||||||
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
|
|
||||||
|
|
||||||
filtered.forEach(r => {
|
filtered.forEach(r => {
|
||||||
const node = map[r.id];
|
const node = map[r.id];
|
||||||
if (node.parentId && map[node.parentId]) {
|
if (node.parentId && map[node.parentId]) {
|
||||||
map[node.parentId].children.push(node);
|
map[node.parentId].children.push(node);
|
||||||
} else {
|
} else {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const flatten = (nodes, depth) => {
|
const flatten = (nodes, depth) => {
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
let indentHtml = '';
|
let indentHtml = '';
|
||||||
for(let i = 0; i < depth; i++) {
|
for(let i = 0; i < depth; i++) {
|
||||||
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
|
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
|
||||||
}
|
}
|
||||||
if (depth > 0) {
|
if (depth > 0) {
|
||||||
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
||||||
}
|
}
|
||||||
n.indentHtml = indentHtml;
|
n.indentHtml = indentHtml;
|
||||||
finalRenderList.push(n);
|
finalRenderList.push(n);
|
||||||
if (n.children.length > 0) {
|
if (n.children.length > 0) {
|
||||||
flatten(n.children, depth + 1);
|
flatten(n.children, depth + 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
flatten(roots, 0);
|
flatten(roots, 0);
|
||||||
} else {
|
|
||||||
finalRenderList = filtered.map(r => ({ ...r, indentHtml: '' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
$.scope.resources.empty();
|
$.scope.resources.empty();
|
||||||
for (const r of finalRenderList) {
|
for (const r of finalRenderList) {
|
||||||
@@ -486,7 +502,10 @@
|
|||||||
populateHostDropdown($('#res-host-id').val());
|
populateHostDropdown($('#res-host-id').val());
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#res-name, #res-kind').on('input change', function() {
|
// 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();
|
const id = $('#res-id').val();
|
||||||
if (!id && $('#res-name').val()) {
|
if (!id && $('#res-name').val()) {
|
||||||
const name = $('#res-name').val();
|
const name = $('#res-name').val();
|
||||||
@@ -502,7 +521,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function openAddModal(parentId, parentKind) {
|
function openAddModal(parentId, parentKind) {
|
||||||
$('#resourceModalTitle').html('<i class="fa-solid fa-plus"></i> Add Resource');
|
openResourceModal('Add Resource', null);
|
||||||
$('#res-id').val('');
|
$('#res-id').val('');
|
||||||
$('#res-name').val('');
|
$('#res-name').val('');
|
||||||
$('#res-slug').val('');
|
$('#res-slug').val('');
|
||||||
@@ -537,12 +556,9 @@
|
|||||||
$('#res-is-external').prop('checked', false);
|
$('#res-is-external').prop('checked', false);
|
||||||
$('#res-is-public').prop('checked', false);
|
$('#res-is-public').prop('checked', false);
|
||||||
$('#res-is-current-site').prop('checked', false);
|
$('#res-is-current-site').prop('checked', false);
|
||||||
$('#edit-only-section').hide();
|
|
||||||
|
|
||||||
populateHostDropdown(parentId || '');
|
populateHostDropdown(parentId || '');
|
||||||
toggleFormFields();
|
toggleFormFields();
|
||||||
|
|
||||||
resourceModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ldapGroupsCache = null;
|
var ldapGroupsCache = null;
|
||||||
@@ -572,34 +588,64 @@
|
|||||||
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||||
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
|
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
|
||||||
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
$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);
|
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) {
|
function refreshGroupsUI(resourceId) {
|
||||||
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
||||||
$.scope.groups.empty();
|
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) {
|
for (const g of myGroups) {
|
||||||
$.scope.groups.push(g);
|
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) {
|
function refreshEdgesUI(resourceId) {
|
||||||
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
||||||
$.scope.edges.empty();
|
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) {
|
for (const e of myEdges) {
|
||||||
const isParent = e.parentId === resourceId;
|
const isParent = e.parentId === resourceId;
|
||||||
const targetId = isParent ? e.childId : e.parentId;
|
const targetId = isParent ? e.childId : e.parentId;
|
||||||
const target = resourcesById[targetId];
|
const target = resourcesById[targetId];
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
|
|
||||||
$.scope.edges.push({
|
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||||
id: e.id,
|
const $span = $('<span></span>');
|
||||||
isParent: isParent,
|
$span.append(isParent
|
||||||
relation: e.relation,
|
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
|
||||||
targetName: target.name + ' (' + target.slug + ')'
|
: '<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');
|
const $target = $('#new-edge-target');
|
||||||
@@ -615,7 +661,7 @@
|
|||||||
const r = resourcesById[id];
|
const r = resourcesById[id];
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
|
|
||||||
$('#resourceModalTitle').html('<i class="fa-solid fa-pen-to-square"></i> Edit Resource');
|
openResourceModal('Edit Resource', id);
|
||||||
$('#res-id').val(r.id);
|
$('#res-id').val(r.id);
|
||||||
$('#res-name').val(r.name);
|
$('#res-name').val(r.name);
|
||||||
$('#res-slug').val(r.slug);
|
$('#res-slug').val(r.slug);
|
||||||
@@ -654,13 +700,10 @@
|
|||||||
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
||||||
toggleFormFields();
|
toggleFormFields();
|
||||||
|
|
||||||
$('#edit-only-section').show();
|
|
||||||
|
|
||||||
refreshGroupsUI(r.id);
|
refreshGroupsUI(r.id);
|
||||||
refreshEdgesUI(r.id);
|
refreshEdgesUI(r.id);
|
||||||
|
refreshChildrenUI(r.id);
|
||||||
await loadLdapGroups();
|
await loadLdapGroups();
|
||||||
|
|
||||||
resourceModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveResource() {
|
async function saveResource() {
|
||||||
@@ -708,29 +751,30 @@
|
|||||||
res = await app.api.post('directory-admin/resources', data);
|
res = await app.api.post('directory-admin/resources', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceModal.hide();
|
app.modal.close();
|
||||||
await loadResources();
|
await loadResources();
|
||||||
|
|
||||||
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
||||||
app.util.alert('OAuth Secret', 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>', 'success');
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert(err.message || 'Failed to save');
|
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateSecret() {
|
async function rotateSecret() {
|
||||||
const id = $('#res-id').val();
|
const id = $('#res-id').val();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (!confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.')) return;
|
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 {
|
try {
|
||||||
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
|
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
|
||||||
app.util.alert('Secret Rotated', 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>', 'success');
|
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) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert(err.message || 'Failed to rotate secret');
|
app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +783,7 @@
|
|||||||
const groupCn = $('#new-group-cn').val().trim();
|
const groupCn = $('#new-group-cn').val().trim();
|
||||||
const accessLevel = $('#new-group-level').val();
|
const accessLevel = $('#new-group-level').val();
|
||||||
|
|
||||||
if (!groupCn) return alert('Group CN is required');
|
if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
|
||||||
try {
|
try {
|
||||||
const res = await app.api.post('directory-admin/groups', {
|
const res = await app.api.post('directory-admin/groups', {
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -751,7 +795,7 @@
|
|||||||
$('#new-group-cn').val('');
|
$('#new-group-cn').val('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('Failed to add group');
|
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +806,7 @@
|
|||||||
refreshGroupsUI($('#res-id').val());
|
refreshGroupsUI($('#res-id').val());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('Failed to remove group');
|
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +816,7 @@
|
|||||||
const targetId = $('#new-edge-target').val();
|
const targetId = $('#new-edge-target').val();
|
||||||
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
||||||
|
|
||||||
if (!targetId) return alert('Select a target resource');
|
if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
|
||||||
|
|
||||||
const data = { relation };
|
const data = { relation };
|
||||||
if (dir === 'parent') {
|
if (dir === 'parent') {
|
||||||
@@ -790,7 +834,7 @@
|
|||||||
$('#new-edge-target').val('');
|
$('#new-edge-target').val('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('Failed to add edge');
|
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,18 +845,23 @@
|
|||||||
refreshEdgesUI($('#res-id').val());
|
refreshEdgesUI($('#res-id').val());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('Failed to remove edge');
|
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteResource(id) {
|
async function deleteResource(id) {
|
||||||
if (!confirm('Are you sure you want to delete this resource? All relationships will be destroyed.')) return;
|
// 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 {
|
try {
|
||||||
await app.api.delete('directory-admin/resources/' + id);
|
await app.api.delete('directory-admin/resources/' + id);
|
||||||
await loadResources();
|
await loadResources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert('Failed to delete');
|
app.messages.action('Failed to delete', $target, 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -117,8 +117,8 @@
|
|||||||
const msgEl = document.getElementById('notif-result');
|
const msgEl = document.getElementById('notif-result');
|
||||||
const $compose = $('#notif-subject').closest('.card-body');
|
const $compose = $('#notif-subject').closest('.card-body');
|
||||||
|
|
||||||
if (!subject || !message) { alert('Subject and message are required.'); return; }
|
if (!subject || !message) { app.messages.action('Subject and message are required.', $compose, 'danger'); return; }
|
||||||
if (!filterCheck) { alert('Choose who to send this to.'); return; }
|
if (!filterCheck) { app.messages.action('Choose who to send this to.', $compose, 'danger'); return; }
|
||||||
const filterType = filterCheck.value;
|
const filterType = filterCheck.value;
|
||||||
|
|
||||||
let filter_value = '';
|
let filter_value = '';
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
// trying the form out — make it a deliberate, confirmed action.
|
// trying the form out — make it a deliberate, confirmed action.
|
||||||
if (filterType === 'all' || filterType === 'all_active') {
|
if (filterType === 'all' || filterType === 'all_active') {
|
||||||
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
||||||
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
|
const confirmed = await app.messages.confirm(`Send this notification to ${label}?`, $compose, 'warning');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,12 @@
|
|||||||
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
||||||
const msgEl = document.getElementById('tos-result');
|
const msgEl = document.getElementById('tos-result');
|
||||||
|
|
||||||
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
|
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) {
|
app.tos.update({content, resetAcceptance}, function(error, data) {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -32,10 +32,37 @@
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// app_sso_service_account is a marker group: membership hides an account
|
||||||
|
// from the Users page's People tab entirely (see users.ejs), which is
|
||||||
|
// exactly right for a non-person account but has silently made a real
|
||||||
|
// person's account look "gone" before (nothing else about it changes).
|
||||||
|
// Everywhere else in this dropdown just fires the PUT directly; only
|
||||||
|
// this one group gets a confirmation first.
|
||||||
|
function addMemberClick(event, groupCN, uid, el){
|
||||||
|
event.preventDefault();
|
||||||
|
const $el = $(el);
|
||||||
|
(async function(){
|
||||||
|
if (groupCN === 'app_sso_service_account') {
|
||||||
|
const ok = await app.messages.confirm(
|
||||||
|
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
||||||
|
$el.closest('.card'), 'warning'
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
||||||
|
await addedUser(data.message, groupCN, uid, $el);
|
||||||
|
} catch(e) {
|
||||||
|
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function addedUser(message, group, user, $form){
|
async function addedUser(message, group, user, $form){
|
||||||
let data = await app.group.get(group);
|
let data = await app.group.get(group);
|
||||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
||||||
app.util.actionMessage(message, $("#group-card-"+group), 'success');
|
app.messages.action(message, $("#group-card-"+group), 'success');
|
||||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
||||||
setTimeout(function(group){
|
setTimeout(function(group){
|
||||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
||||||
@@ -73,44 +100,44 @@
|
|||||||
async function removeMember(groupCN, uid, btn) {
|
async function removeMember(groupCN, uid, btn) {
|
||||||
const $item = $(btn).closest('li');
|
const $item = $(btn).closest('li');
|
||||||
$item.addClass('list-group-item-warning');
|
$item.addClass('list-group-item-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||||
try {
|
try {
|
||||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
||||||
const groupData = await app.group.get(groupCN);
|
const groupData = await app.group.get(groupCN);
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
$item.removeClass('list-group-item-warning');
|
$item.removeClass('list-group-item-warning');
|
||||||
app.util.actionMessage(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeOwner(groupCN, uid, btn) {
|
async function removeOwner(groupCN, uid, btn) {
|
||||||
const $item = $(btn).closest('li');
|
const $item = $(btn).closest('li');
|
||||||
$item.addClass('list-group-item-warning');
|
$item.addClass('list-group-item-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||||
try {
|
try {
|
||||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
||||||
const groupData = await app.group.get(groupCN);
|
const groupData = await app.group.get(groupCN);
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
$item.removeClass('list-group-item-warning');
|
$item.removeClass('list-group-item-warning');
|
||||||
app.util.actionMessage(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteGroup(cn, btn) {
|
async function deleteGroup(cn, btn) {
|
||||||
const $card = $(btn).closest('.card');
|
const $card = $(btn).closest('.card');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete group "${cn}"?`, $card, 'danger');
|
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
try {
|
try {
|
||||||
await app.api.delete(`group/${cn}`);
|
await app.api.delete(`group/${cn}`);
|
||||||
$.scope.groupCard.remove('cn', cn);
|
$.scope.groupCard.remove('cn', cn);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
app.util.actionMessage(e.message || 'Failed to delete group', $card, 'danger');
|
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +241,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||||
{{ #toAdd }}{{#.}}
|
{{ #toAdd }}{{#.}}
|
||||||
<a class="dropdown-item" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
|
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
<i class="fa-solid fa-user"></i> {{uid}}
|
||||||
</a>
|
</a>
|
||||||
{{/.}}{{ /toAdd }}
|
{{/.}}{{ /toAdd }}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
function startImpersonate(uid){
|
function startImpersonate(uid){
|
||||||
app.impersonate.create(uid, function(error, data){
|
app.impersonate.create(uid, function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
alert('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'));
|
app.messages.toast('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$('#impersonateModalTitle').text(data.uid);
|
$('#impersonateModalTitle').text(data.uid);
|
||||||
@@ -79,7 +79,7 @@ function startImpersonate(uid){
|
|||||||
$('#impersonateStopBtn').off('click').on('click', function(){
|
$('#impersonateStopBtn').off('click').on('click', function(){
|
||||||
app.impersonate.revoke(data.uid, function(err){
|
app.impersonate.revoke(data.uid, function(err){
|
||||||
$('#impersonateModal').modal('hide');
|
$('#impersonateModal').modal('hide');
|
||||||
if(!err) app.util.actionMessage('Impersonation ended for ' + data.uid, $('body'), 'success');
|
if(!err) app.messages.action('Impersonation ended for ' + data.uid, $('body'), 'success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function tableAJAX(message){
|
function tableAJAX(message){
|
||||||
app.util.actionMessage(message);
|
app.messages.action(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
|
|||||||
@@ -131,6 +131,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function requestAccess(id) {
|
function requestAccess(id) {
|
||||||
app.util.alert('Access Request', 'This feature is coming soon!', 'info');
|
app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
|
||||||
}
|
}
|
||||||
</script>
|
</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) {
|
function setOtpMethod(method) {
|
||||||
$('#otpMethodInput').val(method);
|
$('#otpMethodInput').val(method);
|
||||||
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
$('#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){
|
app.api.post('oauth/authorize', oauthParams, function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
|
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
|
||||||
app.util.actionMessage(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
app.messages.action(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = data.redirect_url;
|
window.location.href = data.redirect_url;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
async function acceptTos() {
|
async function acceptTos() {
|
||||||
var checkbox = document.getElementById('tosCheckbox');
|
var checkbox = document.getElementById('tosCheckbox');
|
||||||
if (!checkbox.checked) {
|
if (!checkbox.checked) {
|
||||||
alert('Please read and check the box to accept the Terms of Service.');
|
app.messages.toast('Please read and check the box to accept the Terms of Service.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -50,14 +50,14 @@
|
|||||||
document.getElementById('section-tos').style.display = 'none';
|
document.getElementById('section-tos').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not save TOS acceptance. Please try again.');
|
app.messages.toast('Could not save TOS acceptance. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDob() {
|
async function saveDob() {
|
||||||
var dob = document.getElementById('dobInput').value;
|
var dob = document.getElementById('dobInput').value;
|
||||||
if (!dob) {
|
if (!dob) {
|
||||||
alert('Please enter your date of birth.');
|
app.messages.toast('Please enter your date of birth.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
document.getElementById('section-dob').style.display = 'none';
|
document.getElementById('section-dob').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not save date of birth. Please try again.');
|
app.messages.toast('Could not save date of birth. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,11 +81,11 @@
|
|||||||
var pw = document.getElementById('pwInput').value;
|
var pw = document.getElementById('pwInput').value;
|
||||||
var pw2 = document.getElementById('pwInput2').value;
|
var pw2 = document.getElementById('pwInput2').value;
|
||||||
if (!pw || pw.length < 5) {
|
if (!pw || pw.length < 5) {
|
||||||
alert('Password must be at least 5 characters.');
|
app.messages.toast('Password must be at least 5 characters.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pw !== pw2) {
|
if (pw !== pw2) {
|
||||||
alert('Passwords do not match.');
|
app.messages.toast('Passwords do not match.', 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
document.getElementById('section-password').style.display = 'none';
|
document.getElementById('section-password').style.display = 'none';
|
||||||
checkAllDone();
|
checkAllDone();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Could not change password. Please try again.');
|
app.messages.toast('Could not change password. Please try again.', 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@
|
|||||||
|
|
||||||
async function removeFromGroup(cn, btn){
|
async function removeFromGroup(cn, btn){
|
||||||
const $row = $(btn).closest('tr');
|
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;
|
if (!confirmed) return;
|
||||||
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
|
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);
|
$.scope.mygroups.remove('cn', cn);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
for(const cn of cns){
|
for(const cn of cns){
|
||||||
await new Promise(function(resolve){
|
await new Promise(function(resolve){
|
||||||
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
|
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();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -64,10 +64,10 @@
|
|||||||
|
|
||||||
async function removePersonalGroupMember(memberUid, btn){
|
async function removePersonalGroupMember(memberUid, btn){
|
||||||
const $row = $(btn).closest('tr');
|
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;
|
if (!confirmed) return;
|
||||||
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
|
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);
|
$.scope.personalGroupMembers.remove('uid', memberUid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
for(const uid of uids){
|
for(const uid of uids){
|
||||||
await new Promise(function(resolve){
|
await new Promise(function(resolve){
|
||||||
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
|
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();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
|
|
||||||
async function toggleActive(uid, active){
|
async function toggleActive(uid, active){
|
||||||
app.user.setActive(uid, active, async function(error, data){
|
app.user.setActive(uid, active, async function(error, data){
|
||||||
if(error) return alert('Failed to update user status');
|
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
||||||
currentUser = await determinUser();
|
currentUser = await determinUser();
|
||||||
renderProfile(currentUser);
|
renderProfile(currentUser);
|
||||||
});
|
});
|
||||||
@@ -184,11 +184,11 @@
|
|||||||
|
|
||||||
async function deleteUser(uid, btn){
|
async function deleteUser(uid, btn){
|
||||||
const $card = $(btn).closest('.card');
|
const $card = $(btn).closest('.card');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $card, 'warning');
|
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $card, 'warning');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
app.api.delete('user/' + uid, function(error, data){
|
app.api.delete('user/' + uid, function(error, data){
|
||||||
if (error) {
|
if (error) {
|
||||||
app.util.actionMessage(data.message || 'Failed to delete user', $card, 'danger');
|
app.messages.action(data.message || 'Failed to delete user', $card, 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = '/users';
|
window.location.href = '/users';
|
||||||
@@ -662,21 +662,21 @@
|
|||||||
async function revokeToken(id, name, btn){
|
async function revokeToken(id, name, btn){
|
||||||
var $card = $(btn).closest('.card');
|
var $card = $(btn).closest('.card');
|
||||||
$card.addClass('table-warning');
|
$card.addClass('table-warning');
|
||||||
var confirmed = await app.util.actionConfirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
|
var confirmed = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
|
||||||
$card.removeClass('table-warning');
|
$card.removeClass('table-warning');
|
||||||
if(!confirmed) return;
|
if(!confirmed) return;
|
||||||
app.apiToken.remove({id: id}, function(error, data){
|
app.apiToken.remove({id: id}, function(error, data){
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||||
$.scope.apiTokenCard.remove('id', id);
|
$.scope.apiTokenCard.remove('id', id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateToken(id, name, btn){
|
async function rotateToken(id, name, btn){
|
||||||
var $card = $(btn).closest('.card');
|
var $card = $(btn).closest('.card');
|
||||||
var confirmed = await app.util.actionConfirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
|
var confirmed = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
|
||||||
if(!confirmed) return;
|
if(!confirmed) return;
|
||||||
app.apiToken.rotate({id: id}, function(error, data){
|
app.apiToken.rotate({id: id}, function(error, data){
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||||
showSecret(data.token);
|
showSecret(data.token);
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
@@ -700,7 +700,7 @@
|
|||||||
expires_in_days: $('#edit-expires_in_days').val(),
|
expires_in_days: $('#edit-expires_in_days').val(),
|
||||||
};
|
};
|
||||||
app.apiToken.update(payload, function(error, data){
|
app.apiToken.update(payload, function(error, data){
|
||||||
if(error){ app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
if(error){ app.messages.action((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
||||||
editModal.hide();
|
editModal.hide();
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,138 +1,151 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<title><%- name %> <%- title %></title>
|
<title><%- name %> <%- title %></title>
|
||||||
<!-- Favicon -->
|
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
|
||||||
<link rel="icon" type="image/svg+xml" href="<%- logo %>">
|
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
|
||||||
<!-- CSS are placed here -->
|
via app.locals in app.js). Edit all three copies together. -->
|
||||||
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
<!-- Favicon -->
|
||||||
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
<link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
|
||||||
|
<!-- CSS are placed here -->
|
||||||
|
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
|
||||||
|
|
||||||
<link rel='stylesheet' href='/static/css/styles.css' />
|
<link rel='stylesheet' href='/static/css/styles.css' />
|
||||||
<!-- Scripts are placed here -->
|
<!-- Scripts are placed here -->
|
||||||
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
|
||||||
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
|
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
|
||||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
|
||||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
|
||||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav top-nav">
|
<ul class="navbar-nav top-nav">
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%# Items gated on a group start hidden (.group-required) and are
|
||||||
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
|
revealed by app-base.js for the groups the user is in. %>
|
||||||
Users
|
<% for(const item of ui.nav){ %>
|
||||||
</a>
|
<li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
|
||||||
</li>
|
<a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<%- item.label %>
|
||||||
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i>
|
</a>
|
||||||
Groups
|
</li>
|
||||||
</a>
|
<% } %>
|
||||||
</li>
|
</ul>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_directory_admin">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i>
|
<% if(ui.profileUrl){ %>
|
||||||
Directory
|
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||||
</a>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</li>
|
</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(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
|
||||||
|
<i class="fas fa-sign-out"></i>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<% 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(document).ready(function(){
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<!-- Container -->
|
||||||
<a class="nav-link" href="/executive">
|
<div id="spa-shell" class="container-fluid">
|
||||||
<i class="fa-solid fa-gauge-high"></i>
|
<div class="actionMessage" style="display:none;"></div>
|
||||||
Executive
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
|
||||||
</a>
|
|
||||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
|
||||||
<i class="fas fa-sign-out"></i>
|
|
||||||
Login
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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');
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(async function(){
|
|
||||||
|
|
||||||
// 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')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Container -->
|
|
||||||
<div id="spa-shell" class="container-fluid">
|
|
||||||
<div class="actionMessage" style="display:none;"></div>
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
function renderUsers(){
|
function renderUsers(){
|
||||||
app.user.list(function(error, data){
|
app.user.list(function(error, data){
|
||||||
if(error){
|
if(error){
|
||||||
app.util.actionMessage(data.message, $('#tab-people'), 'danger');
|
app.messages.action(data.message, $('#tab-people'), 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$.scope.userRow.empty();
|
$.scope.userRow.empty();
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
function toggleActive(uid, active){
|
function toggleActive(uid, active){
|
||||||
app.user.setActive(uid, active, function(error, data){
|
app.user.setActive(uid, active, function(error, data){
|
||||||
if(error) return alert('Failed to update user status');
|
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
||||||
renderUsers();
|
renderUsers();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
async function revokeInvite(tokenId, btn) {
|
async function revokeInvite(tokenId, btn) {
|
||||||
$thisRow = $(btn).closest('tr');
|
$thisRow = $(btn).closest('tr');
|
||||||
$thisRow.addClass('table-warning');
|
$thisRow.addClass('table-warning');
|
||||||
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
|
let confirmation = await app.messages.confirm('Revoke selected invite token?', $thisRow, 'warning');
|
||||||
if(!confirmation){
|
if(!confirmation){
|
||||||
$thisRow.removeClass('table-warning');
|
$thisRow.removeClass('table-warning');
|
||||||
return;
|
return;
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
await app.api.delete(`user/invite/${tokenId}`);
|
await app.api.delete(`user/invite/${tokenId}`);
|
||||||
loadInvites();
|
loadInvites();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Failed to revoke invite.');
|
app.messages.action('Failed to revoke invite.', $thisRow, 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +153,12 @@
|
|||||||
async function deleteUser(uid, btn){
|
async function deleteUser(uid, btn){
|
||||||
const $row = $(btn).closest('tr');
|
const $row = $(btn).closest('tr');
|
||||||
$row.addClass('table-warning');
|
$row.addClass('table-warning');
|
||||||
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning');
|
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $row, 'warning');
|
||||||
$row.removeClass('table-warning');
|
$row.removeClass('table-warning');
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
app.api.delete('user/' + uid, function(error, data){
|
app.api.delete('user/' + uid, function(error, data){
|
||||||
if (error) {
|
if (error) {
|
||||||
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger');
|
app.messages.action(data.message || 'Failed to delete user', $row, 'danger');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderUsers();
|
renderUsers();
|
||||||
|
|||||||