Unify nav: drop Profile nav item, fold API Tokens into Profile page

- Nav bar already showed the logged-in user's name linking to /profile
  (cl-username); just removed the separate "Profile" and "API Tokens"
  nav items now that Profile covers both.
- Merge api_tokens.ejs into profile.ejs as a section below the existing
  profile card. /api-tokens 301-redirects to /profile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:44:05 -04:00
parent c68fcc9ddb
commit 3f4f98ec4e
4 changed files with 160 additions and 174 deletions
-162
View File
@@ -1,162 +0,0 @@
<%- include('top') %>
<!-- 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">
// Any logged-in user can manage their own API tokens (self-service).
app.auth.forceLogin();
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
var tokensById = {};
function showSecret(secret){
document.getElementById('secretValue').value = secret;
secretModal.show();
}
function copyToken(){
var el = document.getElementById('secretValue');
el.select(); el.setSelectionRange(0, 99999);
try { document.execCommand('copy'); } catch(_){}
}
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 text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
return '<span class="badge text-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;
}
function tableAJAX(){
app.apiToken.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.apiTokenCard.$this, 'danger');
$.scope.apiTokenCard.empty();
(data.results || []).forEach(function(token){
$.scope.apiTokenCard.push(processToken(token));
});
});
}
function revokeToken(id, name, btn){
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
app.apiToken.remove({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
$.scope.apiTokenCard.remove('id', id);
});
}
function rotateToken(id, name, btn){
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
app.apiToken.rotate({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
showSecret(data.token);
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">
<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 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="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="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>
<%- include('bottom') %>
+157
View File
@@ -101,4 +101,161 @@
</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="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();
}
function copyToken(){
var el = document.getElementById('secretValue');
el.select(); el.setSelectionRange(0, 99999);
try { document.execCommand('copy'); } catch(_){}
}
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 text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
return '<span class="badge text-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;
}
function tableAJAX(){
app.apiToken.list(function(error, data){
if(error) return app.util.actionMessage(error, $.scope.apiTokenCard.$this, 'danger');
$.scope.apiTokenCard.empty();
(data.results || []).forEach(function(token){
$.scope.apiTokenCard.push(processToken(token));
});
});
}
function revokeToken(id, name, btn){
if(!confirm('Revoke API token "' + name + '"? It stops working immediately.')) return;
app.apiToken.remove({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
$.scope.apiTokenCard.remove('id', id);
});
}
function rotateToken(id, name, btn){
if(!confirm('Rotate API token "' + name + '"? The old token stops working immediately.')) return;
app.apiToken.rotate({id: id}, function(error, data){
if(error) return app.util.actionMessage(error, $(btn).closest('.card'), 'danger');
showSecret(data.token);
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</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="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="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>
<%- include('bottom') %>
-10
View File
@@ -66,16 +66,6 @@
Groups
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/profile"><i class="fa-solid fa-id-badge"></i>
Profile
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/api-tokens"><i class="fa-solid fa-code"></i>
API Tokens
</a>
</li>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">