Compare commits

...

14 Commits

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

Now the resource-edit modal is only closed when we're NOT about to
immediately show the OAuth secret; app.modal.open() alone already
overwrites the (already-visible) modal's content in place, no close()
needed first.
2026-07-28 20:49:06 -04:00
18 changed files with 374 additions and 209 deletions
+30
View File
@@ -4,6 +4,36 @@ 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 ## [1.8.1] - 2026-07-28
### Fixed ### Fixed
+1 -1
View File
@@ -46,7 +46,7 @@ on, just like anyone else's.
A **group** is just a named list of accounts, used to control access. This A **group** is just a named list of accounts, used to control access. This
app has a handful of built-in groups that grant admin powers (e.g. only app has a handful of built-in groups that grant admin powers (e.g. only
people in the `app_sso_admin` group can see the Users/Groups/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
View File
@@ -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>
+6 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.7.0", "version": "1.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.7.0", "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.6", "@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.6", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==", "integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.8.1", "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.6", "@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",
+6
View File
@@ -7,6 +7,12 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
} }
#spa-shell { #spa-shell {
+21
View File
@@ -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);
+2 -2
View File
@@ -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']);
+6 -5
View File
@@ -48,13 +48,14 @@ 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});
+3 -2
View File
@@ -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){
+10 -1
View File
@@ -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
View File
@@ -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']},
], ],
}; };
+134 -28
View File
@@ -29,31 +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> {{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
<td> {{^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"> <a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong> <strong>{{name}}</strong>
</a> </a>
<br><small class="text-muted">{{slug}}</small> <small class="text-muted">{{slug}}</small>
</td> </td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
</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,7 +73,7 @@
</div> </div>
<script> <script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']); app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
// --- Resource modal tab content, built once. Populated via .val() in // --- Resource modal tab content, built once. Populated via .val() in
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the // openAddModal/openEditModal AFTER app.modal.open() has (re)built the
@@ -259,7 +253,8 @@
`; `;
var groupsTabHtml = ` var groupsTabHtml = `
<div class="mb-3"> <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> <ul class="list-group mb-2 shadow-sm" id="groups-list"></ul>
<div class="input-group shadow-sm mt-2"> <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"> <input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
@@ -606,7 +601,94 @@
// destroyed and recreated runs asynchronously -- populating it // destroyed and recreated runs asynchronously -- populating it
// synchronously right after open() (as this function is called) would race // synchronously right after open() (as this function is called) would race
// that and silently lose the pushed data on the second and later opens. // that and silently lose the pushed data on the second and later opens.
function refreshGroupsUI(resourceId) { // 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);
const $list = $('#groups-list').empty(); const $list = $('#groups-list').empty();
if (!myGroups.length) { if (!myGroups.length) {
@@ -614,16 +696,30 @@
return; return;
} }
for (const g of myGroups) { for (const g of myGroups) {
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>'); $list.append(await renderGroupMembership(g, resourceId));
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); async function addResourceGroupMember(groupCn, uid, resourceId) {
$li.append($span); try {
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>') const data = await app.api.put('group/' + groupCn + '/' + uid, {});
.on('click', function(){ removeGroup(g.id); }) await refreshGroupsUI(resourceId);
.appendTo($li); app.messages.action(data.message, $('#groups-tab-container'), 'success');
$list.append($li); 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');
} }
} }
@@ -756,11 +852,21 @@
res = await app.api.post('directory-admin/resources', data); res = await app.api.post('directory-admin/resources', data);
} }
app.modal.close();
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);
+6 -7
View File
@@ -64,9 +64,7 @@
$.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() {
@@ -91,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) {
@@ -141,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;
@@ -150,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()">
@@ -173,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" />
@@ -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
View File
@@ -220,9 +220,6 @@
if(isOwnProfile){ if(isOwnProfile){
$('#own-api-tokens-section').show(); $('#own-api-tokens-section').show();
tableAJAX(); tableAJAX();
$('form[action="api-token/"]').attr('evalAJAX',
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
);
} }
}); });
</script> </script>
@@ -545,83 +542,35 @@
</div> </div>
</div> </div>
<!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-key"></i> API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer &lt;token&gt;</code></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<!-- Edit modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="card-header actionMessage mb-3" style="display:none"></div>
<input type="hidden" id="edit-id">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" id="edit-name" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" id="edit-description" class="form-control shadow">
</div>
<div class="mb-3">
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
<input type="number" id="edit-expires_in_days" class="form-control shadow" min="0">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
</div>
</div>
</div>
</div>
<script type="text/javascript"> <script type="text/javascript">
// Any logged-in user can manage their own API tokens (self-service). // Any logged-in user can manage their own API tokens (self-service).
// Section is only revealed (see $(document).ready above) when isOwnProfile. // Section is only revealed (see $(document).ready above) when isOwnProfile.
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
var tokensById = {}; var tokensById = {};
function showSecret(secret){ // Shared "reveal secret once" display -- same pattern as jump-host's and
document.getElementById('secretValue').value = secret; // proxy's showToken().
secretModal.show(); function showToken(title, token){
app.modal.open({title: title, bodyHtml:
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.util.escapeHtml(token) + '">'
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
});
} }
function copySecret(){ copyField('secretValue'); } // Not the checkmark-flash technique this file used to use for its copy
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
function copyField(id, btn){ // swapping the <i>'s class silently no-ops. A toast doesn't have that
var el = document.getElementById(id); // problem.
if(!el) return; function copyFieldValue(sel){
el.select(); el.setSelectionRange(0, 99999); document.execCommand('copy'); var $el = $(sel);
if(btn){ var $i = $(btn).find('i'), prev = $i.attr('class'); var text = $el.val();
$i.attr('class', 'fa-solid fa-check'); setTimeout(function(){ $i.attr('class', prev); }, 1200); } if(!text) return;
navigator.clipboard.writeText(text).then(function(){
app.messages.toast('Copied to clipboard', 'success');
}, function(){
app.messages.toast('Could not copy — select and copy manually', 'danger');
});
} }
function fmtTime(ms){ function fmtTime(ms){
@@ -637,9 +586,9 @@
// expires_at is type:number (a real number); isExpired is a class getter // expires_at is type:number (a real number); isExpired is a class getter
// that is NOT serialized to the client, so compute expiry here. // that is NOT serialized to the client, so compute expiry here.
var exp = Number(token.expires_at); var exp = Number(token.expires_at);
if(!exp) return '<span class="badge bg-secondary">never</span>'; if(!exp) return '<span class="badge text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge bg-danger">expired</span>'; if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
return '<span class="badge bg-warning text-dark">' + moment(exp, "x").fromNow() + '</span>'; return '<span class="badge text-bg-warning">' + moment(exp, "x").fromNow() + '</span>';
} }
function processToken(token){ function processToken(token){
@@ -653,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>
+9 -1
View File
@@ -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');
} }
+1 -1
View File
@@ -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();