Compare commits

...

81 Commits

Author SHA1 Message Date
wmantly 6c71c91ff6 Merge pull request #128 from theta42/release/1.9.0
Release 1.9.0: LDAP group membership management, sticky Groups sort bar, merged Directory column
2026-07-29 22:14:51 -04:00
wmantly ac25084113 Release 1.9.0: LDAP group membership management, sticky Groups sort bar, merged Directory column 2026-07-29 22:04:54 -04:00
wmantly a788a99e56 Merge pull request #127 from theta42/feat/directory-groups-membership-ui
Add LDAP group membership management to the Directory modal
2026-07-29 22:04:00 -04:00
wmantly bcd160cca2 Add LDAP group membership management to the Directory modal, pin Groups sort bar, merge Directory columns
- Directory modal's Associated LDAP Groups tab now lets you view/add/remove
  members and owners of each associated group directly, reusing the same
  PUT/DELETE group/:group/:uid routes and member-mapping pattern already
  used on the Groups page -- no backend change needed.
- Groups page: the search/sort bar is now sticky, staying visible while
  scrolling through a long group list. Introduces --sw-content-offset (set
  in top.ejs alongside #spa-shell's margin-top) so an in-page sticky
  element can offset itself below the fixed navbar/update-banner instead
  of being hidden behind them at the viewport's true top:0.
- Directory table: Kind/Name/Env/Host merged into a single "Resource"
  column, matching the same information more compactly.
- app-base.js (byte-identical across the 3 apps): added app.util.revealItem(),
  which scrolls a just-added/-edited element into view and flashes its
  background -- wired into the Directory table, the Groups tab's member
  list, and the Groups page's create-group flow.
- Bumped @simpleworkjs/frontend to ^0.2.7 (published with the same
  revealItem() addition for any future consumer of its app.js, even though
  none of the 3 apps currently load that file directly -- they use the
  legacy app-base.js instead).
2026-07-29 21:50:30 -04:00
wmantly 724f5d8496 Merge pull request #126 from theta42/release/1.8.3
Release 1.8.3: unify profile.ejs's API-token UI onto app.modal
2026-07-28 21:19:07 -04:00
wmantly 8fc7dd11f5 Release 1.8.3: unify profile.ejs's API-token UI onto app.modal 2026-07-28 21:13:01 -04:00
wmantly e91ed6f1f7 Merge pull request #125 from theta42/feat/apitoken-ui-unification
Unify profile.ejs's API-token UI onto app.modal
2026-07-28 21:12:40 -04:00
wmantly 874f7db037 Unify profile.ejs's API-token UI onto app.modal
Retires the static #secretModal/#editModal elements in favor of the
shared app.modal singleton, matching the pattern already shipped in
directory.ejs, proxy, and jump-host this round. Converts the
always-visible create-form card into a "+ New Token" button + modal,
switches badge classes from bg-* to text-bg-*, and replaces the
checkmark-flash copy feedback (broken by FontAwesome's <i>-to-<svg>
replacement) with toast-based copyFieldValue.
2026-07-28 21:10:12 -04:00
wmantly 013c21d4f0 Release 1.8.2: fix OAuth-secret reveal modal race (#124) 2026-07-28 20:52:49 -04:00
wmantly 42a61f8868 Fix OAuth-secret reveal modal race in the resource modal (#123)
saveResource() called app.modal.close() then, after an intervening
await loadResources(), conditionally app.modal.open() to show a newly
created OAuth client's secret. app.modal is a singleton -- close()
immediately followed by open() in the same tick collides with Bootstrap's
hide-transition guard (show() silently no-ops while _isTransitioning is
still true from the just-started hide()). The await made this race
unlikely to lose in practice, but not guaranteed to -- found while fixing
the same bug (with no such await, so guaranteed to lose) in jump-host and
proxy's API-token create flows.

Now the resource-edit modal is only closed when we're NOT about to
immediately show the OAuth secret; app.modal.open() alone already
overwrites the (already-visible) modal's content in place, no close()
needed first.
2026-07-28 20:49:06 -04:00
wmantly b54da5c64c Fix resource modal's LDAP-groups autocomplete going empty after first open (#122)
loadLdapGroups()'s cache guard (if (ldapGroupsCache) return;) also skipped
the DOM-repopulation step on every call after the first, but
#ldap-groups-datalist is rebuilt fresh and empty on every app.modal.open()
-- so the "Associated LDAP Groups" tab's group-name autocomplete silently
lost all its suggestions starting on the second Add/Edit. Now the fetch
stays cached, but the datalist is always repopulated.

Verified live: opened the resource modal on Proxy twice in a row, confirmed
the datalist has all 17 options both times (would have been 0 on the
second open with the old code).
2026-07-28 18:35:15 -04:00
wmantly 782ef69fb8 Release 1.8.0: resource modal standardization, site-slug group prefixing (#121) 2026-07-28 17:45:08 -04:00
wmantly 0e955abc73 Standardize the resource modal: tabs, footer, linkable URL, Children tab, site-slug group prefixing (#120)
* Add Resource audit fields (created/updated by/on) and site-slug group prefixing

Resource had no created_by/created_on/updated_by/updated_on fields at all,
unlike proxy's Host and jump-host's ApiToken which already track this --
needed for the upcoming resource-modal footer. @simpleworkjs/orm has no
auto-timestamp hook, so these are set explicitly in the directory-admin
route handlers on every create/update.

Also: when a host/service resource is created, its two auto-created LDAP
groups (<slug>_access/_admin) now get prefixed with the nearest ancestor
site's slug (via a new Resource.findAncestorSiteSlug walk), so groups from
different sites don't collide/look identical. Falls back to today's
unprefixed naming when a resource has no site ancestor.

Included the checked-in dev inventory.sqlite's ALTER TABLE for the new
columns, since @simpleworkjs/orm's sync() only creates missing tables, never
alters existing ones -- the raw model change alone would have broken every
Resource read/write against this file with "no such column: created_by".

* Migrate Resource modal onto app.modal's tabs/footer/URL, add Children tab

The Directory's resource modal was a separate, hand-rolled, always-in-DOM
Bootstrap modal, independent of the shared app.modal singleton -- migrating
it onto app.modal (now published with tabs/footer/url support in
@simpleworkjs/frontend 0.2.6) is the pilot for standardizing entity modals
across the stack.

- General/Details/Associated LDAP Groups/Children tabs, replacing the old
  single long form (Details keeps every kind-conditional container
  unchanged; toggleFormFields() didn't need to change at all).
- Footer shows created/updated by/on (via the new Resource audit fields)
  and the Save button; Groups/Children tabs are hidden in add-mode since
  they need an existing resource id.
- New Children tab lists a resource's existing children (reusing the
  already-loaded edges/resourcesById data, no new endpoint) and an "Add
  Child Resource" button that reuses openAddModal's existing preset-parent
  support. Folded the pre-existing generic "Relationships (Graph Edges)"
  section in underneath, under an "advanced" subheading, rather than
  dropping it or giving it a 5th tab of its own.
- GET /directory/:slug (mirroring the existing /users/:uid precedent) plus
  a client-side app.modal.deepLinkSlug() check makes a resource's modal
  linkable and directly loadable.
- Converted the groups/edges lists from jq-repeat to plain manual DOM
  rendering: jq-repeat's MutationObserver-based scope (re)registration for
  an element that's destroyed and recreated on every modal open runs
  asynchronously, so populating synchronously right after open() (as
  refreshGroupsUI/refreshEdgesUI must) raced it -- on the second and later
  opens, the old scope's destroy() ran after the new data was pushed onto
  it, silently discarding it. Manual rendering (matching the new Children
  tab) sidesteps the race entirely.
- The #res-name/#res-kind auto-slug handler is now bound via
  app.modal.on() (delegated) instead of directly -- a direct bind would
  have silently stopped firing after the first Add/Edit, since the modal
  body is rebuilt from scratch on every open().

Verified live against the running dev stack: tabs/footer/groups/children
all render and populate correctly (including on a second open, confirming
the jq-repeat race fix), the address bar updates to /directory/{slug} and
reverts on close, browser Back closes the modal via popstate without a
page reload, and a resource created under a Site gets correctly
site-slug-prefixed LDAP groups.
2026-07-28 17:42:05 -04:00
wmantly 69883836e1 Merge pull request #119 from theta42/release/1.7.0
Release 1.7.0
2026-07-28 13:35:05 -04:00
wmantly 17df21041a Release 1.7.0: fresh-install fixes (loading message, missing messages, login context, directory UX)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:30:31 -04:00
wmantly c19fffe3c9 Merge pull request #118 from theta42/feat/directory-tree-only-and-click-detail
Directory: tree view is now the only view; click a name for detail
2026-07-28 13:29:45 -04:00
wmantly b6abfe8f03 Directory: tree view is now the only view; click a name for detail
- Removed the list/tree view toggle -- tree (with indentation/parent
  arrows) is always used. Simplifies renderTable() back down to one
  code path instead of branching on a view mode nobody was toggling
  away from in practice.
- Clicking a resource's name now opens the same modal the pencil/edit
  button does, rather than requiring the small icon click. The edit
  modal already surfaces full detail (parent, addresses, OAuth config,
  groups, edges) for every resource kind, so this reuses it rather than
  building a second, read-only view that would drift from the real one.

Verified live: tree view renders correctly with no toggle present, and
clicking a name (tested on the theta-proxy OAuth resource) opens the
detail modal with the correct parent already selected -- also
confirming the earlier "OAuth client has no parent" fix end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:27:12 -04:00
wmantly 420ccfab3b Merge pull request #117 from theta42/feat/login-redirect-context
Explain why the user landed on the login page
2026-07-28 13:12:31 -04:00
wmantly 8ed4505dc0 Explain why the user landed on the login page
Landing here with ?redirect= and no explanation is exactly what happens
when another app's "Log in with SSO" sends an unauthenticated user
through /oauth/authorize, which bounces them here. Shows a contextual
banner: a specific message when the redirect target is an OAuth
authorize URL, a generic "you'll be sent back" message otherwise.

Verified live for both cases (OAuth-authorize redirect and a plain
redirect) against a local stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:09:53 -04:00
wmantly 451054f0c2 Merge pull request #116 from theta42/fix/loading-message-and-missing-messages
Fix HTML-escaped loading indicator and missing success messages
2026-07-28 13:07:43 -04:00
wmantly 3a46680c8b Fix HTML-escaped loading indicator and missing success messages
Two regressions surfaced by a fresh production install:

- formAJAX's "loading" indicator passed a raw <div class="spinner-border">
  string to app.messages.action, which HTML-escapes its message by design
  (@simpleworkjs/frontend) -- so every form submit briefly showed the
  literal markup as text instead of a spinner. Replaced with plain text
  ("Saving…"), which needs no escaping workaround.

- POST /api/user/ (create) and PUT /api/user/password didn't include a
  `message` field, so the success toast/banner rendered with an empty
  body -- a green notification with nothing in it right after adding a
  user. Added messages matching the convention already used by every
  other route in this file (activate/deactivate, group membership, etc).

Verified live: created a user through the actual modal, confirmed the
POST response now carries a message, and confirmed app.messages.toast
renders plain text cleanly with no escaping artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:52:09 -04:00
wmantly 1b0418e42e Merge pull request #115 from theta42/release/1.6.3
Release 1.6.3
2026-07-28 01:05:12 -04:00
wmantly 4e3aa082d3 Release 1.6.3: fix group-membership cache invalidation, add service-account guardrail
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 01:02:21 -04:00
wmantly 6cb8b259e2 Merge pull request #114 from theta42/ux/service-account-add-confirm
Warn before adding a member to app_sso_service_account
2026-07-28 01:01:45 -04:00
wmantly 2532c492f1 Warn before adding a member to app_sso_service_account
app_sso_service_account is a marker group: membership hides an account
from the Users page's People tab entirely (users.ejs filters it out),
which is exactly right for a non-person account but has no guardrail
against adding a real person by mistake -- which just happened in
production (see #113) and looked exactly like the account had vanished.

Adding a member to any other group via this dropdown is unchanged
(fires immediately, no confirmation); only app_sso_service_account now
asks first, via app.messages.confirm.

Verified live against a local stack: confirmation shows the right
warning, Cancel leaves the group untouched, Confirm adds the member
normally, and every other group's add-member flow is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:59:27 -04:00
wmantly fdc045e166 Merge pull request #113 from theta42/fix/group-cache-invalidation
Fix: group membership changes didn't invalidate the User cache
2026-07-28 00:46:46 -04:00
wmantly 0c2f38f0fe Fix: group membership changes didn't invalidate the User cache
routes/group.js's add/removeMember never called User.clearCache(), unlike
the isServiceAccount handling in routes/user.js (which does this
deliberately, with a comment explaining exactly why). isServiceAccount is
derived at User.get() time from app_sso_service_account membership and
cached for 5 minutes -- so adding or removing a user from ANY group via
this route left group-derived state (isServiceAccount, and by extension
anything else that reads memberOf off a cached User) stale for up to 5
minutes.

In production this manifested as a real user's account appearing to
"vanish": users.ejs's People tab filters out anything with
isServiceAccount truthy, so once that user's membership in
app_sso_service_account changed, they'd disappear from the tab anyone
actually looks at for up to 5 minutes -- looking exactly like data loss,
though the account was never touched. Found by investigating a live "lost
users" report: the account had isServiceAccount: 'yes' and was in fact
still fully present, just hidden.

This does not explain how the account came to be a member of
app_sso_service_account in the first place (unresolved -- possibly a
manual/accidental group-membership change via the Groups UI, which has no
guardrail against adding a real person to what's meant to be a marker
group for non-person accounts). It does fix a real correctness gap: any
admin group-membership change now takes effect immediately instead of on
a timer.

Verified against a real LDAP+Redis harness: the new test fails on the
unfixed code (stale isServiceAccount immediately after the PUT) and
passes with the fix. Full suite: 189/191 passing (2 pre-existing skips).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:44:11 -04:00
wmantly fcba782ac7 Merge pull request #112 from theta42/release/1.6.2
Release 1.6.2
2026-07-28 00:20:32 -04:00
wmantly 6162c6d8a1 Release 1.6.2: fix OAuth client DELETE, add regression tests
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:14:44 -04:00
wmantly 3be8c7fde2 Merge pull request #111 from theta42/fix/oauth-client-delete
Fix DELETE /api/oauth/client/🆔 client.remove is not a function
2026-07-27 21:15:07 -04:00
wmantly 3852e9ba62 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on directory.ejs's "Rotate Client
Secret" -- it froze the tab). Every call site across the app was removed
in favor of app.messages.action/confirm/toast and app.modal.open; this
static check (scans views/ and public/js|lib/js for bare alert(/confirm(/
prompt() calls) keeps a regression from shipping unnoticed the way the
oauth_client.js DELETE bug just did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:10:49 -04:00
wmantly 7f2c71299f Fix DELETE /api/oauth/client/🆔 client.remove is not a function
OAuthClient wraps @simpleworkjs/orm's Resource model, whose instance
delete method is .delete() -- not .remove(), which is what model-redis's
Table instances (e.g. this app's ApiToken, AuthToken) use. The DELETE
route called the wrong one, so every delete silently 500'd; the route's
try/catch turned it into a plain JSON error response rather than a thrown
exception, and the existing tests' cleanup-only delete calls (afterAll,
end of the rotate test) never checked the response status, so the bug
shipped unnoticed. The Directory Management UI was never affected --
routes/api_directory_admin.js's DELETE routes already used .delete()
correctly throughout.

Found and root-caused live against a real deployment's SSO API, then
reproduced and fixed against a local docker stack with a rebuilt image:
confirmed DELETE returned a genuine 500 before the fix and a real 200 +
404-on-subsequent-GET after.

Adds two dedicated tests (PUT and DELETE persistence, each verified by a
follow-up GET rather than trusting the mutating response alone), and
hardens the existing rotate test's incidental delete call with real
assertions. Verified the new DELETE test fails on the old code and
passes on the fix. Full suite (189 tests, real LDAP + Redis) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:03:34 -04:00
wmantly 18119d54aa Merge pull request #110 from theta42/release/1.6.1
Release 1.6.1
2026-07-27 17:24:13 -04:00
wmantly 487e38f1a4 Release 1.6.1: remove native alert()/confirm() calls
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 17:21:37 -04:00
wmantly 2e011dd383 Merge pull request #109 from theta42/fix/no-native-dialogs
Remove all native alert()/confirm() calls
2026-07-27 16:52:39 -04:00
wmantly 3c12ebba16 Remove all native alert()/confirm() calls
Native confirm() dialogs block browser automation entirely (discovered
via a frozen tab while browser-testing the app.messages/app.modal
adoption), and native alert()/confirm() are visually inconsistent with
the rest of the UI. Replaced every call site with
app.messages.action/confirm/toast:

- directory.ejs: rotateSecret/deleteResource confirms and all inline
  save/add/remove-group/edge error alerts now target #resourceModal's
  actionMessage (or, for deleteResource — called from the outer table
  row, not the modal — the page's own card).
- impersonate_modal.ejs, onboarding.ejs: no local .actionMessage target
  exists on these pages, so their alerts became page-wide toasts.
- executive.ejs: two alerts in sendNotification's validation now use the
  existing $compose target; saveTos's alert now reuses the function's
  own msgEl inline-message element instead of introducing a second
  mechanism.
- users.ejs, profile.ejs, proxy's profile.ejs: toggleActive's alert
  (no row context available at the call site) became a toast;
  revokeInvite/revokeToken/rotateToken use the row/card element already
  in scope.
- app.js: removed app.user.remove and app.oauthClient.remove, which
  contained native confirm() guards and had zero callers anywhere in the
  app — dead code, deleted rather than converted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:50:07 -04:00
wmantly ffb2e99199 Merge pull request #108 from theta42/release/1.6.0
Release 1.6.0
2026-07-27 14:18:21 -04:00
wmantly 9d5f106863 Release 1.6.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:14:32 -04:00
wmantly 7f00d4c845 Merge pull request #107 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:05:36 -04:00
wmantly 1d1d29d287 Adopt @simpleworkjs/frontend's messages/modal/validate modules
Replaces the vendored app.util.actionMessage/actionConfirm/alert (the
latter added ad hoc to fix "app.util.alert is not a function") with the
published @simpleworkjs/frontend package: app.messages.action/confirm,
app.modal.open, and app.validate.js (which also replaces the identical
vendored val.js). Gains real HTML-escaping on message content and a toast
fallback when there's no inline .actionMessage target, neither of which
the vendored code had.

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched —
they're app-specific (dual-mode callback/promise API, auth-token header
injection) and not something the generic frontend package's app.js
provides, so it isn't loaded here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 13:35:51 -04:00
wmantly 5665504bc1 Merge pull request #106 from theta42/fix/sshpublickey-oauth-parent
Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
2026-07-26 23:09:57 -04:00
wmantly 2ac1c30112 Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
- User.update/addSSHkey now ensure the ldapPublicKey objectClass is present
  before writing sshPublicKey, so accounts predating that objectClass
  (e.g. the bootstrap admin) no longer 500 on PUT /api/user/:uid.
- populateHostDropdown in directory.ejs was missing an `oauth` branch,
  leaving the parent-Service picker blank when adding an OAuth Integration.
- Bump to 1.5.1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:38:48 -04:00
wmantly 04c18eaf30 Merge pull request #105 from theta42/docs/screenshots-refresh
docs: refresh screenshots for the unified UI
2026-07-26 16:31:46 -04:00
wmantly 6835074b8b docs: refresh screenshots for the unified UI, add directory.png
Screenshots were still showing the pre-unification nav (Dashboard/Sites/
Integrations); replace with the current Users/Groups/Directory/Executive
shell and add a directory.png for the new consolidated inventory page.
Fix a couple of stale "Integrations page" / "Sites" references in the
concept docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:26:11 -04:00
wmantly 59ae30897b Merge pull request #104 from theta42/feature/ui-unification
Release 1.5.0: unified front-end UI shell
2026-07-26 00:30:05 -04:00
wmantly 94a7e07410 Release 1.5.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly a5de279bb4 logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:07 -04:00
wmantly d8b6f6e7a3 app.api.delete: accept the (url, data, callback) form formAJAX uses
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (proxy's host/DNS rows) landed its callback in the
data slot and never ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 208762f0d1 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

sso-manager-node specifics:
- val.js adopts the shared superset (adds the target/hostname rules and
  the password policy, and fixes the let-shadowed `message` that stopped
  custom rule messages from reaching validateMessage).
- GET /api/user/me now also reports isAdmin (membership in app_sso_admin).
- public/js/app.js: $.isFunction -> typeof (removed in jQuery 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly b076498219 Merge pull request #103 from theta42/release/v1.4.0
Release 1.4.0
2026-07-25 16:41:49 -04:00
wmantly fc0d9104d0 Release 1.4.0: shared @simpleworkjs/* packages; fix discovery envelope drift + client_secret_hash leak
Rewire onto @simpleworkjs/directory-schema, /ldap, and /app-stack. The
directory discovery API now returns the {results} envelope via explicit
/resources, /resources/:slug, /graph, /me handlers and routes every read
through projectResource/projectResources, which unconditionally strips
client_secret_hash (and any /secret|password|privatekey/i key) and reduces
metadata to a public allowlist for non-admins — closing the leak where the ORM
serialized metadata wholesale. The dead routes/api_discovery.js (mounted after
the 404 catcher) is removed; ?group= now returns 200 instead of 404. user_ldap
+ group_ldap take escapeFilter/escapeDN + makeClient/withClient from the shared
ldap package (posix/write-side stays app-local; cert validation unchanged).
build_info unified to {buildVersion,buildHash,buildYear}; ldapts ^8.1.8. New
tests/discovery.test.js locks in the envelope + no-secrets guarantees. Lockfile
regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 16:39:10 -04:00
wmantly 82da47cef7 Merge pull request #102 from theta42/docs/jump-host-xref
docs: cross-link the SSH jump host as a directory consumer
2026-07-23 16:23:54 -04:00
wmantly 39779f51dc docs: cross-link the SSH jump host as a directory consumer
- directory.md: new "Consumers of the directory" section explaining how
  the jump host reads the inventory (groups x host resources) to route
  SSH, and pointing at directory_spec.md §9 for planned consumers
- index.md: mention the jump host under Directory & Inventory and in
  Related projects

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:18:31 -04:00
wmantly d9a3cb6044 Merge pull request #101 from theta42/release/v1.3.2
Release 1.3.2 — fix OAuth client API client_id serialization (bootstrap-breaking)
2026-07-23 16:09:37 -04:00
wmantly 0ee6825a01 fix: OAuth client API returned client_id: undefined; unknown id 500'd
The ORM Model.toJSON() serializes only 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[/:id]
responses. client_id came back undefined; the theta-env bootstrap then
POSTed /api/oauth/client/undefined/rotate and got a 500, aborting stack
bring-up whenever proxy-secrets.js lacked a usable secret.

- OAuthClient.get() now emits an explicit public toJSON (client_id, name,
  slug, scopes, redirect_uris, allowed_groups, token_lifetime, is_valid),
  deliberately omitting client_secret_hash so it can't leak over the API.
- OAuthClient.get() null-guards Resource.get() (which returns null, not
  throws) and returns a clean 404 for an unknown/undefined id instead of
  crashing on r.kind.
- Regression tests: list/get expose client_id + hide the secret hash, the
  list-then-rotate bootstrap path, and unknown-id -> 4xx not 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:06:55 -04:00
wmantly 14b6ed5ae0 Merge pull request #97 from theta42/spec/directory-consumers
Directory spec: readiness review of the five planned consumers
2026-07-23 03:37:00 -04:00
wmantly fd98854628 spec: readiness review of the five planned directory consumers
Records what each planned consumer of the directory data needs — end-user
catalog + access requests, SSH jump host, firewall rule generation/drift
testing, local DNS/mDNS, and host access control — what the current
model/API already covers, and the concrete gaps.

Verdict: the graph model is sufficient for all five. Gaps are one new
model (AccessRequest), service-token auth for the read API, documented
metadata conventions (portMappings, sshPort, dnsNames, icon, ...), and
cheap change detection (updated_on/etag). Also flags a real issue found
while auditing: /api/discovery/resources exposes full metadata —
including OAuth client_secret_hash — to any authenticated user; a
metadata privacy projection tops the work list.

Also updates the spec's stale status line (it still said "no code yet").

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:49 -04:00
wmantly 12a99b550c Bump @simpleworkjs/orm to 0.2.8 to fix redis bugs 2026-07-22 23:24:41 -04:00
wmantly 4ce5a5f492 Monkey-patch RedisAdapter 2026-07-22 22:41:57 -04:00
wmantly c84141b2f3 Restore package-lock.json 2026-07-22 22:37:51 -04:00
wmantly f76d93d840 Fix User.login credentials check and flush Redis before tests 2026-07-22 22:37:45 -04:00
wmantly e910a492ba Fix Token API compatibility 2026-07-22 22:30:26 -04:00
wmantly 1ef868e23c Fix ORM API usages in Token subclasses 2026-07-22 22:26:26 -04:00
wmantly e11e39c23a fix: initialize ORM in tests 2026-07-22 22:17:23 -04:00
wmantly b91089ad4c fix: use published @simpleworkjs/orm instead of local path 2026-07-22 22:11:11 -04:00
wmantly 95ae50a924 fix: generate package-lock.json with node 20 for CI 2026-07-22 22:06:31 -04:00
wmantly 0076784fae fix: sync package-lock.json version 2026-07-22 22:03:53 -04:00
wmantly c4d7a1a8e9 feat: actionable metrics, LDAP log parsing, UI updates 2026-07-22 21:58:06 -04:00
86 changed files with 6927 additions and 3030 deletions
+3 -3
View File
@@ -20,9 +20,9 @@
!directory_spec.md !directory_spec.md
!docs/**/*.md !docs/**/*.md
# Tests # Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
nodejs/tests/ # nodejs/tests/
nodejs/*.test.js # nodejs/*.test.js
# Host dependency tree — let the image run a clean `npm ci`. Also avoids # Host dependency tree — let the image run a clean `npm ci`. Also avoids
# copying platform-wrong native modules (e.g. bcrypt built for the host OS). # copying platform-wrong native modules (e.g. bcrypt built for the host OS).
+175 -1
View File
@@ -4,7 +4,181 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [1.9.0] - 2026-07-28
### Added
- **Directory modal's Associated LDAP Groups tab now supports full membership management**: view, add, and remove members/owners of each associated group directly from the tab, reusing the same `PUT`/`DELETE group/:group/:uid` routes and member-mapping pattern already used on the Groups page.
- **`app.util.revealItem()`** (in the shared `app-base.js`, byte-identical across the 3 apps): scrolls a just-added/-edited element into view and flashes its background. Wired into the Directory table, the Groups tab's member list, and the Groups page's create-group flow.
### Changed
- **Groups page's search/sort bar is now sticky**, staying visible while scrolling through a long group list. Introduces `--sw-content-offset` (set in `top.ejs` alongside `#spa-shell`'s margin-top) so an in-page sticky element can offset itself below the fixed navbar/update-banner instead of being hidden behind them.
- **Directory table**: Kind/Name/Env/Host merged into a single "Resource" column.
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
## [1.8.3] - 2026-07-28
### Changed
- **`profile.ejs`'s self-service API-token UI unified onto `app.modal`**, matching the pattern already shipped this round in `directory.ejs`, proxy, and jump-host: the static `#secretModal`/`#editModal` elements are retired in favor of the shared `app.modal` singleton, the always-visible inline create-form card becomes a "+ New Token" button + modal, and badge classes switch from `bg-*` to `text-bg-*`.
- Checkmark-flash copy feedback (silently broken by FontAwesome's `<i>``<svg>` replacement) replaced with toast-based `copyFieldValue`, matching proxy and jump-host.
## [1.8.2] - 2026-07-28
### Fixed
- **Creating a new OAuth integration didn't reliably show the "save this client secret now" reveal modal** — `saveResource()` called `app.modal.close()` immediately before conditionally showing the secret via `app.modal.open()`. `app.modal` is a singleton, and `close()` immediately followed by `open()` collides with Bootstrap's hide-transition guard. An intervening `await loadResources()` made this race unlikely to lose in practice, but not guaranteed to — found while fixing the same, guaranteed-to-lose bug in jump-host and proxy's API-token create flows.
## [1.8.1] - 2026-07-28
### Fixed
- **The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — `loadLdapGroups()`'s fetch-once cache guard (`if (ldapGroupsCache) return;`) also skipped repopulating the `<datalist>` on every call after the first, but the modal body (including that `<datalist>`) is rebuilt fresh and empty on every `app.modal.open()`. Now the fetch is still cached, but the datalist is always repopulated.
## [1.8.0] - 2026-07-28
### Added
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
### Changed
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
### Fixed
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
### Operational note
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
## [1.7.0] - 2026-07-28
### Fixed
- **`formAJAX`'s loading indicator showed literal HTML** ("&lt;div class=..."), not a spinner — it passed raw markup to `app.messages.action`, which HTML-escapes its message by design. Replaced with plain text.
- **`POST /api/user/` (create) and `PUT /api/user/password` had no `message` field** in their response, so the success notification rendered empty. Added messages matching every other route's convention.
- **The user landing on `/login` with a `?redirect=` had no explanation why** — happens whenever another app's "Log in with SSO" bounces an unauthenticated user through `/oauth/authorize`. Now shows a contextual banner explaining what's happening.
### Changed
- **Directory: tree view is now the only view** (the list/tree toggle is gone) — simpler, one code path.
- **Directory: clicking a resource's name opens its detail modal**, not just the pencil/edit icon.
Found via a fresh production install's feedback — see the [theta-env v1.13.0 release](https://github.com/theta42/theta-env/releases) for the full cross-repo summary.
## [1.6.3] - 2026-07-28
### Fixed
- **Group membership changes (`PUT`/`DELETE /api/group/:group/:uid`) didn't invalidate the User cache**, so `isServiceAccount` (and anything else derived from `memberOf`) could stay stale for up to 5 minutes after a change. This is what caused a real "lost user" report — the account had landed in `app_sso_service_account` (which `users.ejs`'s People tab filters out entirely) and looked exactly like data loss, though nothing was ever deleted.
### Added
- **A confirmation before adding anyone to `app_sso_service_account`** via the Groups page — that group's whole purpose is to hide an account from the People tab, and there was no guardrail against doing that to a real person by mistake (which is how the bug above happened). Every other group's add-member flow is unchanged.
## [1.6.2] - 2026-07-28
### Fixed
- **`DELETE /api/oauth/client/:id` 500'd** (`client.remove is not a function`) — `OAuthClient` wraps `@simpleworkjs/orm`'s `Resource` model, whose instance delete method is `.delete()`, not `.remove()`. The Directory Management UI was unaffected (its own delete routes already used `.delete()` correctly); only this legacy/raw API endpoint was broken. Found live against a real deployment's SSO API.
### Added
- **Regression tests**: PUT/DELETE on `/api/oauth/client/:id` now verify persistence with a follow-up GET rather than trusting the mutating response alone (this is what would have caught the bug above). A static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.6.1.
## [1.6.1] - 2026-07-27
### Fixed
- **Removed every native `alert()`/`confirm()` call**, replacing them with `app.messages.action`/`confirm`/`toast`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of the 1.6.0 `app.messages`/`app.modal` adoption, on `directory.ejs`'s "Rotate Client Secret" — it froze the whole tab). Also deleted `app.user.remove`/`app.oauthClient.remove` in `public/js/app.js`, which had native `confirm()` guards and zero callers anywhere in the app.
## [1.6.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm`/`alert` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped (the vendored `alert()` this replaces had no escaping), and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched — they're app-specific (dual-mode callback/promise API, `auth-token` header injection) and not something the frontend package's generic `app.js` provides.
## [1.5.1] - 2026-07-27
### Fixed
- **`PUT /api/user/:uid` 500'd with `ObjectClassViolationError` (LDAP `0x41`) when setting `sshPublicKey`** on any account created before the `ldapPublicKey` auxiliary objectClass was added to new-user creation (e.g. the bootstrap `admin` account). `User.update`'s `sshPublicKey` handling and `User.addSSHkey` (`nodejs/models/user_ldap.js`) now add the `ldapPublicKey` objectClass first (ignoring `TypeOrValueExistsError` if already present), the same pattern already used for `dateOfBirth`/`theta42Person`.
- **OAuth Integration parent dropdown was blank.** `populateHostDropdown` in `nodejs/views/directory.ejs` only built options for `kind === 'host'` and `kind === 'service'` — there was no branch for `kind === 'oauth'`, so choosing "OAuth Integration" in the Directory's add-resource modal left the parent-Service picker empty except the placeholder. Added the missing branch.
## [1.5.0] - 2026-07-26
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Fixed (sso-manager-node)
- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.
- `public/js/app.js` used `$.isFunction`, removed in jQuery 4.
### Added (sso-manager-node)
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.4.0] - 2026-07-25
### Security
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
### Fixed
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404 (the autoRouter's `search` supported `?group=`, but the route was effectively unreachable for jump-host's call pattern).
### Added
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org:
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
- `@simpleworkjs/ldap``models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
## [1.3.2] - 2026-07-23
### Fixed
- **OAuth client management API returned `client_id: undefined` on every GET.** The ORM's `Model.toJSON()` only serializes schema fields, so the mapped `client_id`/`scopes`/`redirect_uris`/… that `OAuthClient.get()` attaches to the wrapped Resource were stripped from `GET /api/oauth/client` and `GET /api/oauth/client/:id` responses. The theta-env bootstrap (which lists clients and rotates by the returned `client_id`) then called `/api/oauth/client/undefined/rotate` and got a 500, aborting stack bring-up when `proxy-secrets.js` had no usable secret. `OAuthClient.get()` now emits an explicit public JSON shape (and deliberately omits `client_secret_hash`, so the secret hash no longer leaks over the API).
- `OAuthClient.get()` no longer 500s on an unknown/`undefined` client id: `Resource.get()` returns `null` (it doesn't throw), which was dereferenced as `r.kind`. It now returns a clean 404.
## [1.3.1] - 2026-07-23
### Added
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
### Changed
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
## [1.3.0] - 2026-07-23
### Added
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
### Fixed
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
### Changed
- Removed the Mobile Phone field from the user create/edit form.
## [1.2.1] - 2026-07-22
### Added
- **Actionable Metrics**: New real-time metrics tracking for failed logins, top IPs, and service usage per user.
- **LDAP Monitor**: Background service to parse OpenLDAP binds over port 389 and track metrics for legacy apps.
- **UI Updates**: Executive dashboard now displays actionable metrics cards instead of raw logs. User profiles show individual service usage stats to admins.
- **Directory Management**: Integrated site/host/service abstractions into directory UI and allowed associating OAuth apps directly to services.
## [1.1.18] - 2026-07-21 ## [1.1.18] - 2026-07-21
+3 -3
View File
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
- Health check: `http://localhost:3001/health``{"status":"ok"}` - Health check: `http://localhost:3001/health``{"status":"ok"}`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` - OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host) - LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS) - LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
### API tokens (personal access tokens) ### API tokens (personal access tokens)
@@ -483,8 +483,8 @@ netstat -tlnp | grep 389
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The 2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain + bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
StartTLS); port 389 is not mapped to the host by default so LAN clients can't StartTLS); port 389 is not mapped to the host by default so LAN clients can't
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
use `ldaps://…:636` or StartTLS. `theta42/proxy`) should use `ldaps://…:636` or StartTLS.
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't 3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
set `JWT_SECRET`, issued tokens invalidate on container recreation. set `JWT_SECRET`, issued tokens invalidate on container recreation.
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front 4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
+3 -1
View File
@@ -56,6 +56,7 @@ RUN apk add --no-cache \
openldap-overlay-memberof \ openldap-overlay-memberof \
openldap-overlay-refint \ openldap-overlay-refint \
openldap-overlay-syncprov \ openldap-overlay-syncprov \
openldap-overlay-auditlog \
openldap-passwd-sha2 \ openldap-passwd-sha2 \
dumb-init \ dumb-init \
bash \ bash \
@@ -130,7 +131,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy) # 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only # 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
# if you want LAN clients to bind without TLS (not recommended). # if you want LAN clients to bind without TLS (not recommended).
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS) # 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
EXPOSE 3001 389 636 EXPOSE 3001 389 636
# Health check # Health check
+50
View File
@@ -0,0 +1,50 @@
# Test-runner image for SSO Manager.
#
# Installs all dependencies (including dev) and bundles the app code plus
# the seed script. The entrypoint waits for LDAP + Redis, seeds the test
# user, then runs whatever command is given (default: npm test).
FROM node:20-alpine
# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script
RUN apk add --no-cache openldap-clients bash
WORKDIR /app
# Copy and install dependencies (including devDependencies for jest/supertest)
COPY nodejs/package*.json ./
RUN npm ci
# Copy the application source
COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY nodejs/tests ./tests
# SQLite database directory (config/inventory.sqlite for Resource model's ORM)
RUN mkdir -p /app/config
# Files expected at the flattened /app path (see Dockerfile.openldap notes)
COPY tos.md /tos.md
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Seed script and utility
COPY test_seed.js ./test_seed.js
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
RUN chmod +x /usr/local/bin/seed-test-user
# Default command: seed the test user, then run the test suite
CMD ["sh", "-c", "seed-test-user && npm test"]
+5 -4
View File
@@ -41,9 +41,10 @@ phone-home, no hosted control plane, and no per-user pricing.
- **Web management UI** — manage users, groups, and OAuth clients from a - **Web management UI** — manage users, groups, and OAuth clients from a
browser; invite and password-reset flows over email; user self-service for browser; invite and password-reset flows over email; user self-service for
profile and API tokens. profile and API tokens.
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and - **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
directory, so you don't maintain a second user database for them. and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
same directory, so you don't maintain a second user database for them.
- **Personal access tokens** — any user can mint a long-lived bearer token to - **Personal access tokens** — any user can mint a long-lived bearer token to
drive the management API from scripts or CI, scoped to their own permissions. drive the management API from scripts or CI, scoped to their own permissions.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run - **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
@@ -151,7 +152,7 @@ details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
┌────────────────────────┐ ┌────────────────────────┐
│ OpenLDAP (slapd) │ │ OpenLDAP (slapd) │
│ - users / groups │ │ - users / groups │
│ - LDAPS :636 │─── legacy apps bind directly │ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
│ - StartTLS :389 │ │ - StartTLS :389 │
└────────────────────────┘ └────────────────────────┘
``` ```
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
oidc: {
clientId: '',
clientSecret: '',
},
};
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Example secrets configuration file (file-based config).
//
// Bare-metal: install.sh seeds a filled-in version of this file at
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
// SMTP is left as a placeholder). Only write this one by hand if you're
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
// manually.
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
//
// Values here override conf/base.js and win over <environment>.js. `app_*` env
// vars (if any are set) override this file too — so the Docker stack passes NO
// app_* env, keeping this file authoritative.
//
// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth).
// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read
// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh)
// and ignored by the app — safe to leave them out for bare-metal use.
module.exports = {
port: 3001,
name: 'SSO Manager', // shown in UI and outbound email
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
ldap: {
url: 'ldap://localhost', // or ldaps://host:636 for TLS
bindDN: 'cn=admin,dc=example,dc=com',
bindPassword: 'ldap-admin-pass',
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
// direct LDAPS binds on /integrations. Leave empty to derive from the
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
// ldapsPort: 636,
},
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false, // true for 465, false for other ports
user: 'noreply@example.com',
pass: 'your-smtp-password',
from: 'SSO Manager <noreply@example.com>',
},
voipms: {
username: '', // VoIP.ms username (optional)
password: '', // VoIP.ms password (optional)
did: '', // VoIP.ms DID (optional)
},
oauth: {
issuer: 'https://sso.example.com', // falls back to the request host at runtime
jwtSecret: 'a-long-random-development-jwt-secret-value-1234567890',
token_lifetime: {
access_token: 3600, // 1 hour in seconds
refresh_token: 2592000 // 30 days in seconds
}
},
// ── Orchestrator-only keys (ignored by the app) ──────────────────────────
// Read by docker-entrypoint.sh (server-side slapd config + validation), the
// superproject bootstrap script, and setup.sh. Omit for bare-metal use.
stack: {
ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs).
// The base DN also appears in ldap.bindDN/userBase/groupBase above and
// in oauth.issuer — keep them consistent with this value
// (cn=admin,<dn>, ou=people,<dn>, ou=groups,<dn>, https://<ssoHost>).
ldapDomain: 'example.com', // default cert CN + OAuth issuer host
ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain
ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL)
proxyHost: 'proxy.example.com', // public proxy hostname
},
bootstrap: {
adminUid: 'admin', // initial SSO admin username
adminPass: 'AdminPass123!', // initial SSO admin password
adminEmail: 'admin@example.com', // initial SSO admin email
},
serviceAccountPass: 'proxy-service-pass', // LDAP password the proxy binds with
};
+166 -13
View File
@@ -1,8 +1,10 @@
# Home-Lab Directory / Inventory — Design Spec # Home-Lab Directory / Inventory — Design Spec
Status: **Draft / agreed direction** (no code yet) Status: **Implemented** (v1.2.1+: model, admin API, UI; v1.3.x: automatic
registration from theta-env + ldap-client). §9 adds the planned-consumer
readiness review.
Owner: wmantly Owner: wmantly
Last updated: 2026-07-02 Last updated: 2026-07-23
--- ---
@@ -95,13 +97,24 @@ common query fields can be promoted to columns later.
| column | type | notes | | column | type | notes |
|--------------|-------------|-------| |--------------|-------------|-------|
| `id` | uuid / pk | | | `id` | uuid / pk | |
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` | | `kind` | enum | `site` \| `host` \| `service` |
| `name` | text | display name ("Home Assistant", "ct101") | | `name` | text | display name ("Home Assistant", "ct101") |
| `slug` | text unique | url-safe id used by the API | | `slug` | text unique | url-safe id used by the API |
| `description`| text | free text | | `description`| text | free text |
| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` | | `metadata` | jsonb | `{ subType, ip, macAddress, address, vmid, port, externalPort, gitRepo, installPath, systemdService, os, kernel, isProduction, isExternalReachable, isPublic }` |
| `created_at` / `updated_at` | timestamptz | | | `created_at` / `updated_at` | timestamptz | |
**Parent Enforcement Rules:**
- A **Host** MUST have a parent **Site** or **Host**.
- A **Service** MUST have a parent **Host**.
- An **OAuth Integration** MUST have a parent **Service**.
**LDAP Group Auto-Creation:**
When a Host or Service is created, the system will automatically create two LDAP groups in the directory (if they do not already exist):
- `<slug>_access` (for standard user access)
- `<slug>_admin` (for administrative access)
Additional groups can still be linked manually.
### `resource_edge` — directed relationships (the graph) ### `resource_edge` — directed relationships (the graph)
| column | type | notes | | column | type | notes |
|--------------|--------|-------| |--------------|--------|-------|
@@ -109,7 +122,7 @@ common query fields can be promoted to columns later.
| `child_id` | fk → resource | | | `child_id` | fk → resource | |
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` | | `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
Represents host←container←service (`hosts`/`runs_on`) and service→service Represents site←host←service (`hosts`/`runs_on`) and service→service
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have (`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
multiple parents/children and multiple relation types. multiple parents/children and multiple relation types.
@@ -164,12 +177,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
- **Interactive users:** existing session auth — `middleware.auth` validating the - **Interactive users:** existing session auth — `middleware.auth` validating the
`auth-token` header (an `AuthToken`, `models/token.js`). No change. `auth-token` header (an `AuthToken`, `models/token.js`). No change.
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service - **CI/CD (machine) access:** scripts and external integrations (like jump hosts) will use the existing `ApiToken` system (Personal Access Tokens) passed in the `Authorization: Bearer sso_...` header. The `ApiToken` inherits the exact LDAP group permissions of the user who created it, seamlessly mapping to existing access controls.
token — `AuthToken` is session-oriented. **Proposed small addition:** a
`ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`),
long-lived, read-only, passed in the same `auth-token` header. Track as its own
task; the discovery API should assume it exists but degrade to normal auth tokens
until then.
- **Read visibility (decision to confirm):** either (a) any authenticated user may - **Read visibility (decision to confirm):** either (a) any authenticated user may
read all resource metadata and only `/me` is filtered, or (b) list endpoints are read all resource metadata and only `/me` is filtered, or (b) list endpoints are
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler, themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
@@ -195,7 +203,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
## 7. Roadmap ## 7. Roadmap
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models, 1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
`/api/discovery/*` endpoints, `ServiceToken` for CI/CD. `/api/discovery/*` endpoints, `ApiToken` for CI/CD.
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`. 2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui` 3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
widgets and the `oauth_clients.ejs` card+modal pattern); gated by widgets and the `oauth_clients.ejs` card+modal pattern); gated by
@@ -213,4 +221,149 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP `description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
for auth, SQL for inventory. for auth, SQL for inventory.
4. **Read-visibility policy:** confirm option (a) vs (b) in §5. 4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
5. **Service token scope:** read-only globally, or per-token resource/kind scoping? 5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
---
## 9. Planned consumers — data-model & API readiness
Five consumers the directory data should be able to power. None are being
built yet; this section records what each needs, what already exists, and the
gaps to close so the model/API never paints us into a corner.
The recurring theme: **the graph model itself (Resource / ResourceEdge /
ResourceGroup + LDAP groups) is sufficient for all five.** The gaps are
(a) one new model (access requests), (b) machine-to-machine auth for the read
API, (c) documented metadata conventions instead of new columns, and
(d) change detection for the drift/sync consumers.
### 9.1 End-user exploration ("Netflix-style" catalog + request access)
A user browses everything that exists — part advertisement, part
documentation — sees what they already have, and requests access to the rest.
Already there:
- `/api/discovery/me` (`getMyAccess`) — the "My Services" half.
- `Resource.owner` + `<slug>_access` / `<slug>_admin` ResourceGroup links —
who approves, and which group an approval means joining.
- The Notification model — the approval-request delivery mechanism.
Gaps:
1. **Catalog projection with metadata privacy.** `/api/discovery/resources`
returns full `metadata` to any authenticated user — including the OAuth
kind's `client_secret_hash`, and operator notes that may name internal
IPs. Needed: a per-kind public projection (name, description, kind,
subType, icon, address, hasAccess, requestable) and a private-key
convention for the rest (e.g. only `app_sso_directory_admin` sees full
metadata). This is a **fix worth doing before any catalog UI exists**.
2. **`AccessRequest` model** — the one genuinely new model:
`{id, uid, resourceId, groupCn, status: pending|approved|denied, note,
requestedOn, decidedBy, decidedOn}`. Approval = LDAP group add + notify.
Endpoints: user POST/GET own; resource owner / directory admin
list/approve/deny.
3. **Catalog metadata conventions**: `icon`, `tagline` (card-length blurb),
`requestable: false` for resources that shouldn't be advertised.
### 9.2 SSH jump host (`username_-_{hostname-or-ip}@publicHost`)
A public jump host parses the target out of the SSH username, checks the user
may reach that host, and proxies the connection (WinSCP-friendly: one
username string, no interactive menu needed — though an interactive picker on
plain `username@` login is the same query).
Already there:
- Hosts carry `ip` (and `host_<hostname>` slugs to resolve by name).
- Access is already group-based (`<slug>_access`), checkable via LDAP alone —
the jump host can run entirely off LDAP (SSSD) + one directory query.
- User SSH keys are in LDAP (openssh-lpk) — the jump host authenticates the
real user without local accounts.
Gaps:
1. **Machine auth for the access query.** The jump host must ask "may user X
reach host Y" / "list hosts user X may reach" *about another user*.
`getMyAccess` only answers for the calling user. Needed: a
service-token-authenticated endpoint (`GET
/api/discovery/access/:uid[/:slug]`). `ServiceToken` already exists and
is even linked to a resource (`resource_id`) — what's missing is an auth
middleware that accepts it and a permission rule ("service tokens may
read access info, scoped read-only").
2. **Connection metadata conventions** on hosts: `sshPort` (default 22),
optional `fqdn` (when IP is dynamic), optional `jumpVia` edge relation if
multi-hop topologies ever appear.
3. Document the username grammar (`{uid}_-_{host-slug-or-ip}`) here so the
seed/ldap-client keep host slugs DNS-safe (they already are: slugify
strips everything but `[a-z0-9-]`).
### 9.3 Firewall port-forward rules (build / update / drift-test)
An automation renders the public firewall's forwarding table from the
directory, applies it, and alerts on drift in either direction.
Already there:
- `metadata.port` / `metadata.externalPort` / `metadata.ip` /
`metadata.isExternalReachable` — the core mapping data, already seeded for
the stack's own services.
Gaps:
1. **Port-mapping convention is too thin for real rules**: no protocol, no
multi-port services. Adopt `metadata.portMappings: [{proto: "tcp"|"udp",
external: n, internal: n, comment}]` as the authoritative form
(`port`/`externalPort` stay as the simple single-mapping case).
2. **Drift detection needs cheap change polling**: an `updated_on` timestamp
on resources surfaced in the graph API, or a graph-level etag/hash, so
the runner can poll without diffing full payloads. (The ORM already
publishes create/update events internally — a future push feed can ride
that; polling comes first.)
3. Same **service-token read auth** as 9.2 — automation must not run on a
human's session token.
### 9.4 Local DNS / mDNS
A DNS (or mDNS advertiser) zone is generated from the directory: hosts get
A records from `metadata.ip`, services get CNAMEs/records from their
addresses, sites map to zones.
Already there:
- `host_<hostname>` + `ip` covers A records; `site_<name>` is a natural zone
boundary; service `address` yields names.
Gaps:
1. **Name conventions**: `metadata.dnsNames: []` for extra aliases, and a
documented rule for which name wins (slug vs `address` hostname). TTL
only if someone actually needs per-record TTLs — default is fine.
2. Same **change detection** as 9.3 (poll `updated_on` / etag; push later).
3. Nothing else — this consumer is nearly free once 9.3's conventions land.
### 9.5 Access control for hosts
Who may log in to / sudo on which machine, driven by the directory.
Already there — this is the original point of the system:
- `<slug>_access` / `<slug>_admin` groups are auto-provisioned per host;
ldap-client configures SSSD/PAM against the directory; `sudoRole` and
openssh-lpk schemas cover sudo and SSH keys.
Gaps:
1. **Close the loop in ldap-client**: joined hosts should set an SSSD access
filter (`access_provider = ldap`, filter on `host_<hostname>_access`
membership) so directory group membership *is* login permission, not just
identity. Today the registration exists but enforcement is host-side
convention.
2. **`accessLevel` granularity**: ResourceGroup's `member`/`owner` maps to
login/admin today; if finer roles emerge (e.g. `login` vs `sudo` vs
`admin`), extend the enum — the join-table shape already supports it.
### 9.6 Consolidated work list (model/API only, no consumers)
Ordered by how much they unblock:
1. **Metadata privacy projection** on the read API (blocks 9.1; fixes the
`client_secret_hash` exposure regardless of any consumer).
2. **Service-token auth for `/api/discovery/*`** + `access/:uid` endpoint
(blocks 9.2, 9.3; ServiceToken model already exists).
3. **`AccessRequest` model + endpoints** (blocks 9.1's request half).
4. **Metadata conventions doc entries** (`sshPort`, `portMappings`,
`dnsNames`, `icon`, `tagline`, `requestable`) in `docs/directory.md`
conventions, not schema changes; the json column already holds them.
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
freshness; trivial once surfaced).
+81
View File
@@ -0,0 +1,81 @@
# Docker Compose for running the SSO Manager test suite.
#
# Spins up:
# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app)
# redis — Standalone Redis for the app's model/token storage
# test-runner — Seeds the test user, then runs `npm test`
#
# Usage:
# docker compose -f docker-compose.test.yml up --build
# # Or to run a specific test file:
# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js
#
# The LDAP service uses the same Dockerfile.openldap image as production but
# overrides the command to only start slapd + redis (the entrypoint handles
# slapd.conf generation, directory initialization, and Redis startup before
# running the given command — "sleep infinity" keeps it alive).
#
# The test-runner connects to ldap:389 and redis:6379 via Docker networking.
# app_* env vars override conf/secrets.js (highest precedence in
# @simpleworkjs/conf), so the production secrets.js is never read.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
# The entrypoint starts slapd + redis, then runs whatever command is given.
# "sleep infinity" keeps the container alive so the test-runner can connect.
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
test-runner:
build:
context: .
dockerfile: Dockerfile.test-runner
environment:
# Tell the app which environment it's in (loads conf/test.js for Redis prefix)
- NODE_ENV=test
# LDAP — point at the ldap service container
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
# Redis — point at the redis service container
- app_redis__redisConf__url=redis://redis:6379
# Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf)
- REDIS_URL=redis://redis:6379
# JWT secret (required by the app, not sensitive in test)
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
# App name
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+6 -1
View File
@@ -139,6 +139,7 @@ moduleload pw-sha2
moduleload ppolicy moduleload ppolicy
moduleload memberof moduleload memberof
moduleload refint moduleload refint
moduleload auditlog
SYNCPROV_MODULE_PLACEHOLDER SYNCPROV_MODULE_PLACEHOLDER
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are # TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
@@ -187,6 +188,10 @@ memberof-memberof-ad memberOf
overlay refint overlay refint
refint_attributes memberOf member manager owner refint_attributes memberOf member manager owner
# auditlog overlay (LDIF audit trail of all changes)
overlay auditlog
auditlog /var/lib/ldap/auditlog.ldif
REPLICATION_BLOCK_PLACEHOLDER REPLICATION_BLOCK_PLACEHOLDER
# Access controls # Access controls
@@ -249,7 +254,7 @@ info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS). # -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on # ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
# Alpine and the container only uses simple bind over ldap://localhost:389. # Alpine and the container only uses simple bind over ldap://localhost:389.
slapd -d 0 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" & slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
SLAPD_PID=$! SLAPD_PID=$!
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state). # Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
+3
View File
@@ -31,6 +31,9 @@ nav:
- title: LDAP - title: LDAP
page: /ldap.html page: /ldap.html
icon: fa-address-book icon: fa-address-book
- title: Directory
page: /directory.html
icon: fa-server
- title: Changelog - title: Changelog
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
icon: fa-list icon: fa-list
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+107
View File
@@ -0,0 +1,107 @@
---
layout: default
title: Directory Management
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
---
# Directory Management
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
## Architecture
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
There are three primary **Kinds** of resources you can define:
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
## Automatic LDAP Group Creation
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
1. `<slug>_access` (Member level access)
2. `<slug>_admin` (Owner level access)
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
## Resource Metadata
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
### Common Metadata
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
- **IP Address**: The internal IP address of the resource.
- **MAC Address**: The hardware address of the primary interface.
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
- **Production Environment**: A boolean toggle indicating if the resource is in production.
### Host Metadata
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
### Service Metadata
- **Internal Port**: The local port the service binds to (e.g. `8080`).
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
## Automatic registration
You don't have to build the graph by hand — the theta42 tooling registers itself:
### The stack itself (theta-env)
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
## Consumers of the directory
The inventory graph isn't just documentation — other components read it to make decisions:
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 221 KiB

+9 -4
View File
@@ -22,10 +22,11 @@ one command).
## Screenshots ## Screenshots
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a> <a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a> <a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a> <a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a> <a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
*(click any screenshot to view full size)* *(click any screenshot to view full size)*
@@ -52,11 +53,13 @@ backend, that's the niche.
- **Web management UI** — users, groups, and OAuth clients from a browser; - **Web management UI** — users, groups, and OAuth clients from a browser;
invite and password-reset flows over email; self-service profile + API invite and password-reset flows over email; self-service profile + API
tokens. tokens.
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea, - **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
Emby, …) uses LDAPS/StartTLS against the same directory. via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
directory.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or - **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
run the pieces separately via `app_*` env config. run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites. - **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
## Get it ## Get it
@@ -76,5 +79,7 @@ That's the standalone quick start. For the full set of install options
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware - **[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.
+3 -2
View File
@@ -14,8 +14,9 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
SSO Manager runs an OpenLDAP directory holding your users and groups. The app SSO Manager runs an OpenLDAP directory holding your users and groups. The app
authenticates against it over `localhost:389` (inside the all-in-one container) authenticates against it over `localhost:389` (inside the all-in-one container)
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
directly — Gitea, Emby, the theta42/proxy, etc. directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
theta42/proxy, etc.
## Directory layout ## Directory layout
+10 -12
View File
@@ -53,20 +53,18 @@ An OAuth client represents an app that authenticates against the SSO. Each has:
### Managing clients ### Managing clients
Clients are managed from the web UI (as a member of the `app_sso_oauth_admin` Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header
from a login):
| Method | Path | Action | | Action | How to do it |
|--------|------|--------| |--------|--------------|
| `GET` | `/api/oauth/client` | list clients | | **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) | | **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
| `GET` | `/api/oauth/client/:id` | get one | | **Delete** | Click the trash can on the OAuth resource in the Directory list. |
| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups | | **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
| `DELETE` | `/api/oauth/client/:id` | delete |
| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret once) |
> All client-management endpoints are gated by the `app_sso_oauth_admin` group. > All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
## Scopes ## Scopes
+11 -3
View File
@@ -25,6 +25,7 @@ app.contoller = require('./controller');
// Background services (self-initializing on require). // Background services (self-initializing on require).
require('./services/update_check'); require('./services/update_check');
require('./services/ldap_monitor');
// Push pubsub over the socket and back. // Push pubsub over the socket and back.
app.onListen.push(function(){ app.onListen.push(function(){
@@ -60,6 +61,11 @@ app.set('trust proxy', 1);
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
// Set as an app local so every res.render has it, including routes that don't
// spread the routers' `values` object.
app.locals.ui = require('./utils/ui');
// Have express server static content( images, CSS, browser JS) from the public // Have express server static content( images, CSS, browser JS) from the public
// local folder. maxAge is short since this is the app's own JS/CSS, which // local folder. maxAge is short since this is the app's own JS/CSS, which
// changes on every deploy and isn't cache-busted/fingerprinted. // changes on every deploy and isn't cache-busted/fingerprinted.
@@ -83,16 +89,18 @@ app.use('/api/token', middleware.auth, require('./routes/token'));
app.use('/api/group', middleware.auth, require('./routes/group')); app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/notification', middleware.auth, require('./routes/notification')); app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
app.use('/api/update-check', middleware.auth, require('./routes/update_check')); app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
app.use('/api/tos', middleware.auth, require('./routes/tos')); app.use('/api/tos', middleware.auth, require('./routes/tos'));
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required. // Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token')); app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// OAuth 2.0 / OpenID Connect // OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter); app.use('/oauth', oauthRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.use('/api/oauth', middleware.auth, oauthApiRouter); app.use('/api/oauth', middleware.auth, oauthApiRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.get('/.well-known/openid-configuration', discovery); app.get('/.well-known/openid-configuration', discovery);
@@ -105,7 +113,7 @@ app.use(function(req, res, next) {
next(err); next(err);
}); });
// Error handler. This is where `next()` will go on error // Error handling
app.use(function(err, req, res, next) { app.use(function(err, req, res, next) {
const SILENT_404S = ['/.well-known/']; const SILENT_404S = ['/.well-known/'];
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p)); const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
+12 -6
View File
@@ -25,13 +25,19 @@ var server = http.createServer(app);
var io = require('socket.io')(server); var io = require('socket.io')(server);
app.io = io; app.io = io;
/** const models = require('../models');
* Listen on provided port, on all network interfaces.
*/
server.listen(port); /**
server.on('error', onError); * Initialize ORM, then Listen on provided port, on all network interfaces.
server.on('listening', onListening); */
models.initORM().then(() => {
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
}).catch(err => {
console.error('Failed to initialize ORM:', err);
process.exit(1);
});
/** /**
* Normalize a port into a number, string, or false. * Normalize a port into a number, string, or false.
Binary file not shown.
+17 -4
View File
@@ -9,10 +9,23 @@ async function auth(req, res, next){
// the same /api/* routes the UI uses. // the same /api/* routes the UI uses.
const authz = req.header('authorization') || ''; const authz = req.header('authorization') || '';
if(authz.slice(0, 7).toLowerCase() === 'bearer '){ if(authz.slice(0, 7).toLowerCase() === 'bearer '){
const user = await Auth.checkApiToken(authz.slice(7)); const tokenStr = authz.slice(7);
if(user && user.uid){ if (tokenStr.startsWith('sso_')) {
req.user = user; const user = await Auth.checkApiToken(tokenStr);
return next(); if(user && user.uid){
req.user = user;
return next();
}
} else {
// Machine token (ServiceToken)
const { ServiceToken } = require('../models/token');
let svcToken;
try { svcToken = await ServiceToken.get(tokenStr); } catch(e) {}
if (svcToken && svcToken.is_valid) {
req.user = { uid: svcToken.resource_id, isMachine: true, name: 'Machine Account' };
req.resourceId = svcToken.resource_id;
return next();
}
} }
} }
+7 -33
View File
@@ -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){
+33 -4
View File
@@ -1,14 +1,43 @@
'use strict'; 'use strict';
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const {setUpTable} = require('model-redis'); const { setUpTable } = require('model-redis');
// Keep model-redis for the ones not yet ported
const Table = setUpTable(conf.redis); const Table = setUpTable(conf.redis);
module.exports = Table; module.exports = Table;
require('./token'); const { Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken } = require('./token');
require('./verification'); require('./verification');
require('./oauth_client');
require('./oauth_code'); require('./oauth_code');
require('./api_token'); require('./api_token');
const { init } = require('@simpleworkjs/orm');
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
storage: './config/inventory.sqlite',
logging: false
};
ormConf.redis = conf.redis;
console.log('[initORM] Starting ORM initialization...');
try {
await init({
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
}
module.exports.initORM = initORM;
+117 -31
View File
@@ -1,51 +1,137 @@
'use strict'; 'use strict';
const Table = require('.'); const { Resource } = require('./resource');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const UUID = () => crypto.randomUUID();
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || { const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
access_token: 3600, access_token: 3600,
refresh_token: 2592000 refresh_token: 2592000
}; };
class OAuthClient extends Table { class OAuthClient {
static _key = 'client_id';
static _keyMap = {
'client_id': {default: UUID, type: 'string'},
'client_secret_hash': {isRequired: true, type: 'string', isPrivate: true},
'name': {isRequired: true, type: 'string', min: 1, max: 255},
'description': {default: '', type: 'string'},
'redirect_uris': {default: [], type: 'object'},
'scopes': {default: ['openid', 'profile', 'email', 'groups'], type: 'object'},
'allowed_groups': {default: [], type: 'object'},
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
'created_by': {isRequired: true, type: 'string'},
'created_on': {default: function(){ return (new Date).getTime() }},
'is_valid': {default: true, type: 'boolean'},
}
static async add(data) { static async add(data) {
const raw_secret = UUID(); const raw_secret = crypto.randomUUID();
data.client_secret_hash = await bcrypt.hash(raw_secret, 10); const client_id = crypto.randomUUID();
data.client_id = UUID(); const client_secret_hash = await bcrypt.hash(raw_secret, 10);
const client = await this.create(data);
client._raw_secret = raw_secret; // Generate a unique slug from the client name
return client; let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
// Ensure uniqueness by appending a suffix if needed
const existing = await Resource.list({ where: { slug } });
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
const r = await Resource.create({
id: client_id,
kind: 'oauth',
name: data.name,
slug: slug,
description: data.description || '',
owner: data.created_by,
metadata: {
client_secret_hash,
redirect_uris: data.redirect_uris || [],
scopes: data.scopes || ['openid', 'profile', 'email', 'groups'],
allowed_groups: data.allowed_groups || [],
token_lifetime: data.token_lifetime || { ...defaultLifetime }
}
});
r._raw_secret = raw_secret;
r.client_id = client_id;
return r;
}
static async get(client_id) {
const notFound = () => {
const e = new Error('OAuthClient not found');
e.status = 404;
return e;
};
let r;
try {
r = await Resource.get(client_id);
} catch (_) {
throw notFound();
}
// Resource.get() returns null (does not throw) for a missing id —
// guard it so a bad/undefined client_id is a clean 404, not a
// "Cannot read properties of null (reading 'kind')" 500.
if (!r || r.kind !== 'oauth') throw notFound();
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
r.client_id = r.id;
r.client_secret_hash = r.metadata.client_secret_hash;
r.redirect_uris = r.metadata.redirect_uris || [];
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
r.allowed_groups = r.metadata.allowed_groups || [];
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
// Resource has no is_valid column; validity lives in metadata (absent = valid)
r.is_valid = r.metadata.is_valid !== false;
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
r.rotateSecret = async () => {
const raw_secret = crypto.randomUUID();
r.metadata.client_secret_hash = await bcrypt.hash(raw_secret, 10);
await r.update({ metadata: r.metadata });
return raw_secret;
};
// The ORM Model.toJSON() only serializes schema fields, so the mapped
// properties above (client_id, scopes, redirect_uris, …) would be
// stripped from any res.json() — that's why GET /api/oauth/client
// returned client_id: undefined and the bootstrap's rotate blew up.
// Emit the public shape explicitly. client_secret_hash is deliberately
// omitted so it never leaks over the API.
r.toJSON = function () {
return {
client_id: r.id,
id: r.id,
kind: r.kind,
name: r.name,
slug: r.slug,
owner: r.owner,
description: r.description,
redirect_uris: r.redirect_uris,
scopes: r.scopes,
allowed_groups: r.allowed_groups,
token_lifetime: r.token_lifetime,
is_valid: r.is_valid,
};
};
// proxy update to handle metadata correctly
const originalUpdate = r.update.bind(r);
r.update = async (data) => {
if (data.redirect_uris !== undefined) r.metadata.redirect_uris = data.redirect_uris;
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
const updateData = { metadata: r.metadata };
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
return originalUpdate(updateData);
};
return r;
} }
async verifySecret(secret) { static async list() {
return bcrypt.compare(secret, this.client_secret_hash); const resources = await Resource.list({ where: { kind: 'oauth' } });
return Promise.all(resources.map(r => this.get(r.id)));
} }
async rotateSecret() { static async listDetail() {
const raw_secret = UUID(); return this.list();
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) }); }
return raw_secret;
static async verifySecret(client_id, secret) {
const client = await this.get(client_id);
return client.verifySecret(secret);
} }
} }
OAuthClient.register();
module.exports = { OAuthClient }; module.exports = { OAuthClient };
+209
View File
@@ -0,0 +1,209 @@
const { Model } = require('@simpleworkjs/orm');
const { Group } = require('./group_ldap');
class Resource extends Model {
static exposedMethods = [
{ method: 'search', route: 'resources', verb: 'get', args: { from: 'query' } },
{ method: 'getBySlug', route: 'resources/:slug', verb: 'get', args: { from: 'params', names: ['slug'] } },
{ method: 'getGraph', route: 'graph', verb: 'get' },
{ method: 'getMyAccess', route: 'me', verb: 'get', args: { from: 'user' } }
];
static async search(query) {
const graph = await this.getGraph();
let resources = graph.resources;
if (query.kind) {
resources = resources.filter(r => r.kind === query.kind);
}
if (query.group) {
const rgs = await ResourceGroup.list({ where: { groupCn: query.group } });
const allowedIds = new Set(rgs.map(rg => rg.resourceId));
resources = resources.filter(r => allowedIds.has(r.id));
}
if (query.parent) {
const parents = graph.resources.filter(r => r.slug === query.parent);
if (parents.length > 0) {
const parentId = parents[0].id;
const childIds = new Set(graph.edges.filter(e => e.parentId === parentId).map(e => e.childId));
resources = resources.filter(r => childIds.has(r.id));
} else {
resources = [];
}
}
return resources;
}
static async getBySlug(slug) {
const graph = await this.getGraph();
const resource = graph.resources.find(r => r.slug === slug);
if (!resource) {
let err = new Error('Resource not found');
err.status = 404;
throw err;
}
const parents = graph.edges.filter(e => e.childId === resource.id);
const children = graph.edges.filter(e => e.parentId === resource.id);
return {
...resource,
parents,
children
};
}
static async getGraph() {
const resources = await this.list();
const edges = await ResourceEdge.list();
// Convert to simple objects so we can mutate metadata properties safely
const resObjs = resources.map(r => {
const obj = r.toJSON ? r.toJSON() : { ...r };
obj.metadata = obj.metadata || {};
return obj;
});
// Bubble up production status: if any child is prod, parent is prod
const isProdCache = new Map();
function checkProd(resId, visited = new Set()) {
if (isProdCache.has(resId)) return isProdCache.get(resId);
if (visited.has(resId)) return false; // Cycle prevention
visited.add(resId);
const r = resObjs.find(x => x.id === resId);
if (!r) return false;
// If intrinsically prod, return true
if (r.metadata.isProduction) {
isProdCache.set(resId, true);
return true;
}
// Check children
const childrenIds = edges.filter(e => e.parentId === resId).map(e => e.childId);
for (const cid of childrenIds) {
if (checkProd(cid, visited)) {
isProdCache.set(resId, true);
return true;
}
}
isProdCache.set(resId, false);
return false;
}
resObjs.forEach(r => {
r.metadata.isProduction = checkProd(r.id);
});
return { resources: resObjs, edges };
}
static async getMyAccess(userDn) {
const userGroups = await Group.list(userDn);
if (!userGroups || userGroups.length === 0) return [];
const resourceGroups = await ResourceGroup.list({
where: { groupCn: { in: userGroups } }
});
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
if (resourceIds.length === 0) return [];
const resources = await this.list({ where: { id: { in: resourceIds } } });
// Resolve inherited addresses from the graph
const graph = await this.getGraph();
function resolveHost(resId, visited = new Set()) {
if (visited.has(resId)) return null; // prevent cycles
visited.add(resId);
const res = graph.resources.find(r => r.id === resId);
if (!res) return null;
if (res.metadata && res.metadata.address) return res.metadata.address;
if (res.metadata && res.metadata.ip) return res.metadata.ip;
const parentEdges = graph.edges.filter(e => e.childId === resId);
for (const edge of parentEdges) {
const found = resolveHost(edge.parentId, visited);
if (found) return found;
}
return null;
}
return resources.map(r => {
const data = { ...r };
data.metadata = data.metadata || {};
data.resolvedAddress = resolveHost(r.id);
return data;
});
}
static fields = {
id: { type: 'uuid', primaryKey: true },
kind: { type: 'string', isRequired: true },
name: { type: 'string', isRequired: true },
slug: { type: 'string', isRequired: true, unique: true },
owner: { type: 'string' },
description: { type: 'text' },
metadata: { type: 'json', default: {} },
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
// are set explicitly by the route handler on every create/update (see
// routes/api_directory_admin.js). Existing rows predating this change
// simply read back undefined -- callers must render a fallback.
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
};
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
// whose kind === 'site', returning its slug (or null if none exists -- a
// top-level resource with no site parent keeps its unprefixed group name).
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
if (visited.has(resourceId)) return null;
visited.add(resourceId);
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
for (const edge of parentEdges) {
const parent = await this.get(edge.parentId);
if (!parent) continue;
if (parent.kind === 'site') return parent.slug;
const found = await this.findAncestorSiteSlug(parent.id, visited);
if (found) return found;
}
return null;
}
}
class ResourceEdge extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
parent: { type: 'hasOne', model: 'Resource' }, // Creates parentId
child: { type: 'hasOne', model: 'Resource' }, // Creates childId
relation: { type: 'string', isRequired: true }
};
}
class ResourceGroup extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
resource: { type: 'hasOne', model: 'Resource' }, // Creates resourceId
groupCn: { type: 'string', isRequired: true },
accessLevel: { type: 'string', isRequired: true }
};
}
module.exports = {
Resource,
ResourceEdge,
ResourceGroup
};
+33 -36
View File
@@ -1,22 +1,17 @@
'use strict'; 'use strict';
const Table = require('.'); const { Model } = require('@simpleworkjs/orm');
const crypto = require('crypto'); const crypto = require('crypto');
const UUID = () => crypto.randomUUID(); const UUID = () => crypto.randomUUID();
class Token extends Model {
class Token extends Table{ static adapterName = 'redis';
static _key = 'token'; static fields = {
static _keyMap = { token: { type: 'string', primaryKey: true, default: UUID, isPrivate: true, min: 36, max: 36 },
'created_by': {isRequired: true, type: 'string', min: 3, max: 500}, created_by: { isRequired: true, type: 'string', min: 3, max: 500 },
'created_on': {default: function(){return (new Date).getTime()}}, created_on: { type: 'integer', default: function(){return (new Date).getTime()} },
'updated_on': {default: function(){return (new Date).getTime()}, always: true}, updated_on: { type: 'integer', default: function(){return (new Date).getTime()}, always: true },
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true}, is_valid: { default: true, type: 'boolean' }
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
super(...args);
} }
async check(){ async check(){
@@ -28,12 +23,10 @@ class Token extends Table{
} }
} }
Token.register();
class AuthToken extends Token{ class AuthToken extends Token{
static _keyMap = { static fields = {
...super._keyMap, ...Token.fields,
user: {model: 'User', rel: 'one', localKey: 'created_by'}, user: {model: 'User', type: 'hasOne', localKey: 'created_by'},
} }
static async create(data){ static async create(data){
@@ -42,11 +35,10 @@ class AuthToken extends Token{
} }
} }
AuthToken.register();
class InviteToken extends Token{ class InviteToken extends Token{
static _keyMap = { static fields = {
...super._keyMap, ...Token.fields,
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'}, claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
mail: {default: '__NONE__', type: 'string'}, mail: {default: '__NONE__', type: 'string'},
mail_token: {default: '__NONE__', type: 'string'}, mail_token: {default: '__NONE__', type: 'string'},
@@ -68,14 +60,13 @@ class InviteToken extends Token{
} }
} }
} }
InviteToken.register();
class ImpersonationToken extends Token { class ImpersonationToken extends Token {
static _keyMap = { static fields = {
...super._keyMap, ...Token.fields,
target_uid: {isRequired: true, type: 'string', min: 1, max: 200}, target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500}, temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'number'}, expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'integer'},
} }
get isExpired() { get isExpired() {
@@ -87,27 +78,24 @@ class ImpersonationToken extends Token {
return this.create(data); return this.create(data);
} }
} }
ImpersonationToken.register();
class PasswordResetToken extends Token {} class PasswordResetToken extends Token {}
PasswordResetToken.register();
class OtpToken extends Token { class OtpToken extends Token {
static _keyMap = { static fields = {
...Token._keyMap, ...Token.fields,
uid: {isRequired: true, type: 'string'}, uid: {isRequired: true, type: 'string'},
code: {isRequired: true, type: 'string'}, code: {isRequired: true, type: 'string'},
method: {isRequired: true, type: 'string'}, method: {isRequired: true, type: 'string'},
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'number'}, expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'integer'},
}; };
get isExpired() { get isExpired() {
return (new Date).getTime() > this.expires_at; return (new Date).getTime() > this.expires_at;
} }
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
static async issue(uid, method) { static async issue(uid, method) {
const existing = await this.listDetail({uid}); const existing = await this.list({where: {uid}});
for (const t of existing) { for (const t of existing) {
if (t.is_valid) await t.update({is_valid: false}); if (t.is_valid) await t.update({is_valid: false});
} }
@@ -116,13 +104,22 @@ class OtpToken extends Token {
} }
static async verify(uid, code) { static async verify(uid, code) {
const tokens = await this.listDetail({uid}); const tokens = await this.list({where: {uid}});
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code); const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
if (!match) return null; if (!match) return null;
await match.update({is_valid: false}); await match.update({is_valid: false});
return match; return match;
} }
} }
OtpToken.register(); class ServiceToken extends Token {
static fields = {
...Token.fields,
resource_id: {isRequired: true, type: 'string'}
}
static async issue(resource_id, created_by) {
return this.create({resource_id, created_by});
}
}
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken}; module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken};
+42 -31
View File
@@ -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',
@@ -891,6 +896,12 @@ User.invite = async function(data = {}){
User.login = async function(data){ User.login = async function(data){
try{ try{
if (!data.uid && !data.username) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
let user = await this.get(data.uid || data.username); let user = await this.get(data.uid || data.username);
const loginClient = makeClient(); const loginClient = makeClient();
+1625 -349
View File
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -1,6 +1,7 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.18", "version": "1.9.0",
"description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
@@ -22,7 +23,12 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/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.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -31,12 +37,12 @@
"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": "^0.4.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
+6
View File
@@ -7,6 +7,12 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
} }
#spa-shell { #spa-shell {
+3 -17
View File
@@ -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 = '';
} }
+327 -170
View File
@@ -1,3 +1,12 @@
// Shared client framework for the theta42 apps.
//
// This file is byte-identical across sso-manager-node, proxy and jump-host —
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
// the /api/user/me response), never from edits to this file. Edit all three
// copies together.
//
// jQuery 4 safe: no $.isFunction, no $.holdReady.
var app = {}; var app = {};
app.pubsub = (function(){ app.pubsub = (function(){
@@ -45,7 +54,7 @@ app.pubsub = (function(){
app.socket = (function(app){ app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js') // $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script> // <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket; var socket;
$(document).ready(function(){ $(document).ready(function(){
socket = io({ socket = io({
@@ -75,11 +84,17 @@ app.socket = (function(app){
app.api = (function(app){ app.api = (function(app){
var baseURL = '/api/' var baseURL = '/api/'
function post(url, data, callback){ // post/put/delete are dual-mode: pass a callback for the node-style
if (!$.isFunction(callback)) { // (error, data, status) form, or omit it to get a Promise that resolves
return new Promise((resolve, reject) => { // with the parsed body and rejects with the error body. get/options return
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
function body(method, url, data, callback){
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({ $.ajax({
type: 'POST', url: baseURL+url, type: method,
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() }, headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data), data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
@@ -88,9 +103,11 @@ app.api = (function(app){
}); });
} }
return $.ajax({ return $.ajax({
type: 'POST', type: method,
url: baseURL+url, url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() }, headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data), data: JSON.stringify(data),
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
@@ -104,40 +121,27 @@ app.api = (function(app){
}); });
} }
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){ function put(url, data, callback){
if (!$.isFunction(callback)) { return body('PUT', url, data, callback);
return new Promise((resolve, reject) => {
$.ajax({
type: 'PUT', url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'PUT',
url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
);
}
});
} }
function remove(url, callback){ // Called both as (url, callback) and — from formAJAX, which always passes
if (!$.isFunction(callback)) { // the serialized form as the second argument — as (url, data, callback).
return new Promise((resolve, reject) => { // No request body is sent either way.
function remove(url, data, callback){
if(typeof data === 'function'){
callback = data;
data = undefined;
}
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({ $.ajax({
type: 'DELETE', url: baseURL+url, type: 'DELETE',
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() }, headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
dataType: 'json', dataType: 'json',
@@ -147,7 +151,9 @@ app.api = (function(app){
return $.ajax({ return $.ajax({
type: 'DELETE', type: 'DELETE',
url: baseURL+url, url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() }, headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
complete: function(res, text){ complete: function(res, text){
@@ -202,7 +208,10 @@ app.api = (function(app){
})(app) })(app)
app.auth = (function(app){ app.auth = (function(app){
var user = {}; // One in-flight/cached GET /api/user/me per page load. Every gating
// decision (nav items, per-view forceLogin, group-required elements) reads
// this same promise instead of re-fetching.
var userPromise = null;
function setToken(token){ function setToken(token){
localStorage.setItem('APIToken', token); localStorage.setItem('APIToken', token);
@@ -216,35 +225,70 @@ app.auth = (function(app){
try{ try{
return await app.api.get('user/me'); return await app.api.get('user/me');
}catch(error){ }catch(error){
if(error?.status === 401) return null; if(error && error.status === 401) return null;
throw error throw error;
} }
} }
// Cached current user, or false when there's no token at all. Callers that
// need a fresh copy (after a login or a profile change) pass force.
function loadUser(force){
if(force || !userPromise){
userPromise = getToken() ? getUser() : Promise.resolve(null);
userPromise = userPromise.then(function(user){
app.auth.user = app.auth.perms = user || null;
return user;
});
}
return userPromise;
}
// The apps report group membership two ways: sso-manager-node returns LDAP
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
// is exposed as a synthetic `admin` group so one gating model covers both.
function groupCNs(user){
var raw = (user && (user.memberOf || user.groups)) || [];
if(!Array.isArray(raw)) raw = [raw];
var names = raw.map(function(group){
return String(group).split(',')[0].replace(/^cn=/i, '');
});
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
return names;
}
async function memberOf(groupNameToFind, user){ async function memberOf(groupNameToFind, user){
try{ user = user || await loadUser();
user = user || await app.auth.asyncUser; if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind] groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
for(let group of user.memberOf){ return groupCNs(user).some(function(group){
group = group.split(',ou=groups')[0].replace('cn=', ''); return groupNameToFind.includes(group);
if(groupNameToFind.includes(group)) return true; });
}
return false;
}catch(error){
throw(error);
}
} }
async function isLoggedIn(){ // True when the logged-in user is a global admin (per user/me). Sync — only
if(getToken()){ // meaningful once isLoggedIn/forceLogin has resolved.
user = await app.auth.asyncUser; function isAdmin(){
return user; return !!(app.auth.perms && app.auth.perms.isAdmin);
}else{ }
return false;
// Dual-mode: returns a Promise resolving to the user (or false), and calls
// an optional node-style callback with the same result.
function isLoggedIn(callback){
var promise = loadUser().then(function(user){
return user || false;
});
if(typeof callback === 'function'){
promise.then(function(user){
callback(null, user);
}, function(error){
callback(error, false);
});
} }
return promise;
} }
function logIn(args, callback){ function logIn(args, callback){
@@ -252,62 +296,125 @@ app.auth = (function(app){
if(data.login){ if(data.login){
setToken(data.token); setToken(data.token);
} }
loadUser(true);
callback(error, !!data.token); callback(error, !!data.token);
}); });
} }
// Clears the session only — the caller decides where to go next (the nav's
// Log Out button uses ui.logoutRedirect).
function logOut(callback){ function logOut(callback){
localStorage.removeItem('APIToken'); localStorage.removeItem('APIToken');
location.replace(`/login${location.href.replace(location.origin, '')}`); userPromise = null;
callback(); app.auth.user = app.auth.perms = null;
if(typeof callback === 'function') callback();
} }
// Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
// Consume an app token handed back by the OIDC callback via the URL
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
// forwards to the intended page. Returns true if a token was consumed.
function consumeTokenFragment(){
if(!location.hash) return false;
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
var token = params.get('token');
if(!token) return false;
setToken(token);
// redirect comes from the URL fragment (attacker-controllable); only
// allow a same-origin path so it can't become an open redirect / XSS.
var redirect = safeInternalPath(params.get('redirect') || '/');
// Drop the token from the address bar before navigating on.
history.replaceState(null, '', location.pathname + location.search);
window.location.href = redirect;
return true;
}
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
// unauthorised user is kept off the page by a redirect / an error panel
// rather than by pausing document ready.
//
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
// `admin` group covers the OIDC clients' isAdmin flag.
async function forceLogin(requiredGroups){ async function forceLogin(requiredGroups){
$.holdReady(true); var user = await loadUser();
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
if(!user){
logOut(function(){});
location.replace('/login?redirect=' + encodeURIComponent(
location.pathname + location.search
));
return false;
}
if(user.onboardingRequired && location.pathname !== '/onboarding'){ if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding'); location.replace('/onboarding');
return false;
} }
if(requiredGroups){ if(requiredGroups && !await memberOf(requiredGroups, user)){
if(!await memberOf(requiredGroups)){ app.messages.action(
console.log("Does not have permission!!!") `<h1>
app.util.actionMessage( <i class="fa-solid fa-triangle-exclamation"></i>
`<h1> <b>You do not have permission to be here.</b>
<i class="fa-solid fa-triangle-exclamation"></i> <i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b> </h1>`,
<i class="fa-solid fa-triangle-exclamation"></i> $('#spa-shell'),
</h1>`, 'danger',
$('#spa-shell'), );
'danger', throw new Error("User does not have permission");
);
throw new Error("User does not have permission");
}
} }
$.holdReady(false); return user;
} }
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> suffix form, constrained to a same-origin path. The
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
// is how the OIDC provider sends an unauthenticated user through login.
function logInRedirect(){ function logInRedirect(){
window.location.href = location.href.replace(location.origin+'/login', '') || '/' var params = new URLSearchParams(location.search);
var target = params.get('redirect')
|| location.href.replace(location.origin + '/login', '')
|| '/';
window.location.href = safeInternalPath(target);
} }
return { return {
getToken: getToken, getToken: getToken,
setToken: setToken, setToken: setToken,
getUser: getUser,
loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn, isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null,
logIn: logIn, logIn: logIn,
logOut: logOut, logOut: logOut,
forceLogin, forceLogin,
logInRedirect, logInRedirect,
getUser,
memberOf,
} }
})(app); })(app);
app.auth.asyncUser = app.auth.getUser();
// Back-compat alias for views that awaited the cached user directly.
Object.defineProperty(app.auth, 'asyncUser', {
get: function(){ return app.auth.loadUser(); },
});
app.user = (function(app){ app.user = (function(app){
function list(callback){ function list(callback){
@@ -338,6 +445,72 @@ app.user = (function(app){
})(app); })(app);
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
// these endpoints; the calls are inert elsewhere.
app.permission = (function(app){
function list(callback){
app.api.get('permission/', function(error, data){
callback(error, data);
});
}
function subjects(callback){
app.api.get('permission/subjects', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('permission/', args, function(error, data){
callback(error, data);
});
}
function remove(id, callback){
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
callback(error, data);
});
}
return {list, subjects, add, remove};
})(app);
app.group = (function(app){
function list(callback){
app.api.get('group/', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('group/', args, function(error, data){
callback(error, data);
});
}
function remove(name, callback){
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
callback(error, data);
});
}
function addMember(name, username, callback){
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
callback(error, data);
});
}
function removeMember(name, username, callback){
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
callback(error, data);
});
}
return {list, add, remove, addMember, removeMember};
})(app);
app.util = (function(app){ app.util = (function(app){
function getUrlParameter(name){ function getUrlParameter(name){
@@ -347,65 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}; };
function actionMessage(message, $targetPassed, type, callback){ // escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
message = message || ''; // app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
let $target = $targetPassed.closest('div.card').find('.actionMessage'); return String(s == null ? '' : s)
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]); .replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
type = type || 'info'; .replace(/>/g, '&gt;')
callback = callback || function(){}; .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
if(!message.includes('<button')) message += `
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
} }
$.fn.serializeObject = function() { $.fn.serializeObject = function() {
@@ -415,8 +538,11 @@ app.util = (function(app){
for (let {name, value} of $(this).serializeArray()) { for (let {name, value} of $(this).serializeArray()) {
console.log(name, value) console.log(name, value)
if (obj[name] === undefined) { if (obj[name] === undefined) {
if (!value if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value') && !$(this).parent().find(`[name="${name}"]`).attr('value')
// Keep empty <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){ ){
continue; continue;
} }
@@ -458,29 +584,64 @@ app.util = (function(app){
document.body.removeChild(element); document.body.removeChild(element);
} }
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return { return {
downloadFile: downloadFile, downloadFile: downloadFile,
getUrlParameter: getUrlParameter, getUrlParameter: getUrlParameter,
actionMessage: actionMessage, escapeHtml: escapeHtml,
actionConfirm, revealItem: revealItem,
} }
})(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 +663,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 +696,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 +714,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 +725,3 @@ function formAJAX(btn){
} }
}); });
} }
-133
View File
@@ -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";
}
}
}
});
+241
View File
@@ -0,0 +1,241 @@
'use strict';
const router = require('express').Router();
const permission = require('../utils/permission');
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { Group } = require('../models/group_ldap');
const { projectResources } = require('@simpleworkjs/directory-schema');
// Require the admin group
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
// --- Resources ---
router.get('/resources', async (req, res, next) => {
try {
const resources = await Resource.list();
// Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
res.json({ results: projectResources(resources, { fullMetadata: true }) });
} catch (err) { next(err); }
});
router.post('/resources', async (req, res, next) => {
try {
if (!req.body.hostId && req.body.parentSlug) {
const parents = await Resource.list({ where: { slug: req.body.parentSlug } });
if (parents.length > 0) req.body.hostId = parents[0].id;
}
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
if (req.body.kind === 'service' && !req.body.hostId) {
return res.status(400).json({ error: 'Services must have a parent Host' });
}
if (req.body.kind === 'oauth' && !req.body.hostId) {
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
}
req.body.owner = req.body.owner || req.user.uid;
const now = Date.now();
req.body.created_by = req.body.created_by || req.user.uid;
req.body.created_on = now;
req.body.updated_by = req.user.uid;
req.body.updated_on = now;
let r;
if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client');
// Pass created_by explicitly for the wrapper (overrides the generic
// assignment above -- this is OAuthClient-wrapper-specific behavior).
req.body.created_by = req.body.owner;
// In the UI we might pass slug, but OAuthClient wrapper expects name
r = await OAuthClient.add(req.body);
} else {
r = await Resource.create(req.body);
}
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
}
if (r.kind === 'host' || r.kind === 'service') {
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
const createGroup = async (suffix, accessLevel) => {
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
try {
await Group.add({
name: cn,
owner: req.user.dn,
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
});
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`Failed to create LDAP group ${cn}:`, err);
}
}
try {
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
} catch(err) { /* ignore duplicate links */ }
};
await createGroup('access', 'member');
await createGroup('admin', 'owner');
}
res.json({ results: r });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A resource with this slug already exists.' });
}
if (err.name === 'SequelizeValidationError') {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.put('/resources/:id', async (req, res, next) => {
try {
let r;
if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client');
r = await OAuthClient.get(req.params.id);
} else {
r = await Resource.get(req.params.id);
}
if (!r) return res.status(404).json({ error: 'Not found' });
req.body.updated_by = req.user.uid;
req.body.updated_on = Date.now();
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
if (req.body.kind === 'service' && !req.body.hostId) {
return res.status(400).json({ error: 'Services must have a parent Host' });
}
if (req.body.kind === 'oauth' && !req.body.hostId) {
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
}
let updated;
if (req.body.kind === 'oauth') {
updated = await r.update(req.body);
} else {
updated = await r.update(req.body);
}
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
for (const e of existingEdges) {
if (e.relation === 'hosts' || e.relation === 'oauth') await e.delete();
}
if (req.body.hostId) {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: updated.kind === 'oauth' ? 'oauth' : 'hosts' });
}
}
res.json({ results: updated });
} catch (err) {
next(err);
}
});
router.post('/resources/:id/rotate-secret', async (req, res, next) => {
try {
const { OAuthClient } = require('../models/oauth_client');
const client = await OAuthClient.get(req.params.id);
const secret = await client.rotateSecret();
res.json({ secret });
} catch (err) {
next(err);
}
});
router.delete('/resources/:id', async (req, res, next) => {
try {
const r = await Resource.get(req.params.id);
if (!r) return res.status(404).json({ error: 'Not found' });
await r.delete();
// Also delete edges and groups involving this resource
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
for (const g of groups) await g.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Edges ---
router.get('/edges', async (req, res, next) => {
try {
const edges = await ResourceEdge.list();
res.json({ results: edges });
} catch (err) { next(err); }
});
router.post('/edges', async (req, res, next) => {
try {
const edge = await ResourceEdge.create(req.body);
res.json({ results: edge });
} catch (err) { next(err); }
});
router.delete('/edges/:id', async (req, res, next) => {
try {
const edge = await ResourceEdge.get(req.params.id);
if (!edge) return res.status(404).json({ error: 'Not found' });
await edge.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Groups ---
router.get('/groups', async (req, res, next) => {
try {
const groups = await ResourceGroup.list();
res.json({ results: groups });
} catch (err) { next(err); }
});
router.post('/groups', async (req, res, next) => {
try {
const g = await ResourceGroup.create(req.body);
res.json({ results: g });
} catch (err) { next(err); }
});
router.delete('/groups/:id', async (req, res, next) => {
try {
const g = await ResourceGroup.get(req.params.id);
if (!g) return res.status(404).json({ error: 'Not found' });
await g.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
router.get('/audit-logs', async (req, res, next) => {
try {
const fs = require('fs');
const { execSync } = require('child_process');
let ldapLogs = '';
let oauthLogs = '';
let auditLogs = '';
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
} catch (err) { next(err); }
});
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
'use strict';
const router = require('express').Router();
const permission = require('../utils/permission');
const metrics = require('../utils/metrics');
// /api/metrics/executive
router.get('/executive', async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
const topIps = await metrics.getTopN('metrics:failed_ips', 7, 5);
const topUsers = await metrics.getTopN('metrics:failed_users', 7, 5);
const topServices = await metrics.getTopN('metrics:service_usage', 7, 5);
res.json({ results: { ips: topIps, users: topUsers, services: topServices } });
} catch(e) {
next(e);
}
});
// /api/metrics/user/:uid
router.get('/user/:uid', async (req, res, next) => {
try {
// Can only view if admin or self
if (req.user.uid !== req.params.uid) {
await permission.byGroup(req.user, ['app_sso_admin']);
}
// Failed logins for user is hard if we didn't track it by user, but wait, we did! metrics:failed_users:YYYY-MM-DD
// However, we didn't track failed IPs per user. We tracked failed_users as a sorted set.
// To get the user's failures, we just query their score from the union.
// Wait, for services we have user_service_usage:<uid>:<date>. No, in metrics.js I wrote:
// `metrics:user_service_usage:${username}:${date}`
const topServices = await metrics.getTopN('metrics:user_service_usage', 7, 5, req.params.uid);
res.json({ results: { services: topServices } });
} catch(e) {
next(e);
}
});
module.exports = router;
+7 -2
View File
@@ -13,6 +13,7 @@ const middleware = require('../middleware/auth');
const rateLimit = require('../middleware/rate_limit'); const rateLimit = require('../middleware/rate_limit');
const permission = require('../utils/permission'); const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const metrics = require('../utils/metrics');
async function findUserByLogin(login) { async function findUserByLogin(login) {
try { try {
@@ -37,12 +38,16 @@ router.get('/username-suggestions', async function(req, res, next) {
router.post('/login', rateLimit.login, async function(req, res, next){ router.post('/login', rateLimit.login, async function(req, res, next){
try{ try{
let auth = await Auth.login(req.body); let auth = await Auth.login(req.body);
metrics.recordServiceUsage('SSO Web UI', req.body.uid);
return res.json({ return res.json({
login: true, login: true,
token: auth.token.token, token: auth.token.token,
message:`${req.body.uid} logged in!`, message:`${req.body.uid} logged in!`,
}); });
}catch(error){ }catch(error){
if (error.name === 'LDAPLoginFailed' || error.status === 401 || error.name === 'UserNotFound') {
metrics.recordFailedLogin(req.ip, req.body.uid);
}
next(error); next(error);
} }
}); });
@@ -198,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
const target = await User.get(req.params.uid); const target = await User.get(req.params.uid);
// Clean up any existing impersonation for this target // Clean up any existing impersonation for this target
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid }); const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
for (const old of existing) { for (const old of existing) {
if (old.is_valid && !old.isExpired) { if (old.is_valid && !old.isExpired) {
try { await target.removeTempPassword(old.temp_hash); } catch(_) {} try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
@@ -232,7 +237,7 @@ router.delete('/impersonate/:uid', middleware.auth, async function(req, res, nex
await permission.byGroup(req.user, ['app_sso_admin']); await permission.byGroup(req.user, ['app_sso_admin']);
const target = await User.get(req.params.uid); const target = await User.get(req.params.uid);
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid }); const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
let revoked = 0; let revoked = 0;
for (const token of existing) { for (const token of existing) {
+42
View File
@@ -0,0 +1,42 @@
const express = require('express');
// Parses arguments according to the exposed method config.
// Extended to support { from: 'user' } which injects `req.user.dn` (LDAP integration).
function extractArgs(req, cfg) {
const args = cfg.args;
if (!args) return [];
if (args.from === 'user') return [req.user.dn];
const source = args.from === 'params' ? req.params
: args.from === 'query' ? req.query
: req.body;
if (Array.isArray(args.names)) return args.names.map(name => source[name]);
return [source || {}];
}
// A mini-auto-router that reads `static exposedMethods` from a @simpleworkjs/orm Model
// and maps them directly into Express endpoints.
function autoRouter(Model) {
const router = express.Router();
if (Model.getExposedMethods) {
for (const cfg of Model.getExposedMethods()) {
router[cfg.verb](cfg.routePath, async function(req, res, next) {
try {
// In a full implementation, we'd load the instance if cfg.kind === 'instance'.
// For now, our methods are all static class methods.
const target = Model;
const result = await target[cfg.method](...extractArgs(req, cfg));
res.json(result);
} catch (error) {
next(error);
}
});
}
}
return router;
}
module.exports = autoRouter;
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Public directory discovery API. Mounted at /api/discovery (app.js, before
// the 404 catcher). Every response uses the `{ results }` envelope and the
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
// OAuth client's client_secret_hash) never leave the server and non-admins only
// see the public metadata allowlist.
//
// This replaces the autoRouter mount (which returned bare arrays — the shape
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
// bridge) and absorbs the dead /me handler that used to live in
// routes/api_discovery.js (mounted after the 404, so unreachable).
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
const {
envelope,
projectResource,
projectResources,
isDirectoryAdmin,
} = require('@simpleworkjs/directory-schema');
// GET /api/discovery/resources[?kind=&group=&parent=]
router.get('/resources', async (req, res, next) => {
try {
const resources = await Resource.search(req.query);
res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) })));
} catch (err) { next(err); }
});
// GET /api/discovery/resources/:slug
router.get('/resources/:slug', async (req, res, next) => {
try {
const resource = await Resource.getBySlug(req.params.slug);
// parents/children are edges (no secrets); project only the resource body.
const projected = projectResource(resource, { fullMetadata: isDirectoryAdmin(req.user) });
projected.parents = resource.parents;
projected.children = resource.children;
res.json(envelope(projected));
} catch (err) { next(err); }
});
// GET /api/discovery/graph
router.get('/graph', async (req, res, next) => {
try {
const graph = await Resource.getGraph();
res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }),
edges: graph.edges,
}));
} catch (err) { next(err); }
});
// GET /api/discovery/me
// Returns the resources the current caller can reach. Machines see only their
// own resource; humans get the union of their LDAP groups' resources plus
// anything flagged isPublic. Uses req.user.groups (populated by the auth
// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it
// works for every auth transport without assuming a .dn is present.
router.get('/me', async (req, res, next) => {
try {
let accessible;
if (req.user && req.user.isMachine) {
accessible = await Resource.list({ where: { id: req.resourceId } });
} else {
const userGroups = (req.user && req.user.groups) || [];
const ids = new Set();
if (userGroups.length) {
const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } });
for (const rg of rgs) ids.add(rg.resourceId);
}
const all = await Resource.list();
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
}
res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) })));
} catch (err) { next(err); }
});
module.exports = router;
+1
View File
@@ -33,6 +33,7 @@ const DOCS = {
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')}, accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')}, 'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')}, 'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')}, changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
+9 -2
View File
@@ -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){
+29 -58
View File
@@ -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) {
@@ -55,17 +48,28 @@ router.get('/tos', async function(req, res, next) {
// Admin dashboard (stats + recent/inactive users) and Notifications // Admin dashboard (stats + recent/inactive users) and Notifications
// (broadcast + history) merged into one page. // (broadcast + history) merged into one page.
router.get('/dashboard', function(req, res) { router.get('/executive', function(req, res) {
res.render('dashboard', {...values}); res.render('executive', {...values});
}); });
router.get('/admin', (req, res) => res.redirect(301, '/dashboard')); router.get('/admin', (req, res) => res.redirect(301, '/executive'));
router.get('/notifications', (req, res) => res.redirect(301, '/dashboard')); router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
router.get('/invites', function(req, res) { router.get('/directory', function(req, res) {
res.render('invites', {...values}); res.render('directory', {...values});
}); });
// Linkable deep-link to a single resource's modal, e.g. from the resource
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
// use of :slug at all -- the client reads location.pathname itself and opens
// the matching resource's modal once the page's own data has loaded.
router.get('/directory/:slug', function(req, res) {
res.render('directory', {...values});
});
// Route removed since it's now in directory
router.get('/onboarding', async function(req, res, next) { router.get('/onboarding', async function(req, res, next) {
try { try {
const tos = await Tos.getCurrent(); const tos = await Tos.getCurrent();
@@ -76,6 +80,10 @@ router.get('/onboarding', async function(req, res, next) {
}); });
router.get('/', async function(req, res, next) { router.get('/', async function(req, res, next) {
res.render('landing', {...values});
});
router.get('/profile', async function(req, res, next) {
res.render('profile', {...values}); res.render('profile', {...values});
}); });
@@ -145,44 +153,7 @@ router.get('/token', function(req, res, next) {
res.render('token', {...values}); res.render('token', {...values});
}); });
router.get('/sites', async function(req, res, next) {
const net = require('net');
const url = require('url');
const myId = process.env.LDAP_SERVER_ID || 'Standalone';
const hostsStr = process.env.LDAP_REPLICATION_HOSTS || '';
const hosts = hostsStr.split(' ').filter(h => h);
const sites = await Promise.all(hosts.map(hostUrl => {
return new Promise((resolve) => {
try {
const u = new url.URL(hostUrl);
const port = u.port || (u.protocol === 'ldaps:' ? 636 : 389);
const hostname = u.hostname;
const socket = new net.Socket();
socket.setTimeout(2000);
socket.on('connect', () => {
socket.destroy();
resolve({ url: hostUrl, status: 'Online' });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ url: hostUrl, status: 'Offline (Timeout)' });
});
socket.on('error', (err) => {
socket.destroy();
resolve({ url: hostUrl, status: 'Offline (' + err.code + ')' });
});
socket.connect(port, hostname);
} catch (e) {
resolve({ url: hostUrl, status: 'Invalid URL' });
}
});
}));
res.render('sites', { ...values, myId, sites });
});
router.get('/login/resetpassword/:token', async function(req, res, next){ router.get('/login/resetpassword/:token', async function(req, res, next){
Binary file not shown.
+5 -2
View File
@@ -45,8 +45,11 @@ router.post('/', async function(req, res, next) {
const client = await OAuthClient.add(req.body); const client = await OAuthClient.add(req.body);
const result = client.toJSON ? client.toJSON() : { ...client };
result.client_id = client.client_id || client.id;
return res.json({ return res.json({
results: client, results: result,
client_secret: client._raw_secret, client_secret: client._raw_secret,
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`, message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
}); });
@@ -94,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
await permission.byGroup(req.user, [ADMIN_GROUP]); await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id); const client = await OAuthClient.get(req.params.client_id);
await client.remove(); await client.delete();
return res.json({ return res.json({
client_id: req.params.client_id, client_id: req.params.client_id,
+10 -4
View File
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
router.get('/:name', async function(req, res, next){ router.get('/:name', async function(req, res, next){
try{ try{
// ORM models: list() on the redis adapter always returns full rows;
// detail is handled by serialization (isPrivate fields are excluded).
return res.json({ return res.json({
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]() results: await tokens[req.params.name].list()
}); });
}catch(error){ }catch(error){
next(error); next(error);
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
router.get('/:name/:token', async function(req, res, next){ router.get('/:name/:token', async function(req, res, next){
try{ try{
return res.json({ const result = await tokens[req.params.name].get(req.params.token);
results: await tokens[req.params.name].get(req.params.token) if (!result) {
}); const error = new Error('Token not found');
error.status = 404;
throw error;
}
return res.json({ results: result });
}catch(error){ }catch(error){
next(error); next(error);
} }
+14 -4
View File
@@ -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);
} }
@@ -231,7 +241,7 @@ router.get('/invite', async function(req, res, next){
try{ try{
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']); await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false); const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
const all = await InviteToken.listDetail(); const all = await InviteToken.list();
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid); const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
const results = visible.map(t => ({ token: t.token, ...t })); const results = visible.map(t => ({ token: t.token, ...t }));
return res.json({ results }); return res.json({ results });
+61
View File
@@ -0,0 +1,61 @@
'use strict';
const fs = require('fs');
const { spawn } = require('child_process');
const metrics = require('../utils/metrics');
function startLdapMonitor() {
const logFile = '/var/lib/ldap/slapd.log';
if (!fs.existsSync(logFile)) {
setTimeout(startLdapMonitor, 5000);
return;
}
const tail = spawn('tail', ['-F', logFile]);
const connections = {}; // connID -> { ip, uid }
tail.stdout.on('data', (data) => {
const lines = data.toString().split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const connMatch = line.match(/conn=(\d+)/);
if (!connMatch) continue;
const conn = connMatch[1];
if (!connections[conn]) {
connections[conn] = {};
}
const ipMatch = line.match(/ACCEPT from IP=([^:]+)/);
if (ipMatch) {
connections[conn].ip = ipMatch[1];
}
const bindMatch = line.match(/BIND dn="uid=([^,]+)/i) || line.match(/BIND dn="cn=([^,]+)/i);
if (bindMatch) {
connections[conn].uid = bindMatch[1];
}
const resultMatch = line.match(/RESULT tag=\d+ err=(\d+)/);
if (resultMatch) {
const errCode = parseInt(resultMatch[1], 10);
const { ip, uid } = connections[conn];
if (errCode === 0 && uid) {
metrics.recordServiceUsage('LDAP Direct', uid);
} else if (errCode === 49 || errCode === 32) {
metrics.recordFailedLogin(ip, uid);
}
}
if (line.includes('closed') || line.includes('UNBIND')) {
delete connections[conn];
}
}
});
tail.on('error', (err) => {
console.error('Failed to start LDAP monitor', err);
});
}
startLdapMonitor();
+47
View File
@@ -0,0 +1,47 @@
const { init } = require('@simpleworkjs/orm');
const { Resource, ResourceEdge, ResourceGroup } = require('./models/resource');
async function test() {
try {
const models = await init({
conf: {
orm: {
dialect: 'sqlite',
storage: ':memory:', // Test in memory
logging: false
}
},
models: [Resource, ResourceEdge, ResourceGroup]
});
console.log('ORM initialized successfully!');
const r1 = await models.Resource.create({
kind: 'proxmox_node',
name: 'pve1',
slug: 'pve1',
metadata: { ip: '10.0.0.1' }
});
const r2 = await models.Resource.create({
kind: 'container',
name: 'ct101',
slug: 'ct101',
metadata: { ip: '10.0.0.2' }
});
await models.ResourceEdge.create({
parentId: r1.id,
childId: r2.id,
relation: 'hosts'
});
const edges = await models.ResourceEdge.list();
console.log('Edges:', JSON.stringify(edges, null, 2));
} catch (err) {
console.error('Failed:', err);
}
}
test();
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const request = require('supertest');
const app = require('../app');
// Note: To test this properly, valid LDAP credentials are required in setup.js
// Currently tests are skipped or rely on valid auth token to avoid LDAP auth failures
describe.skip('Directory Admin API', () => {
let token;
beforeAll(async () => {
// A valid admin token is required
token = 'placeholder_token';
});
test('POST /api/directory-admin/resources requires hostId for services', async () => {
const res = await request(app)
.post('/api/directory-admin/resources')
.set('auth-token', token)
.send({
name: 'Test Service',
slug: 'app_test_service',
kind: 'service'
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('parent Host');
});
test('POST /api/directory-admin/resources creates valid service with parent', async () => {
// This requires a valid host ID to exist first in a real test
const res = await request(app)
.post('/api/directory-admin/resources')
.set('auth-token', token)
.send({
name: 'Test Service',
slug: 'app_test_service',
kind: 'service',
hostId: 'some-uuid-here'
});
// In a fully mocked environment this would be 200
expect(res.status).toBe(200);
});
});
+111
View File
@@ -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();
});
});
+3 -2
View File
@@ -2,10 +2,11 @@
// Flush all test-prefix Redis keys before each test run so state is always clean. // Flush all test-prefix Redis keys before each test run so state is always clean.
// Uses model-redis's own bundled redis client since redis is not a top-level dep. // Uses model-redis's own bundled redis client since redis is not a top-level dep.
const { createClient } = require('../node_modules/model-redis/node_modules/redis'); const { createClient } = require('redis');
module.exports = async function() { module.exports = async function() {
const client = createClient(); const redisUrl = process.env.REDIS_URL || undefined;
const client = createClient(redisUrl ? { url: redisUrl } : {});
await client.connect(); await client.connect();
const keys = await client.keys('sso_manager_test_*'); const keys = await client.keys('sso_manager_test_*');
+26
View File
@@ -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', () => {
-47
View File
@@ -1,47 +0,0 @@
'use strict';
const request = require('supertest');
const app = require('../app');
const conf = require('@simpleworkjs/conf');
const ORIG_LDAP = { ...conf.ldap };
const ORIG_OAUTH = { ...(conf.oauth || {}) };
function restoreConf() {
conf.ldap = { ...conf.ldap, ...ORIG_LDAP };
conf.oauth = { ...(conf.oauth || {}), ...ORIG_OAUTH };
}
beforeEach(() => {
// Start each test from a known state; the local secrets.js may set an issuer.
conf.ldap = { ...conf.ldap, ldapsHost: '', ldapsPort: 636 };
if (conf.oauth) conf.oauth.issuer = '';
});
afterAll(() => {
restoreConf();
});
describe('GET /integrations', () => {
test('renders and derives LDAPS URL from the request host by default', async () => {
const res = await request(app)
.get('/integrations')
.set('Host', 'sso.example.com');
expect(res.status).toBe(200);
expect(res.text).toContain('ldaps://sso.example.com:636');
});
test('uses conf.ldap.ldapsHost when set', async () => {
conf.ldap = { ...conf.ldap, ldapsHost: 'ldap.internal.example.com', ldapsPort: 1636 };
const res = await request(app)
.get('/integrations')
.set('Host', 'public.example.com');
expect(res.status).toBe(200);
expect(res.text).toContain('ldaps://ldap.internal.example.com:1636');
expect(res.text).not.toContain('ldaps://public.example.com:636');
expect(res.text).toContain('Custom <code>conf.ldap.ldapsHost</code>');
});
});
+45
View File
@@ -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([]);
});
+108
View File
@@ -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');
-112
View File
@@ -1,112 +0,0 @@
'use strict';
const { login, request, app } = require('./setup');
const TEST_CLIENT = {
name: 'Test Client',
description: 'Created by automated tests',
redirect_uris: 'https://test.example.com/callback',
scopes: 'openid profile email',
token_lifetime: { access_token: 3600, refresh_token: 86400 },
};
let token;
let clientId;
let clientSecret;
beforeAll(async () => {
token = await login();
});
afterAll(async () => {
if (clientId) {
await request(app)
.delete(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
}
});
describe('OAuth Clients — POST /api/oauth/client/', () => {
test('creates a new client and returns one-time secret', async () => {
const res = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send(TEST_CLIENT);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results');
expect(res.body).toHaveProperty('client_secret');
expect(res.body.results).toHaveProperty('client_id');
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
expect(res.body.results.client_id.length).toBeGreaterThan(0);
clientId = res.body.results.client_id;
clientSecret = res.body.client_secret;
});
test('requires oauth_admin group — 401 not shown here (see group membership)', () => {
// If test user is not in app_sso_oauth_admin, the test above will fail with 401.
// That itself is the correct behavior to verify.
expect(clientId).toBeDefined();
});
});
describe('OAuth Clients — GET /api/oauth/client/', () => {
test('lists clients including the test client', async () => {
const res = await request(app)
.get('/api/oauth/client/')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
const found = res.body.results.find(c => c.client_id === clientId);
expect(found).toBeDefined();
});
});
describe('OAuth Clients — GET /api/oauth/client/:id', () => {
test('returns the test client by id', async () => {
const res = await request(app)
.get(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toHaveProperty('client_id', clientId);
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
});
test('unknown client_id returns 404 or error', async () => {
const res = await request(app)
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('OAuth Clients — PUT /api/oauth/client/:id', () => {
test('updates the client description', async () => {
const res = await request(app)
.put(`/api/oauth/client/${clientId}`)
.set('auth-token', token)
.send({ description: 'Updated by test' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
});
describe('OAuth Clients — POST /api/oauth/client/:id/rotate', () => {
test('rotates the client secret and returns a new one', async () => {
const res = await request(app)
.post(`/api/oauth/client/${clientId}/rotate`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('client_secret');
expect(typeof res.body.client_secret).toBe('string');
expect(res.body.client_secret).not.toBe(clientSecret);
clientSecret = res.body.client_secret;
});
});
+63
View File
@@ -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();
});
});
+13
View File
@@ -3,6 +3,19 @@
const crypto = require('crypto'); const crypto = require('crypto');
const request = require('supertest'); const request = require('supertest');
const app = require('../app'); const app = require('../app');
const { initORM } = require('../models');
beforeAll(async () => {
await initORM();
const { Token } = require('../models/token');
try {
if (Token.orm) {
await Token.orm.adapter(Token).Table.redisClient.flushDb();
}
} catch (e) {
console.warn('Could not flush Redis:', e.message);
}
});
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' }; const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
+12 -25
View File
@@ -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(),
};
+95
View File
@@ -0,0 +1,95 @@
'use strict';
const { createClient } = require('redis');
const conf = require('@simpleworkjs/conf');
let client;
async function getClient() {
if (!client) {
// conf.redis could be an object or a connection string depending on @simpleworkjs/conf
// But for sso-manager, Redis runs locally or is configured via environment
// The tests use createClient() with no args, so we do the same, allowing env vars to override
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis : (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
client = createClient({ url });
client.on('error', (err) => console.error('Redis metrics error', err));
await client.connect();
}
return client;
}
function getTodayKey() {
return new Date().toISOString().split('T')[0];
}
async function recordFailedLogin(ip, username) {
try {
const c = await getClient();
const date = getTodayKey();
const p = c.multi();
if (ip) {
p.zIncrBy(`metrics:failed_ips:${date}`, 1, ip);
p.expire(`metrics:failed_ips:${date}`, 30 * 86400);
}
if (username) {
p.zIncrBy(`metrics:failed_users:${date}`, 1, username);
p.expire(`metrics:failed_users:${date}`, 30 * 86400);
}
await p.exec();
} catch(e) {
console.error('Failed to record failed login metric', e);
}
}
async function recordServiceUsage(serviceName, username) {
try {
const c = await getClient();
const date = getTodayKey();
const p = c.multi();
if (serviceName) {
p.zIncrBy(`metrics:service_usage:${date}`, 1, serviceName);
p.expire(`metrics:service_usage:${date}`, 30 * 86400);
if (username) {
p.zIncrBy(`metrics:user_service_usage:${username}:${date}`, 1, serviceName);
p.expire(`metrics:user_service_usage:${username}:${date}`, 30 * 86400);
}
}
await p.exec();
} catch(e) {
console.error('Failed to record service usage metric', e);
}
}
// Helper to aggregate the last N days of a metric prefix
async function getTopN(prefix, days, topN, user = null) {
try {
const c = await getClient();
const keys = [];
const today = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
if (user) {
keys.push(`${prefix}:${user}:${dateStr}`);
} else {
keys.push(`${prefix}:${dateStr}`);
}
}
const tempKey = `metrics:temp:union:${Date.now()}:${Math.floor(Math.random() * 1000000)}`;
// ZUNIONSTORE is replaced by ZUNIONSTORE in redis v4 Node client, usually zUnionStore
await c.zUnionStore(tempKey, keys.length, keys);
const results = await c.zRangeWithScores(tempKey, 0, topN - 1, { REV: true });
await c.del(tempKey);
return results.map(r => ({ value: r.value, score: r.score }));
} catch(e) {
console.error('Failed to get top N metrics', e);
return [];
}
}
module.exports = {
recordFailedLogin,
recordServiceUsage,
getTopN
};
+46
View File
@@ -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']},
],
};
+10 -5
View File
@@ -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>
&copy; <%- buildYear %> theta42 &middot; &copy; <%- buildYear %> theta42 &middot;
<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>
-442
View File
@@ -1,442 +0,0 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin('app_sso_admin');
// ── Overview (stats, recent signups, inactive users) ────────────────────
async function loadDashboard() {
try {
const stats = await app.api.get('user/stats');
// Stat cards
document.getElementById('stat-total').textContent = stats.totalUsers;
document.getElementById('stat-active').textContent = stats.activeUsers;
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
document.getElementById('stat-groups').textContent = stats.totalGroups;
// Recent signups
stats.recentSignups.forEach(function(u) {
u.createTimestamp = moment(u.createTimestamp, 'YYYYMMDDHHmmssZ').fromNow();
});
$.scope.recentSignups.push(...stats.recentSignups);
// Inactive users
$.scope.inactiveUsers.push(...stats.inactiveList);
document.getElementById('dashboard-overview').style.display = '';
} catch(e) {
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
location.replace('/');
} else {
console.error('Dashboard load error:', e);
}
}
}
async function activateUser(uid) {
try {
await app.api.put('user/' + uid + '/active', { active: true });
$.scope.inactiveUsers.remove('uid', uid);
document.getElementById('stat-inactive').textContent =
parseInt(document.getElementById('stat-inactive').textContent) - 1;
document.getElementById('stat-active').textContent =
parseInt(document.getElementById('stat-active').textContent) + 1;
} catch(e) {
alert('Failed to activate user.');
}
}
async function exportUsers() {
const resp = await fetch('/api/user/export', {
headers: { 'auth-token': localStorage.getItem('APIToken') }
});
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'users.csv';
a.click();
}
// ── Notifications (compose + history) ────────────────────────────────────
async function loadHistory() {
try {
const data = await app.api.get('notification');
const list = data.results || [];
list.forEach(function(n) {
n.created_on_fmt = moment(n.created_on).fromNow();
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
});
$.scope.notificationHistory.push(...list);
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load notification history:', e);
}
}
function formatFilterLabel(type, value, active_only) {
const suffix = active_only ? ' (active only)' : '';
if (type === 'group') return 'Groups: ' + value + suffix;
if (type === 'users') return 'Specific users';
if (type === 'all_active') return 'All active users';
if (type === 'all') return 'All users';
return type;
}
function toggleFilterInputs() {
// No radio is checked by default (see f-all-active below) — a "send to
// everyone" option must be a deliberate choice, not whatever happens to
// be pre-selected when someone's just trying the form out.
const checked = document.querySelector('input[name="notif-filter"]:checked');
const type = checked ? checked.value : null;
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
}
async function sendNotification() {
const subject = document.getElementById('notif-subject').value.trim();
const message = document.getElementById('notif-message').value.trim();
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
const groupValue = document.getElementById('notif-group').value.trim();
const usersValue = document.getElementById('notif-users').value.trim();
const activeOnly = document.getElementById('notif-active-only').checked;
const msgEl = document.getElementById('notif-result');
const $compose = $('#notif-subject').closest('.card-body');
if (!subject || !message) { alert('Subject and message are required.'); return; }
if (!filterCheck) { alert('Choose who to send this to.'); return; }
const filterType = filterCheck.value;
let filter_value = '';
if (filterType === 'group') filter_value = groupValue;
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
// Broadcasting to everyone is easy to trigger by accident while just
// trying the form out — make it a deliberate, confirmed action.
if (filterType === 'all' || filterType === 'all_active') {
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
if (!confirmed) return;
}
msgEl.className = 'alert alert-info mt-2';
msgEl.textContent = 'Sending…';
msgEl.style.display = '';
try {
const result = await app.api.post('notification', {
subject,
message,
filter_type: filterType,
filter_value,
active_only: activeOnly,
});
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
const n = result.results;
n.created_on_fmt = 'just now';
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
$.scope.notificationHistory.unshift([n]);
// Reset form
document.getElementById('notif-subject').value = '';
document.getElementById('notif-message').value = '';
} catch(e) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
}
}
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
});
</script>
<div class="row mb-3 mt-2">
<div class="col-12">
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Dashboard</h4>
</div>
</div>
<div id="dashboard-overview" class="row" style="display:none">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="fa-solid fa-users"></i> Overview</h5>
<button class="btn btn-outline-secondary shadow" onclick="exportUsers()">
<i class="fa-solid fa-file-csv"></i> Export Users CSV
</button>
</div>
<!-- Stat cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow text-center">
<div class="card-body">
<div class="display-6 fw-bold" id="stat-total">—</div>
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-success">
<div class="card-body">
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-danger">
<div class="card-body">
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-info">
<div class="card-body">
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Recent signups -->
<div class="col-md-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-user-plus"></i> Recent Signups
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr><th>User</th><th>Email</th><th>Joined</th></tr>
</thead>
<tbody jq-repeat="recentSignups">
<tr>
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
<td><small>{{mail}}</small></td>
<td><small class="text-muted">{{createTimestamp}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Inactive users -->
<div class="col-md-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-lock"></i> Inactive Users
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr><th>User</th><th>Email</th><th></th></tr>
</thead>
<tbody jq-repeat="inactiveUsers" jq-index-key="uid">
<tr>
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
<td><small>{{mail}}</small></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-success" onclick="activateUser('{{uid}}')">
<i class="fa-solid fa-lock-open"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h5 class="mb-3"><i class="fa-solid fa-paper-plane"></i> Notifications</h5>
</div>
</div>
<div class="row g-3">
<!-- Compose -->
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-pencil"></i> Compose
</div>
<div class="card-header shadow actionMessage" style="display:none"></div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" class="form-control shadow" id="notif-subject" placeholder="Maintenance window tonight" />
</div>
<div class="mb-3">
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
<textarea class="form-control shadow" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Send to</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all-active">All active users</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all">All users (including inactive)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-group">Group members</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-users">Specific users</label>
</div>
</div>
<div id="notif-group-row" class="mb-3" style="display:none">
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
</div>
<div id="notif-users-row" class="mb-3" style="display:none">
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow" id="notif-users" placeholder="wmantly, jsmith" />
</div>
<div id="notif-active-row" class="mb-3" style="display:none">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
<label class="form-check-label" for="notif-active-only">Active members only</label>
</div>
</div>
<button class="btn btn-primary shadow" onclick="sendNotification()">
<i class="fa-solid fa-paper-plane"></i> Send
</button>
<div id="notif-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- History -->
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-clock-rotate-left"></i> History
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Sent</th>
<th>Subject</th>
<th>Filter</th>
<th class="text-center">✓</th>
<th class="text-center">✗</th>
</tr>
</thead>
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
<tr>
<td><small class="text-muted">{{created_on_fmt}}</small></td>
<td><small>{{subject}}</small></td>
<td><small class="text-muted">{{filter_label}}</small></td>
<td class="text-center text-success"><small>{{sent_count}}</small></td>
<td class="text-center text-danger"><small>{{failed_count}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h5 class="mb-3"><i class="fa-solid fa-file-contract"></i> Terms of Service</h5>
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-pencil"></i> Editor
<small class="text-muted float-end" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow" id="tos-content" rows="16"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+980
View File
@@ -0,0 +1,980 @@
<%- include('top') %>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-server"></i> Directory Management
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="name">Name (A-Z)</option>
<option value="kind">Kind</option>
<option value="env">Environment</option>
</select>
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Resource
</button>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="table-responsive">
<table class="card-body table table-striped mb-0">
<thead>
<tr>
<th class="ps-3">Resource</th>
<th>IP / Address</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="resources-list" jq-repeat="resources">
<tr id="resource-row-{{id}}">
<td class="ps-3">
{{{indentHtml}}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
<span class="badge bg-light text-dark border">{{hostName}}</span>
<br>
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong>
</a>
<small class="text-muted">{{slug}}</small>
</td>
<td>
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
</td>
<td>
<button class="btn btn-sm btn-primary" onclick="openEditModal('{{id}}')" title="Edit">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-success" onclick="openAddModal('{{id}}', '{{kind}}')" title="Add Child Resource">
<i class="fa-solid fa-plus"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteResource('{{id}}')">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
// --- Resource modal tab content, built once. Populated via .val() in
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the
// DOM from these strings -- order matters, unlike the old always-in-DOM
// static modal. ---
var generalTabHtml = `
<div class="actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="res-id">
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Name</label>
<input type="text" id="res-name" class="form-control shadow-sm">
</div>
<div class="col-6">
<label class="form-label">Slug</label>
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Kind</label>
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
<option value="site">Site</option>
<option value="host">Host</option>
<option value="service">Service (App)</option>
<option value="oauth">OAuth Integration</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">IP Address</label>
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
</div>
<div class="col-6">
<label class="form-label">Host / URI Address</label>
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
</div>
</div>
<div class="row mb-3">
<div class="col-6">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-production">
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
</div>
`;
var detailsTabHtml = `
<div class="row mb-3" id="site-details-container" style="display: none;">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="res-is-current-site">
<label class="form-check-label" for="res-is-current-site">
Mark as Current Site
</label>
</div>
</div>
</div>
<div class="row mb-3" id="host-parent-container" style="display: none;">
<div class="col-12">
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
<select id="res-host-id" class="form-select shadow-sm border-primary">
<option value="">-- Select Parent --</option>
</select>
</div>
</div>
<div class="row mb-3" id="host-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">VMID</label>
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
</div>
<div class="col-4">
<label class="form-label">MAC Address</label>
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
</div>
<div class="col-4">
<label class="form-label">OS / Kernel</label>
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
</div>
</div>
<div class="row mb-3" id="service-ports-container" style="display: none;">
<div class="col-6">
<label class="form-label">Internal Port</label>
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
</div>
<div class="col-6">
<label class="form-label">External Port</label>
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
<small class="text-muted">Same as Internal if empty</small>
</div>
</div>
<div class="row mb-3" id="service-details-container" style="display: none;">
<div class="col-4">
<label class="form-label">Git Repo</label>
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
</div>
<div class="col-4">
<label class="form-label">Install Path</label>
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
</div>
<div class="col-4">
<label class="form-label">Systemd Service</label>
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
</div>
</div>
<div id="oauth-details-container" style="display: none;">
<hr>
<h5>OAuth Configuration</h5>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
</div>
<div class="row mb-3">
<div class="col-6">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
</div>
<div class="col-6">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
</div>
</div>
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
<button class="btn btn-outline-warning" onclick="rotateSecret()">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
</button>
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
</div>
</div>
<div class="row mb-3">
<div class="col-6" id="external-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-external">
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
</div>
</div>
<div class="col-6" id="public-container" style="display: none;">
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="res-is-public">
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
</div>
</div>
</div>
`;
var groupsTabHtml = `
<div class="mb-3" id="groups-tab-container">
<div class="actionMessage" style="display:none"></div>
<ul class="list-group mb-2 shadow-sm" id="groups-list"></ul>
<div class="input-group shadow-sm mt-2">
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
<datalist id="ldap-groups-datalist"></datalist>
<select class="form-select" id="new-group-level" style="max-width: 140px;">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
`;
var childrenTabHtml = `
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
<button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
<i class="fa-solid fa-plus"></i> Add Child Resource
</button>
</div>
<hr>
<h6 class="text-muted">Other Relationships (advanced)</h6>
<div class="mb-3">
<ul class="list-group mb-2 shadow-sm" id="edges-list"></ul>
<div class="input-group shadow-sm mt-2">
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
<option value="parent">Has child</option>
<option value="child">Is child of</option>
</select>
<select class="form-select" id="new-edge-target">
<option value="">-- Select Resource --</option>
</select>
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
</div>
</div>
`;
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
// the Groups/Children tabs in add-mode (no resource id to scope them to).
function openResourceModal(title, id) {
app.modal.open({
title: title,
size: 'lg',
tabs: [
{id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
],
footer: {
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
},
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
});
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
}
function refreshChildrenUI(resourceId) {
const kids = allEdges.filter(e => e.parentId === resourceId)
.map(e => resourcesById[e.childId]).filter(Boolean);
const $list = $('#children-list').empty();
if (!kids.length) {
$list.append('<li class="list-group-item text-muted">No child resources.</li>');
return;
}
for (const kid of kids) {
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
$('<a href="#" class="text-reset text-decoration-none"></a>')
.text(kid.name + ' (' + kid.slug + ')')
.on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
.appendTo($li);
$('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
$list.append($li);
}
}
var resourcesById = {};
var allGroups = [];
var allEdges = [];
var rawResources = [];
$(document).ready(async function() {
await loadResources();
const slug = app.modal.deepLinkSlug('/directory');
if (slug) {
const match = Object.values(resourcesById).find(r => r.slug === slug);
if (match) openEditModal(match.id);
}
});
async function loadResources() {
try {
const [resResources, resGroups, resEdges] = await Promise.all([
app.api.get('directory-admin/resources'),
app.api.get('directory-admin/groups'),
app.api.get('directory-admin/edges')
]);
resourcesById = {};
for (const r of resResources.results) {
r.metadata = r.metadata || {};
resourcesById[r.id] = r;
}
allGroups = resGroups.results;
allEdges = resEdges.results;
rawResources = [];
for (const r of resResources.results) {
// Compute hostName from edges
r.hostName = '—';
r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) {
r.parentId = parentEdge.parentId;
const parent = resourcesById[parentEdge.parentId];
if (parent) r.hostName = parent.name;
}
rawResources.push(r);
}
renderTable();
} catch (err) {
console.error(err);
app.messages.toast('Failed to load data', 'danger');
}
}
function renderTable() {
const filter = $('#search-filter').val().toLowerCase();
const sort = $('#sort-by').val();
let filtered = rawResources.filter(r => {
if (!filter) return true;
return (r.name || '').toLowerCase().includes(filter) ||
(r.slug || '').toLowerCase().includes(filter) ||
(r.kind || '').toLowerCase().includes(filter) ||
(r.metadata?.subType || '').toLowerCase().includes(filter) ||
(r.metadata?.ip || '').toLowerCase().includes(filter) ||
(r.hostName || '').toLowerCase().includes(filter);
});
// Sort
filtered.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'kind') return a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
if (sort === 'env') {
const ae = a.metadata?.isProduction ? 0 : 1;
const be = b.metadata?.isProduction ? 0 : 1;
return ae - be || a.name.localeCompare(b.name);
}
return 0;
});
let finalRenderList = [];
const map = {};
const roots = [];
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
filtered.forEach(r => {
const node = map[r.id];
if (node.parentId && map[node.parentId]) {
map[node.parentId].children.push(node);
} else {
roots.push(node);
}
});
const flatten = (nodes, depth) => {
nodes.forEach(n => {
let indentHtml = '';
for(let i = 0; i < depth; i++) {
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
}
if (depth > 0) {
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
}
n.indentHtml = indentHtml;
finalRenderList.push(n);
if (n.children.length > 0) {
flatten(n.children, depth + 1);
}
});
};
flatten(roots, 0);
$.scope.resources.empty();
for (const r of finalRenderList) {
$.scope.resources.push(r);
}
}
function toggleFormFields() {
const kind = $('#res-kind').val();
if (kind === 'host') {
$('#host-parent-container').show();
$('#host-details-container').show();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').hide();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').hide();
} else if (kind === 'service') {
$('#host-parent-container').show();
$('#host-details-container').hide();
$('#service-ports-container').show();
$('#service-details-container').show();
$('#oauth-details-container').hide();
$('#external-container').show();
$('#public-container').show();
$('#site-details-container').hide();
} else if (kind === 'oauth') {
$('#host-parent-container').show();
$('#host-details-container').hide();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').show();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').hide();
} else { // site
$('#host-parent-container').hide();
$('#host-details-container').hide();
$('#service-ports-container').hide();
$('#service-details-container').hide();
$('#oauth-details-container').hide();
$('#external-container').hide();
$('#public-container').hide();
$('#site-details-container').show();
}
populateHostDropdown($('#res-host-id').val());
}
// Delegated (not direct) since app.modal.open() rebuilds the modal body
// from scratch every call -- a direct .on() bind here would silently stop
// firing after the first Add/Edit.
app.modal.on('input change', '#res-name, #res-kind', function() {
const id = $('#res-id').val();
if (!id && $('#res-name').val()) {
const name = $('#res-name').val();
const kind = $('#res-kind').val();
let prefix = '';
if (kind === 'service') prefix = 'app_';
if (kind === 'host') prefix = 'host_';
if (kind === 'site') prefix = 'site_';
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
$('#res-slug').val(slug);
}
});
function openAddModal(parentId, parentKind) {
openResourceModal('Add Resource', null);
$('#res-id').val('');
$('#res-name').val('');
$('#res-slug').val('');
let defaultKind = 'service';
if (parentKind === 'site') defaultKind = 'host';
if (parentKind === 'host') defaultKind = 'service';
$('#res-kind').val(defaultKind);
if (!parentId && defaultKind === 'service') {
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (currentSite) parentId = currentSite.id;
}
if (!parentId && defaultKind === 'host') {
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (currentSite) parentId = currentSite.id;
}
$('#res-description').val('');
$('#res-ip').val('');
$('#res-address').val('');
$('#res-subtype').val('');
$('#res-vmid').val('');
$('#res-mac').val('');
$('#res-os').val('');
$('#res-port').val('');
$('#res-external-port').val('');
$('#res-git-repo').val('');
$('#res-install-path').val('');
$('#res-systemd').val('');
$('#res-is-production').prop('checked', false);
$('#res-is-external').prop('checked', false);
$('#res-is-public').prop('checked', false);
$('#res-is-current-site').prop('checked', false);
populateHostDropdown(parentId || '');
toggleFormFields();
}
var ldapGroupsCache = null;
async function loadLdapGroups() {
try {
if (!ldapGroupsCache) {
const res = await app.api.get('group');
ldapGroupsCache = res.results;
}
// Re-populate every call, not just the first -- #ldap-groups-datalist
// is rebuilt fresh (empty) on every app.modal.open(), so returning
// early here on a cache hit left the second and later modal opens
// with no autocomplete options at all.
const $datalist = $('#ldap-groups-datalist');
$datalist.empty();
for (const cn of ldapGroupsCache) {
$datalist.append($('<option>').val(cn));
}
} catch (err) {
console.error('Failed to load LDAP groups', err);
}
}
function populateHostDropdown(selectedId) {
const kind = $('#res-kind').val();
const $target = $('#res-host-id');
$target.empty().append('<option value="">-- Select Parent --</option>');
Object.values(resourcesById).forEach(r => {
if (r.id === $('#res-id').val()) return; // cannot be parent of itself
if (kind === 'host' && (r.kind === 'site' || r.kind === 'host')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'oauth' && r.kind === 'service') {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
}
});
if (selectedId) $target.val(selectedId);
}
// Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
// the modal body on every app.modal.open() call, and jq-repeat's
// MutationObserver-based (re)registration for a same-id element that's
// destroyed and recreated runs asynchronously -- populating it
// synchronously right after open() (as this function is called) would race
// that and silently lose the pushed data on the second and later opens.
// uid from a member/owner DN, e.g. "cn=jdoe,ou=people,..." -> "jdoe".
function uidFromDn(dn) {
const m = dn.match(/cn=[a-zA-Z0-9_\-@.]+/);
return m ? m[0].replace('cn=', '') : dn;
}
var directoryUserCache = null;
async function loadDirectoryUsers() {
if (!directoryUserCache) {
const data = await app.user.list();
directoryUserCache = data.results;
}
return directoryUserCache;
}
// Renders one associated group's member/owner management block: a
// collapsible member list (remove button per member) plus an "add member"
// dropdown of users not already in the group. Fetches the live LDAP group
// detail (member/owner DN arrays) rather than relying on `allGroups`, which
// only holds the resource<->group association record, not membership.
async function renderGroupMembership(g, resourceId) {
const $li = $('<li class="list-group-item"></li>');
const $head = $('<div class="d-flex justify-content-between align-items-center"></div>');
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);
$head.append($span);
const collapseId = 'group-members-' + g.id;
const $actions = $('<span></span>');
$('<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-bs-toggle="collapse"><i class="fa-solid fa-user-group"></i> Members</button>')
.attr('data-bs-target', '#' + collapseId)
.appendTo($actions);
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
.on('click', function(){ removeGroup(g.id); })
.appendTo($actions);
$head.append($actions);
$li.append($head);
const $collapse = $('<div class="collapse mt-2"></div>').attr('id', collapseId);
$li.append($collapse);
let detail, userlist;
try {
[detail, userlist] = await Promise.all([
app.group.get(g.groupCn).then(d => d.results),
loadDirectoryUsers(),
]);
} catch (err) {
console.error(err);
$collapse.append('<p class="text-danger small mb-0">Failed to load group membership.</p>');
return $li;
}
const memberDns = Array.isArray(detail.member) ? detail.member : (detail.member ? [detail.member] : []);
const $memberList = $('<ul class="list-group list-group-flush mb-2"></ul>');
if (!memberDns.length) {
$memberList.append('<li class="list-group-item text-muted px-0">No members.</li>');
}
for (const dn of memberDns) {
const uid = uidFromDn(dn);
const $mLi = $('<li class="list-group-item d-flex justify-content-between align-items-center px-0"></li>');
$('<span></span>').append('<i class="fa-solid fa-user me-1"></i>').append(document.createTextNode(uid)).appendTo($mLi);
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-user-slash"></i></button>')
.on('click', function(){ removeResourceGroupMember(g.groupCn, uid, resourceId); })
.appendTo($mLi);
$memberList.append($mLi);
}
$collapse.append($memberList);
const toAdd = userlist.filter(function(u){ return !memberDns.includes(u.dn); });
const $dropdown = $('<div class="dropdown"></div>');
$('<button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown"><i class="fa-solid fa-user-plus"></i> Add member</button>')
.appendTo($dropdown);
const $menu = $('<div class="dropdown-menu shadow-lg" style="max-height: 240px; overflow-y: auto;"></div>');
for (const u of toAdd) {
$('<a class="dropdown-item" href="#"></a>').text(u.uid)
.on('click', function(e){ e.preventDefault(); addResourceGroupMember(g.groupCn, u.uid, resourceId); })
.appendTo($menu);
}
$dropdown.append($menu);
$collapse.append($dropdown);
return $li;
}
async function refreshGroupsUI(resourceId) {
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
const $list = $('#groups-list').empty();
if (!myGroups.length) {
$list.append('<li class="list-group-item text-muted">No associated groups.</li>');
return;
}
for (const g of myGroups) {
$list.append(await renderGroupMembership(g, resourceId));
}
}
async function addResourceGroupMember(groupCn, uid, resourceId) {
try {
const data = await app.api.put('group/' + groupCn + '/' + uid, {});
await refreshGroupsUI(resourceId);
app.messages.action(data.message, $('#groups-tab-container'), 'success');
app.util.revealItem($('#groups-list'));
} catch (err) {
app.messages.action((err && err.message) || 'Failed to add member', $('#groups-tab-container'), 'danger');
}
}
async function removeResourceGroupMember(groupCn, uid, resourceId) {
const confirmed = await app.messages.confirm('Remove "' + uid + '" from "' + groupCn + '"?', $('#groups-tab-container'), 'warning');
if (!confirmed) return;
try {
const data = await app.api.delete('group/' + groupCn + '/' + uid);
await refreshGroupsUI(resourceId);
app.messages.action(data.message, $('#groups-tab-container'), 'success');
} catch (err) {
app.messages.action((err && err.message) || 'Failed to remove member', $('#groups-tab-container'), 'danger');
}
}
function refreshEdgesUI(resourceId) {
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
const $list = $('#edges-list').empty();
if (!myEdges.length) {
$list.append('<li class="list-group-item text-muted">No other relationships.</li>');
}
for (const e of myEdges) {
const isParent = e.parentId === resourceId;
const targetId = isParent ? e.childId : e.parentId;
const target = resourcesById[targetId];
if (!target) continue;
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
const $span = $('<span></span>');
$span.append(isParent
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
: '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
$('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
$('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
$li.append($span);
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
.on('click', function(){ removeEdge(e.id); })
.appendTo($li);
$list.append($li);
}
const $target = $('#new-edge-target');
$target.empty().append('<option value="">-- Select Resource --</option>');
Object.values(resourcesById).forEach(r => {
if (r.id !== resourceId) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
}
});
}
async function openEditModal(id) {
const r = resourcesById[id];
if (!r) return;
openResourceModal('Edit Resource', id);
$('#res-id').val(r.id);
$('#res-name').val(r.name);
$('#res-slug').val(r.slug);
$('#res-kind').val(r.kind);
$('#res-description').val(r.description || '');
$('#res-ip').val(r.metadata.ip || '');
$('#res-address').val(r.metadata.address || '');
$('#res-subtype').val(r.metadata.subType || '');
$('#res-vmid').val(r.metadata.vmid || '');
$('#res-mac').val(r.metadata.macAddress || '');
let osKernel = '';
if (r.metadata.os) osKernel += r.metadata.os;
if (r.metadata.kernel) osKernel += (osKernel ? ' / ' : '') + r.metadata.kernel;
$('#res-os').val(osKernel);
$('#res-port').val(r.metadata.port || '');
$('#res-external-port').val(r.metadata.externalPort || '');
$('#res-git-repo').val(r.metadata.gitRepo || '');
$('#res-install-path').val(r.metadata.installPath || '');
$('#res-systemd').val(r.metadata.systemdService || '');
$('#res-is-production').prop('checked', !!r.metadata.isProduction);
$('#res-is-external').prop('checked', !!r.metadata.isExternalReachable);
$('#res-is-public').prop('checked', !!r.metadata.isPublic);
$('#res-is-current-site').prop('checked', !!r.metadata.isCurrentSite);
$('#res-redirect-uris').val((r.metadata.redirect_uris || []).join('\n'));
$('#res-scopes').val((r.metadata.scopes || []).join(' '));
$('#res-allowed-groups').val((r.metadata.allowed_groups || []).join(' '));
$('#res-access-ttl').val((r.metadata.token_lifetime || {}).access_token || 3600);
$('#res-refresh-ttl').val((r.metadata.token_lifetime || {}).refresh_token || 2592000);
if (r.kind === 'oauth') $('#oauth-rotate-container').show();
else $('#oauth-rotate-container').hide();
// Find parent host
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
toggleFormFields();
refreshGroupsUI(r.id);
refreshEdgesUI(r.id);
refreshChildrenUI(r.id);
await loadLdapGroups();
}
async function saveResource() {
const id = $('#res-id').val();
const data = {
name: $('#res-name').val(),
slug: $('#res-slug').val(),
kind: $('#res-kind').val(),
hostId: ['host', 'service', 'oauth'].includes($('#res-kind').val()) ? $('#res-host-id').val() : undefined,
description: $('#res-description').val(),
metadata: {
subType: $('#res-subtype').val(),
ip: $('#res-ip').val(),
address: $('#res-address').val(),
vmid: $('#res-vmid').val(),
macAddress: $('#res-mac').val(),
os: $('#res-os').val(),
port: $('#res-port').val(),
externalPort: $('#res-external-port').val() || $('#res-port').val(),
gitRepo: $('#res-git-repo').val(),
installPath: $('#res-install-path').val(),
systemdService: $('#res-systemd').val(),
isProduction: $('#res-is-production').is(':checked'),
isExternalReachable: $('#res-is-external').is(':checked'),
isPublic: $('#res-is-public').is(':checked'),
isCurrentSite: $('#res-is-current-site').is(':checked')
}
};
if (data.kind === 'oauth') {
data.redirect_uris = $('#res-redirect-uris').val().split('\n').map(x => x.trim()).filter(Boolean);
data.scopes = $('#res-scopes').val().split(' ').map(x => x.trim()).filter(Boolean);
data.allowed_groups = $('#res-allowed-groups').val().split(' ').map(x => x.trim()).filter(Boolean);
data.token_lifetime = {
access_token: Number($('#res-access-ttl').val()) || 3600,
refresh_token: Number($('#res-refresh-ttl').val()) || 2592000
};
}
try {
let res;
if (id) {
res = await app.api.put('directory-admin/resources/' + id, data);
} else {
res = await app.api.post('directory-admin/resources', data);
}
await loadResources();
const savedId = id || (res.results && res.results.id);
if (savedId) setTimeout(function(){ app.util.revealItem($('#resource-row-' + savedId)); }, 400);
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
// Deliberately no app.modal.close() before this -- app.modal is a
// singleton, and close() immediately followed by open() in the same
// tick collides with Bootstrap's hide-transition guard (show()
// silently no-ops while _isTransitioning is still true from the
// just-started hide()). open() alone already overwrites the
// (already-visible) modal's content in place. The await above made
// this race unlikely to lose in practice, but not guaranteed to.
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>'});
} else {
app.modal.close();
}
} catch (err) {
console.error(err);
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
}
}
async function rotateSecret() {
const id = $('#res-id').val();
if (!id) return;
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', app.modal.body(), 'warning');
if (!ok) return;
try {
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
} catch (err) {
console.error(err);
app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
}
}
async function addGroup() {
const resourceId = $('#res-id').val();
const groupCn = $('#new-group-cn').val().trim();
const accessLevel = $('#new-group-level').val();
if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
try {
const res = await app.api.post('directory-admin/groups', {
resourceId,
groupCn,
accessLevel
});
allGroups.push(res.results);
refreshGroupsUI(resourceId);
$('#new-group-cn').val('');
} catch (err) {
console.error(err);
app.messages.action('Failed to add group', app.modal.body(), 'danger');
}
}
async function removeGroup(id) {
try {
await app.api.delete('directory-admin/groups/' + id);
allGroups = allGroups.filter(g => g.id !== id);
refreshGroupsUI($('#res-id').val());
} catch (err) {
console.error(err);
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
}
}
async function addEdge() {
const resourceId = $('#res-id').val();
const dir = $('#new-edge-dir').val();
const targetId = $('#new-edge-target').val();
const relation = $('#new-edge-relation').val().trim() || 'hosts';
if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
const data = { relation };
if (dir === 'parent') {
data.parentId = resourceId;
data.childId = targetId;
} else {
data.parentId = targetId;
data.childId = resourceId;
}
try {
const res = await app.api.post('directory-admin/edges', data);
allEdges.push(res.results);
refreshEdgesUI(resourceId);
$('#new-edge-target').val('');
} catch (err) {
console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
}
}
async function removeEdge(id) {
try {
await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val());
} catch (err) {
console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
}
}
async function deleteResource(id) {
// Called from the outer table's row button, not from inside the resource
// modal — target the page's own card so the confirm/error renders
// somewhere actually visible.
const $target = $('#resources-list');
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
if (!ok) return;
try {
await app.api.delete('directory-admin/resources/' + id);
await loadResources();
} catch (err) {
console.error(err);
app.messages.action('Failed to delete', $target, 'danger');
}
}
</script>
<%- include('bottom') %>
+452
View File
@@ -0,0 +1,452 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin('app_sso_admin');
// ── Overview (stats, recent signups, inactive users) ────────────────────
async function loadDashboard() {
try {
const stats = await app.api.get('user/stats');
// Stat cards
document.getElementById('stat-total').textContent = stats.totalUsers;
document.getElementById('stat-active').textContent = stats.activeUsers;
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
document.getElementById('stat-groups').textContent = stats.totalGroups;
try {
const dirRes = await app.api.get('directory-admin/resources');
const resources = dirRes.results || [];
document.getElementById('stat-hosts').textContent = resources.filter(r => r.kind === 'host').length;
document.getElementById('stat-services').textContent = resources.filter(r => r.kind === 'service').length;
document.getElementById('stat-oauth').textContent = resources.filter(r => r.kind === 'oauth').length;
document.getElementById('directory-stats-row').style.display = '';
} catch (err) {}
document.getElementById('dashboard-overview').style.display = '';
} catch(e) {
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
location.replace('/');
} else {
console.error('Dashboard load error:', e);
}
}
}
async function exportUsers() {
const resp = await fetch('/api/user/export', {
headers: { 'auth-token': localStorage.getItem('APIToken') }
});
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'users.csv';
a.click();
}
async function loadMetrics() {
try {
const data = await app.api.get('metrics/executive');
if (data && data.results) {
const renderList = (items, id) => {
const el = document.getElementById(id);
el.innerHTML = '';
if (!items || items.length === 0) {
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
return;
}
items.forEach(item => {
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
${item.value}
<span class="badge bg-primary rounded-pill">${item.score}</span>
</li>`;
});
};
renderList(data.results.ips, 'metrics-ips');
renderList(data.results.users, 'metrics-users');
renderList(data.results.services, 'metrics-services');
}
} catch (e) {
console.error('Failed to load metrics:', e);
}
}
// ── Notifications (compose + history) ────────────────────────────────────
async function loadHistory() {
try {
const data = await app.api.get('notification');
const list = data.results || [];
list.forEach(function(n) {
n.created_on_fmt = moment(n.created_on).fromNow();
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
});
$.scope.notificationHistory.push(...list);
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load notification history:', e);
}
}
function formatFilterLabel(type, value, active_only) {
const suffix = active_only ? ' (active only)' : '';
if (type === 'group') return 'Groups: ' + value + suffix;
if (type === 'users') return 'Specific users';
if (type === 'all_active') return 'All active users';
if (type === 'all') return 'All users';
return type;
}
function toggleFilterInputs() {
// No radio is checked by default (see f-all-active below) — a "send to
// everyone" option must be a deliberate choice, not whatever happens to
// be pre-selected when someone's just trying the form out.
const checked = document.querySelector('input[name="notif-filter"]:checked');
const type = checked ? checked.value : null;
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
}
async function sendNotification() {
const subject = document.getElementById('notif-subject').value.trim();
const message = document.getElementById('notif-message').value.trim();
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
const groupValue = document.getElementById('notif-group').value.trim();
const usersValue = document.getElementById('notif-users').value.trim();
const activeOnly = document.getElementById('notif-active-only').checked;
const msgEl = document.getElementById('notif-result');
const $compose = $('#notif-subject').closest('.card-body');
if (!subject || !message) { app.messages.action('Subject and message are required.', $compose, 'danger'); return; }
if (!filterCheck) { app.messages.action('Choose who to send this to.', $compose, 'danger'); return; }
const filterType = filterCheck.value;
let filter_value = '';
if (filterType === 'group') filter_value = groupValue;
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
// Broadcasting to everyone is easy to trigger by accident while just
// trying the form out — make it a deliberate, confirmed action.
if (filterType === 'all' || filterType === 'all_active') {
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
const confirmed = await app.messages.confirm(`Send this notification to ${label}?`, $compose, 'warning');
if (!confirmed) return;
}
msgEl.className = 'alert alert-info mt-2';
msgEl.textContent = 'Sending…';
msgEl.style.display = '';
try {
const result = await app.api.post('notification', {
subject,
message,
filter_type: filterType,
filter_value,
active_only: activeOnly,
});
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
const n = result.results;
n.created_on_fmt = 'just now';
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
$.scope.notificationHistory.unshift([n]);
// Reset form
document.getElementById('notif-subject').value = '';
document.getElementById('notif-message').value = '';
} catch(e) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
}
}
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
loadMetrics();
});
</script>
<div class="container mt-4">
<div class="row mb-3">
<div class="col-12">
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
</div>
</div>
<!-- Stats Card -->
<div class="card shadow mb-4" id="dashboard-overview" style="display:none">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-chart-pie"></i> Overview Stats</div>
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="exportUsers()">
<i class="fa-solid fa-file-csv"></i> Export Users CSV
</button>
</div>
<div class="card-body bg-light">
<div class="row g-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center">
<div class="card-body">
<div class="display-6 fw-bold" id="stat-total">—</div>
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center border-success">
<div class="card-body">
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center border-danger">
<div class="card-body">
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm text-center border-info">
<div class="card-body">
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mt-1" id="directory-stats-row" style="display:none">
<div class="col-4">
<div class="card shadow-sm text-center border-secondary">
<div class="card-body">
<div class="display-6 fw-bold text-secondary" id="stat-hosts">—</div>
<div class="text-muted small"><i class="fa-solid fa-server"></i> Hosts</div>
</div>
</div>
</div>
<div class="col-4">
<div class="card shadow-sm text-center border-secondary">
<div class="card-body">
<div class="display-6 fw-bold text-secondary" id="stat-services">—</div>
<div class="text-muted small"><i class="fa-solid fa-layer-group"></i> Services</div>
</div>
</div>
</div>
<div class="col-4">
<div class="card shadow-sm text-center border-secondary">
<div class="card-body">
<div class="display-6 fw-bold text-secondary" id="stat-oauth">—</div>
<div class="text-muted small"><i class="fa-solid fa-plug"></i> OAuth Integrations</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Notifications Card -->
<div class="card shadow mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-paper-plane"></i> Notifications</div>
</div>
<ul class="nav nav-tabs px-3 pt-2 border-bottom-0" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-compose" type="button" role="tab"><i class="fa-solid fa-pencil"></i> Compose</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
</li>
</ul>
<div class="tab-content border-top">
<div class="tab-pane fade show active p-4" id="tab-compose" role="tabpanel">
<div class="row">
<div class="col-lg-8">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" class="form-control shadow-sm" id="notif-subject" placeholder="Maintenance window tonight" />
</div>
<div class="mb-3">
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
<textarea class="form-control shadow-sm" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Send to</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all-active">All active users</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all">All users (including inactive)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-group">Group members</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-users">Specific users</label>
</div>
</div>
<div id="notif-group-row" class="mb-3" style="display:none">
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow-sm" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
</div>
<div id="notif-users-row" class="mb-3" style="display:none">
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow-sm" id="notif-users" placeholder="wmantly, jsmith" />
</div>
<div id="notif-active-row" class="mb-3" style="display:none">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
<label class="form-check-label" for="notif-active-only">Active members only</label>
</div>
</div>
<button class="btn btn-primary shadow-sm" onclick="sendNotification()">
<i class="fa-solid fa-paper-plane"></i> Send
</button>
<div id="notif-result" style="display:none" class="mt-3"></div>
</div>
</div>
</div>
<div class="tab-pane fade" id="tab-history" role="tabpanel">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
<tr>
<th class="ps-3">Sent</th>
<th>Subject</th>
<th>Filter</th>
<th class="text-center">✓</th>
<th class="text-center pe-3">✗</th>
</tr>
</thead>
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
<tr>
<td class="ps-3"><small class="text-muted">{{created_on_fmt}}</small></td>
<td><small>{{subject}}</small></td>
<td><small class="text-muted">{{filter_label}}</small></td>
<td class="text-center text-success"><small>{{sent_count}}</small></td>
<td class="text-center text-danger pe-3"><small>{{failed_count}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- TOS Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
<!-- Actionable Metrics Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-chart-line"></i> Actionable Metrics (Last 7 Days)</div>
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="loadMetrics()">
<i class="fa-solid fa-rotate-right"></i> Refresh
</button>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="card shadow-sm h-100 border-danger">
<div class="card-header bg-danger text-white"><i class="fa-solid fa-shield-halved"></i> Top Failed IPs</div>
<ul class="list-group list-group-flush" id="metrics-ips">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm h-100 border-warning">
<div class="card-header bg-warning text-dark"><i class="fa-solid fa-user-xmark"></i> Top Failed Accounts</div>
<ul class="list-group list-group-flush" id="metrics-users">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm h-100 border-success">
<div class="card-header bg-success text-white"><i class="fa-solid fa-plug"></i> Top Services Used</div>
<ul class="list-group list-group-flush" id="metrics-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+43 -17
View File
@@ -32,14 +32,39 @@
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(){ app.util.revealItem($("#group-card-" + group)); }, 400);
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
}, 400, group);
} }
function applySort() { function applySort() {
@@ -64,53 +89,54 @@
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : '')); $('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
} }
async function tableAJAX() { async function tableAJAX(revealCn) {
let data = await app.group.list(); let data = await app.group.list();
allGroups = data.results.map(processGroup); allGroups = data.results.map(processGroup);
applyFilters(); applyFilters();
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
} }
async function removeMember(groupCN, uid, btn) { async function removeMember(groupCN, uid, btn) {
const $item = $(btn).closest('li'); const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning'); $item.addClass('list-group-item-warning');
const confirmed = await app.util.actionConfirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning'); const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; } if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try { try {
const data = await app.api.delete(`group/${groupCN}/${uid}`); const data = await app.api.delete(`group/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN); const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results)); $.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success'); app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) { } catch(e) {
$item.removeClass('list-group-item-warning'); $item.removeClass('list-group-item-warning');
app.util.actionMessage(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger'); app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
} }
} }
async function removeOwner(groupCN, uid, btn) { async function removeOwner(groupCN, uid, btn) {
const $item = $(btn).closest('li'); const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning'); $item.addClass('list-group-item-warning');
const confirmed = await app.util.actionConfirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning'); const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; } if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try { try {
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`); const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN); const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results)); $.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success'); app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) { } catch(e) {
$item.removeClass('list-group-item-warning'); $item.removeClass('list-group-item-warning');
app.util.actionMessage(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger'); app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
} }
} }
async function deleteGroup(cn, btn) { async function deleteGroup(cn, btn) {
const $card = $(btn).closest('.card'); const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm(`Delete group "${cn}"?`, $card, 'danger'); const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
if (!confirmed) return; if (!confirmed) return;
try { try {
await app.api.delete(`group/${cn}`); await app.api.delete(`group/${cn}`);
$.scope.groupCard.remove('cn', cn); $.scope.groupCard.remove('cn', cn);
} catch(e) { } catch(e) {
app.util.actionMessage(e.message || 'Failed to delete group', $card, 'danger'); app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
} }
} }
@@ -121,9 +147,9 @@
tableAJAX(); tableAJAX();
}); });
</script> </script>
<div class="row" style="display:none;"> <div class="container mt-4">
<div class="d-flex flex-wrap gap-2 align-items-center"> <div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
<div class="input-group" style="flex: 1 1 200px;"> <div class="input-group" style="flex: 1 1 200px;">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span> <span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()"> <input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
@@ -146,7 +172,7 @@
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX('')"> <form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Name</label> <label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" /> <input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
@@ -214,7 +240,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 }}
+2 -2
View File
@@ -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');
}); });
}); });
-598
View File
@@ -1,598 +0,0 @@
<%- include('top') %>
<!-- Edit OAuth client modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="card-header actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="edit-client-id">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" id="edit-name" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="edit-description" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes</label>
<div id="edit-scopes"></div>
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
<div id="edit-allowed_groups"></div>
</div>
<div class="row mb-3">
<div class="col">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
</div>
<div class="col">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
</div>
</div>
</div>
</div>
<!-- Secret modal — shared by OAuth client secrets and service account passwords -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="secretModalTitle"><i class="fa-solid fa-key"></i> Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
app.auth.forceLogin(['app_sso_admin', 'app_sso_oauth_admin']);
// The scopes this provider actually understands (see routes/oauth.js discovery).
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
// Widget handles + a lookup of the latest client data (for the edit modal).
var createScopes, createGroups, editScopes, editGroups;
var clientsById = {};
function showSecret(secret, title){
document.getElementById('secretModalTitle').innerHTML = '<i class="fa-solid fa-key"></i> ' + (title || 'Secret');
document.getElementById('secretValue').value = secret;
secretModal.show();
}
function copySecret(){
copyField('secretValue');
}
// Copy the value of an input by id; briefly flips the button icon to a check.
function copyField(id, btn){
var el = document.getElementById(id);
if(!el) return;
el.select();
el.setSelectionRange(0, 99999);
document.execCommand('copy');
if(btn){
var $i = $(btn).find('i');
var prev = $i.attr('class');
$i.attr('class', 'fa-solid fa-check');
setTimeout(function(){ $i.attr('class', prev); }, 1200);
}
}
function fmtTTL(seconds){
if(seconds < 3600) return seconds + 's';
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
return (seconds / 86400).toFixed(1) + 'd';
}
function processClient(client){
clientsById[client.client_id] = client; // keep raw data for the edit modal
client.scopes_display = (client.scopes || []).join(' ');
client.allowed_groups_display = (client.allowed_groups || []).join(', ');
client.has_group_restriction = (client.allowed_groups || []).length > 0;
client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
return client;
}
async function tableAJAX(){
let data = await app.oauthClient.list();
$.scope.oauthClientCard.empty();
$.each(data.results, function(_, client){
$.scope.oauthClientCard.push(processClient(client));
});
}
async function deleteClient(client_id, name, btn){
const $card = $(btn).closest('.card');
$card.addClass('table-warning');
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
$card.removeClass('table-warning');
if (!confirmed) return;
app.api.delete('oauth/client/' + client_id, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
$.scope.oauthClientCard.remove('client_id', client_id);
});
}
async function rotateSecret(client_id, name, btn){
const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
if (!confirmed) return;
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
showSecret(data.client_secret, 'Client Secret');
});
}
// Open the edit modal pre-filled from the client's current values.
function editClient(client_id){
var c = clientsById[client_id];
if(!c) return;
$('#edit-client-id').val(client_id);
$('#edit-name').val(c.name || '');
$('#edit-description').val(c.description || '');
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
// (Re)build the tag widgets fresh each open so they reflect this client.
editScopes = app.ui.tagInput('#edit-scopes', {
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
separator: ' ', placeholder: 'Add a scope…',
});
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
values: c.allowed_groups || [], placeholder: 'Type a group name…',
});
editModal.show();
}
function saveEdit(btn){
var $msg = $('#editModal .actionMessage');
var payload = {
client_id: $('#edit-client-id').val(),
name: $('#edit-name').val(),
description: $('#edit-description').val(),
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
scopes: editScopes.get(),
allowed_groups: editGroups.get(),
token_lifetime: {
access_token: Number($('#edit-access_ttl').val()) || 3600,
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
},
};
app.oauthClient.update(payload, function(error, data){
if(error){
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
return;
}
editModal.hide();
tableAJAX();
});
}
$(document).ready(function(){
tableAJAX();
// Initialise the create-form tag widgets.
createScopes = app.ui.tagInput('#create-scopes', {
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
});
createGroups = app.ui.groupSelect('#create-allowed_groups', {
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
});
// After a successful create, reset the widgets too (form reset ignores them).
$('form[action="oauth/client/"]').attr('evalAJAX',
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
);
});
</script>
<h4><i class="fa-solid fa-plug"></i> Integrations</h4>
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-oauth-btn" data-bs-toggle="tab" data-bs-target="#tab-oauth" type="button" role="tab">
<i class="fa-solid fa-key"></i> OAuth Apps
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-ldap-btn" data-bs-toggle="tab" data-bs-target="#tab-ldap" type="button" role="tab">
<i class="fa-solid fa-network-wired"></i> LDAP
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-oauth" role="tabpanel">
<div class="row" style="display:none">
<div class="col-12 mb-3">
<div class="card shadow-sm border-info">
<div class="card-header bg-info bg-opacity-10">
<i class="fa-solid fa-circle-info"></i>
OpenID Connect Endpoints
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-body">
<p class="mb-2 text-muted small">
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
It advertises the authorization, token, and userinfo endpoints automatically.
</p>
<dl class="row mb-0">
<dt class="col-sm-2">Issuer</dt>
<dd class="col-sm-10"><code><%= issuer %></code></dd>
<dt class="col-sm-2">Discovery URL</dt>
<dd class="col-sm-10">
<div class="input-group input-group-sm">
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header">
<i class="fa-solid fa-plus"></i>
Register OAuth Client
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
</div>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label and <code>**</code> matches any
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
covers every host theta42/proxy fronts under example.com without registering
each one individually.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes</label>
<div id="create-scopes"></div>
</div>
<div class="mb-3">
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
<div id="create-allowed_groups"></div>
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
</div>
<div class="row mb-3">
<div class="col">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
</div>
<div class="col">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
</div>
</div>
<button type="submit" class="btn btn-outline-dark">
<i class="fa-solid fa-plus"></i> Register
</button>
</form>
</div>
</div>
</div>
<div class="col-md-8" style="background-color: initial; border: none">
<div class="card-header actionMessage" style="display:none"></div>
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
<div class="card-header">
<h5>
<i class="fa-solid fa-server"></i>
{{ name }}
</h5>
<small class="text-muted font-monospace">{{ client_id }}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{ #description }}
<p>{{ description }}</p>
{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Client ID</dt>
<dd class="col-sm-9">
<div class="input-group input-group-sm">
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-3">Redirect URIs</dt>
<dd class="col-sm-9">
<ul class="list-unstyled mb-0">
{{ #redirect_uris }}
<li><code>{{ . }}</code></li>
{{ /redirect_uris }}
</ul>
</dd>
<dt class="col-sm-3">Scopes</dt>
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
<dt class="col-sm-3">Access</dt>
<dd class="col-sm-9">
{{ #has_group_restriction }}
<span class="badge bg-warning text-dark"><i class="fa-solid fa-user-lock"></i> Restricted</span>
<code>{{ allowed_groups_display }}</code>
{{ /has_group_restriction }}
{{ ^has_group_restriction }}
<span class="badge bg-secondary"><i class="fa-solid fa-users"></i> Any user</span>
{{ /has_group_restriction }}
</dd>
<dt class="col-sm-3">Access Token</dt>
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
<dt class="col-sm-3">Refresh Token</dt>
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
<dt class="col-sm-3">Created by</dt>
<dd class="col-sm-9">{{ created_by }}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button"
onclick="editClient('{{client_id}}')"
class="btn btn-primary btn-sm">
<i class="fa-solid fa-pen-to-square"></i> Edit
</button>
<button type="button"
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
class="btn btn-warning btn-sm">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
</button>
<button type="button"
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
class="btn btn-danger btn-sm float-end">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="tab-ldap" role="tabpanel">
<p class="text-muted">
Everything a 3rd-party app or host needs to bind this directory, filled in
for <b><%= ssoUrl %></b>.
</p>
<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning bg-opacity-10">
<i class="fa-solid fa-triangle-exclamation"></i>
LDAPS hostname: keep LDAP binds off the public internet
</div>
<div class="card-body">
<p class="small mb-2">
LDAPS requires a <strong>hostname</strong>, not a bare IP address, because
the TLS client verifies the server name against the certificate.
The URL below <% if (ldapsHostExplicit) { %>is set to <code><%= ldapHost %></code> from
<code>conf.ldap.ldapsHost</code>.<% } else { %>currently matches the public
OAuth issuer host — convenient, but that implies clients reach it through
your router on port 636. <strong>Do not port-forward 636 to the internet</strong>
for LDAP simple binds; instead pick an internal-only hostname and set
<code>conf.ldap.ldapsHost</code>.<% } %>
</p>
<ul class="small mb-2">
<li><strong>Same Docker/network host (recommended for the proxy or apps on this machine):</strong>
use <code>ldaps://sso-manager:636</code> (the internal service name).
Set <code>conf.ldap.ldapsHost = 'sso-manager'</code>.</li>
<li><strong>LAN host:</strong> create an internal DNS record like
<code>ldap.internal.example.com</code> → the local IP, get or generate a cert
whose SAN matches that name, and set <code>conf.ldap.ldapsHost</code>.
A wildcard for <code>*.internal.example.com</code> works well.</li>
<li><strong>Public hostname:</strong> only acceptable behind a VPN or firewall
lockdown — never exposed to the open internet.</li>
</ul>
<p class="small mb-0">
<b>Trusting the cert:</b> The bundled slapd uses a self-signed cert unless you
mount your own at <code>/etc/openldap/certs</code>. Clients must either trust
that cert, or set <code>TLS_REQCERT never</code> / <code>rejectUnauthorized: false</code>
for LAN-only use. See <a href="/docs/ldap">LDAP docs</a> for the full
runbook, including how to set <code>ldapsHost</code> in
<code>conf/secrets.js</code> or via <code>app_ldap__ldapsHost=...</code>.
</p>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-circle-info"></i> Connection details
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-body">
<p class="text-muted small">
For a single app's own "LDAP authentication" settings — see
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
</p>
<dl class="row mb-0">
<dt class="col-sm-4">LDAPS URL</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
<% if (ldapsHostExplicit) { %>
<small class="field-help text-muted d-block">
Custom <code>conf.ldap.ldapsHost</code> — override in your secrets file if this name doesn't resolve from the client.
</small>
<% } %>
</dd>
<dt class="col-sm-4">Base DN</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">User search base</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">Group search base</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">User filter</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">Username attribute</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
</dd>
<dt class="col-sm-4">Example bind DN</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div>
<small class="field-help text-muted d-block">
A read-only bind account — create one from
<a href="/users">Users &gt; Service Accounts</a> (don't reuse a real
person's login or the admin DN).
</small>
</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-body">
<p class="text-muted small">
For full host login, SSH keys, and sudo via LDAP (not just one app) —
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
and run this on the host. Fill in a service account's password
(create one from <a href="/users">Users &gt; Service Accounts</a>) and,
if you want this host's access/sudo groups auto-registered, an
<a href="/">API token</a> from your Profile.
</p>
<div class="input-group">
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
</div>
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
<i class="fa-solid fa-copy"></i> Copy
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
(function(){
var lines = [
'git clone https://github.com/theta42/ldap-client.git',
'cd ldap-client',
'cat > ldap.vars << \'EOF\'',
'# LDAPS host advertised on the Integrations page. If this is an internal-only',
'# hostname, make sure it resolves from this host and the cert SAN matches it.',
'export ldap_host="<%= ldapHost %>"',
'export ldap_base_dn="<%= baseDn %>"',
'',
'# A read-only service account -- create one under Users > Service',
'# Accounts, then fill in its password below.',
'export ldap_bind_dn="<%= exampleBindDn %>"',
'export ldap_bind_password="CHANGE-ME"',
'',
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
'# Manager. Create a personal access token under Profile > API Tokens',
'# and paste it here; leave blank to skip.',
'export sso_url="<%= ssoUrl %>"',
'export sso_token=""',
'',
'# Optional: set this if you run ldap-client against more than one site.',
'export ldap_location=""',
'',
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
'EOF',
'',
'sudo ./index.sh',
];
document.getElementById('f-bashSnippet').value = lines.join('\n');
})();
</script>
<%- include('bottom') %>
+1 -1
View File
@@ -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(){
-306
View File
@@ -1,306 +0,0 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['app_sso_admin', 'app_sso_invite']);
let allGroups = [];
let currentUserUid = null;
let isAdmin = false;
async function init() {
const [user, groupData] = await Promise.all([
app.api.get('user/me'),
app.api.get('group/'),
]);
currentUserUid = user.uid;
isAdmin = (user.memberOf || []).some(dn => dn.startsWith('cn=app_sso_admin,'));
allGroups = groupData.results || [];
// Populate create-form group select
populateGroupSelect('create-groups', allGroups, []);
loadInvites();
}
function fuzzyMatch(query, text) {
query = query.toLowerCase();
text = text.toLowerCase();
let qi = 0;
for (let i = 0; i < text.length && qi < query.length; i++) {
if (text[i] === query[qi]) qi++;
}
return qi === query.length;
}
function filterGroups(inputEl, selectId) {
const query = inputEl.value;
[...document.getElementById(selectId).options].forEach(function(opt) {
opt.hidden = query ? !fuzzyMatch(query, opt.value) : false;
});
}
function populateGroupSelect(id, groups, selected) {
const sel = document.getElementById(id);
sel.innerHTML = '';
groups.forEach(function(cn) {
const opt = document.createElement('option');
opt.value = cn;
opt.textContent = cn;
opt.selected = selected.includes(cn);
sel.appendChild(opt);
});
}
function inviteStatus(t) {
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
return { label: 'Pending', cls: 'text-success' };
}
function groupsDisplay(groups) {
try {
const arr = JSON.parse(groups || '[]');
return arr.length ? arr.join(', ') : '—';
} catch(_) { return '—'; }
}
async function loadInvites() {
try {
const data = await app.api.get('user/invite');
const tbody = document.getElementById('invite-tbody');
tbody.innerHTML = '';
(data.results || [])
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
.forEach(function(t) {
const status = inviteStatus(t);
const canEdit = isAdmin || t.created_by === currentUserUid;
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
const tr = document.createElement('tr');
tr.dataset.token = t.token;
tr.innerHTML = `
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
<td><small>${t.created_by}</small></td>
<td><small>${mail}</small></td>
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
<td><small class="${status.cls}">${status.label}</small></td>
<td class="text-nowrap">
${canEdit && isPending ? `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
<i class="fa-solid fa-ban"></i>
</button>
` : ''}
${isPending ? `
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
<i class="fa-solid fa-copy"></i>
</button>
` : ''}
</td>
`;
tbody.appendChild(tr);
});
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load invites:', e);
}
}
async function createInvite() {
const mail = document.getElementById('create-email').value.trim();
const groups = [...document.getElementById('create-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('create-result');
result.style.display = 'none';
try {
const data = await app.api.post('user/invite', { mail, groups });
result.style.display = '';
if (data.mail_sent) {
result.className = 'alert alert-success mt-2';
result.textContent = `Invite sent to ${mail}`;
} else {
result.className = 'alert alert-info mt-2';
result.innerHTML = `Link: <a href="${data.link}" target="_blank">${data.link}</a>`;
}
document.getElementById('create-email').value = '';
[...document.getElementById('create-groups').options].forEach(o => o.selected = false);
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
function openEdit(tokenId) {
// Find cached data from the DOM isn't reliable — re-fetch
app.api.get('user/invite').then(function(data) {
const t = (data.results || []).find(x => x.token === tokenId);
if (!t) return;
document.getElementById('edit-token').value = tokenId;
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
const selected = JSON.parse(t.groups || '[]');
populateGroupSelect('edit-groups', allGroups, selected);
const modal = new bootstrap.Modal(document.getElementById('editModal'));
modal.show();
});
}
async function saveEdit() {
const tokenId = document.getElementById('edit-token').value;
const mail = document.getElementById('edit-email').value.trim();
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('edit-result');
result.style.display = 'none';
try {
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
async function revokeInvite(tokenId, btn) {
$thisRow = $(btn).closest('tr');
$thisRow.addClass('table-warning');
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
if(!confirmation){
$thisRow.removeClass('table-warning');
return;
}
// lets not use `confirm``
// if (!confirm('Revoke this invite token?')) return;
try {
await app.api.delete(`user/invite/${tokenId}`);
loadInvites();
} catch(e) {
alert('Failed to revoke invite.');
}
}
function copyLink(tokenId, btn) {
const link = `${location.origin}/login/invite/${tokenId}`;
navigator.clipboard.writeText(link).then(function() {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = orig, 1500);
});
}
$(document).ready(function() {
init();
});
</script>
<div class="row">
<div class="col-12">
<div class="d-flex align-items-center mb-3 mt-2">
<h4 class="mb-0"><i class="fa-solid fa-envelope-open-text"></i> Invites</h4>
</div>
<div class="row g-3">
<!-- Create invite -->
<div class="col-lg-4">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-plus"></i> Create Invite
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional)</small></label>
<input type="email" id="create-email" class="form-control shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'create-groups')" />
<select id="create-groups" class="form-select shadow" multiple size="5"></select>
</div>
<button class="btn btn-primary shadow" onclick="createInvite()">
<i class="fa-solid fa-paper-plane"></i> Send Invite
</button>
<div id="create-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- Invite list -->
<div class="col-lg-8">
<div class="card shadow-lg">
<div class="card-header shadow d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-list"></i> Invite Tokens</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadInvites()">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
<div class="card-header actionMessage" style="display:none;"></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Created</th>
<th>By</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="invite-tbody">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-token" />
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
</div>
<div class="mb-3">
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
</div>
<div id="edit-result" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+136
View File
@@ -0,0 +1,136 @@
<%- include('top') %>
<style>
/* App Portal styling using Bootstrap defaults */
.portal-banner {
background-color: var(--bs-primary);
color: white;
padding: 3rem 1rem;
margin-bottom: 2rem;
border-radius: .5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.portal-banner h1 {
font-weight: 700;
}
.carousel-container {
display: flex;
overflow-x: auto;
gap: 1.5rem;
padding-bottom: 1.5rem;
scrollbar-width: thin;
}
.service-card {
min-width: 280px;
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
}
.service-card .card-body {
flex: 1;
}
</style>
<div class="container mt-4">
<div class="portal-banner text-center">
<h1>SSO Portal</h1>
<p class="lead">Explore and access all your services in one place.</p>
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
</div>
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
<div class="card shadow-sm service-card border-success">
<div class="card-body">
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
</div>
<div class="card-footer bg-transparent border-top-0 pt-0">
<span class="badge bg-success">Access Granted</span>
</div>
</div>
</a>
</div>
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
<div class="card-body">
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
</div>
<div class="card-footer bg-transparent border-top-0 pt-0">
<span class="badge bg-secondary">Request Access</span>
</div>
</div>
</div>
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
<div class="card shadow-sm service-card" style="min-width: 280px;">
<div class="card-body">
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
</div>
</div>
</div>
</div>
<script type="text/javascript">
app.auth.forceLogin();
$(document).ready(async function() {
try {
let res = await app.api.get('discovery/me');
let allAccessible = res.results || [];
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
let myServices = [];
let otherServices = [];
let hosts = [];
allAccessible.forEach(r => {
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
r.description = r.description || 'No description provided';
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
if (r.kind === 'host') hosts.push(r);
});
if (allRes && allRes.results) {
allRes.results.forEach(r => {
r.description = r.description || 'No description provided';
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
otherServices.push(r);
}
});
}
$.scope.myservices.empty();
$.scope.myservices.push(...myServices);
$.scope.otherservices.empty();
$.scope.otherservices.push(...otherServices);
$.scope.hosts.empty();
$.scope.hosts.push(...hosts);
} catch (e) {
console.error('Failed to load discovery data:', e);
}
});
function requestAccess(id) {
app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
}
</script>
+16
View File
@@ -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');
+1 -1
View File
@@ -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;
+7 -7
View File
@@ -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');
} }
} }
+242 -158
View File
@@ -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();
}); });
}); });
@@ -89,6 +89,47 @@
renderPersonalGroupMembers(currentUser); renderPersonalGroupMembers(currentUser);
} }
async function renderMyServices(){
try{
let res = await app.api.get('discovery/me');
res.results.forEach(r => {
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || 'N/A';
});
$.scope.myservices.empty();
$.scope.myservices.push(...res.results);
}catch(error){
console.error('renderMyServices error:', error)
}
}
async function renderMyMetrics(){
try{
let uid = currentUser.uid;
let res = await app.api.get(`metrics/user/${uid}`);
if (res && res.results) {
const renderList = (items, id) => {
const el = document.getElementById(id);
if(!el) return;
el.innerHTML = '';
if (!items || items.length === 0) {
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
return;
}
items.forEach(item => {
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
${item.value}
<span class="badge bg-primary rounded-pill">${item.score}</span>
</li>`;
});
};
renderList(res.results.services, 'metrics-user-services');
}
}catch(error){
console.error('renderMyMetrics error:', error)
}
}
async function determinUser(){ async function determinUser(){
if(location.pathname.includes('/users/')){ if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', ''); let uid = location.pathname.replace('/users/', '');
@@ -135,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);
}); });
@@ -143,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';
@@ -160,6 +201,11 @@
renderProfile(currentUser); renderProfile(currentUser);
renderUserGroups(currentUser); renderUserGroups(currentUser);
renderPersonalGroupMembers(currentUser); renderPersonalGroupMembers(currentUser);
renderMyServices();
if(!isOwnProfile) {
renderMyMetrics();
$('#user-metrics-card').show();
}
$('#personal-group-uid-label').text(currentUser.uid); $('#personal-group-uid-label').text(currentUser.uid);
addGroupSelect = app.ui.groupSelect('#add-group-select', { addGroupSelect = app.ui.groupSelect('#add-group-select', {
@@ -174,9 +220,6 @@
if(isOwnProfile){ if(isOwnProfile){
$('#own-api-tokens-section').show(); $('#own-api-tokens-section').show();
tableAJAX(); tableAJAX();
$('form[action="api-token/"]').attr('evalAJAX',
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
);
} }
}); });
</script> </script>
@@ -397,6 +440,60 @@
</div> </div>
</div> </div>
<div class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fa-solid fa-layer-group"></i>
My Services
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<th>Name</th>
<th>Address / IP</th>
<th>Description</th>
<th>Kind</th>
</thead>
<tbody jq-repeat="myservices">
<tr>
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
<td>{{description}}</td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
<div class="card-header shadow bg-success text-white">
<i class="fa-solid fa-chart-line"></i>
Security & Usage Stats (Last 7 Days)
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body p-0">
<div class="row m-0">
<div class="col-12 p-3">
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin"> <div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
<div class="card-header shadow"> <div class="card-header shadow">
<i class="fa-solid fa-people-group"></i> <i class="fa-solid fa-people-group"></i>
@@ -445,83 +542,35 @@
</div> </div>
</div> </div>
<!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-key"></i> API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer &lt;token&gt;</code></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<!-- Edit modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="card-header actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="edit-id">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" id="edit-name" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="edit-description" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
<input type="number" id="edit-expires_in_days" class="form-control shadow" min="0">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
</div>
</div>
</div>
</div>
<script type="text/javascript"> <script type="text/javascript">
// Any logged-in user can manage their own API tokens (self-service). // Any logged-in user can manage their own API tokens (self-service).
// Section is only revealed (see $(document).ready above) when isOwnProfile. // Section is only revealed (see $(document).ready above) when isOwnProfile.
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
var tokensById = {}; var tokensById = {};
function showSecret(secret){ // Shared "reveal secret once" display -- same pattern as jump-host's and
document.getElementById('secretValue').value = secret; // proxy's showToken().
secretModal.show(); function showToken(title, token){
app.modal.open({title: title, bodyHtml:
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.util.escapeHtml(token) + '">'
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
});
} }
function copySecret(){ copyField('secretValue'); } // Not the checkmark-flash technique this file used to use for its copy
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
function copyField(id, btn){ // swapping the <i>'s class silently no-ops. A toast doesn't have that
var el = document.getElementById(id); // problem.
if(!el) return; function copyFieldValue(sel){
el.select(); el.setSelectionRange(0, 99999); document.execCommand('copy'); var $el = $(sel);
if(btn){ var $i = $(btn).find('i'), prev = $i.attr('class'); var text = $el.val();
$i.attr('class', 'fa-solid fa-check'); setTimeout(function(){ $i.attr('class', prev); }, 1200); } if(!text) return;
navigator.clipboard.writeText(text).then(function(){
app.messages.toast('Copied to clipboard', 'success');
}, function(){
app.messages.toast('Could not copy — select and copy manually', 'danger');
});
} }
function fmtTime(ms){ function fmtTime(ms){
@@ -537,9 +586,9 @@
// expires_at is type:number (a real number); isExpired is a class getter // expires_at is type:number (a real number); isExpired is a class getter
// that is NOT serialized to the client, so compute expiry here. // that is NOT serialized to the client, so compute expiry here.
var exp = Number(token.expires_at); var exp = Number(token.expires_at);
if(!exp) return '<span class="badge bg-secondary">never</span>'; if(!exp) return '<span class="badge text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge bg-danger">expired</span>'; if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
return '<span class="badge bg-warning text-dark">' + moment(exp, "x").fromNow() + '</span>'; return '<span class="badge text-bg-warning">' + moment(exp, "x").fromNow() + '</span>';
} }
function processToken(token){ function processToken(token){
@@ -553,55 +602,107 @@
async function tableAJAX(){ async function tableAJAX(){
let data = await app.apiToken.list(); let data = await app.apiToken.list();
var tokens = data.results || [];
$.scope.apiTokenCard.empty(); $.scope.apiTokenCard.empty();
$.each(data.results, function(_, token){ tokens.forEach(function(token){
$.scope.apiTokenCard.push(processToken(token)); $.scope.apiTokenCard.push(processToken(token));
}); });
$('#api-tokens-empty').toggle(tokens.length === 0);
} }
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); showToken('API Token Rotated', data.token);
tableAJAX(); tableAJAX();
}); });
} }
function editToken(id){ // Create is a native <form>+formAJAX submission (matching this app's own
var t = tokensById[id]; if(!t) return; // hostModal-style convention) rather than a JS-built payload. Deliberately
$('#edit-id').val(id); // does NOT call app.modal.close() before showToken() -- app.modal is a
$('#edit-name').val(t.name || ''); // singleton, and close() immediately followed by open() in the same tick
$('#edit-description').val(t.description || ''); // collides with Bootstrap's hide-transition guard (show() silently
$('#edit-expires_in_days').val(''); // no-ops while _isTransitioning is still true from the just-started
editModal.show(); // hide()). open() alone already overwrites the (already-visible) modal's
// content in place.
function createApiToken(){
var $body = app.modal.open({
title: 'New API Token',
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>'
+ '<form id="newTokenForm" action="api-token/" method="post" onsubmit="formAJAX(this)" evalAJAX="showToken(\'API Token Created\', data.token); tableAJAX();">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control shadow" name="name" placeholder="CI user sync" validate=":1">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control shadow" name="description" placeholder="Used by the nightly sync job">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>'
+ '<input type="number" class="form-control shadow" name="expires_in_days" value="0" min="0">'
+ '</div>'
+ '</form>',
footer: {
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
+ '<button type="submit" form="newTokenForm" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>',
},
});
$body.find('[name=name]').focus();
} }
function saveEdit(btn){ function editToken(id){
var $msg = $('#editModal .actionMessage'); var t = tokensById[id]; if(!t) return;
app.modal.open({
title: 'Edit Token',
bodyHtml:
'<input type="hidden" id="edit-token-id" value="' + app.util.escapeHtml(id) + '">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control shadow" id="edit-token-name" value="' + app.util.escapeHtml(t.name || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control shadow" id="edit-token-description" value="' + app.util.escapeHtml(t.description || '') + '">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
+ '<input type="number" class="form-control shadow" id="edit-token-days" min="0">'
+ '</div>',
footer: {
metaHtml: 'Created by ' + app.util.escapeHtml(t.created_by || '—') + ' on ' + fmtTime(t.created_on),
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
},
});
}
function saveEditToken(){
var payload = { var payload = {
id: $('#edit-id').val(), id: $('#edit-token-id').val(),
name: $('#edit-name').val(), name: $('#edit-token-name').val(),
description: $('#edit-description').val(), description: $('#edit-token-description').val(),
expires_in_days: $('#edit-expires_in_days').val(), expires_in_days: $('#edit-token-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.', app.modal.body(), 'danger'); return; }
editModal.hide(); app.modal.close();
tableAJAX(); tableAJAX();
}); });
} }
@@ -611,62 +712,45 @@
runs before this page's own ready handler and would unhide any div.row runs before this page's own ready handler and would unhide any div.row
unconditionally, defeating the isOwnProfile check below. --> unconditionally, defeating the isOwnProfile check below. -->
<div id="own-api-tokens-section" style="display:none"> <div id="own-api-tokens-section" style="display:none">
<div class="row mt-3"> <div class="row mt-3 justify-content-center">
<div class="col-12">
<h5 class="mb-3"><i class="fa-solid fa-code"></i> API Tokens</h5>
</div>
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
<a href="/docs/api-tokens" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
<form action="api-token/" method="post" onsubmit="formAJAX(this)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="CI user sync" validate=":1">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control shadow" name="description" placeholder="Used by the nightly sync job">
</div>
<div class="mb-3">
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
<input type="number" class="form-control shadow" name="expires_in_days" value="0" min="0">
</div>
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>
</form>
</div>
</div>
</div>
<div class="col-md-8"> <div class="col-md-8">
<div class="card-header actionMessage" style="display:none"></div> <div class="card shadow-lg">
<div class="card-header d-flex justify-content-between align-items-center">
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3"> <span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
<div class="card-header"> <span>
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5> <a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<small class="text-muted font-monospace">{{ id_short }}</small> <button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
</span>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<p class="text-muted small px-3 pt-3 mb-0">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
<div class="card-body"> <div class="card-body">
{{ #description }}<p>{{ description }}</p>{{ /description }} <p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
<dl class="row mb-0"> <div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<dt class="col-sm-3">Token ID</dt> <div class="card-header">
<dd class="col-sm-9"><code>{{ id_short }}</code></dd> <h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<dt class="col-sm-3">Created</dt> <small class="text-muted font-monospace">{{ id_short }}</small>
<dd class="col-sm-9">{{{ created_display }}}</dd> </div>
<dt class="col-sm-3">Last used</dt> <div class="card-header actionMessage" style="display:none"></div>
<dd class="col-sm-9">{{{ last_used_display }}}</dd> <div class="card-body">
<dt class="col-sm-3">Expires</dt> {{ #description }}<p>{{ description }}</p>{{ /description }}
<dd class="col-sm-9">{{{ expires_display }}}</dd> <dl class="row mb-0">
</dl> <dt class="col-sm-3">Token ID</dt>
</div> <dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<div class="card-footer"> <dt class="col-sm-3">Created</dt>
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button> <dd class="col-sm-9">{{{ created_display }}}</dd>
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button> <dt class="col-sm-3">Last used</dt>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button> <dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
-51
View File
@@ -1,51 +0,0 @@
<%- include('top') %>
<div class="container-fluid" style="margin-top: 80px;">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-lg mb-4">
<div class="card-header">
<i class="fa-solid fa-network-wired"></i> Sites & Replication
</div>
<div class="card-body">
<p class="mb-4">
This page shows the status of Multi-Master LDAP replication peers.
<br/>Your Server ID: <strong><%= myId %></strong>
</p>
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Site LDAP URL</th>
<th>Replication Status</th>
</tr>
</thead>
<tbody>
<% if (sites.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).</td>
</tr>
<% } else { %>
<% sites.forEach(function(site) { %>
<tr>
<td class="align-middle"><strong><%= site.url %></strong></td>
<td class="align-middle">
<% if (site.status === 'Online') { %>
<span class="badge bg-success"><i class="fa-solid fa-circle-check"></i> Online</span>
<% } else { %>
<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark"></i> <%= site.status %></span>
<% } %>
</td>
</tr>
<% }); %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+145 -135
View File
@@ -1,149 +1,159 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%- name %> <%- title %></title> <title><%- name %> <%- title %></title>
<!-- Favicon --> <!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
<link rel="icon" type="image/svg+xml" href="<%- logo %>"> jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
<!-- CSS are placed here --> via app.locals in app.js). Edit all three copies together. -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css"> <!-- Favicon -->
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css"> <link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
<!-- CSS are placed here -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
<link rel='stylesheet' href='/static/css/styles.css' /> <link rel='stylesheet' href='/static/css/styles.css' />
<!-- Scripts are placed here --> <!-- Scripts are placed here -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script> <script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script> <script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> --> <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> --> <script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script> <script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script> <script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script> <script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script> <script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script> <script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script> <script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script> <script type="text/javascript" src="/static/js/app.js"></script>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a> <a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent"> <div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav"> <ul class="navbar-nav top-nav">
<li class="nav-item group-required group-required-app_sso_admin"> <%# Items gated on a group start hidden (.group-required) and are
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i> revealed by app-base.js for the groups the user is in. %>
Users <% for(const item of ui.nav){ %>
</a> <li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
</li> <a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
<li class="nav-item group-required group-required-app_sso_admin"> <%- item.label %>
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i> </a>
Groups </li>
</a> <% } %>
</li> </ul>
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_oauth_admin"> <div class="form-inline mt-2 mt-md-0">
<a class="nav-link" href="/integrations"> <% if(ui.profileUrl){ %>
<i class="fa-solid fa-plug"></i> <a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
Integrations <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a> </a>
</li> <% } else { %>
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite"> <span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<a class="nav-link" href="/invites"> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
<i class="fa-solid fa-envelope-open-text"></i> </span>
Invites <% } %>
</a> <a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
</li> <i class="fas fa-sign-in"></i>
<li class="nav-item group-required group-required-app_sso_admin"> Login
<a class="nav-link" href="/sites"> </a>
<i class="fa-solid fa-network-wired"></i>
Sites
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/dashboard">
<i class="fa-solid fa-gauge-high"></i>
Dashboard
</a>
</li>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;"> <button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i> <i class="fas fa-sign-out"></i>
Log Out Log Out
</button> </button>
</div> </div>
</div> </div>
</nav> </nav>
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;"> <% if(ui.updateCheck){ %>
<span id="update-banner-text"></span> <!-- Admin-only "a newer release is available" notice (services/update_check.js).
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button> Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
</div> Fixed-positioned below the fixed navbar (a plain in-flow div here would render
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript"> <script type="text/javascript">
function showUpdateBanner(){ // --sw-content-offset tracks the same height as #spa-shell's margin-top
let $nav = $('nav.fixed-top'); // (fixed navbar, plus the update banner while it's shown), so any
let $banner = $('#update-banner'); // in-page sticky element (e.g. a sticky search/sort bar) can offset
$banner.css('top', $nav.outerHeight() + 'px').show(); // itself below both fixed elements via `top: var(--sw-content-offset)`
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px'); // instead of colliding with them at the viewport's true top:0.
} function showUpdateBanner(){
function dismissUpdateBanner(){ let $nav = $('nav.fixed-top');
$('#update-banner').hide(); let $banner = $('#update-banner');
$('#spa-shell').css('margin-top', ''); $banner.css('top', $nav.outerHeight() + 'px').show();
sessionStorage.setItem('update-banner-dismissed', '1'); let offset = $nav.outerHeight() + $banner.outerHeight();
} $('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
}
$(document).ready(async function(){ function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1');
}
// Set the correct link to active in the top nav bar function checkForUpdate(){
$('.top-nav a').each(function(index){ if(sessionStorage.getItem('update-banner-dismissed')) return;
let $this = $(this); app.api.get('update-check', function(error, info){
$this.removeClass('active'); if(error || !info || !info.updateAvailable) return;
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){ $('#update-banner-text').html(
$this.addClass('active') 'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
} '(running v' + info.currentVersion + ') — ' +
}) '<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
// Set the correct login/logout button, and reveal the current user's <script type="text/javascript">
// name (linking to their profile) once we know who they are. $(document).ready(function(){
var me = await app.auth.isLoggedIn();
if(me){
$('#cl-logout-button').show();
if(me.uid){
$('#cl-username-text').text(me.uid);
$('#cl-username').css('display', '');
}
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){ // Set the correct link to active in the top nav bar
app.api.get('update-check', function(error, info){ $('.top-nav a').each(function(index){
if(error || !info || !info.updateAvailable) return; let $this = $(this);
$('#update-banner-text').html( $this.removeClass('active');
'A newer version of SSO Manager is available: <b>v' + info.latestVersion + '</b> ' + if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
'(running v' + info.currentVersion + ') — ' + $this.addClass('active')
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.' }
); })
showUpdateBanner();
});
}
}else{
$('#cl-login-button').show();
}
}); // Set the correct login/logout button, and reveal the current user's
</script> // name once we know who they are. Group-gated nav items are revealed
// by app-base.js off the same cached user/me.
app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show();
let username = me.uid || me.username;
if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', '');
}
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{
$('#cl-login-button').show();
}
});
});
</script>
<!-- Container --> <!-- Container -->
<div id="spa-shell" class="container-fluid"> <div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div> <div class="actionMessage" style="display:none;"></div>
+17 -4
View File
@@ -64,6 +64,7 @@ async function fetchUsernameSuggestions() {
// simply can't bind). Disabling (not just hiding) keeps disabled // simply can't bind). Disabling (not just hiding) keeps disabled
// fields out of both form serialization and validation. // fields out of both form serialization and validation.
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked); $form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked); $form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked); $form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
@@ -136,10 +137,7 @@ async function fetchUsernameSuggestions() {
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." /> <input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
</div> </div>
<div class="mb-3">
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label> <label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
@@ -166,3 +164,18 @@ async function fetchUsernameSuggestions() {
</div> </div>
<button type="submit" class="btn btn-outline-dark">Add</button> <button type="submit" class="btn btn-outline-dark">Add</button>
</form> </form>
<script>
$(document).ready(async function() {
const $loc = $('[name="location"]');
if (!$loc.val()) {
try {
const dirRes = await app.api.get('directory-admin/resources');
const resources = dirRes.results || [];
const site = resources.find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
if (site) {
$loc.val(site.name);
}
} catch(e) {}
}
});
</script>
+377 -178
View File
@@ -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,20 +19,146 @@
function toggleActive(uid, active){ function toggleActive(uid, active){
app.user.setActive(uid, active, function(error, data){ app.user.setActive(uid, active, function(error, data){
if(error) return alert('Failed to update user status'); if(error) return app.messages.toast('Failed to update user status', 'danger');
renderUsers(); renderUsers();
}); });
} }
function inviteStatus(t) {
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
return { label: 'Pending', cls: 'text-success' };
}
function groupsDisplay(groups) {
try {
const arr = JSON.parse(groups || '[]');
return arr.length ? arr.join(', ') : '—';
} catch(_) { return '—'; }
}
async function loadInvites() {
try {
const data = await app.api.get('user/invite');
const tbody = document.getElementById('invite-tbody');
tbody.innerHTML = '';
(data.results || [])
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
.forEach(function(t) {
const status = inviteStatus(t);
const canEdit = true; // all users on this page are admins
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
const tr = document.createElement('tr');
tr.dataset.token = t.token;
tr.innerHTML = `
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
<td><small>${t.created_by}</small></td>
<td><small>${mail}</small></td>
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
<td><small class="${status.cls}">${status.label}</small></td>
<td class="text-nowrap text-end">
${canEdit && isPending ? `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
<i class="fa-solid fa-ban"></i>
</button>
` : ''}
${isPending ? `
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
<i class="fa-solid fa-copy"></i>
</button>
` : ''}
</td>
`;
tbody.appendChild(tr);
});
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load invites:', e);
}
}
function populateGroupSelect(id, groups, selected) {
const sel = document.getElementById(id);
sel.innerHTML = '';
groups.forEach(function(cn) {
const opt = document.createElement('option');
opt.value = cn;
opt.textContent = cn;
opt.selected = selected.includes(cn);
sel.appendChild(opt);
});
}
function openEdit(tokenId) {
app.api.get('user/invite').then(function(data) {
const t = (data.results || []).find(x => x.token === tokenId);
if (!t) return;
document.getElementById('edit-token').value = tokenId;
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
const selected = JSON.parse(t.groups || '[]');
const allGroups = [...document.getElementById('invite-groups').options].map(o => o.value);
populateGroupSelect('edit-groups', allGroups, selected);
const modal = new bootstrap.Modal(document.getElementById('editModal'));
modal.show();
});
}
async function saveEdit() {
const tokenId = document.getElementById('edit-token').value;
const mail = document.getElementById('edit-email').value.trim();
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('edit-result');
result.style.display = 'none';
try {
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
async function revokeInvite(tokenId, btn) {
$thisRow = $(btn).closest('tr');
$thisRow.addClass('table-warning');
let confirmation = await app.messages.confirm('Revoke selected invite token?', $thisRow, 'warning');
if(!confirmation){
$thisRow.removeClass('table-warning');
return;
}
try {
await app.api.delete(`user/invite/${tokenId}`);
loadInvites();
} catch(e) {
app.messages.action('Failed to revoke invite.', $thisRow, 'danger');
}
}
function copyLink(tokenId, btn) {
const link = `${location.origin}/login/invite/${tokenId}`;
navigator.clipboard.writeText(link).then(function() {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = orig, 1500);
});
}
async function deleteUser(uid, btn){ async function deleteUser(uid, btn){
const $row = $(btn).closest('tr'); const $row = $(btn).closest('tr');
$row.addClass('table-warning'); $row.addClass('table-warning');
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning'); const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $row, 'warning');
$row.removeClass('table-warning'); $row.removeClass('table-warning');
if (!confirmed) return; if (!confirmed) return;
app.api.delete('user/' + uid, function(error, data){ app.api.delete('user/' + uid, function(error, data){
if (error) { if (error) {
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger'); app.messages.action(data.message || 'Failed to delete user', $row, 'danger');
return; return;
} }
renderUsers(); renderUsers();
@@ -84,6 +210,11 @@
} }
document.getElementById('invite-email').value = ''; document.getElementById('invite-email').value = '';
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false); [...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
loadInvites();
setTimeout(() => {
bootstrap.Modal.getInstance(document.getElementById("inviteUserModal"))?.hide();
result.style.display = 'none';
}, 3000);
}catch(e){ }catch(e){
result.style.display = ''; result.style.display = '';
result.className = 'alert alert-danger mt-2'; result.className = 'alert alert-danger mt-2';
@@ -97,199 +228,267 @@
$(document).ready(function(){ $(document).ready(function(){
renderUsers(); renderUsers();
loadInviteGroups(); loadInviteGroups();
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")') loadInvites();
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success"); bootstrap.Modal.getInstance(document.getElementById("addUserModal"))?.hide();')
}); });
})(); })();
</script> </script>
<h4><i class="fa-solid fa-users"></i> Users</h4> <div class="container mt-4">
<ul class="nav nav-tabs mb-3" role="tablist"> <div class="row">
<li class="nav-item" role="presentation"> <div class="col-md-12">
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
<i class="fa-solid fa-user"></i> People
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
<i class="fa-solid fa-gears"></i> Service Accounts
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
<div class="row" style="display:none">
<div class="col-md-4">
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fas fa-user-plus"></i>
Invite User
<span class="float-end">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i>
</span>
</div>
<div class="card-header shadow actionMessage" style="display: none;"></div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
</div>
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
<i class="fa-solid fa-envelope"></i> Send Invite
</button>
<div id="invite-result" style="display:none" class="mt-2"></div>
</div>
</div>
<div class="card shadow-lg">
<div class="card-header">
<i class="fas fa-user-plus"></i>
Add new user
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<%- include('user_form', {adminMode: true}) %>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow"> <div class="card shadow">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<i class="fa-solid fa-users"></i> <div>
User List <i class="fa-solid fa-users"></i>
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a> User List
</div>
<div>
<button class="btn btn-sm btn-outline-primary me-2 group-required group-required-app_sso_admin" data-bs-toggle="modal" data-bs-target="#inviteUserModal">
<i class="fas fa-envelope"></i> Invite User
</button>
<button class="btn btn-sm btn-primary me-2" data-bs-toggle="modal" data-bs-target="#addUserModal">
<i class="fas fa-user-plus"></i> Add User
</button>
<a href="/docs/accounts" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0"> <div class="px-3 pt-3 border-bottom">
<thead> <ul class="nav nav-tabs border-bottom-0" role="tablist">
<th>ID</th> <li class="nav-item" role="presentation">
<th>Name</th> <button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
<th>eMail</th> <i class="fa-solid fa-user"></i> People
<th>Key</th> </button>
<th>Active</th> </li>
<th>TOS</th> <li class="nav-item" role="presentation">
<th></th> <button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
</thead> <i class="fa-solid fa-gears"></i> Service Accounts
<tbody id="tableAJAX"> </button>
<tr jq-repeat="userRow"> </li>
<td> <li class="nav-item" role="presentation">
{{ uidNumber }} <button class="nav-link" id="tab-invites-btn" data-bs-toggle="tab" data-bs-target="#tab-invites" type="button" role="tab">
</td> <i class="fa-solid fa-envelope-open-text"></i> Invites
<td> </button>
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a> </li>
</td> </ul>
<td> </div>
{{mail}}
</td> <div class="tab-content">
<td> <div class="tab-pane fade show active" id="tab-people" role="tabpanel">
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}} <div class="table-responsive">
</td> <table class="card-body table table-striped" style="margin-bottom:0">
<td> <thead>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}} <th>ID</th>
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}} <th>Name</th>
</td> <th>eMail</th>
<td> <th>Key</th>
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}} <th>Active</th>
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}} <th>TOS</th>
</td> <th></th>
<td class="text-nowrap"> </thead>
{{#isActive}} <tbody id="tableAJAX">
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)"> <tr jq-repeat="userRow">
<i class="fa-solid fa-lock"></i> <td>
</button> {{ uidNumber }}
{{/isActive}} </td>
{{#isInactive}} <td>
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)"> <a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
<i class="fa-solid fa-lock-open"></i> </td>
</button> <td>
{{/isInactive}} {{mail}}
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')"> </td>
<i class="fa-solid fa-user-secret"></i> <td>
</button> {{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)"> </td>
<i class="fa-solid fa-user-slash"></i> <td>
</button> {{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
</td> {{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</tr> </td>
</tbody> <td>
</table> {{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add User" form.
</div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Username</th>
<th>Description</th>
<th>Manager(s)</th>
<th>Created</th>
<th>Active</th>
<th></th>
</thead>
<tbody>
<tr jq-repeat="serviceAccountRow">
<td>
<a href='/users/{{uid}}'>{{uid}}</a>
</td>
<td>
{{description}}
</td>
<td>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
</td>
<td>
{{createTimestamp}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="tab-pane fade" id="tab-invites" role="tabpanel">
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Created</th>
<th>By</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th></th>
</thead>
<tbody id="invite-tbody">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
<div class="row" style="display:none"> <!-- Invite User Modal -->
<div class="col-12"> <div class="modal fade" id="inviteUserModal" tabindex="-1" aria-labelledby="inviteUserModalLabel" aria-hidden="true">
<div class="card shadow"> <div class="modal-dialog">
<div class="card-header"> <div class="modal-content">
<i class="fa-solid fa-gears"></i> <div class="modal-header shadow">
Service Accounts <h5 class="modal-title" id="inviteUserModalLabel"><i class="fas fa-user-plus"></i> Invite User</h5>
<small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a> </div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
</div>
<div id="invite-result" style="display:none" class="mt-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" onclick="sendInvite()" class="btn btn-primary shadow">
<i class="fa-solid fa-envelope"></i> Send Invite
</button>
</div>
</div>
</div>
</div>
<!-- Add User Modal -->
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header shadow">
<h5 class="modal-title" id="addUserModalLabel">
<i class="fas fa-user-plus"></i> Add new user
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="small text-muted mb-3">Check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab.</p>
<%- include('user_form', {adminMode: true}) %>
</div>
</div>
</div>
</div>
<!-- Edit Invite Modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header shadow">
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-token" />
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="mb-3">
<div class="table-responsive"> <label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<table class="card-body table table-striped" style="margin-bottom:0"> <input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
<thead> <select id="edit-groups" class="form-select shadow" multiple size="6"></select>
<th>Username</th>
<th>Description</th>
<th>Manager(s)</th>
<th>Created</th>
<th>Active</th>
<th></th>
</thead>
<tbody>
<tr jq-repeat="serviceAccountRow">
<td>
<a href='/users/{{uid}}'>{{uid}}</a>
</td>
<td>
{{description}}
</td>
<td>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
</td>
<td>
{{createTimestamp}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
<div id="edit-result" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<%- include('impersonate_modal') %> <%- include('impersonate_modal') %>
<%- include('bottom') %> <%- include('bottom') %>
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# seed-test-user.sh — Create the test user in LDAP for the test suite.
#
# The test suite (tests/setup.js) logs in as uid=test / password=MyTestPassword!2.
# This script creates that user in the LDAP directory along with its personal
# posixGroup, and adds it to the app_sso_admin group so admin-gated tests pass.
#
# Environment variables (from docker-compose.test.yml):
# LDAP_HOST — LDAP server hostname (default: ldap)
# LDAP_PORT — LDAP server port (default: 389)
# BIND_DN — LDAP admin bind DN (default: cn=admin,dc=test,dc=local)
# BIND_PW — LDAP admin password (default: secret)
# BASE_DN — LDAP base DN (default: dc=test,dc=local)
set -euo pipefail
LDAP_HOST="${LDAP_HOST:-ldap}"
LDAP_PORT="${LDAP_PORT:-389}"
BIND_DN="${BIND_DN:-cn=admin,dc=test,dc=local}"
BIND_PW="${BIND_PW:-secret}"
BASE_DN="${BASE_DN:-dc=test,dc=local}"
LDAP_URI="ldap://${LDAP_HOST}:${LDAP_PORT}"
USER_UID="test"
USER_PASSWORD="MyTestPassword!2"
info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; }
# ── Wait for LDAP to be reachable ────────────────────────────────────────────
info "Waiting for LDAP at ${LDAP_URI}..."
for i in $(seq 1 30); do
if ldapsearch -x -H "$LDAP_URI" -b '' -s base '(objectClass=*)' >/dev/null 2>&1; then
info "LDAP is reachable"
break
fi
if [ "$i" -eq 30 ]; then
error "LDAP not reachable after 30 attempts"
exit 1
fi
sleep 1
done
# ── Check if the test user already exists ────────────────────────────────────
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' already exists — skipping seed"
exit 0
fi
# ── Generate the SSHA512 password hash ───────────────────────────────────────
# Inline the hash function to avoid requiring the full model chain (which
# tries to connect to Redis/LDAP during module loading and would hang).
info "Generating password hash..."
PASSWORD_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('${USER_PASSWORD}').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
info "Password hash generated"
# ── Create a temporary LDIF file ─────────────────────────────────────────────
TMPFILE=$(mktemp /tmp/seed-test-user.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT
cat > "$TMPFILE" << LDIF
# Personal posixGroup for the test user
dn: cn=${USER_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${USER_UID}
gidNumber: 1500
description: Personal group for test user
# Test user posixAccount
dn: cn=${USER_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${USER_UID}
sn: Test
uid: ${USER_UID}
uidNumber: 1500
gidNumber: 1500
homeDirectory: /home/${USER_UID}
loginShell: /bin/bash
mail: test@test.local
userPassword: ${PASSWORD_HASH}
description: Test user for automated test suite
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${USER_UID}
LDIF
# ── Add the entries to LDAP ──────────────────────────────────────────────────
info "Creating test user '${USER_UID}' in LDAP..."
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' exists or was created"
else
error "Failed to create test user '${USER_UID}'"
exit 1
fi
# ── Add the test user to required SSO groups ─────────────────────────────────
info "Adding test user to SSO admin groups..."
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
ldapmodify -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" << EOF 2>/dev/null || true
dn: cn=${group},ou=groups,${BASE_DN}
changetype: modify
add: member
member: cn=${USER_UID},ou=people,${BASE_DN}
EOF
done
info "Test user added to SSO admin groups"
# ── Create additional users needed by tests ─────────────────────────────────
# wmantly is referenced by OTP (otp.test.js) and impersonation (impersonate.test.js)
# tests as an existing non-admin user.
WMANTLY_UID="wmantly"
if ! ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Creating additional test user '${WMANTLY_UID}'..."
# Generate password hash for wmantly
WMANTLY_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('testpass').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
cat > "$TMPFILE" << LDIF
dn: cn=${WMANTLY_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${WMANTLY_UID}
gidNumber: 1501
dn: cn=${WMANTLY_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${WMANTLY_UID}
sn: Mantly
uid: ${WMANTLY_UID}
uidNumber: 1501
gidNumber: 1501
homeDirectory: /home/${WMANTLY_UID}
loginShell: /bin/bash
mail: wmantly@test.local
userPassword: ${WMANTLY_HASH}
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${WMANTLY_UID}
LDIF
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Additional test user '${WMANTLY_UID}' exists or was created"
else
error "Failed to create additional test user '${WMANTLY_UID}'"
exit 1
fi
fi
info "Seed complete — all test users are ready"
+2
View File
@@ -0,0 +1,2 @@
const { hashPasswordSSHA512 } = require('./nodejs/models/user_ldap.js');
console.log(hashPasswordSSHA512('MyTestPassword!2'));