Compare commits

..

6 Commits

Author SHA1 Message Date
wmantly 2bfba93e00 Merge pull request #194 from theta42/release/1.8.0
Release 1.8.0: page width standardization, card layouts, external-user marking
2026-07-29 22:13:59 -04:00
wmantly 8cee583da3 Release 1.8.0: page width standardization, card layouts, external-user marking 2026-07-29 22:12:30 -04:00
wmantly b5fab6c91a Merge pull request #193 from theta42/feat/ui-standardization-external-users
Standardize page width, card layouts; mark SSO users external and read-only
2026-07-29 22:11:39 -04:00
wmantly c7ec65e0d9 Standardize page width, card layouts; mark SSO users external and read-only
- All pages now wrap their content in <div class="container mt-4">,
  matching sso-manager-node's width instead of rendering full-bleed inside
  the fluid shell.
- Users and Permissions pages converted from bare <table>s to the same
  card-grid convention already used on the Groups page.
- Users backed by SSO/OIDC login (backing === 'oidc', set by the redis
  user model's JIT-provisioning path) are now marked "External (SSO)" and
  their password-change control is hidden; PUT /password/:username also
  rejects with 403 server-side for such users. Deletion stays allowed.
  Redis-backend only -- LDAP/PAM deployments have no per-record marker for
  this today.
- app-base.js (byte-identical across the 3 apps): added
  app.util.revealItem(), wired into the Users/Permissions create flows.
- Bumped @simpleworkjs/frontend to ^0.2.7.
2026-07-29 22:09:50 -04:00
wmantly 3f2ef57c78 Release 1.7.0: API-token modal-based create, Edit modal, reveal-modal fix (#192) 2026-07-28 20:43:42 -04:00
wmantly c0e04d1a56 Unify API-token UI: modal-based create, new Edit modal (#191)
Continues the cross-app API-token UI unification (jump-host landed first).
proxy already had the card grid and a description field, so this is a
smaller diff: converts the always-visible inline create-form card into a
"+ New Token" button + app.modal (matching the Add-Resource/Add-Host
convention used stack-wide, per explicit direction to standardize on the
modal-button approach rather than sso-manager-node's inline-card style),
adds a net-new Edit modal (proxy's PUT /api-token/:id already fully
supported it -- no route change needed), and replaces the static
#secretModal with the same bare app.modal showToken()/copyFieldValue()
pattern jump-host uses.

Found and fixed a real timing bug along the way: the create flow's
evalAJAX called app.modal.close() immediately before showToken() (which
calls app.modal.open()) in the same synchronous tick. app.modal is a
singleton, and close() immediately followed by open() collides with
Bootstrap's hide-transition guard -- show() silently no-ops while
_isTransitioning is still true from the just-started hide(), so the
"secret revealed" modal never actually appeared after creating a token.
Confirmed via a live click-through: the reveal modal stayed invisible
(title set, `.show` class never added) with the close() call, and rendered
correctly with it removed.

Also fixed the same latent bug in jump-host's already-shipped v1.10.0
(submitApiToken() had the identical close()-then-open() sequence) and in
sso-manager-node's directory.ejs (saveResource()'s OAuth-secret-reveal
path, softened there by an intervening `await loadResources()` but not
guaranteed race-free).

Verified live: create -> reveal modal now appears correctly; Edit modal
shows real created-by/on data, saves a description change, card refreshes.
2026-07-28 20:40:39 -04:00
13 changed files with 280 additions and 143 deletions
+19
View File
@@ -6,6 +6,25 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.8.0] - 2026-07-28
### Added
- **Users backed by SSO/OIDC login are now marked "External (SSO)"** and read-only: their password-change control is hidden, and `PUT /password/:username` rejects with 403 server-side. Deletion stays allowed. Redis user-backend only.
- **`app.util.revealItem()`** (shared `app-base.js`): scrolls a just-added/-edited element into view and flashes its background. Wired into the Users/Permissions create flows.
### Changed
- **All pages now wrap their content in a standard-width container**, matching sso-manager-node instead of rendering full-bleed.
- **Users and Permissions pages converted from bare `<table>`s to the card-grid convention** already used on the Groups page.
- `@simpleworkjs/frontend` bumped to `^0.2.7`.
## [1.7.0] - 2026-07-28
### Added
- **API tokens: "+ New Token" modal button (replacing the always-visible inline create-form card) and a new Edit modal** — continues the cross-app API-token UI unification started in jump-host. The Edit modal's footer shows real created-by/on data; the `PUT /api-token/:id` route already fully supported editing, so no backend change was needed.
### Fixed
- **Creating an API token didn't show the "save this secret now" reveal modal** — the create flow called `app.modal.close()` immediately before `app.modal.open()` (to show the secret) in the same tick; since `app.modal` is a singleton, that collided with Bootstrap's hide-transition guard and the reveal modal silently never appeared.
## [1.6.0] - 2026-07-28
### Added
+6 -6
View File
@@ -1,19 +1,19 @@
{
"name": "proxy-api",
"version": "1.5.3",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.5.3",
"version": "1.7.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
@@ -310,9 +310,9 @@
}
},
"node_modules/@simpleworkjs/frontend": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.7.tgz",
"integrity": "sha512-s5oBc9dKLjd1bVhOQWR6+97faqQsbVKi0QYn5sNqOP6pGkUYUg2mY88ruHHg4Fp710owrzO/F3of/7tteFiGCw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "proxy-api",
"version": "1.6.0",
"version": "1.8.0",
"author": [
{
"name": "William Mantly",
@@ -23,7 +23,7 @@
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/oidc-client": "^1.0.0",
"acme-client": "^5.4.0",
+6
View File
@@ -7,6 +7,12 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
/* Height of the fixed navbar (plus the update banner, while shown --
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
sticky element offset itself below both fixed elements via
`top: var(--sw-content-offset)` instead of colliding with them at the
viewport's true top:0. */
--sw-content-offset: 4.5rem;
}
#spa-shell {
+21
View File
@@ -584,10 +584,31 @@ app.util = (function(app){
document.body.removeChild(element);
}
// Scroll a just-added/-edited element into view and flash its
// background, so the user's eye lands on the row that changed instead of
// it silently appearing/updating somewhere off-screen. Takes a jQuery
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
function revealItem(el){
var node = el && el.jquery ? el[0] : el;
if (!node) return;
if (typeof node.scrollIntoView === 'function') {
node.scrollIntoView({behavior: 'smooth', block: 'center'});
}
var prevTransition = node.style.transition;
var prevBg = node.style.backgroundColor;
node.style.transition = 'background-color 1.5s ease';
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
setTimeout(function(){
node.style.backgroundColor = prevBg;
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
}, 300);
}
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
escapeHtml: escapeHtml,
revealItem: revealItem,
}
})(app);
+10 -1
View File
@@ -81,11 +81,20 @@ router.put('/password', async function(req, res, next){
}
});
// Admin: reset another user's password.
// Admin: reset another user's password. Blocked for SSO/OIDC-provisioned
// accounts (backing === 'oidc') -- they authenticate through the IdP, not a
// local password, so resetting one here would be a no-op at best and a
// false sense of control at worst. Only applies to the redis user backend;
// LDAP/PAM-backed deployments have no per-record marker for this.
router.put('/password/:username', authz.requireAdmin, async function(req, res, next){
try{
validatePassword(req.body.password);
let user = await User.get(req.params.username);
if(user.backing === 'oidc'){
let e = new Error('Cannot set a password for an SSO-authenticated user.');
e.status = 403;
throw e;
}
return res.json({results: await user.setPassword(req.body)});
}catch(error){
next(error);
+2
View File
@@ -113,6 +113,7 @@
});
</script>
<div class="container mt-4">
<div class="row mb-3" style="display:none">
<div class="col-md-3">
<div class="card shadow-lg mb-3">
@@ -299,4 +300,5 @@
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+2
View File
@@ -78,6 +78,7 @@
});
</script>
<div class="container mt-4">
<datalist id="groupUsers"></datalist>
<div class="row" style="display:none">
@@ -143,4 +144,5 @@
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+2
View File
@@ -752,6 +752,7 @@
});
</script>
<div class="container mt-4">
<div class="row" style="display:none">
<div class="col-12">
<div class="card shadow-lg hostListPanel">
@@ -886,5 +887,6 @@
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+22 -23
View File
@@ -66,6 +66,7 @@
app.subscribe(/^model:Permission:create/, function(data){
$.scope.Permission.remove(data.id);
$.scope.Permission.unshift(data);
setTimeout(function(){ app.util.revealItem($('#permission-row-' + data.id)); }, 100);
});
app.subscribe(/^model:Permission:remove/, function(data, topic){
$.scope.Permission.remove(topic.split(':')[3]);
@@ -73,6 +74,7 @@
});
</script>
<div class="container mt-4">
<datalist id="subjectUsers"></datalist>
<datalist id="subjectGroups"></datalist>
@@ -145,34 +147,31 @@
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Type</th>
<th>Subject</th>
<th>Scope</th>
<th>Domain</th>
<th>Role</th>
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="Permission" jq-repeat-index="id" style="display:none">
<td class="align-middle">{{ subjectType }}</td>
<td class="align-middle">{{ subject }}</td>
<td class="align-middle">{{ scope }}</td>
<td class="align-middle">{{ domain }}</td>
<td class="align-middle">{{ role }}</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removePermission('{{id}}')">
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards">
<div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="mb-2">
<span class="badge text-bg-secondary">{{ subjectType }}</span>
{{ subject }}
</h6>
<dl class="row mb-2 small">
<dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd>
<dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd>
<dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd>
</dl>
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+143 -86
View File
@@ -69,6 +69,7 @@
});
</script>
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow-lg" id="profile-card">
@@ -102,43 +103,33 @@
</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="copyToken()" 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>
<script type="text/javascript">
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var tokensById = {};
function showSecret(secret){
document.getElementById('secretValue').value = secret;
secretModal.show();
// Shared "reveal secret once" display -- same pattern as jump-host's
// showToken(), which sso-manager-node also uses.
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 copyToken(){
var el = document.getElementById('secretValue');
el.select(); el.setSelectionRange(0, 99999);
try { document.execCommand('copy'); } catch(_){}
// Not the checkmark-flash technique some of this codebase's other copy
// buttons use -- FontAwesome replaces <i> icons with inline <svg>, so
// swapping the <i>'s class silently no-ops. A toast doesn't have that
// problem.
function copyFieldValue(sel){
var $el = $(sel);
var text = $el.val();
if(!text) return;
navigator.clipboard.writeText(text).then(function(){
app.messages.toast('Copied to clipboard', 'success');
}, function(){
app.messages.toast('Could not copy — select and copy manually', 'danger');
});
}
function fmtTime(ms){
@@ -171,10 +162,12 @@
function tableAJAX(){
app.apiToken.list(function(error, data){
if(error) return app.messages.action(error, $.scope.apiTokenCard.$this, 'danger');
var tokens = data.results || [];
$.scope.apiTokenCard.empty();
(data.results || []).forEach(function(token){
tokens.forEach(function(token){
$.scope.apiTokenCard.push(processToken(token));
});
$('#api-tokens-empty').toggle(tokens.length === 0);
});
}
@@ -192,74 +185,138 @@
if(!ok) return;
app.apiToken.rotate({id: id}, function(error, data){
if(error) return app.messages.action(error, $(btn).closest('.card'), 'danger');
showSecret(data.token);
showToken('API Token Rotated', data.token);
tableAJAX();
});
}
// Create is a native <form>+formAJAX submission (matching this app's own
// hostModal convention) rather than a JS-built payload -- the form now
// lives inside app.modal's body, rebuilt fresh on every open(), so
// .actionMessage must be a descendant of the form (not a sibling, as the
// old static create-form card had it) for formAJAX's error/success
// targeting to resolve correctly (it falls back to searching descendants
// of the form once app.modal's card-less .modal-content fails the
// closest('div.card') check).
function createApiToken(){
var $body = app.modal.open({
title: 'New API Token',
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>'
// Deliberately does NOT call app.modal.close() before showToken() --
// app.modal is a singleton, and close() immediately followed by
// open() in the same synchronous 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.
+ '<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" name="name" placeholder="CI host sync" required>'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Description</label>'
+ '<input type="text" class="form-control" 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" 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 editToken(id){
var t = tokensById[id]; if(!t) return;
app.modal.open({
title: 'Edit Token',
bodyHtml:
'<input type="hidden" id="edit-token-id" value="' + app.util.escapeHtml(id) + '">'
+ '<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" 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" 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" id="edit-token-days" min="0">'
+ '</div>',
footer: {
metaHtml: 'Created by ' + app.util.escapeHtml(t.created_by || '—') + ' on ' + fmtTime(t.created_on),
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
},
});
}
function saveEditToken(){
var payload = {
id: $('#edit-token-id').val(),
name: $('#edit-token-name').val(),
description: $('#edit-token-description').val(),
expires_in_days: $('#edit-token-days').val(),
};
app.apiToken.update(payload, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to update token', app.modal.body(), 'danger');
app.modal.close();
tableAJAX();
});
}
$(document).ready(function(){
tableAJAX();
// After a successful create, show the raw token once + refresh the list.
$('form[action="api-token/"]').attr('evalAJAX',
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
);
});
</script>
<div class="row mt-3">
<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 proxy 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" name="name" placeholder="CI host sync" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" 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" name="expires_in_days" value="0" min="0">
</div>
<button type="submit" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>
</form>
</div>
</div>
</div>
<div class="row mt-3 justify-content-center">
<div class="col-md-8">
<div class="card-header actionMessage" style="display:none"></div>
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<div class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<small class="text-muted font-monospace">{{ id_short }}</small>
<div class="card shadow-lg">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
<span>
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
</span>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<p class="text-muted small px-3 pt-3 mb-0">A personal access token lets scripts and services call the proxy management API as you, with your permissions. Treat it like a password.</p>
<div class="card-body">
{{ #description }}<p>{{ description }}</p>{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{ created_display }}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
<div class="card-header">
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
<small class="text-muted font-monospace">{{ id_short }}</small>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
{{ #description }}<p>{{ description }}</p>{{ /description }}
<dl class="row mb-0">
<dt class="col-sm-3">Token ID</dt>
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">{{{ created_display }}}</dd>
<dt class="col-sm-3">Last used</dt>
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
<dt class="col-sm-3">Expires</dt>
<dd class="col-sm-9">{{{ expires_display }}}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+9 -1
View File
@@ -82,16 +82,24 @@
</div>
<script type="text/javascript">
// --sw-content-offset tracks the same height as #spa-shell's margin-top
// (fixed navbar, plus the update banner while it's shown), so any
// in-page sticky element (e.g. a sticky search/sort bar) can offset
// itself below both fixed elements via `top: var(--sw-content-offset)`
// instead of colliding with them at the viewport's true top:0.
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
let offset = $nav.outerHeight() + $banner.outerHeight();
$('#spa-shell').css('margin-top', offset + 'px');
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
sessionStorage.setItem('update-banner-dismissed', '1');
}
+36 -24
View File
@@ -20,11 +20,16 @@
<script type="text/javascript">
function processUser(user){
user.isExternal = user.backing === 'oidc';
return user;
}
function populateUsers(actionMessage){
app.user.list(function(error, data){
if(error) return app.messages.action(error, $.scope.users.$this, 'danger');
for(let user of data.results){
$.scope.users.push(user);
$.scope.users.push(processUser(user));
}
$.scope.users.__put = function($el, item, list){
$el.addClass('bg-success');
@@ -54,6 +59,7 @@
});
</script>
<div class="container mt-4">
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
@@ -74,7 +80,8 @@
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="user/" onsubmit="formAJAX(this)" evalAJAX="
$.scope.users.splice(0, 0, data);
$.scope.users.splice(0, 0, processUser(data));
setTimeout(function(){ app.util.revealItem($('#user-row-' + data.username)); }, 100);
">
<input type="hidden" class="form-control" name="delete" value="false" />
<div class="form-group">
@@ -114,37 +121,42 @@
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Name</th>
<th>Password</th>
<th>Delete</th>
</thead>
<tbody>
<tr jq-repeat="users" jq-repeat-index="username" style="display:none" >
<td class="align-middle">
{{ username }}
</td>
<td>
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
<div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="d-flex align-items-center mb-2">
<i class="fa-solid fa-user me-2"></i>
{{ username }}
{{#isExternal}}
<span class="badge text-bg-secondary ms-2" title="Provisioned via SSO login; no local password to manage here.">
<i class="fa-solid fa-cloud"></i> External (SSO)
</span>
{{/isExternal}}
</h6>
<form class="input-group" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change {{ username }} password" aria-label="Update password">
{{^isExternal}}
<form class="input-group input-group-sm mb-2" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
<button class="btn btn-warning" type="submit">Change</button>
</form>
{{/isExternal}}
{{#isExternal}}
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
{{/isExternal}}
</td>
<td class="align-middle">
<button type="button" class="btn btn-danger" onclick="removeUser('{{username}}')">
<button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
<i class="fa-solid fa-user-slash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>