Compare commits

..

22 Commits

Author SHA1 Message Date
wmantly 1d09f243dd Release 1.8.1: Redis persistence fix (#20) 2026-07-28 15:50:11 -04:00
wmantly ec4ca97af4 Fix: Redis had zero persistence — every rebuild wiped sessions, (#18)
in-flight OAuth logins, and any admin-created API token

redis-server ran with --save '' --appendonly no (deliberately ephemeral,
per the original "audit/metrics/session storage" framing). That stopped
being a safe assumption once API tokens (PATs) lived in this same Redis
-- a PAT is supposed to be a stable, long-lived credential, not
disposable session state, but every `docker rm -f jump-host` + rebuild
silently invalidated every one that existed.

Matches proxy's existing pattern exactly: AOF + periodic RDB persisted
to $REDIS_DATA_DIR (default /data), which the deployment mounts as a
volume (see the companion theta-env change).

Verified against a live container: minted a real PAT, force-recreated
the container (docker rm -f + rebuild), confirmed the same token still
authenticates afterward.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 15:46:40 -04:00
wmantly 21ef8960c4 Merge pull request #17 from theta42/release/1.8.0
Release 1.8.0
2026-07-28 13:35:18 -04:00
wmantly a5bef2980b Release 1.8.0: fix TUI-mode SSH connection drops, HTML-escaped loading indicator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:32:28 -04:00
wmantly ab2ee0fed3 Merge pull request #16 from theta42/fix/tui-session-listener-race
Fix: TUI-mode SSH connections could drop with PTY/shell request failed
2026-07-28 13:07:55 -04:00
wmantly ab9e04e007 Merge pull request #15 from theta42/fix/loading-message-html-escaped
Fix HTML-escaped loading indicator in formAJAX
2026-07-28 13:07:49 -04:00
wmantly a3a6787776 Fix: TUI-mode SSH connections could drop with "PTY allocation
request failed" / "shell request failed"

runTuiSession awaited audit.create() (a Redis round-trip) and then
accessibleHosts() (a directory API call) BEFORE calling runTui(), which
is where the pty/shell/exec/subsystem listeners actually get attached to
the session. The client sends its pty-req and shell requests immediately
after opening the session -- if either await took long enough for those
requests to arrive first, ssh2 auto-rejects any channel request with no
listener (CHANNEL_FAILURE), which is exactly what OpenSSH reports as
"PTY allocation request failed on channel 0" / "shell request failed on
channel 0". The connection then just sat there, since nothing was left
to drive it.

runGrammar already has this exact fix (see its own comment); runTuiSession
never got the equivalent treatment. Fixed the same way: register the
session listeners synchronously, before any await, by having runTui take
a Promise for the hosts list instead of the resolved list -- the shell
handler awaits it internally once the client actually sends a shell
request, which by construction happens only after the listener already
exists.

Verified: publickey auth against a real deployment succeeds (confirms
the earlier ldaps:// fix holds), and the failure reproduces with the
exact reported error strings for a bare `ssh user@host` (TUI/picker mode,
no target) connection. Full suite: 50/50 passing, no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:58:49 -04:00
wmantly 0ead0199a3 Fix HTML-escaped loading indicator in formAJAX
Same fix as sso-manager-node/proxy: formAJAX's loading indicator passed a
raw <div class="spinner-border"> string to app.messages.action, which
HTML-escapes its message by design (@simpleworkjs/frontend). Replaced
with plain text ("Saving…").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:53:08 -04:00
wmantly 0af2fc7e3d Merge pull request #14 from theta42/release/1.7.1
Release 1.7.1
2026-07-28 00:20:38 -04:00
wmantly bc2180116f Release 1.7.1: add no-native-dialogs regression test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:19:25 -04:00
wmantly 1b70701795 Merge pull request #13 from theta42/test/no-native-dialogs
Add regression test: no native alert()/confirm()/prompt()
2026-07-27 23:58:56 -04:00
wmantly 98e1e0e279 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on sso-manager-node's equivalent
secret-rotate flow -- it froze the tab). This app has no such call sites
(never did); this static check (scans views/ and public/js|lib/js for
bare alert(/confirm(/prompt() calls) keeps it that way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 23:57:41 -04:00
wmantly b03fcefaae Merge pull request #12 from theta42/release/1.7.0
Release 1.7.0
2026-07-27 20:26:56 -04:00
wmantly 3474482f6f Release 1.7.0: self-service API tokens
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 20:25:40 -04:00
wmantly da361a8a86 Merge pull request #11 from theta42/feature/api-tokens
Add self-service API tokens (PATs)
2026-07-27 20:13:40 -04:00
wmantly 4326d5588e Add self-service API tokens (PATs) — the UI had no way to create one
jump-host had zero API-token support: no model, no route, no UI, and
Auth.checkApiToken was explicitly absent from the createOidcClient() call
(per the comment it left behind). proxy and sso-manager-node both have
this; jump-host didn't.

Ports proxy's models/api_token.js + routes/api_token.js pattern (jmp_
prefix instead of prx_), wires checkApiToken into createOidcClient(), adds
Bearer-token support to middleware/auth.js, and adds a token management
card to dashboard.ejs (create/list/rotate/revoke) using app.modal/
app.messages.

Scope note: a jump-host token carries no group claims (unlike proxy's,
which snapshots the creator's groups), so it authenticates as its creator
for non-admin routes (e.g. GET /api/user/hosts) but can never pass
requireAdmin — a deliberate, conservative default rather than recomputing
live admin status per-request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 19:44:04 -04:00
wmantly 4a70b5b27e Merge pull request #10 from theta42/release/1.6.0
Release 1.6.0
2026-07-27 14:18:33 -04:00
wmantly 43caac13d5 Release 1.6.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:17:13 -04:00
wmantly 465f923393 Merge pull request #9 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:05:40 -04:00
wmantly 782a829cb9 Adopt @simpleworkjs/frontend's messages/modal/validate modules
Same swap as sso-manager-node/proxy: vendored app.util.actionMessage/
actionConfirm replaced by @simpleworkjs/frontend's app.messages.action/
confirm; vendored val.js replaced by the package's app.validate.js.
jump-host's views don't call actionMessage/actionConfirm/alert directly,
so no view changes are needed beyond the script includes.

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched, same
reasoning as the other two apps' PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:02:15 -04:00
wmantly fd863b89ca Merge pull request #8 from theta42/feature/dashboard-hosts
Dashboard: list hosts you can reach; fix key-injection ObjectClassViolationError
2026-07-26 23:12:30 -04:00
wmantly 4fb4e77007 Dashboard: list hosts you can reach; bump @simpleworkjs/ldap to 1.0.1
- New GET /api/user/hosts (auth-only): all hosts for admins, group-filtered
  list for everyone else.
- accessibleHosts() accepts a pre-resolved user.groups, so the web UI's
  OIDC session skips a redundant LDAP getGroups(dn) call.
- Dashboard shows a "Hosts you can reach" / "All hosts" table.
- @simpleworkjs/ldap 1.0.1 fixes addSshKey's ObjectClassViolationError on
  accounts predating the ldapPublicKey objectClass -- was aborting key
  injection (and the SSH connection) on affected accounts.
- Bump to 1.5.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 23:11:23 -04:00
20 changed files with 602 additions and 312 deletions
+35
View File
@@ -4,6 +4,41 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.8.1] - 2026-07-28
### Fixed
- **Redis had zero persistence** (`--save '' --appendonly no`, no data-dir volume) — every container rebuild/recreation silently wiped all sessions, in-flight OAuth logins, and any admin-created API token. This is why re-running `setup.sh` appeared to "break OAuth with jump": the jump-host container gets recreated, and any token or in-flight login vanished with it. Now Redis persists (AOF + periodic RDB) to `/data`, mounted as a named volume (`jump-redis-data`) in theta-env's compose file. Verified live: minted a PAT, force-recreated the container, confirmed the same PAT still authenticated afterward.
## [1.8.0] - 2026-07-28
### Fixed
- **TUI-mode SSH connections (a bare `ssh user@host`, no target) could drop with "PTY allocation request failed" / "shell request failed"** — `runTuiSession` awaited two real round-trips (an audit-log write, then a directory API call) *before* attaching the session's pty/shell/exec listeners, so a client that sent those requests quickly enough got auto-rejected by ssh2 before anything was listening. `runGrammar` (the `uid_-_target` path) already had the equivalent fix; this ports it to the picker path.
- **`formAJAX`'s loading indicator showed literal HTML**, not a spinner — same fix as sso-manager-node/proxy's companion releases.
## [1.7.1] - 2026-07-28
### Added
- **Regression test**: a static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page. This app has never had one; keeps it that way.
## [1.7.0] - 2026-07-27
### Added
- **Self-service API tokens (PATs)** — `models/api_token.js` + `routes/api_token.js` (mounted at `/api-token`), Bearer-token support in `middleware/auth.js`, and a create/list/rotate/revoke card on the dashboard. Ports proxy's `jmp_<id>_<secret>` pattern; unlike proxy's, a jump-host token carries no group claims, so it authenticates as its creator for non-admin routes only (never passes `requireAdmin`). jump-host previously had no PAT support at all.
## [1.6.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js` (unused by any current view here, so this is dedup/future-proofing rather than a behavior change). `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched.
## [1.5.0] - 2026-07-27
### Added
- **Web UI dashboard now lists the hosts you can reach** ("Hosts you can reach", or "All hosts" for admins) — previously the dashboard only showed usage metrics, with no way to see your actual access from the browser. Backed by a new `GET /api/user/hosts` endpoint (auth-only, not admin-gated): admins get the full inventory via `utils/access.js`'s new `allHosts()`, everyone else gets the same group-based resolution the SSH front door uses.
- `utils/access.js`'s `accessibleHosts()` now accepts a pre-resolved `groups` array on the user object, skipping the LDAP `getGroups(dn)` round-trip — the web UI's OIDC session already has its groups claim and has no LDAP `dn` to query with.
### Fixed
- **Bumped `@simpleworkjs/ldap` to 1.0.1**, which fixes `addSshKey` throwing `ObjectClassViolationError` (LDAP `0x41`) on accounts predating the `ldapPublicKey` auxiliary objectClass. This is the code path this jump host's key-injection (`utils/key_inject.js`) uses on every first connection for a user — on affected accounts it aborted the SSH connection entirely (`key-inject-failed`).
## [1.4.0] - 2026-07-26
### Added
+11 -3
View File
@@ -12,9 +12,17 @@ if [[ -f /config/jump-secrets.js ]]; then
info "Loaded config from /config/jump-secrets.js"
fi
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379).
info "Starting redis..."
redis-server --daemonize yes --save '' --appendonly no
# Redis for audit/metrics/session AND api-token storage (app connects to
# 127.0.0.1:6379). Persisted (AOF + periodic RDB) to /data, which the
# deployment should mount as a volume -- without this, every container
# recreation silently wiped every session, in-flight OAuth login, and any
# admin-created API token, which is especially bad for the last one since a
# PAT is meant to be a stable, long-lived credential, not session state.
REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
mkdir -p "$REDIS_DATA_DIR"
info "Starting redis (AOF persisted to $REDIS_DATA_DIR)..."
redis-server --daemonize yes --dir "$REDIS_DATA_DIR" --appendonly yes \
--appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000
# Wait for redis to answer before starting the app.
for _ in $(seq 1 20); do
+18
View File
@@ -9,6 +9,24 @@ const { Auth } = require('../models');
async function auth(req, res, next){
try{
// API-only token: `Authorization: Bearer jmp_<id>_<secret>`. Carries no
// group claims (see models/api_token.js), so it authenticates as its
// creator but never passes requireAdmin below.
const authz = req.header('authorization') || '';
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
const t = await Auth.checkApiToken(authz.slice(7));
req.token = {
user: {username: t.created_by},
created_by: t.created_by,
groupsArray: () => [],
check: () => true,
is_valid: true,
};
req.user = req.token.user;
req.groups = [];
return next();
}
req.token = await Auth.checkToken(req.header('auth-token'));
req.user = req.token.user;
req.groups = typeof req.token.groupsArray === 'function' ? req.token.groupsArray() : [];
+83
View File
@@ -0,0 +1,83 @@
'use strict';
const Table = require('.');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
// Self-service personal access token (PAT) for the jump host's own API.
// Format: jmp_<id>_<secret>
// id — 24-char hex, stored plaintext as the record key (O(1) lookup)
// secret — 48-char hex, stored only as a bcrypt hash (isPrivate); shown ONCE
//
// Authenticated via `Authorization: Bearer jmp_...`. Mirrors proxy's
// models/api_token.js — see that file for the fuller design notes. jump-host
// has no per-user group snapshot the way proxy/sso do (its authz is a single
// admin/non-admin bit off conf.auth.adminGroups/adminUsers), so a token
// authenticates as its creator only; the auth middleware re-derives
// admin-ness from that user's current groups, same as a live session.
//
// No `static _ttl`: records persist (lifetime is the optional expires_at field).
const PREFIX = 'jmp_';
const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex');
class ApiToken extends Table{
static _key = 'id';
static _keyMap = {
'id': {default: function(){ return randomHex(12) }, type: 'string'},
'secret_hash': {isRequired: true, type: 'string', isPrivate: true},
'name': {isRequired: true, type: 'string', min: 1, max: 255},
'description': {default: '', type: 'string'},
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'expires_at': {default: 0, type: 'number'}, // epoch ms; 0 = never
'last_used_on': {default: 0, type: 'number'},
'is_valid': {default: true, type: 'boolean'},
}
get isExpired() {
return this.expires_at > 0 && (new Date).getTime() > this.expires_at;
}
static async add(data){
const id = randomHex(12);
const secret = randomHex(24);
data.id = id;
data.secret_hash = await bcrypt.hash(secret, 10);
const token = await this.create(data);
token._raw_token = `${PREFIX}${id}_${secret}`;
return token;
}
async rotate(){
const secret = randomHex(24);
await this.update({ secret_hash: await bcrypt.hash(secret, 10) });
return `${PREFIX}${this.id}_${secret}`;
}
// Validate a raw `jmp_<id>_<secret>` string. Throws a generic Error on any
// failure so the caller (Auth.checkApiToken) can collapse every case into
// one 401 (no existence / wrong-secret / expired leak).
static async authenticate(raw){
const m = /^jmp_([0-9a-f]{24})_([0-9a-f]{48})$/i.exec(String(raw || ''));
if(!m) throw new Error('InvalidApiToken');
let token;
try{
token = await this.get(m[1]);
}catch(e){
throw new Error('InvalidApiToken');
}
if(!token) throw new Error('InvalidApiToken');
const ok = await bcrypt.compare(m[2], token.secret_hash);
if(!ok || !token.is_valid || token.isExpired) throw new Error('InvalidApiToken');
// Best-effort: stamp last use. Fire-and-forget so a Redis hiccup never
// fails an otherwise-valid request.
try{ await token.update({ last_used_on: (new Date).getTime() }); }catch(_){}
return token;
}
}
ApiToken.register();
module.exports = {ApiToken};
+7 -4
View File
@@ -32,14 +32,17 @@ async function getRedis() {
module.exports.getRedis = getRedis;
// Register models (order matters: User before AuthToken's relation resolves).
// Register models (order matters: User before AuthToken's relation resolves,
// and before ApiToken so `require('.')`'s Table is already exporting User).
require('./user_redis'); // User (redis-backed local + OIDC JIT)
const { ApiToken } = require('./api_token');
module.exports.ApiToken = ApiToken;
// Shared OIDC client (authorization-code + PKCE): session models (Token,
// AuthToken, OidcState), the Auth service, and the /login /logout /oidc/start
// /oidc/callback router — all created on this app's Table/redis. jump-host has
// no Bearer PATs, so checkApiToken is omitted (Auth.checkApiToken is absent).
const oidcClient = createOidcClient({ Table });
// /oidc/callback router — all created on this app's Table/redis. checkApiToken
// wraps ApiToken.authenticate, same wiring as proxy's models/index.js.
const oidcClient = createOidcClient({ Table, checkApiToken: (raw) => ApiToken.authenticate(raw) });
module.exports.Token = oidcClient.Token;
module.exports.AuthToken = oidcClient.AuthToken;
module.exports.OidcState = oidcClient.OidcState;
+16 -6
View File
@@ -1,19 +1,20 @@
{
"name": "t42-jump-host",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.3.0",
"version": "1.5.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/ldap": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
@@ -176,10 +177,19 @@
"node": ">=18.0.0"
}
},
"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==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/ldap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
"integrity": "sha512-saDmwk+KJ6kIWj9/MF37d+BM9KQisy6DsI9umyt1FWNyx6+wnEEat/1RUTwXKBd4IKJK+zPT5lC/B6gfa2CuAA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.1.tgz",
"integrity": "sha512-1jz3WQ9ghwNHz2mI+H33qw8nIQpLkoNHEy8lgdgsE/nCLK8JGU1SIKEdbsdMeXEQ0seKqyjuyXwmhdotsx6Lww==",
"license": "MIT",
"dependencies": {
"ldapts": "^8.1.8"
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.4.0",
"version": "1.8.1",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
@@ -20,10 +20,11 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/ldap": "^1.0.1",
"@simpleworkjs/oidc-client": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
+11 -1
View File
@@ -11,7 +11,17 @@ app.jump = (function(app){
var qs = $.param(query || {});
app.api.get('audit' + (qs ? '?' + qs : ''), cb);
}
return {metrics: metrics, sessions: sessions, audit: audit};
function hosts(cb){ app.api.get('user/hosts', cb); }
return {metrics: metrics, sessions: sessions, audit: audit, hosts: hosts};
})(app);
// Self-service API token (PAT) management.
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 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};
})(app);
// Shared render helpers.
+21 -78
View File
@@ -363,7 +363,7 @@ app.auth = (function(app){
}
if(requiredGroups && !await memberOf(requiredGroups, user)){
app.util.actionMessage(
app.messages.action(
`<h1>
<i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b>
@@ -520,68 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
};
function actionMessage(message, $targetPassed, type, callback){
message = message || '';
let $target = $targetPassed.closest('div.card').find('.actionMessage');
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
type = type || 'info';
callback = callback || function(){};
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
// Messages that bring their own buttons (actionConfirm) are left
// alone; everything else gets the standard dismiss button.
if(!message.includes('<button')) message = `
<span class="align-middle">${message}</span>
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
// app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
$.fn.serializeObject = function() {
@@ -640,8 +587,7 @@ app.util = (function(app){
return {
downloadFile: downloadFile,
getUrlParameter: getUrlParameter,
actionMessage: actionMessage,
actionConfirm,
escapeHtml: escapeHtml,
}
})(app);
@@ -696,9 +642,9 @@ $( document ).ready(async function(){
$(this).closest('.card').slideUp('fast');
});
$('.actionMessage').on('click', 'button.action-close', function(event){
app.util.actionMessage(null, $(this));
});
// action-close click handling is wired by @simpleworkjs/frontend's
// app.messages.js (delegated on document, so it also covers messages
// rendered after this ready handler runs).
setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{
@@ -729,20 +675,17 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
app.messages.action('Please fix the form errors.', $form, 'danger')
return false;
}
app.util.actionMessage(
`<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>`,
$form,
'info'
);
// Plain text: app.messages.action HTML-escapes its message (by design,
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
// render literally instead of as an element.
app.messages.action('Saving…', $form, 'info');
app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear();
if(!error){
$form.trigger("reset");
@@ -750,7 +693,7 @@ function formAJAX(btn){
}else{
console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
}
if(data && data.keys){
console.log('form key errors', data.keys)
-201
View File
@@ -1,201 +0,0 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
// incoming host may be a wildcard ("*.example.com"); the target may not.
(function(){
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
// Either one bare label (Docker service names, /etc/hosts entries) or a
// dotted hostname with an alphabetic TLD.
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
var FORBIDDEN = /[\s/:]/;
function isIPv4( value ) {
var parts = value.split( '.' );
if ( parts.length !== 4 ) return false;
return parts.every( function( p ) {
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
});
}
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
// (any number of fragments, incl. a bare "**" global catch-all).
function isHostPattern( value ) {
if ( value.length > 253 ) return false;
return value.split( '.' ).every( function( l ) {
return l === '*' || l === '**' || LABEL.test( l );
});
}
function forbidden( value ) {
return FORBIDDEN.test( value ) || value.includes( '://' );
}
// Incoming host: IPv4 or a wildcard host pattern.
function checkHost( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || isHostPattern( value ) ) return;
return "Enter a valid host or wildcard (*, **)";
}
// Downstream target: IPv4 or a strict hostname, no wildcard.
function checkTarget( value ) {
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
return "Enter a valid hostname or IP";
}
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
host: function( value ) {
return checkHost( value );
},
// Downstream target — hostname or IPv4, no wildcard.
target: function( value ) {
return checkTarget( value );
},
// Back-compat alias (no wildcard).
hostname: function( value ) {
return checkTarget( value );
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
// or at least 3 of {lowercase, uppercase, number, symbol}.
password: function( value ) {
if ( typeof value !== 'string' || value.length < 8 ) {
return "Password must be at least 8 characters";
}
if ( value.length >= 12 ) return;
var classes = 0;
if ( /[a-z]/.test( value ) ) classes++;
if ( /[A-Z]/.test( value ) ) classes++;
if ( /[0-9]/.test( value ) ) classes++;
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
if ( classes < 3 ) {
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
}
}
}
});
})();
+4
View File
@@ -9,6 +9,10 @@ router.use('/auth', require('../models').authRouter);
// Who am I — needs a valid session but no admin gate (drives the login state).
router.use('/user', middleware.auth, require('./user'));
// Self-service API token (PAT) management — any authenticated user, no
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
router.use('/api-token', middleware.auth, require('./api_token'));
// Jump-host data — admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireAdmin, require('./jump'));
+120
View File
@@ -0,0 +1,120 @@
'use strict';
// Self-service API token (PAT) management. Every endpoint is owner-scoped: a
// user only sees / mutates tokens where created_by === req.user.username.
// Mirrors proxy's routes/api_token.js. Mounted under middleware.auth only
// (no requireAdmin) — any authenticated user may mint one, but the token
// itself carries no group claims (see models/api_token.js), so it can only
// reach non-admin routes (e.g. GET /api/user/hosts), never the admin-gated
// ones under routes/jump.js.
const router = require('express').Router();
const {ApiToken} = require('../models');
function forbidden(){
let error = new Error('Forbidden');
error.name = 'Forbidden';
error.message = 'You do not own this API token.';
error.status = 403;
return error;
}
// Resolve a token the caller owns. Missing or not-yours both raise 403 (no
// existence leak; ids are unguessable random hex anyway).
async function getOwned(req, id){
let token;
try{
token = await ApiToken.get(id);
}catch(e){
throw forbidden();
}
if(!token || token.created_by !== req.user.username) throw forbidden();
return token;
}
router.get('/', async function(req, res, next){
try{
return res.json({results: await ApiToken.listDetail({created_by: req.user.username})});
}catch(error){
next(error);
}
});
router.post('/', async function(req, res, next){
try{
const days = req.body.expires_in_days !== '' && req.body.expires_in_days !== undefined
? Number(req.body.expires_in_days) : 0;
const token = await ApiToken.add({
name: req.body.name,
description: req.body.description || '',
created_by: req.user.username,
expires_at: days > 0 ? (new Date).getTime() + days * 86400000 : 0,
});
return res.json({
results: token,
token: token._raw_token,
message: `API token '${token.name}' created. Save it now — it will not be shown again.`,
});
}catch(error){
next(error);
}
});
router.get('/:id', async function(req, res, next){
try{
return res.json({results: await getOwned(req, req.params.id)});
}catch(error){
next(error);
}
});
router.put('/:id', async function(req, res, next){
try{
const token = await getOwned(req, req.params.id);
const update = {};
for(const k of ['name', 'description']){
if(req.body[k] !== undefined) update[k] = req.body[k];
}
if(req.body.expires_in_days !== undefined && req.body.expires_in_days !== ''){
const days = Number(req.body.expires_in_days);
update.expires_at = days > 0 ? (new Date).getTime() + days * 86400000 : 0;
}else if(req.body.expires_at !== undefined){
update.expires_at = Number(req.body.expires_at) || 0;
}
return res.json({
results: await token.update(update),
message: `API token '${token.name}' updated.`,
});
}catch(error){
next(error);
}
});
router.delete('/:id', async function(req, res, next){
try{
const token = await getOwned(req, req.params.id);
await token.remove();
return res.json({id: req.params.id, message: `API token '${token.name}' revoked.`});
}catch(error){
next(error);
}
});
router.post('/:id/rotate', async function(req, res, next){
try{
const token = await getOwned(req, req.params.id);
const raw = await token.rotate();
return res.json({
token: raw,
message: `API token '${token.name}' rotated. Save it — it will not be shown again.`,
});
}catch(error){
next(error);
}
});
module.exports = router;
+1 -1
View File
@@ -21,7 +21,7 @@ const values = {
// as the sibling apps), and the app's own JS/CSS/img from public/.
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat'],
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', 'jq-repeat', '@simpleworkjs/frontend'],
});
// Liveness probe — no auth.
+13
View File
@@ -5,6 +5,7 @@
const router = require('express').Router();
const { isAdmin } = require('../middleware/auth');
const access = require('../utils/access');
router.get('/me', (req, res) => {
res.json({
@@ -14,4 +15,16 @@ router.get('/me', (req, res) => {
});
});
// The hosts this session can SSH to — every host for an admin, otherwise the
// same group-based resolution the SSH front door uses (accessibleHosts),
// fed the OIDC session's already-known groups instead of an LDAP lookup.
router.get('/hosts', async (req, res, next) => {
try {
const hosts = isAdmin(req)
? await access.allHosts()
: await access.accessibleHosts({ uid: req.user && req.user.username, groups: req.groups || [] });
res.json({ results: hosts });
} catch (err) { next(err); }
});
module.exports = router;
+37 -7
View File
@@ -180,6 +180,23 @@ async function runGrammar(session, client, state) {
}
async function runTuiSession(session, client, state) {
// Register session listeners IMMEDIATELY, before any await — same fix,
// same reason, as runGrammar above. The client sends pty-req and shell
// requests right after opening the session; awaiting audit.create() and
// accessibleHosts() first (both real round-trips: Redis, then the
// directory API) left a window where those requests could arrive before
// runTui had attached any listener for them, and ssh2 auto-rejects an
// unlistened channel request with CHANNEL_FAILURE — surfacing to the
// client as "PTY allocation request failed" / "shell request failed",
// with the connection then just sitting there (nothing left to drive it).
let resolveHosts, rejectHosts;
const hostsPromise = new Promise((res, rej) => { resolveHosts = res; rejectHosts = rej; });
// A silent catch so a rejection isn't "unhandled" if the client never
// sends a shell request at all (exec-only) — runTui's own .catch() below
// still runs independently when it does.
hostsPromise.catch(() => {});
const tuiPromise = runTui(session, state.uid, hostsPromise);
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
const finishFail = async (reason) => {
@@ -189,10 +206,15 @@ async function runTuiSession(session, client, state) {
};
let hosts;
try { hosts = await accessibleHosts(state.user); }
catch (_) { return finishFail('directory-unreachable'); }
try {
hosts = await accessibleHosts(state.user);
resolveHosts(hosts);
} catch (_) {
rejectHosts(new Error('directory-unreachable'));
return finishFail('directory-unreachable');
}
const tui = await runTui(session, state.uid, hosts);
const tui = await tuiPromise;
if (!tui.host) return finishFail('cancelled');
state.target = tui.host.slug;
@@ -253,7 +275,10 @@ function reasonMessage(reason) {
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
// host is null if the user quit. exec/subsystem in picker mode are rejected.
function runTui(session, uid, hosts) {
// Takes a Promise for the accessible-hosts list (not the resolved list)
// so the caller can register these listeners before that lookup completes
// — see the comment in runTuiSession for why that ordering matters.
function runTui(session, uid, hostsPromise) {
return new Promise((resolve) => {
let ptyInfo = null;
let settled = false;
@@ -262,9 +287,14 @@ function runTui(session, uid, hosts) {
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('shell', (accept) => {
const channel = accept();
pickHost(channel, uid, hosts).then((host) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
hostsPromise.then((hosts) => {
pickHost(channel, uid, hosts).then((host) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
});
}).catch(() => {
try { channel.write('\r\n Could not reach the directory.\r\n'); channel.close(); } catch (_) {}
finish({ host: null });
});
});
session.on('exec', (accept) => {
+27 -1
View File
@@ -2,7 +2,7 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { accessibleHosts, clearCache } = require('../../utils/access');
const { accessibleHosts, allHosts, clearCache } = require('../../utils/access');
function stubLdap(groups) {
return { getGroups: async () => groups };
@@ -54,6 +54,32 @@ test('caches per uid', async () => {
assert.strictEqual(calls, 1);
});
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
clearCache();
let ldapCalled = false;
const user = { uid: 'erin', groups: ['host_web01_access'] };
const fetchImpl = stubFetch({
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
});
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
assert.strictEqual(ldapCalled, false);
});
test('allHosts fetches the whole host inventory with no group filter', async () => {
const fetchImpl = async (url) => {
assert.ok(!url.includes('group='), 'must not filter by group');
assert.ok(url.includes('kind=host'));
return { ok: true, json: async () => ({ results: [
{ id: '1', kind: 'host', slug: 'host_a' },
{ id: '2', kind: 'host', slug: 'host_b' },
] }) };
};
const hosts = await allHosts({ fetchImpl });
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
});
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
clearCache();
const user = { uid: 'dave', dn: 'd' };
@@ -0,0 +1,43 @@
'use strict';
// Regression guard: native alert()/confirm()/prompt() calls block all further
// browser events on the page (found live, mid browser-automation testing, on
// sso-manager-node's equivalent secret-rotate flow) and are visually
// inconsistent with the rest of the UI. This app has no such call sites;
// keep it that way.
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', '..', d));
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
function walk(dir) {
let files = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files = files.concat(walk(full));
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
}
return files;
}
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
const offenders = [];
for (const root of ROOTS) {
for (const file of walk(root)) {
const src = fs.readFileSync(file, 'utf8');
let m;
NATIVE_DIALOG_RE.lastIndex = 0;
while ((m = NATIVE_DIALOG_RE.exec(src))) {
const line = src.slice(0, m.index).split('\n').length;
offenders.push(`${path.relative(path.join(__dirname, '..', '..'), file)}:${line}${m[2]}(`);
}
}
}
assert.deepStrictEqual(offenders, []);
});
+15 -4
View File
@@ -8,9 +8,10 @@
const conf = require('@simpleworkjs/conf');
if (conf.standalone && conf.standalone.enabled) {
// Standalone mode: use the ORM-backed host inventory.
// Standalone mode: use the ORM-backed host inventory. Every host is
// accessible to every user, so allHosts and accessibleHosts coincide.
const { accessibleHosts } = require('./hosts_file');
module.exports = { accessibleHosts, clearCache: () => {} };
module.exports = { accessibleHosts, allHosts: () => accessibleHosts(), clearCache: () => {} };
} else {
// Production mode: LDAP groups + SSO API (unchanged).
@@ -48,11 +49,21 @@ if (conf.standalone && conf.standalone.enabled) {
return directoryClient({ fetchImpl }).getResourcesByGroup(group);
}
// Every host in the inventory, unfiltered — for admins (the web UI's own
// account is already gated by requireAdmin before this is ever called).
async function allHosts({ fetchImpl = fetch } = {}) {
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
return resources.filter(r => r.kind === 'host');
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
const hit = cache.get(user.uid);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
const groups = await ldap.getGroups(user.dn);
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
// look them up; the web UI already has the session's OIDC groups claim
// and passes it directly, skipping a redundant LDAP round-trip.
const groups = user.groups || await ldap.getGroups(user.dn);
const seen = new Map();
for (const cn of groups) {
@@ -80,5 +91,5 @@ if (conf.standalone && conf.standalone.enabled) {
else cache.clear();
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
module.exports = { accessibleHosts, allHosts, clearCache, fetchResourcesByGroup };
}
+133 -2
View File
@@ -28,7 +28,16 @@
</div>
</div>
<div class="row g-3">
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header"><i class="fa-solid fa-network-wired me-1"></i> <span id="my-hosts-title">Hosts you can reach</span></div>
<table class="table table-sm mb-0"><tbody id="my-hosts"></tbody></table>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm"><div class="card-header"><i class="fa-solid fa-server me-1"></i> Top hosts</div>
<table class="table table-sm mb-0"><tbody id="top-hosts"></tbody></table>
@@ -41,6 +50,27 @@
</div>
</div>
<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm">
<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>
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<p class="text-muted small px-3 pt-3 mb-0">
Personal access tokens authenticate as you against this jump host's own API
(e.g. <code>GET /api/user/hosts</code>) — not for SSH login. A token carries
no group claims, so it can't reach admin-only endpoints.
</p>
<table class="table table-sm mb-0">
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
<tbody id="api-tokens"></tbody>
</table>
</div>
</div>
</div>
<script type="text/javascript">
function rows(sel, list){
var $b = $(sel).empty();
@@ -49,7 +79,101 @@
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
});
}
$(document).ready(function(){
function hostRows(sel, hosts){
var $b = $(sel).empty();
if(!hosts || !hosts.length){ $b.append('<tr><td class="text-muted">No hosts reachable.</td></tr>'); return; }
hosts.forEach(function(h){
var addr = (h.metadata && (h.metadata.ip || h.metadata.address)) || '';
$b.append('<tr><td>' + app.jump.esc(h.displayName || h.name || h.slug) + '</td>'
+ '<td class="text-muted small">' + app.jump.esc(h.slug) + '</td>'
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td></tr>');
});
}
function tokenRows(tokens){
var $b = $('#api-tokens').empty();
if(!tokens || !tokens.length){ $b.append('<tr><td colspan="5" class="text-muted">No API tokens.</td></tr>'); 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(
'<tr>'
+ '<td>' + app.jump.esc(t.name) + '</td>'
+ '<td class="text-muted small">' + app.jump.fmtTime(t.created_on) + '</td>'
+ '<td class="text-muted small">' + lastUsed + '</td>'
+ '<td class="text-muted small">' + expires + '</td>'
+ '<td class="text-end">'
+ '<button class="btn btn-sm btn-outline-secondary" onclick="rotateApiToken(\'' + t.id + '\', this)" title="Rotate"><i class="fa-solid fa-rotate"></i></button> '
+ '<button class="btn btn-sm btn-outline-danger" onclick="revokeApiToken(\'' + t.id + '\', this)" title="Revoke"><i class="fa-solid fa-trash"></i></button>'
+ '</td>'
+ '</tr>'
);
});
}
function loadApiTokens(){
app.apiToken.list(function(error, data){
if(error) return tokenRows([]);
tokenRows(data && data.results);
});
}
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" readonly value="' + app.jump.esc(token) + '"></div>'
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.jump.esc(token) + '</code></p>'
});
}
function createApiToken(){
var $body = app.modal.open({title: 'New API Token', bodyHtml:
'<div class="mb-3">'
+ '<label class="form-label">Name</label>'
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Expires in (days, blank = never)</label>'
+ '<input type="number" class="form-control" id="new-token-days" min="1">'
+ '</div>'
+ '<button class="btn btn-primary" onclick="submitApiToken()"><i class="fa-solid fa-check"></i> Create</button>'
});
$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');
app.apiToken.add({
name: name,
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');
showToken('API Token Created', data.token);
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');
if(!ok) return;
app.apiToken.remove(id, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to revoke token', $card, 'danger');
loadApiTokens();
});
}
async function rotateApiToken(id, btn){
var $card = $(btn).closest('.card');
var ok = await app.messages.confirm('Rotate this API token? The old token stops working immediately.', $card, 'warning');
if(!ok) return;
app.apiToken.rotate(id, function(error, data){
if(error) return app.messages.action((data && data.message) || 'Failed to rotate token', $card, 'danger');
showToken('API Token Rotated', data.token);
loadApiTokens();
});
}
$(document).ready(async function(){
app.jump.metrics(function(error, data){
if(error || !data) return;
$('#stat-active').text(data.active);
@@ -59,6 +183,13 @@
rows('#top-hosts', data.topHosts);
rows('#top-users', data.topUsers);
});
await app.auth.loadUser();
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
app.jump.hosts(function(error, data){
if(error) return hostRows('#my-hosts', []);
hostRows('#my-hosts', data && data.results);
});
loadApiTokens();
});
</script>
<%- include('bottom') %>
+3 -1
View File
@@ -21,9 +21,11 @@
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script>
</head>
<body>