796e013234
- api_tokens.ejs: created_on/last_used_on come back from Redis as strings (model-redis only coerces fields with an explicit `type`), so `new Date(ms)` yielded "Invalid date". Use `moment(ms, "x")` (the hosts.ejs/dns.ejs precedent) which parses a numeric string-or-number as a Unix-ms timestamp. - api_tokens.ejs: `isExpired` is a class getter not serialized to the client JSON, so the "expired" badge never showed — compute expiry in the view via `Date.now() > Number(expires_at)`. Also guard the `last_used_on: 0` / falsy case (string "0" is truthy) so unset timestamps render "—" not "1970". - middleware/auth.js: authIO did `checkToken(socket.handshake.auth.token || 0)`, so any socket connect without a token (login page, pre-login) did an `AuthToken.get(0)` lookup and logged a noisy `EntryNotFound` trace. Guard: reject the socket with a generic 401 when there's no token (behavior- preserving — unauth sockets were already rejected; just no Redis lookup / 404). Co-authored-by: Claude <noreply@anthropic.com>
227 lines
9.1 KiB
Plaintext
227 lines
9.1 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
|
|
<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 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();
|
|
});
|
|
}
|
|
|
|
$(document).ready(function(){
|
|
tableAJAX();
|
|
$('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 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>
|
|
<%- include('bottom') %> |