b91ef2792d
Personal access tokens so scripts/CI can call the management API without a browser session. Each logged-in user mints their own token; it authenticates as the creator (carries their LDAP group permissions, re-resolved live), so the existing permission.byGroup checks apply unchanged. - models/api_token.js: new ApiToken model (sso_<id>_<secret> format; id is the lookup key, secret bcrypt-hashed + isPrivate, shown once). add()/rotate()/ authenticate(); optional expires_at; best-effort last_used_on. No _ttl (persists; lifetime via expires_at). - routes/api_token.js: self-service CRUD (list/get/update/delete/rotate), owner-scoped (created_by === req.user.uid, 403 otherwise). - middleware/auth.js + models/auth.js: accept `Authorization: Bearer sso_...` (precedence over the auth-token session header); checkApiToken collapses every failure to one generic 401 (no existence/secret/expiry leak). - views/api_tokens.ejs + routes/index.js (GET /api-tokens): self-service page (forceLogin, no group gate) — create (token shown once), edit, rotate, revoke. - views/top.ejs: "API Tokens" nav entry visible to all logged-in users. - public/js/app.js: app.apiToken client module. - DEPLOYMENT.md + docs/deployment.md: API tokens section. Co-authored-by: Claude <noreply@anthropic.com>
219 lines
8.6 KiB
Plaintext
219 lines
8.6 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){
|
|
if(!ms) return '—';
|
|
return moment(new Date(ms)).fromNow() + ' <span class="text-muted">(' + moment(new Date(ms)).format('YYYY-MM-DD HH:mm') + ')</span>';
|
|
}
|
|
function fmtExpiry(token){
|
|
if(!token.expires_at) return '<span class="badge bg-secondary">never</span>';
|
|
if(token.isExpired) return '<span class="badge bg-danger">expired</span>';
|
|
return '<span class="badge bg-warning text-dark">' + moment(new Date(token.expires_at)).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') %> |