Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c71c91ff6 | |||
| ac25084113 | |||
| a788a99e56 | |||
| bcd160cca2 | |||
| 724f5d8496 | |||
| 8fc7dd11f5 | |||
| e91ed6f1f7 | |||
| 874f7db037 | |||
| 013c21d4f0 | |||
| 42a61f8868 | |||
| b54da5c64c | |||
| 782ef69fb8 | |||
| 0e955abc73 | |||
| 69883836e1 | |||
| 17df21041a | |||
| c19fffe3c9 | |||
| b6abfe8f03 | |||
| 420ccfab3b | |||
| 8ed4505dc0 | |||
| 451054f0c2 | |||
| 3a46680c8b |
@@ -4,6 +4,63 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Directory modal's Associated LDAP Groups tab now supports full membership management**: view, add, and remove members/owners of each associated group directly from the tab, reusing the same `PUT`/`DELETE group/:group/:uid` routes and member-mapping pattern already used on the Groups page.
|
||||||
|
- **`app.util.revealItem()`** (in the shared `app-base.js`, byte-identical across the 3 apps): scrolls a just-added/-edited element into view and flashes its background. Wired into the Directory table, the Groups tab's member list, and the Groups page's create-group flow.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Groups page's search/sort bar is now sticky**, staying visible while scrolling through a long group list. Introduces `--sw-content-offset` (set in `top.ejs` alongside `#spa-shell`'s margin-top) so an in-page sticky element can offset itself below the fixed navbar/update-banner instead of being hidden behind them.
|
||||||
|
- **Directory table**: Kind/Name/Env/Host merged into a single "Resource" column.
|
||||||
|
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
|
||||||
|
|
||||||
|
## [1.8.3] - 2026-07-28
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`profile.ejs`'s self-service API-token UI unified onto `app.modal`**, matching the pattern already shipped this round in `directory.ejs`, proxy, and jump-host: the static `#secretModal`/`#editModal` elements are retired in favor of the shared `app.modal` singleton, the always-visible inline create-form card becomes a "+ New Token" button + modal, and badge classes switch from `bg-*` to `text-bg-*`.
|
||||||
|
- Checkmark-flash copy feedback (silently broken by FontAwesome's `<i>`→`<svg>` replacement) replaced with toast-based `copyFieldValue`, matching proxy and jump-host.
|
||||||
|
|
||||||
|
## [1.8.2] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Creating a new OAuth integration didn't reliably show the "save this client secret now" reveal modal** — `saveResource()` called `app.modal.close()` immediately before conditionally showing the secret via `app.modal.open()`. `app.modal` is a singleton, and `close()` immediately followed by `open()` collides with Bootstrap's hide-transition guard. An intervening `await loadResources()` made this race unlikely to lose in practice, but not guaranteed to — found while fixing the same, guaranteed-to-lose bug in jump-host and proxy's API-token create flows.
|
||||||
|
|
||||||
|
## [1.8.1] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The resource modal's "Associated LDAP Groups" autocomplete went empty after the first Add/Edit** — `loadLdapGroups()`'s fetch-once cache guard (`if (ldapGroupsCache) return;`) also skipped repopulating the `<datalist>` on every call after the first, but the modal body (including that `<datalist>`) is rebuilt fresh and empty on every `app.modal.open()`. Now the fetch is still cached, but the datalist is always repopulated.
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
|
||||||
|
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
|
||||||
|
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
|
||||||
|
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
|
||||||
|
|
||||||
|
### Operational note
|
||||||
|
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
|
||||||
|
|
||||||
|
## [1.7.0] - 2026-07-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`formAJAX`'s loading indicator showed literal HTML** ("<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
|
## [1.6.3] - 2026-07-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Binary file not shown.
@@ -152,10 +152,36 @@ class Resource extends Model {
|
|||||||
owner: { type: 'string' },
|
owner: { type: 'string' },
|
||||||
description: { type: 'text' },
|
description: { type: 'text' },
|
||||||
metadata: { type: 'json', default: {} },
|
metadata: { type: 'json', default: {} },
|
||||||
|
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||||
|
// are set explicitly by the route handler on every create/update (see
|
||||||
|
// routes/api_directory_admin.js). Existing rows predating this change
|
||||||
|
// simply read back undefined -- callers must render a fallback.
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||||
|
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||||
|
// top-level resource with no site parent keeps its unprefixed group name).
|
||||||
|
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||||
|
if (visited.has(resourceId)) return null;
|
||||||
|
visited.add(resourceId);
|
||||||
|
|
||||||
|
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||||
|
for (const edge of parentEdges) {
|
||||||
|
const parent = await this.get(edge.parentId);
|
||||||
|
if (!parent) continue;
|
||||||
|
if (parent.kind === 'site') return parent.slug;
|
||||||
|
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResourceEdge extends Model {
|
class ResourceEdge extends Model {
|
||||||
|
|||||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.5.1",
|
"version": "1.8.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.5.1",
|
"version": "1.8.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.5",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
@@ -1280,9 +1280,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@simpleworkjs/frontend": {
|
"node_modules/@simpleworkjs/frontend": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
|
||||||
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
|
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.6.3",
|
"version": "1.9.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.5",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||||
|
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||||
|
sticky element offset itself below both fixed elements via
|
||||||
|
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||||
|
viewport's true top:0. */
|
||||||
|
--sw-content-offset: 4.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#spa-shell {
|
#spa-shell {
|
||||||
|
|||||||
@@ -584,10 +584,31 @@ app.util = (function(app){
|
|||||||
document.body.removeChild(element);
|
document.body.removeChild(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll a just-added/-edited element into view and flash its
|
||||||
|
// background, so the user's eye lands on the row that changed instead of
|
||||||
|
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||||
|
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||||
|
function revealItem(el){
|
||||||
|
var node = el && el.jquery ? el[0] : el;
|
||||||
|
if (!node) return;
|
||||||
|
if (typeof node.scrollIntoView === 'function') {
|
||||||
|
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||||
|
}
|
||||||
|
var prevTransition = node.style.transition;
|
||||||
|
var prevBg = node.style.backgroundColor;
|
||||||
|
node.style.transition = 'background-color 1.5s ease';
|
||||||
|
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||||
|
setTimeout(function(){
|
||||||
|
node.style.backgroundColor = prevBg;
|
||||||
|
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
downloadFile: downloadFile,
|
downloadFile: downloadFile,
|
||||||
getUrlParameter: getUrlParameter,
|
getUrlParameter: getUrlParameter,
|
||||||
escapeHtml: escapeHtml,
|
escapeHtml: escapeHtml,
|
||||||
|
revealItem: revealItem,
|
||||||
}
|
}
|
||||||
})(app);
|
})(app);
|
||||||
|
|
||||||
@@ -679,13 +700,10 @@ function formAJAX(btn){
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.messages.action(
|
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||||
`<div class="spinner-border" role="status">
|
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||||
<span class="visually-hidden">Loading...</span>
|
// render literally instead of as an element.
|
||||||
</div>`,
|
app.messages.action('Saving…', $form, 'info');
|
||||||
$form,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
|
|
||||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||||
|
|||||||
@@ -43,25 +43,33 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.body.owner = req.body.owner || req.user.uid;
|
req.body.owner = req.body.owner || req.user.uid;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
req.body.created_by = req.body.created_by || req.user.uid;
|
||||||
|
req.body.created_on = now;
|
||||||
|
req.body.updated_by = req.user.uid;
|
||||||
|
req.body.updated_on = now;
|
||||||
|
|
||||||
let r;
|
let r;
|
||||||
if (req.body.kind === 'oauth') {
|
if (req.body.kind === 'oauth') {
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
// Pass created_by explicitly for the wrapper
|
// Pass created_by explicitly for the wrapper (overrides the generic
|
||||||
|
// assignment above -- this is OAuthClient-wrapper-specific behavior).
|
||||||
req.body.created_by = req.body.owner;
|
req.body.created_by = req.body.owner;
|
||||||
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||||
r = await OAuthClient.add(req.body);
|
r = await OAuthClient.add(req.body);
|
||||||
} else {
|
} else {
|
||||||
r = await Resource.create(req.body);
|
r = await Resource.create(req.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
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' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
if (r.kind === 'host' || r.kind === 'service') {
|
||||||
|
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
const createGroup = async (suffix, accessLevel) => {
|
||||||
const cn = `${r.slug}_${suffix}`;
|
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
|
||||||
try {
|
try {
|
||||||
await Group.add({
|
await Group.add({
|
||||||
name: cn,
|
name: cn,
|
||||||
@@ -103,7 +111,10 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
r = await Resource.get(req.params.id);
|
r = await Resource.get(req.params.id);
|
||||||
}
|
}
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
req.body.updated_by = req.user.uid;
|
||||||
|
req.body.updated_on = Date.now();
|
||||||
|
|
||||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ router.get('/directory', function(req, res) {
|
|||||||
res.render('directory', {...values});
|
res.render('directory', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||||
|
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||||
|
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||||
|
// the matching resource's modal once the page's own data has loaded.
|
||||||
|
router.get('/directory/:slug', function(req, res) {
|
||||||
|
res.render('directory', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
// Route removed since it's now in directory
|
// Route removed since it's now in directory
|
||||||
|
|
||||||
router.get('/onboarding', async function(req, res, next) {
|
router.get('/onboarding', async function(req, res, next) {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ router.post('/', async function(req, res, next){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({results: user});
|
return res.json({results: user, message: `User ${user.uid} created.`});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ router.put('/password', async function(req, res, next){
|
|||||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||||
await verif.update({ password_must_change: false });
|
await verif.update({ password_must_change: false });
|
||||||
User.clearCache();
|
User.clearCache();
|
||||||
return res.json({results: result});
|
return res.json({results: result, message: 'Password changed.'});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+494
-342
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -7,6 +7,22 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Landing here with no explanation ("why am I on the SSO login page?") is
|
||||||
|
// exactly what happens when another app's "Log in with SSO" button sends
|
||||||
|
// an unauthenticated user through /oauth/authorize, which bounces them
|
||||||
|
// here with ?redirect=. Tell them what's happening instead of leaving it
|
||||||
|
// a mystery.
|
||||||
|
$(document).ready(function(){
|
||||||
|
var redirect = <%- JSON.stringify(redirect || '') %>;
|
||||||
|
if(redirect){
|
||||||
|
var isOauth = /\/oauth\/authorize/.test(redirect);
|
||||||
|
var message = isOauth
|
||||||
|
? 'Log in to continue — an application is requesting access to your account.'
|
||||||
|
: "Log in to continue to what you were doing — you'll be sent back afterward.";
|
||||||
|
app.messages.action(message, $('.card').first(), 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function setOtpMethod(method) {
|
function setOtpMethod(method) {
|
||||||
$('#otpMethodInput').val(method);
|
$('#otpMethodInput').val(method);
|
||||||
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
||||||
|
|||||||
+129
-145
@@ -220,9 +220,6 @@
|
|||||||
if(isOwnProfile){
|
if(isOwnProfile){
|
||||||
$('#own-api-tokens-section').show();
|
$('#own-api-tokens-section').show();
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
$('form[action="api-token/"]').attr('evalAJAX',
|
|
||||||
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -545,83 +542,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token modal (shown once on create/rotate) -->
|
|
||||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="fa-solid fa-key"></i> API Token</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
|
|
||||||
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
|
|
||||||
<i class="fa-solid fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer <token></code></p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit modal -->
|
|
||||||
<div class="modal fade" id="editModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit API Token</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
|
||||||
<input type="hidden" id="edit-id">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" id="edit-name" class="form-control shadow">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<input type="text" id="edit-description" class="form-control shadow">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
|
|
||||||
<input type="number" id="edit-expires_in_days" class="form-control shadow" min="0">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
// Any logged-in user can manage their own API tokens (self-service).
|
// Any logged-in user can manage their own API tokens (self-service).
|
||||||
// Section is only revealed (see $(document).ready above) when isOwnProfile.
|
// Section is only revealed (see $(document).ready above) when isOwnProfile.
|
||||||
|
|
||||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
||||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
|
||||||
var tokensById = {};
|
var tokensById = {};
|
||||||
|
|
||||||
function showSecret(secret){
|
// Shared "reveal secret once" display -- same pattern as jump-host's and
|
||||||
document.getElementById('secretValue').value = secret;
|
// proxy's showToken().
|
||||||
secretModal.show();
|
function showToken(title, token){
|
||||||
|
app.modal.open({title: title, bodyHtml:
|
||||||
|
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
|
||||||
|
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.util.escapeHtml(token) + '">'
|
||||||
|
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
|
||||||
|
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function copySecret(){ copyField('secretValue'); }
|
// Not the checkmark-flash technique this file used to use for its copy
|
||||||
|
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
|
||||||
function copyField(id, btn){
|
// swapping the <i>'s class silently no-ops. A toast doesn't have that
|
||||||
var el = document.getElementById(id);
|
// problem.
|
||||||
if(!el) return;
|
function copyFieldValue(sel){
|
||||||
el.select(); el.setSelectionRange(0, 99999); document.execCommand('copy');
|
var $el = $(sel);
|
||||||
if(btn){ var $i = $(btn).find('i'), prev = $i.attr('class');
|
var text = $el.val();
|
||||||
$i.attr('class', 'fa-solid fa-check'); setTimeout(function(){ $i.attr('class', prev); }, 1200); }
|
if(!text) return;
|
||||||
|
navigator.clipboard.writeText(text).then(function(){
|
||||||
|
app.messages.toast('Copied to clipboard', 'success');
|
||||||
|
}, function(){
|
||||||
|
app.messages.toast('Could not copy — select and copy manually', 'danger');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(ms){
|
function fmtTime(ms){
|
||||||
@@ -637,9 +586,9 @@
|
|||||||
// expires_at is type:number (a real number); isExpired is a class getter
|
// expires_at is type:number (a real number); isExpired is a class getter
|
||||||
// that is NOT serialized to the client, so compute expiry here.
|
// that is NOT serialized to the client, so compute expiry here.
|
||||||
var exp = Number(token.expires_at);
|
var exp = Number(token.expires_at);
|
||||||
if(!exp) return '<span class="badge bg-secondary">never</span>';
|
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
|
||||||
if(Date.now() > exp) return '<span class="badge bg-danger">expired</span>';
|
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
|
||||||
return '<span class="badge bg-warning text-dark">' + moment(exp, "x").fromNow() + '</span>';
|
return '<span class="badge text-bg-warning">' + moment(exp, "x").fromNow() + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function processToken(token){
|
function processToken(token){
|
||||||
@@ -653,10 +602,12 @@
|
|||||||
|
|
||||||
async function tableAJAX(){
|
async function tableAJAX(){
|
||||||
let data = await app.apiToken.list();
|
let data = await app.apiToken.list();
|
||||||
|
var tokens = data.results || [];
|
||||||
$.scope.apiTokenCard.empty();
|
$.scope.apiTokenCard.empty();
|
||||||
$.each(data.results, function(_, token){
|
tokens.forEach(function(token){
|
||||||
$.scope.apiTokenCard.push(processToken(token));
|
$.scope.apiTokenCard.push(processToken(token));
|
||||||
});
|
});
|
||||||
|
$('#api-tokens-empty').toggle(tokens.length === 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revokeToken(id, name, btn){
|
async function revokeToken(id, name, btn){
|
||||||
@@ -677,31 +628,81 @@
|
|||||||
if(!confirmed) return;
|
if(!confirmed) return;
|
||||||
app.apiToken.rotate({id: id}, function(error, data){
|
app.apiToken.rotate({id: id}, function(error, data){
|
||||||
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||||
showSecret(data.token);
|
showToken('API Token Rotated', data.token);
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function editToken(id){
|
// Create is a native <form>+formAJAX submission (matching this app's own
|
||||||
var t = tokensById[id]; if(!t) return;
|
// hostModal-style convention) rather than a JS-built payload. Deliberately
|
||||||
$('#edit-id').val(id);
|
// does NOT call app.modal.close() before showToken() -- app.modal is a
|
||||||
$('#edit-name').val(t.name || '');
|
// singleton, and close() immediately followed by open() in the same tick
|
||||||
$('#edit-description').val(t.description || '');
|
// collides with Bootstrap's hide-transition guard (show() silently
|
||||||
$('#edit-expires_in_days').val('');
|
// no-ops while _isTransitioning is still true from the just-started
|
||||||
editModal.show();
|
// hide()). open() alone already overwrites the (already-visible) modal's
|
||||||
|
// content in place.
|
||||||
|
function createApiToken(){
|
||||||
|
var $body = app.modal.open({
|
||||||
|
title: 'New API Token',
|
||||||
|
bodyHtml:
|
||||||
|
'<div class="actionMessage mb-3" style="display:none"></div>'
|
||||||
|
+ '<form id="newTokenForm" action="api-token/" method="post" onsubmit="formAJAX(this)" evalAJAX="showToken(\'API Token Created\', data.token); tableAJAX();">'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Name</label>'
|
||||||
|
+ '<input type="text" class="form-control shadow" name="name" placeholder="CI user sync" validate=":1">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Description</label>'
|
||||||
|
+ '<input type="text" class="form-control shadow" name="description" placeholder="Used by the nightly sync job">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>'
|
||||||
|
+ '<input type="number" class="form-control shadow" name="expires_in_days" value="0" min="0">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</form>',
|
||||||
|
footer: {
|
||||||
|
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
|
||||||
|
+ '<button type="submit" form="newTokenForm" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
$body.find('[name=name]').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEdit(btn){
|
function editToken(id){
|
||||||
var $msg = $('#editModal .actionMessage');
|
var t = tokensById[id]; if(!t) return;
|
||||||
|
app.modal.open({
|
||||||
|
title: 'Edit Token',
|
||||||
|
bodyHtml:
|
||||||
|
'<input type="hidden" id="edit-token-id" value="' + app.util.escapeHtml(id) + '">'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Name</label>'
|
||||||
|
+ '<input type="text" class="form-control shadow" id="edit-token-name" value="' + app.util.escapeHtml(t.name || '') + '">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Description</label>'
|
||||||
|
+ '<input type="text" class="form-control shadow" id="edit-token-description" value="' + app.util.escapeHtml(t.description || '') + '">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
|
||||||
|
+ '<input type="number" class="form-control shadow" id="edit-token-days" min="0">'
|
||||||
|
+ '</div>',
|
||||||
|
footer: {
|
||||||
|
metaHtml: 'Created by ' + app.util.escapeHtml(t.created_by || '—') + ' on ' + fmtTime(t.created_on),
|
||||||
|
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEditToken(){
|
||||||
var payload = {
|
var payload = {
|
||||||
id: $('#edit-id').val(),
|
id: $('#edit-token-id').val(),
|
||||||
name: $('#edit-name').val(),
|
name: $('#edit-token-name').val(),
|
||||||
description: $('#edit-description').val(),
|
description: $('#edit-token-description').val(),
|
||||||
expires_in_days: $('#edit-expires_in_days').val(),
|
expires_in_days: $('#edit-token-days').val(),
|
||||||
};
|
};
|
||||||
app.apiToken.update(payload, function(error, data){
|
app.apiToken.update(payload, function(error, data){
|
||||||
if(error){ app.messages.action((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
if(error){ app.messages.action((data && data.message) || 'Update failed.', app.modal.body(), 'danger'); return; }
|
||||||
editModal.hide();
|
app.modal.close();
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -711,62 +712,45 @@
|
|||||||
runs before this page's own ready handler and would unhide any div.row
|
runs before this page's own ready handler and would unhide any div.row
|
||||||
unconditionally, defeating the isOwnProfile check below. -->
|
unconditionally, defeating the isOwnProfile check below. -->
|
||||||
<div id="own-api-tokens-section" style="display:none">
|
<div id="own-api-tokens-section" style="display:none">
|
||||||
<div class="row mt-3">
|
<div class="row mt-3 justify-content-center">
|
||||||
<div class="col-12">
|
|
||||||
<h5 class="mb-3"><i class="fa-solid fa-code"></i> API Tokens</h5>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
|
|
||||||
<a href="/docs/api-tokens" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
|
|
||||||
<form action="api-token/" method="post" onsubmit="formAJAX(this)">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="name" placeholder="CI user sync" validate=":1">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<input type="text" class="form-control shadow" name="description" placeholder="Used by the nightly sync job">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>
|
|
||||||
<input type="number" class="form-control shadow" name="expires_in_days" value="0" min="0">
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card shadow-lg">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
<div class="card-header">
|
<span>
|
||||||
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
<small class="text-muted font-monospace">{{ id_short }}</small>
|
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<p class="text-muted small px-3 pt-3 mb-0">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
||||||
<dl class="row mb-0">
|
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
||||||
<dt class="col-sm-3">Token ID</dt>
|
<div class="card-header">
|
||||||
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
||||||
<dt class="col-sm-3">Created</dt>
|
<small class="text-muted font-monospace">{{ id_short }}</small>
|
||||||
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
</div>
|
||||||
<dt class="col-sm-3">Last used</dt>
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
|
<div class="card-body">
|
||||||
<dt class="col-sm-3">Expires</dt>
|
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
||||||
<dd class="col-sm-9">{{{ expires_display }}}</dd>
|
<dl class="row mb-0">
|
||||||
</dl>
|
<dt class="col-sm-3">Token ID</dt>
|
||||||
</div>
|
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
||||||
<div class="card-footer">
|
<dt class="col-sm-3">Created</dt>
|
||||||
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
|
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
||||||
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
<dt class="col-sm-3">Last used</dt>
|
||||||
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
|
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
|
||||||
|
<dt class="col-sm-3">Expires</dt>
|
||||||
|
<dd class="col-sm-9">{{{ expires_display }}}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
|
||||||
|
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
||||||
|
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,16 +82,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||||
|
// (fixed navbar, plus the update banner while it's shown), so any
|
||||||
|
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||||
|
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||||
|
// instead of colliding with them at the viewport's true top:0.
|
||||||
function showUpdateBanner(){
|
function showUpdateBanner(){
|
||||||
let $nav = $('nav.fixed-top');
|
let $nav = $('nav.fixed-top');
|
||||||
let $banner = $('#update-banner');
|
let $banner = $('#update-banner');
|
||||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||||
|
$('#spa-shell').css('margin-top', offset + 'px');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissUpdateBanner(){
|
function dismissUpdateBanner(){
|
||||||
$('#update-banner').hide();
|
$('#update-banner').hide();
|
||||||
$('#spa-shell').css('margin-top', '');
|
$('#spa-shell').css('margin-top', '');
|
||||||
|
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user