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
|
||||
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
|
||||
|
||||
### 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
|
||||
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
|
||||
connect — say, a group listing everyone who should be allowed into your
|
||||
photo server. Once a group exists, add or remove members from the
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ one command).
|
||||
|
||||
## 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/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>
|
||||
|
||||
Binary file not shown.
@@ -152,10 +152,36 @@ class Resource extends Model {
|
||||
owner: { type: 'string' },
|
||||
description: { type: 'text' },
|
||||
metadata: { type: 'json', default: {} },
|
||||
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||
// are set explicitly by the route handler on every create/update (see
|
||||
// routes/api_directory_admin.js). Existing rows predating this change
|
||||
// simply read back undefined -- callers must render a fallback.
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
updated_by: { type: 'string' },
|
||||
updated_on: { type: 'integer' },
|
||||
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||
};
|
||||
|
||||
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||
// top-level resource with no site parent keeps its unprefixed group name).
|
||||
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||
if (visited.has(resourceId)) return null;
|
||||
visited.add(resourceId);
|
||||
|
||||
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||
for (const edge of parentEdges) {
|
||||
const parent = await this.get(edge.parentId);
|
||||
if (!parent) continue;
|
||||
if (parent.kind === 'site') return parent.slug;
|
||||
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceEdge extends Model {
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.1",
|
||||
"version": "1.8.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.1",
|
||||
"version": "1.8.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.5",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -1280,9 +1280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/frontend": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
|
||||
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
|
||||
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.6.2",
|
||||
"version": "1.10.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -26,7 +26,7 @@
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.5",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
|
||||
@@ -7,6 +7,12 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
|
||||
@@ -584,10 +584,31 @@ app.util = (function(app){
|
||||
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 {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
@@ -679,13 +700,10 @@ function formAJAX(btn){
|
||||
return false;
|
||||
}
|
||||
|
||||
app.messages.action(
|
||||
`<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>`,
|
||||
$form,
|
||||
'info'
|
||||
);
|
||||
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||
// render literally instead of as an element.
|
||||
app.messages.action('Saving…', $form, 'info');
|
||||
|
||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
|
||||
@@ -43,25 +43,33 @@ router.post('/resources', async (req, res, next) => {
|
||||
}
|
||||
|
||||
req.body.owner = req.body.owner || req.user.uid;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
req.body.created_by = req.body.created_by || req.user.uid;
|
||||
req.body.created_on = now;
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = now;
|
||||
|
||||
let r;
|
||||
if (req.body.kind === 'oauth') {
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
// Pass created_by explicitly for the wrapper
|
||||
// Pass created_by explicitly for the wrapper (overrides the generic
|
||||
// assignment above -- this is OAuthClient-wrapper-specific behavior).
|
||||
req.body.created_by = req.body.owner;
|
||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||
r = await OAuthClient.add(req.body);
|
||||
} else {
|
||||
r = await Resource.create(req.body);
|
||||
}
|
||||
|
||||
|
||||
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||
}
|
||||
|
||||
|
||||
if (r.kind === 'host' || r.kind === 'service') {
|
||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||
const createGroup = async (suffix, accessLevel) => {
|
||||
const cn = `${r.slug}_${suffix}`;
|
||||
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
|
||||
try {
|
||||
await Group.add({
|
||||
name: cn,
|
||||
@@ -103,7 +111,10 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
r = await Resource.get(req.params.id);
|
||||
}
|
||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = Date.now();
|
||||
|
||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const metrics = require('../utils/metrics');
|
||||
|
||||
// /api/metrics/executive
|
||||
router.get('/executive', async (req, res, next) => {
|
||||
// /api/metrics/overview
|
||||
router.get('/overview', async (req, res, next) => {
|
||||
try {
|
||||
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 user = await User.get(req.params.uid);
|
||||
const results = await group.addMember(user);
|
||||
// Group membership feeds directly into cached-User-derived state
|
||||
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
|
||||
// a membership change here is invisible for up to the cache's TTL.
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results: await group.addMember(user),
|
||||
results,
|
||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
@@ -98,8 +103,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
||||
|
||||
var group = await Group.get(req.params.group);
|
||||
var user = await User.get(req.params.uid);
|
||||
const results = await group.removeMember(user);
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results: await group.removeMember(user),
|
||||
results,
|
||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
|
||||
+14
-5
@@ -48,18 +48,27 @@ router.get('/tos', async function(req, res, next) {
|
||||
|
||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||
// (broadcast + history) merged into one page.
|
||||
router.get('/executive', function(req, res) {
|
||||
res.render('executive', {...values});
|
||||
router.get('/overview', function(req, res) {
|
||||
res.render('overview', {...values});
|
||||
});
|
||||
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
||||
router.get('/admin', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||
|
||||
router.get('/directory', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||
// the matching resource's modal once the page's own data has loaded.
|
||||
router.get('/directory/:slug', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Route removed since it's now in directory
|
||||
|
||||
router.get('/onboarding', async function(req, res, next) {
|
||||
|
||||
@@ -49,7 +49,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({results: user});
|
||||
return res.json({results: user, message: `User ${user.uid} created.`});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
@@ -78,11 +78,12 @@ router.get('/me', async function(req, res, next){
|
||||
|
||||
// The shared client framework gates the UI on a single effective-rights
|
||||
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||
// membership in app_sso_admin; group-level gating still reads memberOf.
|
||||
// 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){
|
||||
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);
|
||||
}catch(error){
|
||||
@@ -107,7 +108,7 @@ router.put('/password', async function(req, res, next){
|
||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||
await verif.update({ password_must_change: false });
|
||||
User.clearCache();
|
||||
return res.json({results: result});
|
||||
return res.json({results: result, message: 'Password changed.'});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
||||
});
|
||||
|
||||
// Regression: adding/removing a member here didn't clear User's LRU
|
||||
// cache (ttl 5 minutes), so isServiceAccount -- derived from
|
||||
// app_sso_service_account membership at GET /api/user/:uid time -- could
|
||||
// stay wrong for up to 5 minutes after the group change. In production
|
||||
// this hid a real person's account from the Users page's "People" tab
|
||||
// (it filters out anything with isServiceAccount) for however long the
|
||||
// stale cache entry lived, which looked exactly like the account had
|
||||
// vanished.
|
||||
test('PUT app_sso_service_account/:uid immediately flips isServiceAccount (no stale cache)', async () => {
|
||||
const added = await request(app)
|
||||
.put(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||
.set('auth-token', token);
|
||||
expect(added.status).toBe(200);
|
||||
|
||||
const afterAdd = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||
expect(afterAdd.body.results.isServiceAccount).toBeTruthy();
|
||||
|
||||
const removed = await request(app)
|
||||
.delete(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||
.set('auth-token', token);
|
||||
expect(removed.status).toBe(200);
|
||||
|
||||
const afterRemove = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||
expect(afterRemove.body.results.isServiceAccount).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Groups — owner management', () => {
|
||||
|
||||
@@ -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 SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||
|
||||
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){
|
||||
try{
|
||||
group = await Group.get(group);
|
||||
@@ -28,4 +37,4 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
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
|
||||
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||
nav: [
|
||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
|
||||
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
|
||||
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
|
||||
{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', 'admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||
],
|
||||
};
|
||||
|
||||
+492
-340
File diff suppressed because it is too large
Load Diff
+34
-8
@@ -32,14 +32,39 @@
|
||||
return value;
|
||||
}
|
||||
|
||||
// app_sso_service_account is a marker group: membership hides an account
|
||||
// from the Users page's People tab entirely (see users.ejs), which is
|
||||
// exactly right for a non-person account but has silently made a real
|
||||
// person's account look "gone" before (nothing else about it changes).
|
||||
// Everywhere else in this dropdown just fires the PUT directly; only
|
||||
// this one group gets a confirmation first.
|
||||
function addMemberClick(event, groupCN, uid, el){
|
||||
event.preventDefault();
|
||||
const $el = $(el);
|
||||
(async function(){
|
||||
if (groupCN === 'app_sso_service_account') {
|
||||
const ok = await app.messages.confirm(
|
||||
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
||||
$el.closest('.card'), 'warning'
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
||||
await addedUser(data.message, groupCN, uid, $el);
|
||||
} catch(e) {
|
||||
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
||||
}
|
||||
})();
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addedUser(message, group, user, $form){
|
||||
let data = await app.group.get(group);
|
||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
||||
app.messages.action(message, $("#group-card-"+group), 'success');
|
||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
||||
setTimeout(function(group){
|
||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
||||
}, 400, group);
|
||||
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
||||
}
|
||||
|
||||
function applySort() {
|
||||
@@ -64,10 +89,11 @@
|
||||
$('#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();
|
||||
allGroups = data.results.map(processGroup);
|
||||
applyFilters();
|
||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
||||
}
|
||||
|
||||
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(){
|
||||
userlist = (await app.user.list()).results;
|
||||
@@ -123,7 +149,7 @@
|
||||
</script>
|
||||
<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;">
|
||||
<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()">
|
||||
@@ -146,7 +172,7 @@
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<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">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
||||
@@ -214,7 +240,7 @@
|
||||
</button>
|
||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||
{{ #toAdd }}{{#.}}
|
||||
<a class="dropdown-item" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
|
||||
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
||||
<i class="fa-solid fa-user"></i> {{uid}}
|
||||
</a>
|
||||
{{/.}}{{ /toAdd }}
|
||||
|
||||
@@ -7,6 +7,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Landing here with no explanation ("why am I on the SSO login page?") is
|
||||
// exactly what happens when another app's "Log in with SSO" button sends
|
||||
// an unauthenticated user through /oauth/authorize, which bounces them
|
||||
// here with ?redirect=. Tell them what's happening instead of leaving it
|
||||
// a mystery.
|
||||
$(document).ready(function(){
|
||||
var redirect = <%- JSON.stringify(redirect || '') %>;
|
||||
if(redirect){
|
||||
var isOauth = /\/oauth\/authorize/.test(redirect);
|
||||
var message = isOauth
|
||||
? 'Log in to continue — an application is requesting access to your account.'
|
||||
: "Log in to continue to what you were doing — you'll be sent back afterward.";
|
||||
app.messages.action(message, $('.card').first(), 'info');
|
||||
}
|
||||
});
|
||||
|
||||
function setOtpMethod(method) {
|
||||
$('#otpMethodInput').val(method);
|
||||
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin('app_sso_admin');
|
||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
||||
async function loadDashboard() {
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
async function loadMetrics() {
|
||||
try {
|
||||
const data = await app.api.get('metrics/executive');
|
||||
const data = await app.api.get('metrics/overview');
|
||||
if (data && data.results) {
|
||||
const renderList = (items, id) => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -213,7 +213,7 @@
|
||||
<div class="container mt-4">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
|
||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Overview</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+129
-145
@@ -220,9 +220,6 @@
|
||||
if(isOwnProfile){
|
||||
$('#own-api-tokens-section').show();
|
||||
tableAJAX();
|
||||
$('form[action="api-token/"]').attr('evalAJAX',
|
||||
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -545,83 +542,35 @@
|
||||
</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">
|
||||
// Any logged-in user can manage their own API tokens (self-service).
|
||||
// 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 = {};
|
||||
|
||||
function showSecret(secret){
|
||||
document.getElementById('secretValue').value = secret;
|
||||
secretModal.show();
|
||||
// Shared "reveal secret once" display -- same pattern as jump-host's and
|
||||
// proxy's showToken().
|
||||
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'); }
|
||||
|
||||
function copyField(id, btn){
|
||||
var el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
el.select(); el.setSelectionRange(0, 99999); document.execCommand('copy');
|
||||
if(btn){ var $i = $(btn).find('i'), prev = $i.attr('class');
|
||||
$i.attr('class', 'fa-solid fa-check'); setTimeout(function(){ $i.attr('class', prev); }, 1200); }
|
||||
// Not the checkmark-flash technique this file used to use for its copy
|
||||
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
|
||||
// swapping the <i>'s class silently no-ops. A toast doesn't have that
|
||||
// problem.
|
||||
function copyFieldValue(sel){
|
||||
var $el = $(sel);
|
||||
var text = $el.val();
|
||||
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){
|
||||
@@ -637,9 +586,9 @@
|
||||
// expires_at is type:number (a real number); isExpired is a class getter
|
||||
// that is NOT serialized to the client, so compute expiry here.
|
||||
var exp = Number(token.expires_at);
|
||||
if(!exp) return '<span class="badge bg-secondary">never</span>';
|
||||
if(Date.now() > exp) return '<span class="badge bg-danger">expired</span>';
|
||||
return '<span class="badge bg-warning text-dark">' + moment(exp, "x").fromNow() + '</span>';
|
||||
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
|
||||
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
|
||||
return '<span class="badge text-bg-warning">' + moment(exp, "x").fromNow() + '</span>';
|
||||
}
|
||||
|
||||
function processToken(token){
|
||||
@@ -653,10 +602,12 @@
|
||||
|
||||
async function tableAJAX(){
|
||||
let data = await app.apiToken.list();
|
||||
var tokens = data.results || [];
|
||||
$.scope.apiTokenCard.empty();
|
||||
$.each(data.results, function(_, token){
|
||||
tokens.forEach(function(token){
|
||||
$.scope.apiTokenCard.push(processToken(token));
|
||||
});
|
||||
$('#api-tokens-empty').toggle(tokens.length === 0);
|
||||
}
|
||||
|
||||
async function revokeToken(id, name, btn){
|
||||
@@ -677,31 +628,81 @@
|
||||
if(!confirmed) return;
|
||||
app.apiToken.rotate({id: id}, function(error, data){
|
||||
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||
showSecret(data.token);
|
||||
showToken('API Token Rotated', data.token);
|
||||
tableAJAX();
|
||||
});
|
||||
}
|
||||
|
||||
function editToken(id){
|
||||
var t = tokensById[id]; if(!t) return;
|
||||
$('#edit-id').val(id);
|
||||
$('#edit-name').val(t.name || '');
|
||||
$('#edit-description').val(t.description || '');
|
||||
$('#edit-expires_in_days').val('');
|
||||
editModal.show();
|
||||
// Create is a native <form>+formAJAX submission (matching this app's own
|
||||
// hostModal-style convention) rather than a JS-built payload. Deliberately
|
||||
// does NOT call app.modal.close() before showToken() -- 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.
|
||||
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){
|
||||
var $msg = $('#editModal .actionMessage');
|
||||
function editToken(id){
|
||||
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 = {
|
||||
id: $('#edit-id').val(),
|
||||
name: $('#edit-name').val(),
|
||||
description: $('#edit-description').val(),
|
||||
expires_in_days: $('#edit-expires_in_days').val(),
|
||||
id: $('#edit-token-id').val(),
|
||||
name: $('#edit-token-name').val(),
|
||||
description: $('#edit-token-description').val(),
|
||||
expires_in_days: $('#edit-token-days').val(),
|
||||
};
|
||||
app.apiToken.update(payload, function(error, data){
|
||||
if(error){ app.messages.action((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
||||
editModal.hide();
|
||||
if(error){ app.messages.action((data && data.message) || 'Update failed.', app.modal.body(), 'danger'); return; }
|
||||
app.modal.close();
|
||||
tableAJAX();
|
||||
});
|
||||
}
|
||||
@@ -711,62 +712,45 @@
|
||||
runs before this page's own ready handler and would unhide any div.row
|
||||
unconditionally, defeating the isOwnProfile check below. -->
|
||||
<div id="own-api-tokens-section" style="display:none">
|
||||
<div class="row mt-3">
|
||||
<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="row mt-3 justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
|
||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
||||
<div class="card-header">
|
||||
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
||||
<small class="text-muted font-monospace">{{ id_short }}</small>
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||
<span>
|
||||
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||
</span>
|
||||
</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">
|
||||
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Token ID</dt>
|
||||
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
||||
<dt class="col-sm-3">Created</dt>
|
||||
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
||||
<dt class="col-sm-3">Last used</dt>
|
||||
<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>
|
||||
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
||||
<div class="card-header">
|
||||
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
||||
<small class="text-muted font-monospace">{{ id_short }}</small>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Token ID</dt>
|
||||
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
||||
<dt class="col-sm-3">Created</dt>
|
||||
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
||||
<dt class="col-sm-3">Last used</dt>
|
||||
<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>
|
||||
|
||||
@@ -82,16 +82,24 @@
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||
// (fixed navbar, plus the update banner while it's shown), so any
|
||||
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||
// instead of colliding with them at the viewport's true top:0.
|
||||
function showUpdateBanner(){
|
||||
let $nav = $('nav.fixed-top');
|
||||
let $banner = $('#update-banner');
|
||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||
$('#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(){
|
||||
$('#update-banner').hide();
|
||||
$('#spa-shell').css('margin-top', '');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
}
|
||||
|
||||
(async function(){
|
||||
await app.auth.forceLogin('app_sso_admin');
|
||||
await app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
$(document).ready(function(){
|
||||
renderUsers();
|
||||
|
||||
Reference in New Issue
Block a user