Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2592ea4e | |||
| 9cf0ce34ca | |||
| 3b6d1ceda9 | |||
| e9b808d1c2 | |||
| 6c71c91ff6 | |||
| ac25084113 | |||
| a788a99e56 | |||
| bcd160cca2 | |||
| 724f5d8496 | |||
| 8fc7dd11f5 | |||
| e91ed6f1f7 | |||
| 874f7db037 | |||
| 013c21d4f0 | |||
| 42a61f8868 | |||
| b54da5c64c | |||
| 782ef69fb8 | |||
| 0e955abc73 | |||
| 69883836e1 | |||
| 17df21041a | |||
| c19fffe3c9 | |||
| b6abfe8f03 | |||
| 420ccfab3b | |||
| 8ed4505dc0 | |||
| 451054f0c2 | |||
| 3a46680c8b | |||
| 1b0418e42e | |||
| 4e3aa082d3 | |||
| 6cb8b259e2 | |||
| 2532c492f1 | |||
| fdc045e166 | |||
| 0c2f38f0fe |
@@ -4,6 +4,79 @@ 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.10.0] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`app_super_admin` cross-app group**: members are full admins here regardless of `app_sso_admin` membership. Bypassed centrally in `utils/permission.js`'s `byGroup`, folded into `GET /api/user/me`'s `isAdmin` flag, and added to nav/`forceLogin` gates. The same group is now also recognized by proxy and jump-host, and by `ldap-client`'s SSSD access filter (SSH login on every host).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Renamed the Executive page to Overview** (route, view, `/api/metrics/overview`, nav label, docs). `/executive` kept as a 301 redirect alongside the existing `/admin`, `/notifications`, `/dashboard` legacy redirects.
|
||||||
|
|
||||||
|
## [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** ("<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
|
## [1.6.2] - 2026-07-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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/Directory/Executive
|
people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
|
||||||
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
@@ -22,7 +22,7 @@ one command).
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
|
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Overview 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/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
||||||
|
|||||||
Binary file not shown.
@@ -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 {
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.5.1",
|
"version": "1.8.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.5.1",
|
"version": "1.8.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -14,7 +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.5",
|
"@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",
|
||||||
@@ -1280,9 +1280,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@simpleworkjs/frontend": {
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
|
||||||
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
|
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.6.2",
|
"version": "1.10.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,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.5",
|
"@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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -584,10 +584,31 @@ 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,
|
||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -679,13 +700,10 @@ function formAJAX(btn){
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.messages.action(
|
// 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.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ const router = require('express').Router();
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const metrics = require('../utils/metrics');
|
const metrics = require('../utils/metrics');
|
||||||
|
|
||||||
// /api/metrics/executive
|
// /api/metrics/overview
|
||||||
router.get('/executive', async (req, res, next) => {
|
router.get('/overview', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
|
||||||
|
|||||||
@@ -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){
|
||||||
|
|||||||
+14
-5
@@ -48,18 +48,27 @@ router.get('/tos', async function(req, res, next) {
|
|||||||
|
|
||||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||||
// (broadcast + history) merged into one page.
|
// (broadcast + history) merged into one page.
|
||||||
router.get('/executive', function(req, res) {
|
router.get('/overview', function(req, res) {
|
||||||
res.render('executive', {...values});
|
res.render('overview', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
|
||||||
router.get('/directory', function(req, res) {
|
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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -78,11 +78,12 @@ router.get('/me', async function(req, res, next){
|
|||||||
|
|
||||||
// The shared client framework gates the UI on a single effective-rights
|
// The shared client framework gates the UI on a single effective-rights
|
||||||
// flag (the OIDC-client apps send the same key). Here "admin" means
|
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||||
// membership in app_sso_admin; group-level gating still reads memberOf.
|
// membership in app_sso_admin or the cross-app app_super_admin group;
|
||||||
|
// group-level gating still reads memberOf.
|
||||||
const groups = (user.memberOf || []).map(function(dn){
|
const groups = (user.memberOf || []).map(function(dn){
|
||||||
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||||
});
|
});
|
||||||
user.isAdmin = groups.includes('app_sso_admin');
|
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
|
||||||
|
|
||||||
return res.json(user);
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
@@ -107,7 +108,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
|||||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||||
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: adding/removing a member here didn't clear User's LRU
|
||||||
|
// cache (ttl 5 minutes), so isServiceAccount -- derived from
|
||||||
|
// app_sso_service_account membership at GET /api/user/:uid time -- could
|
||||||
|
// stay wrong for up to 5 minutes after the group change. In production
|
||||||
|
// this hid a real person's account from the Users page's "People" tab
|
||||||
|
// (it filters out anything with isServiceAccount) for however long the
|
||||||
|
// stale cache entry lived, which looked exactly like the account had
|
||||||
|
// vanished.
|
||||||
|
test('PUT app_sso_service_account/:uid immediately flips isServiceAccount (no stale cache)', async () => {
|
||||||
|
const added = await request(app)
|
||||||
|
.put(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(added.status).toBe(200);
|
||||||
|
|
||||||
|
const afterAdd = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterAdd.body.results.isServiceAccount).toBeTruthy();
|
||||||
|
|
||||||
|
const removed = await request(app)
|
||||||
|
.delete(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(removed.status).toBe(200);
|
||||||
|
|
||||||
|
const afterRemove = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||||
|
expect(afterRemove.body.results.isServiceAccount).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Groups — owner management', () => {
|
describe('Groups — owner management', () => {
|
||||||
|
|||||||
@@ -0,0 +1,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
|
|
||||||
|
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||||
|
|
||||||
let byGroup = async function(user, groups, ownerOf){
|
let byGroup = async function(user, groups, ownerOf){
|
||||||
|
try{
|
||||||
|
let superAdmin = await Group.get(SUPER_ADMIN_GROUP);
|
||||||
|
if(superAdmin.member.includes(user.dn)) return true
|
||||||
|
}catch(error){
|
||||||
|
// group not found, continue checking
|
||||||
|
}
|
||||||
|
|
||||||
for(let group of groups){
|
for(let group of groups){
|
||||||
try{
|
try{
|
||||||
group = await Group.get(group);
|
group = await Group.get(group);
|
||||||
@@ -28,4 +37,4 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {byGroup};
|
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
||||||
|
|||||||
+4
-4
@@ -38,9 +38,9 @@ module.exports = {
|
|||||||
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
// 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).
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
nav: [
|
nav: [
|
||||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||||
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
|
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin', 'admin']},
|
||||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||||
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
|
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+478
-326
@@ -15,12 +15,6 @@
|
|||||||
<option value="kind">Kind</option>
|
<option value="kind">Kind</option>
|
||||||
<option value="env">Environment</option>
|
<option value="env">Environment</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="btn-group btn-group-sm shadow-sm" role="group">
|
|
||||||
<input type="radio" class="btn-check" name="viewMode" id="view-list" value="list" autocomplete="off" checked onchange="renderTable()">
|
|
||||||
<label class="btn btn-outline-secondary" for="view-list"><i class="fa-solid fa-list"></i></label>
|
|
||||||
<input type="radio" class="btn-check" name="viewMode" id="view-tree" value="tree" autocomplete="off" onchange="renderTable()">
|
|
||||||
<label class="btn btn-outline-secondary" for="view-tree"><i class="fa-solid fa-folder-tree"></i></label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
||||||
<i class="fas fa-plus"></i> Add Resource
|
<i class="fas fa-plus"></i> Add Resource
|
||||||
</button>
|
</button>
|
||||||
@@ -35,26 +29,25 @@
|
|||||||
<table class="card-body table table-striped mb-0">
|
<table class="card-body table table-striped mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="ps-3">Kind</th>
|
<th class="ps-3">Resource</th>
|
||||||
<th>Name</th>
|
|
||||||
<th>Env</th>
|
|
||||||
<th>Host</th>
|
|
||||||
<th>IP / Address</th>
|
<th>IP / Address</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="resources-list" jq-repeat="resources">
|
<tbody id="resources-list" jq-repeat="resources">
|
||||||
<tr>
|
<tr id="resource-row-{{id}}">
|
||||||
<td class="ps-3 text-nowrap">
|
<td class="ps-3">
|
||||||
{{{indentHtml}}}
|
{{{indentHtml}}}
|
||||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||||
</td>
|
|
||||||
<td><strong>{{name}}</strong><br><small class="text-muted">{{slug}}</small></td>
|
|
||||||
<td>
|
|
||||||
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
||||||
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
||||||
|
<span class="badge bg-light text-dark border">{{hostName}}</span>
|
||||||
|
<br>
|
||||||
|
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
|
||||||
|
<strong>{{name}}</strong>
|
||||||
|
</a>
|
||||||
|
<small class="text-muted">{{slug}}</small>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge bg-light text-dark border">{{hostName}}</span></td>
|
|
||||||
<td>
|
<td>
|
||||||
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
|
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
|
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
|
||||||
@@ -79,252 +72,270 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Resource Modal -->
|
<script>
|
||||||
<div class="modal fade" id="resourceModal" tabindex="-1">
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
// --- Resource modal tab content, built once. Populated via .val() in
|
||||||
<div class="modal-header shadow">
|
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the
|
||||||
<h5 class="modal-title" id="resourceModalTitle">Resource</h5>
|
// DOM from these strings -- order matters, unlike the old always-in-DOM
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
// static modal. ---
|
||||||
|
|
||||||
|
var generalTabHtml = `
|
||||||
|
<div class="actionMessage mb-3" style="display:none"></div>
|
||||||
|
<input type="hidden" id="res-id">
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" id="res-name" class="form-control shadow-sm">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="col-6">
|
||||||
<div class="actionMessage mb-3" style="display:none"></div>
|
<label class="form-label">Slug</label>
|
||||||
<input type="hidden" id="res-id">
|
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" id="res-name" class="form-control shadow-sm">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Slug</label>
|
|
||||||
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Kind</label>
|
|
||||||
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
|
|
||||||
<option value="site">Site</option>
|
|
||||||
<option value="host">Host</option>
|
|
||||||
<option value="service">Service (App)</option>
|
|
||||||
<option value="oauth">OAuth Integration</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Sub Type</label>
|
|
||||||
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="site-details-container" style="display: none;">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-current-site">
|
|
||||||
<label class="form-check-label" for="res-is-current-site">
|
|
||||||
Mark as Current Site
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="host-parent-container" style="display: none;">
|
|
||||||
<div class="col-12">
|
|
||||||
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
|
|
||||||
<select id="res-host-id" class="form-select shadow-sm border-primary">
|
|
||||||
<option value="">-- Select Parent --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">IP Address</label>
|
|
||||||
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Host / URI Address</label>
|
|
||||||
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="host-details-container" style="display: none;">
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">VMID</label>
|
|
||||||
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">MAC Address</label>
|
|
||||||
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">OS / Kernel</label>
|
|
||||||
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="service-ports-container" style="display: none;">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Internal Port</label>
|
|
||||||
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">External Port</label>
|
|
||||||
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
|
|
||||||
<small class="text-muted">Same as Internal if empty</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3" id="service-details-container" style="display: none;">
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">Git Repo</label>
|
|
||||||
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">Install Path</label>
|
|
||||||
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-label">Systemd Service</label>
|
|
||||||
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="oauth-details-container" style="display: none;">
|
|
||||||
<hr>
|
|
||||||
<h5>OAuth Configuration</h5>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
||||||
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
|
|
||||||
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
|
|
||||||
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
|
|
||||||
<button class="btn btn-outline-warning" onclick="rotateSecret()">
|
|
||||||
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
|
|
||||||
</button>
|
|
||||||
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-production">
|
|
||||||
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-4" id="external-container" style="display: none;">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-external">
|
|
||||||
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-4" id="public-container" style="display: none;">
|
|
||||||
<div class="form-check form-switch mt-2">
|
|
||||||
<input class="form-check-input" type="checkbox" id="res-is-public">
|
|
||||||
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div id="edit-only-section" style="display: none;">
|
|
||||||
<h5>Associated LDAP Groups</h5>
|
|
||||||
<div class="mb-3">
|
|
||||||
<ul class="list-group mb-2 shadow-sm" id="groups-list" jq-repeat="groups">
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span>
|
|
||||||
<i class="fa-solid fa-users text-muted me-2"></i>
|
|
||||||
<strong>{{groupCn}}</strong>
|
|
||||||
<span class="badge bg-primary ms-2">{{accessLevel}}</span>
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="removeGroup('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="input-group shadow-sm mt-2">
|
|
||||||
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
|
|
||||||
<datalist id="ldap-groups-datalist"></datalist>
|
|
||||||
<select class="form-select" id="new-group-level" style="max-width: 140px;">
|
|
||||||
<option value="member">Member</option>
|
|
||||||
<option value="owner">Owner</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h5>Relationships (Graph Edges)</h5>
|
|
||||||
<div class="mb-3">
|
|
||||||
<ul class="list-group mb-2 shadow-sm" id="edges-list" jq-repeat="edges">
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span>
|
|
||||||
{{#isParent}}
|
|
||||||
<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
|
||||||
{{/isParent}}
|
|
||||||
{{^isParent}}
|
|
||||||
<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
|
||||||
{{/isParent}}
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="removeEdge('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="input-group shadow-sm mt-2">
|
|
||||||
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
|
|
||||||
<option value="parent">Has child</option>
|
|
||||||
<option value="child">Is child of</option>
|
|
||||||
</select>
|
|
||||||
<select class="form-select" id="new-edge-target">
|
|
||||||
<option value="">-- Select Resource --</option>
|
|
||||||
</select>
|
|
||||||
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
|
|
||||||
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveResource()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save Resource
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<div class="row mb-3">
|
||||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
|
<div class="col-6">
|
||||||
|
<label class="form-label">Kind</label>
|
||||||
|
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
|
||||||
|
<option value="site">Site</option>
|
||||||
|
<option value="host">Host</option>
|
||||||
|
<option value="service">Service (App)</option>
|
||||||
|
<option value="oauth">OAuth Integration</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Sub Type</label>
|
||||||
|
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">IP Address</label>
|
||||||
|
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Host / URI Address</label>
|
||||||
|
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-production">
|
||||||
|
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
var detailsTabHtml = `
|
||||||
|
<div class="row mb-3" id="site-details-container" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-current-site">
|
||||||
|
<label class="form-check-label" for="res-is-current-site">
|
||||||
|
Mark as Current Site
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="host-parent-container" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
|
||||||
|
<select id="res-host-id" class="form-select shadow-sm border-primary">
|
||||||
|
<option value="">-- Select Parent --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="host-details-container" style="display: none;">
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">VMID</label>
|
||||||
|
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">MAC Address</label>
|
||||||
|
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">OS / Kernel</label>
|
||||||
|
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="service-ports-container" style="display: none;">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Internal Port</label>
|
||||||
|
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">External Port</label>
|
||||||
|
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
|
||||||
|
<small class="text-muted">Same as Internal if empty</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="service-details-container" style="display: none;">
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">Git Repo</label>
|
||||||
|
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">Install Path</label>
|
||||||
|
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">Systemd Service</label>
|
||||||
|
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="oauth-details-container" style="display: none;">
|
||||||
|
<hr>
|
||||||
|
<h5>OAuth Configuration</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||||
|
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
|
||||||
|
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
|
||||||
|
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
|
||||||
|
<button class="btn btn-outline-warning" onclick="rotateSecret()">
|
||||||
|
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
|
||||||
|
</button>
|
||||||
|
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6" id="external-container" style="display: none;">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-external">
|
||||||
|
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6" id="public-container" style="display: none;">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-public">
|
||||||
|
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
var groupsTabHtml = `
|
||||||
|
<div class="mb-3" id="groups-tab-container">
|
||||||
|
<div class="actionMessage" style="display:none"></div>
|
||||||
|
<ul class="list-group mb-2 shadow-sm" id="groups-list"></ul>
|
||||||
|
<div class="input-group shadow-sm mt-2">
|
||||||
|
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
|
||||||
|
<datalist id="ldap-groups-datalist"></datalist>
|
||||||
|
<select class="form-select" id="new-group-level" style="max-width: 140px;">
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
var childrenTabHtml = `
|
||||||
|
<div class="mb-3">
|
||||||
|
<ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
|
||||||
|
<button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
|
||||||
|
<i class="fa-solid fa-plus"></i> Add Child Resource
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h6 class="text-muted">Other Relationships (advanced)</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<ul class="list-group mb-2 shadow-sm" id="edges-list"></ul>
|
||||||
|
<div class="input-group shadow-sm mt-2">
|
||||||
|
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
|
||||||
|
<option value="parent">Has child</option>
|
||||||
|
<option value="child">Is child of</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select" id="new-edge-target">
|
||||||
|
<option value="">-- Select Resource --</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
|
||||||
|
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
|
||||||
|
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
|
||||||
|
// the Groups/Children tabs in add-mode (no resource id to scope them to).
|
||||||
|
function openResourceModal(title, id) {
|
||||||
|
app.modal.open({
|
||||||
|
title: title,
|
||||||
|
size: 'lg',
|
||||||
|
tabs: [
|
||||||
|
{id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
|
||||||
|
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
|
||||||
|
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
||||||
|
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
||||||
|
],
|
||||||
|
footer: {
|
||||||
|
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
||||||
|
buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
|
||||||
|
},
|
||||||
|
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
|
||||||
|
});
|
||||||
|
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshChildrenUI(resourceId) {
|
||||||
|
const kids = allEdges.filter(e => e.parentId === resourceId)
|
||||||
|
.map(e => resourcesById[e.childId]).filter(Boolean);
|
||||||
|
const $list = $('#children-list').empty();
|
||||||
|
if (!kids.length) {
|
||||||
|
$list.append('<li class="list-group-item text-muted">No child resources.</li>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const kid of kids) {
|
||||||
|
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||||
|
$('<a href="#" class="text-reset text-decoration-none"></a>')
|
||||||
|
.text(kid.name + ' (' + kid.slug + ')')
|
||||||
|
.on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
|
||||||
|
.appendTo($li);
|
||||||
|
$('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
|
||||||
|
$list.append($li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var resourceModal = new bootstrap.Modal(document.getElementById('resourceModal'));
|
|
||||||
var resourcesById = {};
|
var resourcesById = {};
|
||||||
var allGroups = [];
|
var allGroups = [];
|
||||||
var allEdges = [];
|
var allEdges = [];
|
||||||
@@ -332,6 +343,11 @@
|
|||||||
|
|
||||||
$(document).ready(async function() {
|
$(document).ready(async function() {
|
||||||
await loadResources();
|
await loadResources();
|
||||||
|
const slug = app.modal.deepLinkSlug('/directory');
|
||||||
|
if (slug) {
|
||||||
|
const match = Object.values(resourcesById).find(r => r.slug === slug);
|
||||||
|
if (match) openEditModal(match.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadResources() {
|
async function loadResources() {
|
||||||
@@ -376,7 +392,6 @@
|
|||||||
function renderTable() {
|
function renderTable() {
|
||||||
const filter = $('#search-filter').val().toLowerCase();
|
const filter = $('#search-filter').val().toLowerCase();
|
||||||
const sort = $('#sort-by').val();
|
const sort = $('#sort-by').val();
|
||||||
const viewMode = $('input[name="viewMode"]:checked').val();
|
|
||||||
|
|
||||||
let filtered = rawResources.filter(r => {
|
let filtered = rawResources.filter(r => {
|
||||||
if (!filter) return true;
|
if (!filter) return true;
|
||||||
@@ -402,41 +417,37 @@
|
|||||||
|
|
||||||
let finalRenderList = [];
|
let finalRenderList = [];
|
||||||
|
|
||||||
if (viewMode === 'tree') {
|
const map = {};
|
||||||
const map = {};
|
const roots = [];
|
||||||
const roots = [];
|
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
|
||||||
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
|
|
||||||
|
|
||||||
filtered.forEach(r => {
|
filtered.forEach(r => {
|
||||||
const node = map[r.id];
|
const node = map[r.id];
|
||||||
if (node.parentId && map[node.parentId]) {
|
if (node.parentId && map[node.parentId]) {
|
||||||
map[node.parentId].children.push(node);
|
map[node.parentId].children.push(node);
|
||||||
} else {
|
} else {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const flatten = (nodes, depth) => {
|
const flatten = (nodes, depth) => {
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
let indentHtml = '';
|
let indentHtml = '';
|
||||||
for(let i = 0; i < depth; i++) {
|
for(let i = 0; i < depth; i++) {
|
||||||
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
|
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
|
||||||
}
|
}
|
||||||
if (depth > 0) {
|
if (depth > 0) {
|
||||||
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
||||||
}
|
}
|
||||||
n.indentHtml = indentHtml;
|
n.indentHtml = indentHtml;
|
||||||
finalRenderList.push(n);
|
finalRenderList.push(n);
|
||||||
if (n.children.length > 0) {
|
if (n.children.length > 0) {
|
||||||
flatten(n.children, depth + 1);
|
flatten(n.children, depth + 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
flatten(roots, 0);
|
flatten(roots, 0);
|
||||||
} else {
|
|
||||||
finalRenderList = filtered.map(r => ({ ...r, indentHtml: '' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
$.scope.resources.empty();
|
$.scope.resources.empty();
|
||||||
for (const r of finalRenderList) {
|
for (const r of finalRenderList) {
|
||||||
@@ -486,7 +497,10 @@
|
|||||||
populateHostDropdown($('#res-host-id').val());
|
populateHostDropdown($('#res-host-id').val());
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#res-name, #res-kind').on('input change', function() {
|
// Delegated (not direct) since app.modal.open() rebuilds the modal body
|
||||||
|
// from scratch every call -- a direct .on() bind here would silently stop
|
||||||
|
// firing after the first Add/Edit.
|
||||||
|
app.modal.on('input change', '#res-name, #res-kind', function() {
|
||||||
const id = $('#res-id').val();
|
const id = $('#res-id').val();
|
||||||
if (!id && $('#res-name').val()) {
|
if (!id && $('#res-name').val()) {
|
||||||
const name = $('#res-name').val();
|
const name = $('#res-name').val();
|
||||||
@@ -502,7 +516,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function openAddModal(parentId, parentKind) {
|
function openAddModal(parentId, parentKind) {
|
||||||
$('#resourceModalTitle').html('<i class="fa-solid fa-plus"></i> Add Resource');
|
openResourceModal('Add Resource', null);
|
||||||
$('#res-id').val('');
|
$('#res-id').val('');
|
||||||
$('#res-name').val('');
|
$('#res-name').val('');
|
||||||
$('#res-slug').val('');
|
$('#res-slug').val('');
|
||||||
@@ -537,20 +551,22 @@
|
|||||||
$('#res-is-external').prop('checked', false);
|
$('#res-is-external').prop('checked', false);
|
||||||
$('#res-is-public').prop('checked', false);
|
$('#res-is-public').prop('checked', false);
|
||||||
$('#res-is-current-site').prop('checked', false);
|
$('#res-is-current-site').prop('checked', false);
|
||||||
$('#edit-only-section').hide();
|
|
||||||
|
|
||||||
populateHostDropdown(parentId || '');
|
populateHostDropdown(parentId || '');
|
||||||
toggleFormFields();
|
toggleFormFields();
|
||||||
|
|
||||||
resourceModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var ldapGroupsCache = null;
|
var ldapGroupsCache = null;
|
||||||
async function loadLdapGroups() {
|
async function loadLdapGroups() {
|
||||||
if (ldapGroupsCache) return;
|
|
||||||
try {
|
try {
|
||||||
const res = await app.api.get('group');
|
if (!ldapGroupsCache) {
|
||||||
ldapGroupsCache = res.results;
|
const res = await app.api.get('group');
|
||||||
|
ldapGroupsCache = res.results;
|
||||||
|
}
|
||||||
|
// Re-populate every call, not just the first -- #ldap-groups-datalist
|
||||||
|
// is rebuilt fresh (empty) on every app.modal.open(), so returning
|
||||||
|
// early here on a cache hit left the second and later modal opens
|
||||||
|
// with no autocomplete options at all.
|
||||||
const $datalist = $('#ldap-groups-datalist');
|
const $datalist = $('#ldap-groups-datalist');
|
||||||
$datalist.empty();
|
$datalist.empty();
|
||||||
for (const cn of ldapGroupsCache) {
|
for (const cn of ldapGroupsCache) {
|
||||||
@@ -579,29 +595,158 @@
|
|||||||
if (selectedId) $target.val(selectedId);
|
if (selectedId) $target.val(selectedId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshGroupsUI(resourceId) {
|
// Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
|
||||||
|
// the modal body on every app.modal.open() call, and jq-repeat's
|
||||||
|
// MutationObserver-based (re)registration for a same-id element that's
|
||||||
|
// destroyed and recreated runs asynchronously -- populating it
|
||||||
|
// synchronously right after open() (as this function is called) would race
|
||||||
|
// that and silently lose the pushed data on the second and later opens.
|
||||||
|
// uid from a member/owner DN, e.g. "cn=jdoe,ou=people,..." -> "jdoe".
|
||||||
|
function uidFromDn(dn) {
|
||||||
|
const m = dn.match(/cn=[a-zA-Z0-9_\-@.]+/);
|
||||||
|
return m ? m[0].replace('cn=', '') : dn;
|
||||||
|
}
|
||||||
|
|
||||||
|
var directoryUserCache = null;
|
||||||
|
async function loadDirectoryUsers() {
|
||||||
|
if (!directoryUserCache) {
|
||||||
|
const data = await app.user.list();
|
||||||
|
directoryUserCache = data.results;
|
||||||
|
}
|
||||||
|
return directoryUserCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders one associated group's member/owner management block: a
|
||||||
|
// collapsible member list (remove button per member) plus an "add member"
|
||||||
|
// dropdown of users not already in the group. Fetches the live LDAP group
|
||||||
|
// detail (member/owner DN arrays) rather than relying on `allGroups`, which
|
||||||
|
// only holds the resource<->group association record, not membership.
|
||||||
|
async function renderGroupMembership(g, resourceId) {
|
||||||
|
const $li = $('<li class="list-group-item"></li>');
|
||||||
|
const $head = $('<div class="d-flex justify-content-between align-items-center"></div>');
|
||||||
|
const $span = $('<span></span>');
|
||||||
|
$span.append('<i class="fa-solid fa-users text-muted me-2"></i>');
|
||||||
|
$('<strong></strong>').text(g.groupCn).appendTo($span);
|
||||||
|
$('<span class="badge bg-primary ms-2"></span>').text(g.accessLevel).appendTo($span);
|
||||||
|
$head.append($span);
|
||||||
|
|
||||||
|
const collapseId = 'group-members-' + g.id;
|
||||||
|
const $actions = $('<span></span>');
|
||||||
|
$('<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-bs-toggle="collapse"><i class="fa-solid fa-user-group"></i> Members</button>')
|
||||||
|
.attr('data-bs-target', '#' + collapseId)
|
||||||
|
.appendTo($actions);
|
||||||
|
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
||||||
|
.on('click', function(){ removeGroup(g.id); })
|
||||||
|
.appendTo($actions);
|
||||||
|
$head.append($actions);
|
||||||
|
$li.append($head);
|
||||||
|
|
||||||
|
const $collapse = $('<div class="collapse mt-2"></div>').attr('id', collapseId);
|
||||||
|
$li.append($collapse);
|
||||||
|
|
||||||
|
let detail, userlist;
|
||||||
|
try {
|
||||||
|
[detail, userlist] = await Promise.all([
|
||||||
|
app.group.get(g.groupCn).then(d => d.results),
|
||||||
|
loadDirectoryUsers(),
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
$collapse.append('<p class="text-danger small mb-0">Failed to load group membership.</p>');
|
||||||
|
return $li;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberDns = Array.isArray(detail.member) ? detail.member : (detail.member ? [detail.member] : []);
|
||||||
|
const $memberList = $('<ul class="list-group list-group-flush mb-2"></ul>');
|
||||||
|
if (!memberDns.length) {
|
||||||
|
$memberList.append('<li class="list-group-item text-muted px-0">No members.</li>');
|
||||||
|
}
|
||||||
|
for (const dn of memberDns) {
|
||||||
|
const uid = uidFromDn(dn);
|
||||||
|
const $mLi = $('<li class="list-group-item d-flex justify-content-between align-items-center px-0"></li>');
|
||||||
|
$('<span></span>').append('<i class="fa-solid fa-user me-1"></i>').append(document.createTextNode(uid)).appendTo($mLi);
|
||||||
|
$('<button type="button" class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-user-slash"></i></button>')
|
||||||
|
.on('click', function(){ removeResourceGroupMember(g.groupCn, uid, resourceId); })
|
||||||
|
.appendTo($mLi);
|
||||||
|
$memberList.append($mLi);
|
||||||
|
}
|
||||||
|
$collapse.append($memberList);
|
||||||
|
|
||||||
|
const toAdd = userlist.filter(function(u){ return !memberDns.includes(u.dn); });
|
||||||
|
const $dropdown = $('<div class="dropdown"></div>');
|
||||||
|
$('<button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown"><i class="fa-solid fa-user-plus"></i> Add member</button>')
|
||||||
|
.appendTo($dropdown);
|
||||||
|
const $menu = $('<div class="dropdown-menu shadow-lg" style="max-height: 240px; overflow-y: auto;"></div>');
|
||||||
|
for (const u of toAdd) {
|
||||||
|
$('<a class="dropdown-item" href="#"></a>').text(u.uid)
|
||||||
|
.on('click', function(e){ e.preventDefault(); addResourceGroupMember(g.groupCn, u.uid, resourceId); })
|
||||||
|
.appendTo($menu);
|
||||||
|
}
|
||||||
|
$dropdown.append($menu);
|
||||||
|
$collapse.append($dropdown);
|
||||||
|
|
||||||
|
return $li;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGroupsUI(resourceId) {
|
||||||
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
||||||
$.scope.groups.empty();
|
const $list = $('#groups-list').empty();
|
||||||
|
if (!myGroups.length) {
|
||||||
|
$list.append('<li class="list-group-item text-muted">No associated groups.</li>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const g of myGroups) {
|
for (const g of myGroups) {
|
||||||
$.scope.groups.push(g);
|
$list.append(await renderGroupMembership(g, resourceId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addResourceGroupMember(groupCn, uid, resourceId) {
|
||||||
|
try {
|
||||||
|
const data = await app.api.put('group/' + groupCn + '/' + uid, {});
|
||||||
|
await refreshGroupsUI(resourceId);
|
||||||
|
app.messages.action(data.message, $('#groups-tab-container'), 'success');
|
||||||
|
app.util.revealItem($('#groups-list'));
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.action((err && err.message) || 'Failed to add member', $('#groups-tab-container'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeResourceGroupMember(groupCn, uid, resourceId) {
|
||||||
|
const confirmed = await app.messages.confirm('Remove "' + uid + '" from "' + groupCn + '"?', $('#groups-tab-container'), 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
const data = await app.api.delete('group/' + groupCn + '/' + uid);
|
||||||
|
await refreshGroupsUI(resourceId);
|
||||||
|
app.messages.action(data.message, $('#groups-tab-container'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.action((err && err.message) || 'Failed to remove member', $('#groups-tab-container'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshEdgesUI(resourceId) {
|
function refreshEdgesUI(resourceId) {
|
||||||
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
||||||
$.scope.edges.empty();
|
const $list = $('#edges-list').empty();
|
||||||
|
if (!myEdges.length) {
|
||||||
|
$list.append('<li class="list-group-item text-muted">No other relationships.</li>');
|
||||||
|
}
|
||||||
for (const e of myEdges) {
|
for (const e of myEdges) {
|
||||||
const isParent = e.parentId === resourceId;
|
const isParent = e.parentId === resourceId;
|
||||||
const targetId = isParent ? e.childId : e.parentId;
|
const targetId = isParent ? e.childId : e.parentId;
|
||||||
const target = resourcesById[targetId];
|
const target = resourcesById[targetId];
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
|
|
||||||
$.scope.edges.push({
|
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||||
id: e.id,
|
const $span = $('<span></span>');
|
||||||
isParent: isParent,
|
$span.append(isParent
|
||||||
relation: e.relation,
|
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
|
||||||
targetName: target.name + ' (' + target.slug + ')'
|
: '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
|
||||||
});
|
$('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
|
||||||
|
$('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
|
||||||
|
$li.append($span);
|
||||||
|
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
||||||
|
.on('click', function(){ removeEdge(e.id); })
|
||||||
|
.appendTo($li);
|
||||||
|
$list.append($li);
|
||||||
}
|
}
|
||||||
|
|
||||||
const $target = $('#new-edge-target');
|
const $target = $('#new-edge-target');
|
||||||
@@ -617,7 +762,7 @@
|
|||||||
const r = resourcesById[id];
|
const r = resourcesById[id];
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
|
|
||||||
$('#resourceModalTitle').html('<i class="fa-solid fa-pen-to-square"></i> Edit Resource');
|
openResourceModal('Edit Resource', id);
|
||||||
$('#res-id').val(r.id);
|
$('#res-id').val(r.id);
|
||||||
$('#res-name').val(r.name);
|
$('#res-name').val(r.name);
|
||||||
$('#res-slug').val(r.slug);
|
$('#res-slug').val(r.slug);
|
||||||
@@ -656,13 +801,10 @@
|
|||||||
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
||||||
toggleFormFields();
|
toggleFormFields();
|
||||||
|
|
||||||
$('#edit-only-section').show();
|
|
||||||
|
|
||||||
refreshGroupsUI(r.id);
|
refreshGroupsUI(r.id);
|
||||||
refreshEdgesUI(r.id);
|
refreshEdgesUI(r.id);
|
||||||
|
refreshChildrenUI(r.id);
|
||||||
await loadLdapGroups();
|
await loadLdapGroups();
|
||||||
|
|
||||||
resourceModal.show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveResource() {
|
async function saveResource() {
|
||||||
@@ -710,22 +852,32 @@
|
|||||||
res = await app.api.post('directory-admin/resources', data);
|
res = await app.api.post('directory-admin/resources', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
resourceModal.hide();
|
|
||||||
await loadResources();
|
await loadResources();
|
||||||
|
const savedId = id || (res.results && res.results.id);
|
||||||
|
if (savedId) setTimeout(function(){ app.util.revealItem($('#resource-row-' + savedId)); }, 400);
|
||||||
|
|
||||||
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
||||||
|
// Deliberately no app.modal.close() before this -- app.modal is a
|
||||||
|
// singleton, and close() immediately followed by open() in the same
|
||||||
|
// tick collides with Bootstrap's hide-transition guard (show()
|
||||||
|
// silently no-ops while _isTransitioning is still true from the
|
||||||
|
// just-started hide()). open() alone already overwrites the
|
||||||
|
// (already-visible) modal's content in place. The await above made
|
||||||
|
// this race unlikely to lose in practice, but not guaranteed to.
|
||||||
app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
|
app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
|
||||||
|
} else {
|
||||||
|
app.modal.close();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action(err.message || 'Failed to save', $('#resourceModal'), 'danger');
|
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateSecret() {
|
async function rotateSecret() {
|
||||||
const id = $('#res-id').val();
|
const id = $('#res-id').val();
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', $('#resourceModal'), 'warning');
|
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', app.modal.body(), 'warning');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -733,7 +885,7 @@
|
|||||||
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
|
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action(err.message || 'Failed to rotate secret', $('#resourceModal'), 'danger');
|
app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,7 +894,7 @@
|
|||||||
const groupCn = $('#new-group-cn').val().trim();
|
const groupCn = $('#new-group-cn').val().trim();
|
||||||
const accessLevel = $('#new-group-level').val();
|
const accessLevel = $('#new-group-level').val();
|
||||||
|
|
||||||
if (!groupCn) return app.messages.action('Group CN is required', $('#resourceModal'), 'danger');
|
if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
|
||||||
try {
|
try {
|
||||||
const res = await app.api.post('directory-admin/groups', {
|
const res = await app.api.post('directory-admin/groups', {
|
||||||
resourceId,
|
resourceId,
|
||||||
@@ -754,7 +906,7 @@
|
|||||||
$('#new-group-cn').val('');
|
$('#new-group-cn').val('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add group', $('#resourceModal'), 'danger');
|
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +917,7 @@
|
|||||||
refreshGroupsUI($('#res-id').val());
|
refreshGroupsUI($('#res-id').val());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove group', $('#resourceModal'), 'danger');
|
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,7 +927,7 @@
|
|||||||
const targetId = $('#new-edge-target').val();
|
const targetId = $('#new-edge-target').val();
|
||||||
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
||||||
|
|
||||||
if (!targetId) return app.messages.action('Select a target resource', $('#resourceModal'), 'danger');
|
if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
|
||||||
|
|
||||||
const data = { relation };
|
const data = { relation };
|
||||||
if (dir === 'parent') {
|
if (dir === 'parent') {
|
||||||
@@ -793,7 +945,7 @@
|
|||||||
$('#new-edge-target').val('');
|
$('#new-edge-target').val('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add edge', $('#resourceModal'), 'danger');
|
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,14 +956,14 @@
|
|||||||
refreshEdgesUI($('#res-id').val());
|
refreshEdgesUI($('#res-id').val());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove edge', $('#resourceModal'), 'danger');
|
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteResource(id) {
|
async function deleteResource(id) {
|
||||||
// Called from the outer table's row button, not from inside
|
// Called from the outer table's row button, not from inside the resource
|
||||||
// #resourceModal — target the page's own card so the confirm/error
|
// modal — target the page's own card so the confirm/error renders
|
||||||
// renders somewhere actually visible.
|
// somewhere actually visible.
|
||||||
const $target = $('#resources-list');
|
const $target = $('#resources-list');
|
||||||
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
|
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|||||||
+34
-8
@@ -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.messages.action(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,10 +89,11 @@
|
|||||||
$('#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) {
|
||||||
@@ -114,7 +140,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.auth.forceLogin('app_sso_admin');
|
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
$(document).ready(async function(){
|
$(document).ready(async function(){
|
||||||
userlist = (await app.user.list()).results;
|
userlist = (await app.user.list()).results;
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
app.auth.forceLogin('app_sso_admin');
|
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
async function loadMetrics() {
|
async function loadMetrics() {
|
||||||
try {
|
try {
|
||||||
const data = await app.api.get('metrics/executive');
|
const data = await app.api.get('metrics/overview');
|
||||||
if (data && data.results) {
|
if (data && data.results) {
|
||||||
const renderList = (items, id) => {
|
const renderList = (items, id) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
|
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Overview</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
+129
-145
@@ -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 <token></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,10 +602,12 @@
|
|||||||
|
|
||||||
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){
|
||||||
@@ -677,31 +628,81 @@
|
|||||||
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.messages.action('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.messages.action((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>
|
||||||
|
|||||||
@@ -82,16 +82,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<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(){
|
function showUpdateBanner(){
|
||||||
let $nav = $('nav.fixed-top');
|
let $nav = $('nav.fixed-top');
|
||||||
let $banner = $('#update-banner');
|
let $banner = $('#update-banner');
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||||
|
$('#spa-shell').css('margin-top', offset + 'px');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissUpdateBanner(){
|
function dismissUpdateBanner(){
|
||||||
$('#update-banner').hide();
|
$('#update-banner').hide();
|
||||||
$('#spa-shell').css('margin-top', '');
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
(async function(){
|
(async function(){
|
||||||
await app.auth.forceLogin('app_sso_admin');
|
await app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
renderUsers();
|
renderUsers();
|
||||||
|
|||||||
Reference in New Issue
Block a user