|
|
|
@@ -220,9 +220,6 @@
|
|
|
|
|
if(isOwnProfile){
|
|
|
|
|
$('#own-api-tokens-section').show();
|
|
|
|
|
tableAJAX();
|
|
|
|
|
$('form[action="api-token/"]').attr('evalAJAX',
|
|
|
|
|
'showSecret(data.token); tableAJAX(); $form.trigger("reset");'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
@@ -545,83 +542,35 @@
|
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
<!-- 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();
|
|
|
|
|
// Shared "reveal secret once" display -- same pattern as jump-host's and
|
|
|
|
|
// proxy's showToken().
|
|
|
|
|
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'); }
|
|
|
|
|
|
|
|
|
|
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); }
|
|
|
|
|
// Not the checkmark-flash technique this file used to use for its copy
|
|
|
|
|
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
|
|
|
|
|
// swapping the <i>'s class silently no-ops. A toast doesn't have that
|
|
|
|
|
// problem.
|
|
|
|
|
function copyFieldValue(sel){
|
|
|
|
|
var $el = $(sel);
|
|
|
|
|
var text = $el.val();
|
|
|
|
|
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){
|
|
|
|
@@ -637,9 +586,9 @@
|
|
|
|
|
// 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>';
|
|
|
|
|
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">' + moment(exp, "x").fromNow() + '</span>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function processToken(token){
|
|
|
|
@@ -653,10 +602,12 @@
|
|
|
|
|
|
|
|
|
|
async function tableAJAX(){
|
|
|
|
|
let data = await app.apiToken.list();
|
|
|
|
|
var tokens = data.results || [];
|
|
|
|
|
$.scope.apiTokenCard.empty();
|
|
|
|
|
$.each(data.results, function(_, token){
|
|
|
|
|
tokens.forEach(function(token){
|
|
|
|
|
$.scope.apiTokenCard.push(processToken(token));
|
|
|
|
|
});
|
|
|
|
|
$('#api-tokens-empty').toggle(tokens.length === 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function revokeToken(id, name, btn){
|
|
|
|
@@ -677,31 +628,81 @@
|
|
|
|
|
if(!confirmed) return;
|
|
|
|
|
app.apiToken.rotate({id: id}, function(error, data){
|
|
|
|
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
|
|
|
|
showSecret(data.token);
|
|
|
|
|
showToken('API Token Rotated', 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();
|
|
|
|
|
// Create is a native <form>+formAJAX submission (matching this app's own
|
|
|
|
|
// hostModal-style convention) rather than a JS-built payload. Deliberately
|
|
|
|
|
// does NOT call app.modal.close() before showToken() -- 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.
|
|
|
|
|
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){
|
|
|
|
|
var $msg = $('#editModal .actionMessage');
|
|
|
|
|
function editToken(id){
|
|
|
|
|
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 = {
|
|
|
|
|
id: $('#edit-id').val(),
|
|
|
|
|
name: $('#edit-name').val(),
|
|
|
|
|
description: $('#edit-description').val(),
|
|
|
|
|
expires_in_days: $('#edit-expires_in_days').val(),
|
|
|
|
|
id: $('#edit-token-id').val(),
|
|
|
|
|
name: $('#edit-token-name').val(),
|
|
|
|
|
description: $('#edit-token-description').val(),
|
|
|
|
|
expires_in_days: $('#edit-token-days').val(),
|
|
|
|
|
};
|
|
|
|
|
app.apiToken.update(payload, function(error, data){
|
|
|
|
|
if(error){ app.messages.action((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
|
|
|
|
editModal.hide();
|
|
|
|
|
if(error){ app.messages.action((data && data.message) || 'Update failed.', app.modal.body(), 'danger'); return; }
|
|
|
|
|
app.modal.close();
|
|
|
|
|
tableAJAX();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
@@ -711,62 +712,45 @@
|
|
|
|
|
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
|
|
|
|
|
<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="row mt-3 justify-content-center">
|
|
|
|
|
<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 class="card shadow-lg">
|
|
|
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
|
|
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
|
|
|
|
<span>
|
|
|
|
|
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
|
|
|
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
|
|
|
|
</span>
|
|
|
|
|
</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">
|
|
|
|
|
{{ #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>
|
|
|
|
|
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
|
|
|
|
<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>
|
|
|
|
|