Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd4464ed19 | |||
| 061a044a70 | |||
| c6a4a3c841 | |||
| 0ef451e15d | |||
| 6771904932 | |||
| c002afe043 | |||
| 0a2c25ae75 | |||
| a15decb6f7 | |||
| 599136e4dc | |||
| a7d5efc764 | |||
| 8a76f71edd | |||
| e482f52f10 | |||
| a6af160627 | |||
| 8c9646b65c | |||
| 1d09f243dd | |||
| ec4ca97af4 |
@@ -4,6 +4,40 @@ 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.10.2] - 2026-07-30
|
||||
|
||||
### Changed
|
||||
- **Dashboard, Sessions, and Audit pages now match sso-manager-node/proxy's page width**, wrapping content in a standard container instead of rendering full-bleed inside the fluid shell.
|
||||
- **Audit's nav entry is now admin-gated** (`groups: ['admin']` in `utils/ui.js`), reusing the existing synthetic-admin-group nav-gating convention — the API route was already server-side admin-gated; this hides the nav link for non-admins too.
|
||||
|
||||
## [1.10.1] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **The API-token reveal modal silently didn't show after creating a token** — `submitApiToken()` called `app.modal.close()` immediately before `showToken()`'s `app.modal.open()` in the same tick, colliding with Bootstrap's hide-transition guard on the singleton modal. Same root cause as the OAuth-secret-reveal race fixed in sso-manager-node (v1.8.2) and the create-token race fixed in proxy (v1.7.0).
|
||||
|
||||
## [1.10.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **API-token UI unified with sso-manager-node/proxy**: card grid replacing the bare table, a new Edit modal (footer shows real created-by/on data), and a Description field on both the create and edit flows — the model and API already fully supported all of this, it just wasn't exposed anywhere in the dashboard.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/frontend` bumped to `^0.2.6` (this app was still on `^0.2.5`).
|
||||
|
||||
## [1.9.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **"Quick Jump" copy-to-clipboard section on the dashboard** — the `uid_-_target` grammar-mode SSH command was documented in the README but nowhere in the UI. A new card gives a one-click-copy command for interactive-picker mode, and every row in "Hosts you can reach" has its own copy button for the exact grammar-mode command to that host, ready to paste and run as-is (uses the logged-in user's own uid).
|
||||
|
||||
## [1.8.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **Audit records for a failed upstream connection only ever said `upstream-unreachable`** — `resolveAndConnect` discarded the real error from `connectUpstream` (ECONNREFUSED, ETIMEDOUT, an ssh2 auth-failure message, etc.) and replaced it with that one generic string, so there was no way to tell a network-layer failure from an auth failure from the audit log alone. This is what blocked root-causing the "Could not reach 192.168.1.206" (emby host) report — the real error is now captured and surfaced as a new `failDetail` field on the audit record, shown as a tooltip on the fail badge in the admin audit table.
|
||||
|
||||
## [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
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
Generated
+6
-6
@@ -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"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.2",
|
||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||
"author": [
|
||||
{
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,12 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* Height of the fixed navbar (plus the update banner, while shown --
|
||||
see top.ejs's showUpdateBanner/dismissUpdateBanner). Lets an in-page
|
||||
sticky element offset itself below both fixed elements via
|
||||
`top: var(--sw-content-offset)` instead of colliding with them at the
|
||||
viewport's true top:0. */
|
||||
--sw-content-offset: 4.5rem;
|
||||
}
|
||||
|
||||
#spa-shell {
|
||||
|
||||
@@ -19,13 +19,15 @@ 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.
|
||||
app.jump.fmtTime = function(ts){ return ts ? moment(Number(ts)).format('YYYY-MM-DD HH:mm:ss') : '—'; };
|
||||
app.jump.esc = function(s){ return $('<div>').text(s == null ? '' : String(s)).html(); };
|
||||
app.jump.result = function(e){ return e.success ? '<span class="badge bg-success">ok</span>'
|
||||
: '<span class="badge bg-danger">' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||
app.jump.result = function(e){ if (e.success) return '<span class="badge bg-success">ok</span>';
|
||||
var title = e.failDetail ? ' title="' + app.jump.esc(e.failDetail) + '"' : '';
|
||||
return '<span class="badge bg-danger"' + title + '>' + app.jump.esc(e.failReason || 'fail') + '</span>'; };
|
||||
|
||||
@@ -584,10 +584,31 @@ app.util = (function(app){
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
// Scroll a just-added/-edited element into view and flash its
|
||||
// background, so the user's eye lands on the row that changed instead of
|
||||
// it silently appearing/updating somewhere off-screen. Takes a jQuery
|
||||
// object or a raw DOM node (e.g. jq-repeat's `item.__jq_$el`).
|
||||
function revealItem(el){
|
||||
var node = el && el.jquery ? el[0] : el;
|
||||
if (!node) return;
|
||||
if (typeof node.scrollIntoView === 'function') {
|
||||
node.scrollIntoView({behavior: 'smooth', block: 'center'});
|
||||
}
|
||||
var prevTransition = node.style.transition;
|
||||
var prevBg = node.style.backgroundColor;
|
||||
node.style.transition = 'background-color 1.5s ease';
|
||||
node.style.backgroundColor = 'var(--bs-success-bg-subtle, #d1e7dd)';
|
||||
setTimeout(function(){
|
||||
node.style.backgroundColor = prevBg;
|
||||
setTimeout(function(){ node.style.transition = prevTransition; }, 1500);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
escapeHtml: escapeHtml,
|
||||
revealItem: revealItem,
|
||||
}
|
||||
})(app);
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ const values = {
|
||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
// The SSH front door's port -- the dashboard's "quick jump" copy buttons
|
||||
// need this to build a real, working `ssh ...` command (the web UI and
|
||||
// SSH front door share a hostname but not a port).
|
||||
sshPort: (conf.ssh && conf.ssh.listenPort) || 22,
|
||||
...buildInfo,
|
||||
};
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (_) { throw fail('key-inject-failed'); }
|
||||
catch (err) { throw fail('key-inject-failed', err.message); }
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
@@ -139,12 +139,16 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||
uid: state.uid, justInjected, onHostKey,
|
||||
});
|
||||
} catch (_) { throw fail('upstream-unreachable'); }
|
||||
} catch (err) { throw fail('upstream-unreachable', err.message); }
|
||||
|
||||
return { upstream, host, endpoint };
|
||||
}
|
||||
|
||||
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
|
||||
// detail carries the real underlying error message (e.g. ECONNREFUSED,
|
||||
// ETIMEDOUT, an ssh2 auth-failure string) so audit records aren't reduced to
|
||||
// just the generic reason code -- without it, a network-layer failure and an
|
||||
// SSH auth failure both looked identical in the audit log.
|
||||
function fail(reason, detail) { const e = new Error(reason); e.reason = reason; e.detail = detail; return e; }
|
||||
|
||||
async function runGrammar(session, client, state) {
|
||||
// Register session listeners IMMEDIATELY — before any async work.
|
||||
@@ -174,7 +178,7 @@ async function runGrammar(session, client, state) {
|
||||
} catch (err) {
|
||||
const reason = err.reason || 'error';
|
||||
rejectUp(new Error(reasonMessage(reason)));
|
||||
await record.finish({ success: false, failReason: reason });
|
||||
await record.finish({ success: false, failReason: reason, failDetail: err.detail });
|
||||
await metrics.bump({ uid: state.uid, success: false });
|
||||
}
|
||||
}
|
||||
@@ -199,8 +203,8 @@ async function runTuiSession(session, client, state) {
|
||||
|
||||
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
|
||||
|
||||
const finishFail = async (reason) => {
|
||||
await record.finish({ success: false, failReason: reason });
|
||||
const finishFail = async (reason, detail) => {
|
||||
await record.finish({ success: false, failReason: reason, failDetail: detail });
|
||||
await metrics.bump({ uid: state.uid, success: false });
|
||||
try { client.end(); } catch (_) {}
|
||||
};
|
||||
@@ -223,7 +227,7 @@ async function runTuiSession(session, client, state) {
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (_) { return finishFail('key-inject-failed'); }
|
||||
catch (err) { return finishFail('key-inject-failed', err.message); }
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
@@ -232,9 +236,9 @@ async function runTuiSession(session, client, state) {
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
||||
});
|
||||
} catch (_) {
|
||||
} catch (err) {
|
||||
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
||||
return finishFail('upstream-unreachable');
|
||||
return finishFail('upstream-unreachable', err.message);
|
||||
}
|
||||
|
||||
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
|
||||
|
||||
@@ -156,6 +156,29 @@ test('shell bridges and echoes', async () => {
|
||||
assert.match(out, /echo:ping/);
|
||||
});
|
||||
|
||||
test('connectUpstream rejects with a specific, non-generic error when the target refuses the connection', async () => {
|
||||
// Regression coverage for ssh_server.js's resolveAndConnect: it used to
|
||||
// discard this error entirely (catch (_) { throw fail('upstream-unreachable') }),
|
||||
// so the audit log recorded the same generic reason for a refused port, a
|
||||
// timeout, or a bad key alike. Now the real message is threaded through as
|
||||
// failDetail, so this must stay meaningful.
|
||||
// Bind a server just to reserve a free port, then close it immediately so
|
||||
// nothing is listening there — guarantees ECONNREFUSED rather than relying
|
||||
// on a hardcoded port number that might be in use.
|
||||
const closedPort = await new Promise((resolve) => {
|
||||
const probe = require('net').createServer();
|
||||
probe.listen(0, '127.0.0.1', () => { const p = probe.address().port; probe.close(() => resolve(p)); });
|
||||
});
|
||||
await assert.rejects(
|
||||
connectUpstream({ host: '127.0.0.1', port: closedPort, username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false }),
|
||||
(err) => {
|
||||
assert.ok(err.message && err.message.length > 0);
|
||||
assert.notStrictEqual(err.message, 'upstream-unreachable');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('sftp subsystem bytes pass through', async () => {
|
||||
const { conn, ready } = connectJump();
|
||||
await ready;
|
||||
|
||||
+1
-1
@@ -37,6 +37,6 @@ module.exports = {
|
||||
nav: [
|
||||
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: []},
|
||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin']},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><i class="fa-solid fa-clipboard-list me-1"></i> Audit log</div>
|
||||
<div class="card-body pb-0">
|
||||
@@ -29,6 +30,7 @@
|
||||
<button class="btn btn-sm btn-outline-secondary" id="next" onclick="changePage(1)">next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
var page = 0;
|
||||
|
||||
+163
-32
@@ -1,6 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card shadow-sm text-center"><div class="card-body">
|
||||
@@ -28,6 +29,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-terminal me-1"></i> Quick Jump</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Skip the picker: <code>ssh <your-username>_-_<host-slug>@<this-jump-host></code>
|
||||
connects straight to a host. Or just <code>ssh <your-username>@<this-jump-host></code>
|
||||
for the interactive picker.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="quick-jump-cmd" readonly>
|
||||
<button class="btn btn-outline-secondary" onclick="copyFieldValue('#quick-jump-cmd')" title="Copy">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
@@ -63,13 +85,40 @@
|
||||
(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 class="card-body">
|
||||
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
||||
<div id="api-tokens">
|
||||
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow-sm mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="fa-solid fa-key"></i> {{name}}</h6>
|
||||
<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 small">
|
||||
<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="rotateApiToken('{{id}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
||||
<button type="button" onclick="revokeApiToken('{{id}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function rows(sel, list){
|
||||
@@ -79,48 +128,79 @@
|
||||
$b.append('<tr><td>' + app.jump.esc(x.name) + '</td><td class="text-end">' + x.count + '</td></tr>');
|
||||
});
|
||||
}
|
||||
// The web UI and the SSH front door share a hostname, just not a port.
|
||||
var SSH_PORT = <%- JSON.stringify(sshPort) %>;
|
||||
function sshCommand(target){
|
||||
var uid = app.auth.user && app.auth.user.username;
|
||||
if(!uid) return '';
|
||||
var portFlag = SSH_PORT === 22 ? '' : ' -p ' + SSH_PORT;
|
||||
return 'ssh ' + uid + (target ? '_-_' + target : '') + '@' + location.hostname + portFlag;
|
||||
}
|
||||
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 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)) || '';
|
||||
var rowId = 'host-cmd-' + h.slug.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
$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>');
|
||||
+ '<td class="text-end text-muted small">' + app.jump.esc(addr) + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ '<input type="hidden" id="' + rowId + '" value="' + app.jump.esc(sshCommand(h.slug)) + '">'
|
||||
+ '<button class="btn btn-sm btn-outline-secondary" onclick="copyFieldValue(\'#' + rowId + '\')" title="Copy quick-jump command"><i class="fa-solid fa-copy"></i></button>'
|
||||
+ '</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>'
|
||||
);
|
||||
});
|
||||
// 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 '<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).fromNow() + '</span>';
|
||||
}
|
||||
|
||||
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:
|
||||
'<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>'
|
||||
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.jump.esc(token) + '">'
|
||||
// Reuses the same copy-to-clipboard helper as the Quick Jump card
|
||||
// above (toast feedback -- FontAwesome replaces <i> icons with
|
||||
// inline <svg>, so a checkmark-flash-the-icon approach silently
|
||||
// no-ops; the toast doesn't have that problem).
|
||||
+ '<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.jump.esc(token) + '</code></p>'
|
||||
});
|
||||
}
|
||||
@@ -132,27 +212,77 @@
|
||||
+ '<input type="text" class="form-control" id="new-token-name" placeholder="e.g. laptop-cron">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Description</label>'
|
||||
+ '<input type="text" class="form-control" id="new-token-description" placeholder="optional">'
|
||||
+ '</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>'
|
||||
+ '</div>',
|
||||
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');
|
||||
// Deliberately no app.modal.close() here -- app.modal is a
|
||||
// singleton, and close() immediately followed by open() (inside
|
||||
// showToken) in the same tick collides with Bootstrap's
|
||||
// hide-transition guard, so the reveal modal silently never
|
||||
// shows. open() alone already overwrites the (already-visible)
|
||||
// modal's content in place.
|
||||
showToken('API Token Created', data.token);
|
||||
loadApiTokens();
|
||||
});
|
||||
}
|
||||
|
||||
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.jump.esc(id) + '">'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Name</label>'
|
||||
+ '<input type="text" class="form-control" id="edit-token-name" value="' + app.jump.esc(t.name || '') + '">'
|
||||
+ '</div>'
|
||||
+ '<div class="mb-3">'
|
||||
+ '<label class="form-label">Description</label>'
|
||||
+ '<input type="text" class="form-control" id="edit-token-description" value="' + app.jump.esc(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" id="edit-token-days" min="0">'
|
||||
+ '</div>',
|
||||
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');
|
||||
@@ -185,6 +315,7 @@
|
||||
});
|
||||
await app.auth.loadUser();
|
||||
if(app.auth.isAdmin()) $('#my-hosts-title').text('All hosts');
|
||||
$('#quick-jump-cmd').val(sshCommand());
|
||||
app.jump.hosts(function(error, data){
|
||||
if(error) return hostRows('#my-hosts', []);
|
||||
hostRows('#my-hosts', data && data.results);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">app.auth.forceLogin();</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fa-solid fa-plug-circle-bolt me-1"></i> Active sessions</span>
|
||||
@@ -13,6 +14,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function loadSessions(){
|
||||
|
||||
@@ -82,16 +82,24 @@
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
// --sw-content-offset tracks the same height as #spa-shell's margin-top
|
||||
// (fixed navbar, plus the update banner while it's shown), so any
|
||||
// in-page sticky element (e.g. a sticky search/sort bar) can offset
|
||||
// itself below both fixed elements via `top: var(--sw-content-offset)`
|
||||
// instead of colliding with them at the viewport's true top:0.
|
||||
function showUpdateBanner(){
|
||||
let $nav = $('nav.fixed-top');
|
||||
let $banner = $('#update-banner');
|
||||
$banner.css('top', $nav.outerHeight() + 'px').show();
|
||||
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
|
||||
let offset = $nav.outerHeight() + $banner.outerHeight();
|
||||
$('#spa-shell').css('margin-top', offset + 'px');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', offset + 'px');
|
||||
}
|
||||
|
||||
function dismissUpdateBanner(){
|
||||
$('#update-banner').hide();
|
||||
$('#spa-shell').css('margin-top', '');
|
||||
document.documentElement.style.setProperty('--sw-content-offset', $('nav.fixed-top').outerHeight() + 'px');
|
||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user