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.

- - - -
NameCreatedLast usedExpires
+
+ +
+
+
+
{{name}}
+ {{id_short}} +
+ +
+ {{#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 , so a checkmark-flash-the-icon approach silently + // no-ops; the toast doesn't have that problem). + + '
' + '

Use it as a bearer token:
Authorization: Bearer ' + app.jump.esc(token) + '

' }); } @@ -177,27 +210,72 @@ + '' + '' + '
' + + '' + + '' + + '
' + + '
' + '' + '' - + '
' - + '' + + '', + 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');