0a659428dd
Same swap as sso-manager-node/jump-host: vendored app.util.actionMessage/ actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/ confirm (real HTML-escaping, toast fallback); vendored val.js replaced by the package's app.validate.js. proxy's host/target/hostname validation rules (mirrored from the backend's utils/hostname_validate.js — wildcard DNS patterns, not something other apps need) move to public/js/app.js, registered via $.validateSettings, since they're proxy-specific and don't belong in the shared package's generic rule set (eq/user/password/ip). app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same reasoning as sso-manager-node's PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
264 lines
9.8 KiB
Plaintext
264 lines
9.8 KiB
Plaintext
<%- include('top') %>
|
|
<script type="text/javascript">
|
|
// Any authenticated user may view their own profile.
|
|
app.auth.forceLogin();
|
|
</script>
|
|
|
|
<style type="text/css">
|
|
.card-title{ font-weight: bold; }
|
|
.profile-label{ font-weight: bold; }
|
|
</style>
|
|
|
|
<script type="text/javascript">
|
|
|
|
function roleBadge(role){
|
|
let cls = role === 'admin' ? 'text-bg-danger'
|
|
: role === 'manager' ? 'text-bg-primary'
|
|
: role === 'viewer' ? 'text-bg-secondary' : 'text-bg-light';
|
|
return $('<span>').addClass('badge ' + cls).text(role);
|
|
}
|
|
|
|
function renderProfile(me){
|
|
$('#profile-username').text(me.username || '(unknown)');
|
|
|
|
// Access summary badges.
|
|
let $access = $('#profile-access').empty();
|
|
if(me.isAdmin){
|
|
$access.append($('<span>').addClass('badge text-bg-danger fs-6 me-1').text('Global administrator'));
|
|
}
|
|
if(me.global){
|
|
$access.append($('<span>').addClass('badge text-bg-primary fs-6 me-1').text('Global ' + me.global));
|
|
}
|
|
if(!me.isAdmin && !me.global){
|
|
$access.append($('<span>').addClass('text-muted').text('No global role.'));
|
|
}
|
|
|
|
// Groups (mark which are app-managed local groups).
|
|
let local = new Set(me.localGroups || []);
|
|
let $groups = $('#profile-groups').empty();
|
|
let groups = me.groups || [];
|
|
if(!groups.length){
|
|
$groups.append($('<span>').addClass('text-muted').text('Not a member of any group.'));
|
|
}
|
|
for(let g of groups){
|
|
let $b = $('<span>').addClass('badge me-1 mb-1 fs-6')
|
|
.addClass(local.has(g) ? 'text-bg-success' : 'text-bg-info').text(g);
|
|
if(local.has(g)) $b.append($('<i>').addClass('fa-solid fa-house-user ms-1').attr('title', 'local group'));
|
|
$groups.append($b);
|
|
}
|
|
|
|
// Per-domain roles.
|
|
let $domains = $('#profile-domains').empty();
|
|
let domains = me.domains || {};
|
|
let keys = Object.keys(domains).sort();
|
|
if(!keys.length){
|
|
$domains.append($('<tr>').append($('<td colspan="2">').addClass('text-muted').text('No per-domain roles.')));
|
|
}
|
|
for(let d of keys){
|
|
$domains.append($('<tr>')
|
|
.append($('<td>').addClass('align-middle').append($('<code>').text(d)))
|
|
.append($('<td>').addClass('align-middle').append(roleBadge(domains[d]))));
|
|
}
|
|
}
|
|
|
|
$(document).ready(function(){
|
|
app.api.get('user/me', function(error, data){
|
|
if(error) return app.messages.action(error, $('#profile-card'), 'danger');
|
|
renderProfile(data);
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<div class="row justify-content-center">
|
|
<div class="col-md-8">
|
|
<div class="card shadow-lg" id="profile-card">
|
|
<div class="card-header text-center">
|
|
<span class="card-icon float-start"><i class="fa-solid fa-id-badge"></i></span>
|
|
<span class="card-title">My Profile</span>
|
|
</div>
|
|
<div class="card-header actionMessage" style="display:none"></div>
|
|
<div class="card-body">
|
|
<h3 class="mb-3"><i class="fa-solid fa-user me-2"></i><span id="profile-username">…</span></h3>
|
|
|
|
<div class="mb-3">
|
|
<div class="profile-label">Access</div>
|
|
<div id="profile-access"></div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="profile-label">Groups</div>
|
|
<div id="profile-groups"></div>
|
|
</div>
|
|
|
|
<div class="mb-1 profile-label">Domain permissions</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-striped">
|
|
<thead><th>Domain</th><th>Role</th></thead>
|
|
<tbody id="profile-domains"></tbody>
|
|
</table>
|
|
</div>
|
|
</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="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 <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">
|
|
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.messages.action(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.messages.action(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.messages.action(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
|
|
<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 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') %>
|