Compare commits

...

6 Commits

Author SHA1 Message Date
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
4 changed files with 150 additions and 147 deletions
+11
View File
@@ -4,6 +4,17 @@ 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.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
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.8.1", "version": "1.8.3",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
+9 -1
View File
@@ -756,11 +756,19 @@
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();
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);
+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>