Compare commits

...

50 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
44 changed files with 1714 additions and 1028 deletions
+115
View File
@@ -4,6 +4,121 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.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 ## [1.4.0] - 2026-07-25
### Security ### Security
+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
+2
View File
@@ -58,6 +58,8 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it. The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions ## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention: Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
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

+3 -2
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)*
+2
View File
@@ -64,6 +64,8 @@ Clients are managed directly from the **Directory** tab in the web UI. They are
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group. > All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
## Scopes ## Scopes
| Scope | Claims / access | | Scope | Claims / access |
+5
View File
@@ -61,6 +61,11 @@ app.set('trust proxy', 1);
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
// Set as an app local so every res.render has it, including routes that don't
// spread the routers' `values` object.
app.locals.ui = require('./utils/ui');
// Have express server static content( images, CSS, browser JS) from the public // Have express server static content( images, CSS, browser JS) from the public
// local folder. maxAge is short since this is the app's own JS/CSS, which // local folder. maxAge is short since this is the app's own JS/CSS, which
// changes on every deploy and isn't cache-busted/fingerprinted. // changes on every deploy and isn't cache-busted/fingerprinted.
Binary file not shown.
+26
View File
@@ -152,10 +152,36 @@ class Resource extends Model {
owner: { type: 'string' }, owner: { type: 'string' },
description: { type: 'text' }, description: { type: 'text' },
metadata: { type: 'json', default: {} }, metadata: { type: 'json', default: {} },
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
// are set explicitly by the route handler on every create/update (see
// routes/api_directory_admin.js). Existing rows predating this change
// simply read back undefined -- callers must render a fallback.
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' }, edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' }, edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' } groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
}; };
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
// whose kind === 'site', returning its slug (or null if none exists -- a
// top-level resource with no site parent keeps its unprefixed group name).
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
if (visited.has(resourceId)) return null;
visited.add(resourceId);
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
for (const edge of parentEdges) {
const parent = await this.get(edge.parentId);
if (!parent) continue;
if (parent.kind === 'site') return parent.slug;
const found = await this.findAncestorSiteSlug(parent.id, visited);
if (found) return found;
}
return null;
}
} }
class ResourceEdge extends Model { class ResourceEdge extends Model {
+26
View File
@@ -473,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',
@@ -784,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',
+16 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.3.2", "version": "1.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.3.2", "version": "1.8.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
@@ -14,6 +14,7 @@
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0", "@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8", "@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -24,7 +25,7 @@
"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.8", "ldapts": "^8.1.8",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
@@ -1278,6 +1279,15 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/ldap": { "node_modules/@simpleworkjs/ldap": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
@@ -4853,9 +4863,9 @@
} }
}, },
"node_modules/jquery": { "node_modules/jquery": {
"version": "3.7.1", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-tokens": { "node_modules/js-tokens": {
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.4.0", "version": "1.9.0",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
@@ -23,10 +23,11 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0", "@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8", "@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
@@ -36,7 +37,7 @@
"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.8", "ldapts": "^8.1.8",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
+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 = '';
} }
+324 -167
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(){
@@ -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() {
@@ -417,6 +540,9 @@ app.util = (function(app){
if (obj[name] === undefined) { if (obj[name] === undefined) {
if (!value if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value') && !$(this).parent().find(`[name="${name}"]`).attr('value')
// Keep empty <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){ ){
continue; continue;
} }
@@ -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";
}
}
}
});
+13 -2
View File
@@ -44,10 +44,17 @@ router.post('/resources', async (req, res, next) => {
req.body.owner = req.body.owner || req.user.uid; req.body.owner = req.body.owner || req.user.uid;
const now = Date.now();
req.body.created_by = req.body.created_by || req.user.uid;
req.body.created_on = now;
req.body.updated_by = req.user.uid;
req.body.updated_on = now;
let r; let r;
if (req.body.kind === 'oauth') { if (req.body.kind === 'oauth') {
const { OAuthClient } = require('../models/oauth_client'); const { OAuthClient } = require('../models/oauth_client');
// Pass created_by explicitly for the wrapper // Pass created_by explicitly for the wrapper (overrides the generic
// assignment above -- this is OAuthClient-wrapper-specific behavior).
req.body.created_by = req.body.owner; req.body.created_by = req.body.owner;
// In the UI we might pass slug, but OAuthClient wrapper expects name // In the UI we might pass slug, but OAuthClient wrapper expects name
r = await OAuthClient.add(req.body); r = await OAuthClient.add(req.body);
@@ -60,8 +67,9 @@ router.post('/resources', async (req, res, next) => {
} }
if (r.kind === 'host' || r.kind === 'service') { if (r.kind === 'host' || r.kind === 'service') {
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
const createGroup = async (suffix, accessLevel) => { const createGroup = async (suffix, accessLevel) => {
const cn = `${r.slug}_${suffix}`; const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
try { try {
await Group.add({ await Group.add({
name: cn, name: cn,
@@ -104,6 +112,9 @@ router.put('/resources/:id', async (req, res, next) => {
} }
if (!r) return res.status(404).json({ error: 'Not found' }); if (!r) return res.status(404).json({ error: 'Not found' });
req.body.updated_by = req.user.uid;
req.body.updated_on = Date.now();
if (req.body.kind === 'host' && !req.body.hostId) { if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' }); return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
} }
+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){
+9 -1
View File
@@ -28,7 +28,7 @@ const values ={
// every deploy and isn't cache-busted/fingerprinted. // every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, { mountStaticModules(router, {
root: path.join(__dirname, '..'), root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'], deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
}); });
// Public health endpoint for container/orchestration healthchecks. // Public health endpoint for container/orchestration healthchecks.
@@ -60,6 +60,14 @@ router.get('/directory', function(req, res) {
res.render('directory', {...values}); res.render('directory', {...values});
}); });
// Linkable deep-link to a single resource's modal, e.g. from the resource
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
// use of :slug at all -- the client reads location.pathname itself and opens
// the matching resource's modal once the page's own data has loaded.
router.get('/directory/:slug', function(req, res) {
res.render('directory', {...values});
});
// Route removed since it's now in directory // Route removed since it's now in directory
router.get('/onboarding', async function(req, res, next) { router.get('/onboarding', async function(req, res, next) {
+1 -1
View File
@@ -97,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
await permission.byGroup(req.user, [ADMIN_GROUP]); await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id); const client = await OAuthClient.get(req.params.client_id);
await client.remove(); await client.delete();
return res.json({ return res.json({
client_id: req.params.client_id, client_id: req.params.client_id,
+13 -3
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);
} }
+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', () => {
+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([]);
});
+50 -1
View File
@@ -69,6 +69,51 @@ describe('OAuth client management API — /api/oauth/client', () => {
expect(res.body.results).not.toHaveProperty('client_secret_hash'); 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 () => { 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, // Reproduces exactly what the theta-env bootstrap does: create, list,
// find by name, rotate by the client_id from the list response. Uses a // find by name, rotate by the client_id from the list response. Uses a
@@ -90,7 +135,11 @@ describe('OAuth client management API — /api/oauth/client', () => {
expect(rotated.status).toBe(200); expect(rotated.status).toBe(200);
expect(rotated.body.client_secret).toBeTruthy(); expect(rotated.body.client_secret).toBeTruthy();
await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token); 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 () => { test('GET /:id unknown id returns 404, not 500', async () => {
+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();
});
});
+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>
+490 -330
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -117,8 +117,8 @@
const msgEl = document.getElementById('notif-result'); const msgEl = document.getElementById('notif-result');
const $compose = $('#notif-subject').closest('.card-body'); const $compose = $('#notif-subject').closest('.card-body');
if (!subject || !message) { alert('Subject and message are required.'); return; } if (!subject || !message) { app.messages.action('Subject and message are required.', $compose, 'danger'); return; }
if (!filterCheck) { alert('Choose who to send this to.'); return; } if (!filterCheck) { app.messages.action('Choose who to send this to.', $compose, 'danger'); return; }
const filterType = filterCheck.value; const filterType = filterCheck.value;
let filter_value = ''; let filter_value = '';
@@ -129,7 +129,7 @@
// trying the form out — make it a deliberate, confirmed action. // trying the form out — make it a deliberate, confirmed action.
if (filterType === 'all' || filterType === 'all_active') { if (filterType === 'all' || filterType === 'all_active') {
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users'; const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning'); const confirmed = await app.messages.confirm(`Send this notification to ${label}?`, $compose, 'warning');
if (!confirmed) return; if (!confirmed) return;
} }
@@ -179,7 +179,12 @@
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked; const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result'); const msgEl = document.getElementById('tos-result');
if (!content) { alert('Terms of Service text cannot be empty.'); return; } if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) { app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) { if (error) {
+42 -16
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');
} }
} }
@@ -123,7 +149,7 @@
</script> </script>
<div class="container mt-4"> <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');
}); });
}); });
+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(){
+1 -1
View File
@@ -131,6 +131,6 @@
}); });
function requestAccess(id) { function requestAccess(id) {
app.util.alert('Access Request', 'This feature is coming soon!', 'info'); app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
} }
</script> </script>
+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');
} }
} }
+142 -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();
}); });
}); });
@@ -176,7 +176,7 @@
async function toggleActive(uid, active){ async function toggleActive(uid, active){
app.user.setActive(uid, active, async function(error, data){ app.user.setActive(uid, active, async function(error, data){
if(error) return alert('Failed to update user status'); if(error) return app.messages.toast('Failed to update user status', 'danger');
currentUser = await determinUser(); currentUser = await determinUser();
renderProfile(currentUser); renderProfile(currentUser);
}); });
@@ -184,11 +184,11 @@
async function deleteUser(uid, btn){ async function deleteUser(uid, btn){
const $card = $(btn).closest('.card'); const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $card, 'warning'); const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $card, 'warning');
if (!confirmed) return; if (!confirmed) return;
app.api.delete('user/' + uid, function(error, data){ app.api.delete('user/' + uid, function(error, data){
if (error) { if (error) {
app.util.actionMessage(data.message || 'Failed to delete user', $card, 'danger'); app.messages.action(data.message || 'Failed to delete user', $card, 'danger');
return; return;
} }
window.location.href = '/users'; window.location.href = '/users';
@@ -220,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>
@@ -545,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){
@@ -637,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){
@@ -653,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();
}); });
} }
@@ -711,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>
+153 -132
View File
@@ -1,138 +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_directory_admin"> <div class="form-inline mt-2 mt-md-0">
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> <% if(ui.profileUrl){ %>
Directory <a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
</a> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</li> </a>
<% } else { %>
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</span>
<% } %>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-in"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
</div>
</div>
</nav>
<% if(ui.updateCheck){ %>
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript">
// --sw-content-offset tracks the same height as #spa-shell's margin-top
// (fixed navbar, plus the update banner while it's shown), so any
// in-page sticky element (e.g. a sticky search/sort bar) can offset
// itself below both fixed elements via `top: var(--sw-content-offset)`
// instead of colliding with them at the viewport's true top:0.
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
let offset = $nav.outerHeight() + $banner.outerHeight();
$('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
}
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');
}
function checkForUpdate(){
if(sessionStorage.getItem('update-banner-dismissed')) return;
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
<script type="text/javascript">
$(document).ready(function(){
// Set the correct link to active in the top nav bar
$('.top-nav a').each(function(index){
let $this = $(this);
$this.removeClass('active');
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
$this.addClass('active')
}
})
// Set the correct login/logout button, and reveal the current user's
// name once we know who they are. Group-gated nav items are revealed
// by app-base.js off the same cached user/me.
app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show();
let username = me.uid || me.username;
if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', '');
}
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{
$('#cl-login-button').show();
}
});
});
</script>
<li class="nav-item group-required group-required-app_sso_admin"> <!-- Container -->
<a class="nav-link" href="/executive"> <div id="spa-shell" class="container-fluid">
<i class="fa-solid fa-gauge-high"></i> <div class="actionMessage" style="display:none;"></div>
Executive
</a>
</li>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
</div>
</div>
</nav>
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript">
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
sessionStorage.setItem('update-banner-dismissed', '1');
}
$(document).ready(async function(){
// Set the correct link to active in the top nav bar
$('.top-nav a').each(function(index){
let $this = $(this);
$this.removeClass('active');
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
$this.addClass('active')
}
})
// Set the correct login/logout button, and reveal the current user's
// name (linking to their profile) once we know who they are.
var me = await app.auth.isLoggedIn();
if(me){
$('#cl-logout-button').show();
if(me.uid){
$('#cl-username-text').text(me.uid);
$('#cl-username').css('display', '');
}
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of SSO Manager is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
}else{
$('#cl-login-button').show();
}
});
</script>
<!-- Container -->
<div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+6 -6
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,7 +19,7 @@
function toggleActive(uid, active){ function toggleActive(uid, active){
app.user.setActive(uid, active, function(error, data){ app.user.setActive(uid, active, function(error, data){
if(error) return alert('Failed to update user status'); if(error) return app.messages.toast('Failed to update user status', 'danger');
renderUsers(); renderUsers();
}); });
} }
@@ -128,7 +128,7 @@
async function revokeInvite(tokenId, btn) { async function revokeInvite(tokenId, btn) {
$thisRow = $(btn).closest('tr'); $thisRow = $(btn).closest('tr');
$thisRow.addClass('table-warning'); $thisRow.addClass('table-warning');
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning'); let confirmation = await app.messages.confirm('Revoke selected invite token?', $thisRow, 'warning');
if(!confirmation){ if(!confirmation){
$thisRow.removeClass('table-warning'); $thisRow.removeClass('table-warning');
return; return;
@@ -137,7 +137,7 @@
await app.api.delete(`user/invite/${tokenId}`); await app.api.delete(`user/invite/${tokenId}`);
loadInvites(); loadInvites();
} catch(e) { } catch(e) {
alert('Failed to revoke invite.'); app.messages.action('Failed to revoke invite.', $thisRow, 'danger');
} }
} }
@@ -153,12 +153,12 @@
async function deleteUser(uid, btn){ async function deleteUser(uid, btn){
const $row = $(btn).closest('tr'); const $row = $(btn).closest('tr');
$row.addClass('table-warning'); $row.addClass('table-warning');
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning'); const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $row, 'warning');
$row.removeClass('table-warning'); $row.removeClass('table-warning');
if (!confirmed) return; if (!confirmed) return;
app.api.delete('user/' + uid, function(error, data){ app.api.delete('user/' + uid, function(error, data){
if (error) { if (error) {
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger'); app.messages.action(data.message || 'Failed to delete user', $row, 'danger');
return; return;
} }
renderUsers(); renderUsers();