diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json
index d44bc97..dad1fcc 100644
--- a/nodejs/package-lock.json
+++ b/nodejs/package-lock.json
@@ -1,19 +1,19 @@
{
"name": "t42-jump-host",
- "version": "1.5.0",
+ "version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
- "version": "1.5.0",
+ "version": "1.9.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
- "@simpleworkjs/frontend": "^0.2.5",
+ "@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
@@ -178,9 +178,9 @@
}
},
"node_modules/@simpleworkjs/frontend": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
- "integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
+ "integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
diff --git a/nodejs/package.json b/nodejs/package.json
index 32254ea..6915c96 100644
--- a/nodejs/package.json
+++ b/nodejs/package.json
@@ -23,7 +23,7 @@
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
- "@simpleworkjs/frontend": "^0.2.5",
+ "@simpleworkjs/frontend": "^0.2.6",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
diff --git a/nodejs/public/js/app.js b/nodejs/public/js/app.js
index 5bd9595..d3e5eb6 100644
--- a/nodejs/public/js/app.js
+++ b/nodejs/public/js/app.js
@@ -19,9 +19,10 @@ app.jump = (function(app){
app.apiToken = (function(app){
function list(cb){ app.api.get('api-token/', cb); }
function add(args, cb){ app.api.post('api-token/', args, cb); }
+ function update(args, cb){ app.api.put('api-token/' + args.id, args, cb); }
function remove(id, cb){ app.api.delete('api-token/' + id, cb); }
function rotate(id, cb){ app.api.post('api-token/' + id + '/rotate', {}, cb); }
- return {list: list, add: add, remove: remove, rotate: rotate};
+ return {list: list, add: add, update: update, remove: remove, rotate: rotate};
})(app);
// Shared render helpers.
diff --git a/nodejs/views/dashboard.ejs b/nodejs/views/dashboard.ejs
index 8366985..271d89d 100644
--- a/nodejs/views/dashboard.ejs
+++ b/nodejs/views/dashboard.ejs
@@ -40,7 +40,7 @@
-
@@ -84,10 +84,36 @@
(e.g. GET /api/user/hosts) — not for SSH login. A token carries
no group claims, so it can't reach admin-only endpoints.
-
- | Name | Created | Last used | Expires | |
-
-
+
+
No API tokens.
+
+
+
+
+
+ {{#description}}
{{description}}
{{/description}}
+
+ - Token ID
+ {{id_short}}
+ - Created
+ - {{{created_display}}}
+ - Last used
+ - {{{last_used_display}}}
+ - Expires
+ - {{{expires_display}}}
+
+
+
+
+
+
@@ -108,7 +134,7 @@
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
}
- function copySshCommand(sel){
+ function copyFieldValue(sel){
var $el = $(sel);
var text = $el.val();
if(!text) return;
@@ -130,42 +156,49 @@
+ '' + app.jump.esc(addr) + ' | '
+ ''
+ ''
- + ''
+ + ''
+ ' | ');
});
}
- function tokenRows(tokens){
- var $b = $('#api-tokens').empty();
- if(!tokens || !tokens.length){ $b.append('| No API tokens. |
'); return; }
- tokens.forEach(function(t){
- var expires = t.expires_at ? app.jump.fmtTime(t.expires_at) : 'Never';
- var lastUsed = t.last_used_on ? app.jump.fmtTime(t.last_used_on) : 'Never';
- $b.append(
- ''
- + '| ' + app.jump.esc(t.name) + ' | '
- + '' + app.jump.fmtTime(t.created_on) + ' | '
- + '' + lastUsed + ' | '
- + '' + expires + ' | '
- + ''
- + ' '
- + ''
- + ' | '
- + '
'
- );
- });
+ // expires_at/created_on/last_used_on come back as redis-hash strings for
+ // some fields and real numbers for others depending on the model's field
+ // type -- fmtTime already handles both via moment(ms, 'x').
+ function fmtExpiry(token){
+ var exp = Number(token.expires_at);
+ if(!exp) return 'never';
+ if(Date.now() > exp) return 'expired';
+ return '' + moment(exp).fromNow() + '';
+ }
+
+ var tokensById = {};
+ function processToken(token){
+ tokensById[token.id] = token;
+ token.id_short = token.id.slice(0, 12) + '…';
+ token.expires_display = fmtExpiry(token);
+ token.created_display = app.jump.fmtTime(token.created_on);
+ token.last_used_display = token.last_used_on ? app.jump.fmtTime(token.last_used_on) : 'Never';
+ return token;
}
function loadApiTokens(){
app.apiToken.list(function(error, data){
- if(error) return tokenRows([]);
- tokenRows(data && data.results);
+ var tokens = (!error && data && data.results) || [];
+ $.scope.apiTokenCard.empty();
+ tokens.forEach(function(t){ $.scope.apiTokenCard.push(processToken(t)); });
+ $('#api-tokens-empty').toggle(tokens.length === 0);
});
}
+ // Shared "reveal secret once" display -- also used by proxy/sso-manager-node.
function showToken(title, token){
app.modal.open({title: title, bodyHtml:
' Save this token now — it will not be shown again.
'
- + ''
+ + ''
+ // Reuses the same copy-to-clipboard helper as the Quick Jump card
+ // above (toast feedback -- FontAwesome replaces icons with
+ // inline
'
+ 'Use it as a bearer token:
Authorization: Bearer ' + app.jump.esc(token) + '
'
});
}
@@ -177,27 +210,72 @@
+ ''
+ ''
+ ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ ''
+ ''
- + '
'
- + ' Create'
+ + '',
+ footer: {buttonsHtml: app.modal.footerButtons({onSave: 'submitApiToken()', saveLabel: 'Create'})},
});
$body.find('#new-token-name').focus();
}
function submitApiToken(){
var name = $('#new-token-name').val().trim();
- var $card = $('#api-tokens').closest('.card');
- if(!name) return app.messages.action('Name is required', $card, 'danger');
+ if(!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
app.apiToken.add({
name: name,
+ description: $('#new-token-description').val(),
expires_in_days: $('#new-token-days').val(),
}, function(error, data){
- if(error) return app.messages.action((data && data.message) || 'Failed to create token', $card, 'danger');
+ if(error) return app.messages.action((data && data.message) || 'Failed to create token', app.modal.body(), 'danger');
+ app.modal.close();
showToken('API Token Created', data.token);
loadApiTokens();
});
}
+
+ function editToken(id){
+ var t = tokensById[id]; if(!t) return;
+ app.modal.open({
+ title: 'Edit Token',
+ bodyHtml:
+ ''
+ + ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + ''
+ + '
',
+ footer: {
+ metaHtml: 'Created by ' + app.jump.esc(t.created_by || '—') + ' on ' + app.jump.fmtTime(t.created_on),
+ buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
+ },
+ });
+ }
+
+ function saveEditToken(){
+ var payload = {
+ 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) return app.messages.action((data && data.message) || 'Failed to update token', app.modal.body(), 'danger');
+ app.modal.close();
+ loadApiTokens();
+ });
+ }
+
async function revokeApiToken(id, btn){
var $card = $(btn).closest('.card');
var ok = await app.messages.confirm('Revoke this API token? It stops working immediately.', $card, 'danger');