Unify nav: merge Admin+Notifications into Dashboard, fold API Tokens into Profile

- Replace the separate Profile/API Tokens nav items with a single link
  showing the logged-in user's name, pointing at their own profile.
- Merge admin.ejs + notifications.ejs into a new dashboard.ejs page.
  /admin and /notifications now 301-redirect to /dashboard.
- Fold the API Tokens page into profile.ejs as a self-service-only
  section, gated on isOwnProfile so it never appears when an admin
  views another user's profile via /users/:uid. /api-tokens 301s to /.
- Fix: the section must not carry class="row" — app-base.js runs a
  page-wide $('div.row').fadeIn() on every page load that would reveal
  it regardless of the isOwnProfile check, since it fires before this
  page's own gating logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:42:28 -04:00
parent f45349e0cd
commit 2788dcd796
7 changed files with 622 additions and 624 deletions
+238
View File
@@ -3,6 +3,7 @@
<script type="text/javascript">
app.auth.forceLogin();
var currentUser;
var isOwnProfile = !location.pathname.includes('/users/');
function renderProfile(user){
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
@@ -76,6 +77,16 @@
renderProfile(currentUser);
renderUserGroups(currentUser);
// API Tokens are self-service only — never shown when an admin is
// viewing someone else's profile via /users/:uid.
if(isOwnProfile){
$('#own-api-tokens-section').show();
tableAJAX();
$('form[action="api-token/"]').attr('evalAJAX',
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
);
}
});
</script>
@@ -306,5 +317,232 @@
</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">
// Any logged-in user can manage their own API tokens (self-service).
// Section is only revealed (see $(document).ready above) when isOwnProfile.
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
var tokensById = {};
function showSecret(secret){
document.getElementById('secretValue').value = secret;
secretModal.show();
}
function copySecret(){ copyField('secretValue'); }
function copyField(id, btn){
var el = document.getElementById(id);
if(!el) return;
el.select(); el.setSelectionRange(0, 99999); document.execCommand('copy');
if(btn){ var $i = $(btn).find('i'), prev = $i.attr('class');
$i.attr('class', 'fa-solid fa-check'); setTimeout(function(){ $i.attr('class', prev); }, 1200); }
}
function fmtTime(ms){
// created_on/last_used_on come back from Redis as strings (model-redis
// only coerces fields with an explicit `type`); moment(value, "x") parses
// a numeric string-or-number as a Unix-ms timestamp, unlike new Date(str).
if(!ms || Number(ms) === 0) return '—';
var t = moment(ms, "x");
if(!t.isValid()) return '—';
return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>';
}
function fmtExpiry(token){
// expires_at is type:number (a real number); isExpired is a class getter
// that is NOT serialized to the client, so compute expiry here.
var exp = Number(token.expires_at);
if(!exp) return '<span class="badge bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge bg-danger">expired</span>';
return '<span class="badge bg-warning text-dark">' + moment(exp, "x").fromNow() + '</span>';
}
function processToken(token){
tokensById[token.id] = token;
token.id_short = token.id.slice(0, 12) + '…';
token.expires_display = fmtExpiry(token);
token.created_display = fmtTime(token.created_on);
token.last_used_display = fmtTime(token.last_used_on);
return token;
}
async function tableAJAX(){
let data = await app.apiToken.list();
$.scope.apiTokenCard.empty();
$.each(data.results, function(_, token){
$.scope.apiTokenCard.push(processToken(token));
});
}
async function revokeToken(id, name, btn){
var $card = $(btn).closest('.card');
$card.addClass('table-warning');
var confirmed = await app.util.actionConfirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
$card.removeClass('table-warning');
if(!confirmed) return;
app.apiToken.remove({id: id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
$.scope.apiTokenCard.remove('id', id);
});
}
async function rotateToken(id, name, btn){
var $card = $(btn).closest('.card');
var confirmed = await app.util.actionConfirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
if(!confirmed) return;
app.apiToken.rotate({id: id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
showSecret(data.token);
tableAJAX();
});
}
function editToken(id){
var t = tokensById[id]; if(!t) return;
$('#edit-id').val(id);
$('#edit-name').val(t.name || '');
$('#edit-description').val(t.description || '');
$('#edit-expires_in_days').val('');
editModal.show();
}
function saveEdit(btn){
var $msg = $('#editModal .actionMessage');
var payload = {
id: $('#edit-id').val(),
name: $('#edit-name').val(),
description: $('#edit-description').val(),
expires_in_days: $('#edit-expires_in_days').val(),
};
app.apiToken.update(payload, function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
editModal.hide();
tableAJAX();
});
}
</script>
<!-- Not class="row": app-base.js's `$('div.row').fadeIn('slow')` page-reveal
runs before this page's own ready handler and would unhide any div.row
unconditionally, defeating the isOwnProfile check below. -->
<div id="own-api-tokens-section" style="display:none">
<div class="row mt-3">
<div class="col-12">
<h5 class="mb-3"><i class="fa-solid fa-code"></i> API Tokens</h5>
</div>
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</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="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>
<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>
<%- include('impersonate_modal') %>
<%- include('bottom') %>