Files
rustguac/static/sessions.html
T
Jad Seifeddine 1ef48871e3 sessions: move the ad-hoc session form into a modal
The ad-hoc session form sat in a collapsible framed box above the session
list. It now opens as a modal from a button on the right of the Sessions
heading, dropping the page's framed form box inside the dialog, with the
primary button first and Cancel at the same height as Connect, matching the
other modals. Section headings centre their heading and action button on
one line.

Part of #254

tests: the ad-hoc session form opens as a modal
(cherry picked from commit 872cc61fce)
2026-10-16 16:51:35 +07:00

746 lines
38 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>rustguac - Sessions</title>
<link rel="stylesheet" href="/rustguac.css">
<style>
form {
background: var(--surface);
border: 1px solid var(--border);
padding: var(--s-4) var(--s-5);
border-radius: var(--radius);
max-width: 480px;
margin: var(--s-4) 0;
}
form input {
display: block;
width: 100%;
box-sizing: border-box;
}
form button {
margin-top: var(--s-4);
min-width: 140px;
}
#sessions { margin-top: var(--s-5); }
#sessions a { text-decoration: none; font-size: var(--fz-sm); }
#sessions a:hover { text-decoration: underline; }
#sessions a.shadow-link {
color: var(--status-pending);
font-weight: bold;
letter-spacing: 0.04em;
}
#sessions a.shadow-link:hover { color: var(--primary); }
#sessions a.shadow-link-busy { opacity: 0.6; pointer-events: none; }
/* Owner column cues so admins can scan own vs others' sessions */
#sessions td.owner-self { color: var(--text-muted); }
#sessions td.owner-other { color: var(--accent); font-weight: bold; }
.btn-add-hop {
background: transparent;
color: var(--accent);
border: 1px solid var(--border);
font-size: var(--fz-sm);
height: var(--ctl-sm);
padding: 0 var(--s-3);
margin-top: var(--s-2);
font-weight: normal;
}
.btn-add-hop:hover { background: var(--surface); border-color: var(--accent); }
.adhoc-modal form { background: transparent; border: 0; padding: 0; margin: 0; max-width: none; width: auto; }
.adhoc-modal #new-session-fields { margin-top: 0; }
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
</head>
<body>
<div class="brand-row">
<img id="site-logo" src="/logo.svg" alt="">
<h1>rustguac</h1>
</div>
<nav>
<a href="/connections.html">Connections</a>
<a href="/sessions.html" class="active" id="sessions-link" data-min-role="poweruser">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" data-min-role="poweruser">Reports</a>
<a href="/docs.html">Docs</a>
<a href="/tokens.html" id="tokens-link" data-min-role="operator">Tokens</a>
<a href="/admin.html" id="admin-link" data-min-role="admin">Admin</a>
<a href="/connections.html#my-credentials" id="my-creds-nav" title="Manage your saved credential variables">&#128273; Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; Settings</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div></div></span>
</nav>
<div id="adhoc-notice" style="display:none;background:var(--surface);padding:1em 1.5em;border-radius:6px;max-width:400px;margin:1.5em 0;color:var(--text-muted);">
Use the <a href="/connections.html">Connections</a> to connect to saved entries.
</div>
<div id="adhoc-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="adhoc-modal-title">
<div class="modal adhoc-modal">
<h3 id="adhoc-modal-title">New Ad-Hoc Session</h3>
<p class="modal-hint">A one-off session to any host. Nothing is saved; use Connections for entries you come back to.</p>
<form id="session-form">
<div id="new-session-fields">
<label>Type
<select id="session-type" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
<option value="ssh" selected>SSH</option>
<option value="rdp">RDP</option>
<option value="vnc">VNC</option>
<option value="web">Web</option>
<option value="vdi">VDI (Docker)</option>
</select>
</label>
<div id="ssh-fields">
<label>Host
<input type="text" id="hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="port" value="22">
</label>
<label>Username (optional — SSH will prompt if empty)
<input type="text" id="username">
</label>
<label>Password (optional — SSH will prompt if empty)
<input type="password" id="password">
</label>
<label style="margin-top:0.8em">
<input type="checkbox" id="generate-keypair" style="display:inline;width:auto;margin-right:0.4em"> Generate ephemeral SSH keypair
</label>
<div id="private-key-field">
<label>Private key (optional — paste OpenSSH PEM)
<textarea id="private-key" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:0.85em;border-radius:3px;box-sizing:border-box;resize:vertical;"></textarea>
</label>
<div style="color:var(--primary);font-size:0.8em;margin-top:0.3em;">Warning: Do not paste important production keys here. Use ephemeral or test keys only.</div>
</div>
</div>
<div id="rdp-fields" style="display:none">
<label>Host
<input type="text" id="rdp-hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="rdp-port" value="3389">
</label>
<label>Username
<input type="text" id="rdp-username">
</label>
<label>Password
<input type="password" id="rdp-password">
</label>
<label>Domain (optional)
<input type="text" id="rdp-domain">
</label>
<label>Security
<select id="rdp-security" style="display:block;width:100%;padding:0.4em;margin-top:0.2em;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;border-radius:3px;">
<option value="" selected>Default</option>
<option value="tls">TLS</option>
<option value="nla">NLA</option>
<option value="rdp">RDP</option>
</select>
</label>
<label style="margin-top:0.8em">
<input type="checkbox" id="rdp-ignore-cert" style="display:inline;width:auto;margin-right:0.4em"> Ignore certificate errors
</label>
</div>
<div id="vnc-fields" style="display:none">
<label>Host
<input type="text" id="vnc-hostname" placeholder="192.168.1.1">
</label>
<label>Port
<input type="number" id="vnc-port" value="5900">
</label>
<label>Username (optional)
<input type="text" id="vnc-username">
</label>
<label>Password (optional)
<input type="password" id="vnc-password">
</label>
</div>
<div id="web-fields" style="display:none">
<label>URL
<input type="text" id="url" placeholder="https://example.com">
</label>
</div>
<div id="vdi-fields" style="display:none">
<label>Container Image
<input type="text" id="vdi-image" placeholder="e.g. myregistry/xrdp-desktop:latest">
</label>
<div style="color:var(--text-muted);font-size:0.8em;margin-top:0.3em">Docker image with xrdp on port 3389. Container is ephemeral.</div>
</div>
<div id="jump-section" style="display:none;margin-top:1em;padding-top:0.8em;border-top:1px solid var(--border)">
<label style="cursor:pointer;color:var(--accent);font-size:0.95em" id="jump-toggle">
<span id="jump-arrow">&#9654;</span> SSH Tunnel / Jump Hosts
</label>
<div id="jump-fields" style="display:none">
<div style="color:#555;font-size:0.8em;margin-bottom:0.5em">Route through one or more SSH bastion hosts (multi-hop).</div>
<div id="flow-diagram" class="flow-diagram"></div>
<div id="hops-list"></div>
<button type="button" class="btn-add-hop" id="add-hop-btn">+ Add Jump Host</button>
</div>
</div>
<label>Banner message (shown to user before session starts)
<input type="text" id="banner" placeholder="e.g. Please enter your credentials when prompted">
</label>
<div class="modal-actions">
<button type="submit" id="connect-btn">Connect</button>
<button type="button" class="btn-cancel" id="adhoc-cancel">Cancel</button>
</div>
<div id="error"></div>
</div>
</form>
</div>
</div>
<div id="sessions">
<div class="section-head">
<strong>Sessions</strong>
<span class="section-count" id="session-counts"></span>
<button type="button" class="btn-add section-action" id="new-session-btn" style="display:none" title="Start a one-off session to any host. Nothing is saved.">&#9889; New ad-hoc session</button>
</div>
<div id="session-filters" class="list-tools"></div>
<table>
<thead><tr>
<th>ID</th><th>Type</th><th>Host</th><th>User</th><th>Owner</th><th>Status</th><th>Viewers</th><th></th><th></th>
</tr></thead>
<tbody id="session-list"></tbody>
</table>
<p id="session-empty" class="empty" style="display:none"></p>
</div>
<script>
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='aurora';
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
if(d.site_title){document.title=d.site_title+' - Sessions';document.querySelector('h1').textContent=d.site_title;}
initTheme(d.theme);
});
var apiKey = sessionStorage.getItem('rustguac_api_key');
// Check auth and role
var roleLevel = { admin: 4, poweruser: 3, operator: 2, viewer: 1 };
var myName = null;
var myRole = null;
function checkRole() {
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(res) {
if (res.status === 401 || res.status === 403) { window.location.href = '/'; return; }
if (!res.ok) return;
return res.json();
})
.then(function(data) {
if (!data) return;
myName = data.name || null;
myRole = data.role || null;
var level = roleLevel[data.role] || 0;
// Reconcile the server-written nav gate (see rustguac.css): the page
// GET carried no API key, so key users arrive on the anonymous variant.
document.documentElement.setAttribute('data-role-level', String((typeof apiKey !== 'undefined' && apiKey) ? 4 : level));
if (level < 3) {
window.location.href = '/connections.html';
return;
}
if (data.role === 'admin') {
document.getElementById('admin-link').style.display = '';
}
if (level >= 2) {
document.getElementById('tokens-link').style.display = '';
}
if (level >= 3) {
// poweruser+ can create ad-hoc sessions
document.getElementById('new-session-btn').style.display = '';
document.getElementById('reports-link').style.display = '';
}
})
.catch(function() {});
}
if (apiKey) {
// A key is never on the page GET, so the server served the anonymous variant.
document.documentElement.setAttribute('data-role-level', '4');
// API key users are always admin
myRole = 'admin';
document.getElementById('new-session-btn').style.display = '';
document.getElementById('admin-link').style.display = '';
document.getElementById('tokens-link').style.display = '';
document.getElementById('reports-link').style.display = '';
// Fetch /api/me anyway to learn our display_name for own-session checks.
fetch('/api/me', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(d) { if (d && d.name) myName = d.name; })
.catch(function() {});
} else {
checkRole();
}
document.getElementById('logout-item').addEventListener('click', function() {
sessionStorage.removeItem('rustguac_api_key');
// Also clear server session cookie via logout endpoint
fetch('/auth/logout', { credentials: 'same-origin' })
.finally(function() { window.location.href = '/'; });
});
function apiHeaders(extra) {
var h = {};
if (apiKey) h['Authorization'] = 'Bearer ' + apiKey;
if (extra) { for (var k in extra) h[k] = extra[k]; }
return h;
}
var form = document.getElementById('session-form');
var errorEl = document.getElementById('error');
var btn = document.getElementById('connect-btn');
var sessionType = document.getElementById('session-type');
var sshFields = document.getElementById('ssh-fields');
var rdpFields = document.getElementById('rdp-fields');
var vncFields = document.getElementById('vnc-fields');
var webFields = document.getElementById('web-fields');
var jumpSection = document.getElementById('jump-section');
var generateKeypair = document.getElementById('generate-keypair');
var privateKeyField = document.getElementById('private-key-field');
var passwordField = document.getElementById('password');
generateKeypair.addEventListener('change', function() {
if (generateKeypair.checked) {
privateKeyField.style.display = 'none';
passwordField.disabled = true;
passwordField.value = '';
} else {
privateKeyField.style.display = '';
passwordField.disabled = false;
}
});
var vdiFields = document.getElementById('vdi-fields');
sessionType.addEventListener('change', function() {
sshFields.style.display = 'none';
rdpFields.style.display = 'none';
vncFields.style.display = 'none';
webFields.style.display = 'none';
vdiFields.style.display = 'none';
var v = sessionType.value;
if (v === 'ssh') sshFields.style.display = '';
else if (v === 'rdp') rdpFields.style.display = '';
else if (v === 'vnc') vncFields.style.display = '';
else if (v === 'web') webFields.style.display = '';
else if (v === 'vdi') vdiFields.style.display = '';
// Hide jump hosts for VDI (local containers)
jumpSection.style.display = v === 'vdi' ? 'none' : '';
});
// "New ad-hoc session" opens the form in a modal; Cancel, Escape or
// a click on the backdrop closes it. The form itself is unchanged.
var adhocModal = document.getElementById('adhoc-modal');
function openAdhoc() {
adhocModal.classList.add('active');
document.getElementById('error').textContent = '';
var first = document.getElementById('session-type');
if (first) { try { first.focus(); } catch (e) {} }
}
function closeAdhoc() { adhocModal.classList.remove('active'); }
document.getElementById('new-session-btn').addEventListener('click', openAdhoc);
document.getElementById('adhoc-cancel').addEventListener('click', closeAdhoc);
adhocModal.addEventListener('mousedown', function(e) { if (e.target === adhocModal) closeAdhoc(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && adhocModal.classList.contains('active')) closeAdhoc();
});
// Show jump section on initial load (default is SSH)
jumpSection.style.display = '';
document.getElementById('jump-toggle').addEventListener('click', function() {
var fields = document.getElementById('jump-fields');
var arrow = document.getElementById('jump-arrow');
if (fields.style.display === 'none') {
fields.style.display = '';
arrow.innerHTML = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// ── Escape helpers ──
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Multi-hop management ──
var hopList = []; // Array of {hostname, port, username, password, private_key, expanded}
function renderHops() {
var container = document.getElementById('hops-list');
container.innerHTML = '';
hopList.forEach(function(hop, i) {
var card = document.createElement('div');
card.className = 'hop-card';
// Header
var header = document.createElement('div');
header.className = 'hop-card-header';
var num = document.createElement('span');
num.className = 'hop-num';
num.textContent = '#' + (i + 1);
header.appendChild(num);
var hostWrap = document.createElement('span');
hostWrap.className = 'hop-hostname';
var hostInput = document.createElement('input');
hostInput.type = 'text';
hostInput.placeholder = 'bastion.example.com';
hostInput.value = hop.hostname || '';
hostInput.setAttribute('data-hop', i);
hostInput.setAttribute('data-field', 'hostname');
hostInput.addEventListener('input', onHopFieldChange);
hostInput.addEventListener('click', function(e) { e.stopPropagation(); });
hostWrap.appendChild(hostInput);
header.appendChild(hostWrap);
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'hop-remove';
toggle.textContent = hop.expanded ? 'collapse' : 'expand';
toggle.setAttribute('data-hop', i);
toggle.addEventListener('click', function(e) {
e.stopPropagation();
hopList[i].expanded = !hopList[i].expanded;
renderHops();
});
header.appendChild(toggle);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'hop-remove';
removeBtn.textContent = 'remove';
removeBtn.setAttribute('data-hop', i);
removeBtn.addEventListener('click', function(e) {
e.stopPropagation();
hopList.splice(i, 1);
renderHops();
updateFlowDiagram();
});
header.appendChild(removeBtn);
card.appendChild(header);
// Body (collapsible)
var body = document.createElement('div');
body.className = 'hop-card-body' + (hop.expanded ? ' expanded' : '');
body.innerHTML =
'<label>SSH Port<input type="number" data-hop="' + i + '" data-field="port" value="' + (hop.port || 22) + '"></label>' +
'<label>Username<input type="text" data-hop="' + i + '" data-field="username" value="' + escapeAttr(hop.username || '') + '"></label>' +
'<label>Password<input type="password" data-hop="' + i + '" data-field="password" value="' + escapeAttr(hop.password || '') + '"></label>' +
'<label>Private key (PEM)<textarea data-hop="' + i + '" data-field="private_key" rows="2" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----">' + escapeHtml(hop.private_key || '') + '</textarea></label>';
var inputs = body.querySelectorAll('input, textarea');
for (var j = 0; j < inputs.length; j++) {
inputs[j].addEventListener('input', onHopFieldChange);
}
card.appendChild(body);
container.appendChild(card);
});
updateFlowDiagram();
}
function onHopFieldChange(e) {
var idx = parseInt(e.target.getAttribute('data-hop'));
var field = e.target.getAttribute('data-field');
if (field === 'port') {
hopList[idx][field] = parseInt(e.target.value) || 22;
} else {
hopList[idx][field] = e.target.value;
}
if (field === 'hostname') updateFlowDiagram();
}
function updateFlowDiagram() {
var diag = document.getElementById('flow-diagram');
if (!diag) return;
var html = '<span class="flow-node flow-node-you">You</span>';
hopList.forEach(function(hop) {
var label = hop.hostname || '???';
html += '<span class="flow-arrow">&rarr;</span><span class="flow-node flow-node-hop">' + escapeHtml(label) + '</span>';
});
// Add target
var type = sessionType.value;
var targetHost = '';
var targetPort = '';
var typeLabel = type.toUpperCase();
if (type === 'ssh') {
targetHost = document.getElementById('hostname').value || '???';
targetPort = document.getElementById('port').value || '22';
} else if (type === 'rdp') {
targetHost = document.getElementById('rdp-hostname').value || '???';
targetPort = document.getElementById('rdp-port').value || '3389';
} else if (type === 'vnc') {
targetHost = document.getElementById('vnc-hostname').value || '???';
targetPort = document.getElementById('vnc-port').value || '5900';
} else if (type === 'web') {
// Parse host:port from URL
try {
var u = new URL(document.getElementById('url').value);
targetHost = u.hostname || '???';
targetPort = u.port || (u.protocol === 'https:' ? '443' : '80');
} catch(e) {
targetHost = '???';
targetPort = '80';
}
}
if (targetHost) {
html += '<span class="flow-arrow">&rarr;</span><span class="flow-node flow-node-target">' + escapeHtml(targetHost) + ':' + escapeHtml(targetPort) + ' ' + typeLabel + '</span>';
}
// Show URL rewrite warning for web sessions with hops
var warn = '';
if (type === 'web' && hopList.length > 0) {
warn = '<div style="color:var(--primary);font-size:0.8em;margin-top:0.4em">Note: The URL will be rewritten to 127.0.0.1:{tunnel_port} inside the headless browser. TLS certificate errors are expected if the target uses HTTPS.</div>';
}
diag.innerHTML = hopList.length > 0 ? html + warn : '';
}
document.getElementById('add-hop-btn').addEventListener('click', function() {
hopList.push({ hostname: '', port: 22, username: '', password: '', private_key: '', expanded: true });
renderHops();
});
form.addEventListener('submit', function(e) {
e.preventDefault();
errorEl.textContent = '';
btn.disabled = true;
btn.textContent = 'Connecting...';
var body = {};
if (sessionType.value === 'vdi') {
body.session_type = 'vdi';
body.container_image = document.getElementById('vdi-image').value;
} else if (sessionType.value === 'web') {
body.session_type = 'web';
body.url = document.getElementById('url').value;
} else if (sessionType.value === 'vnc') {
body.session_type = 'vnc';
body.hostname = document.getElementById('vnc-hostname').value;
body.port = parseInt(document.getElementById('vnc-port').value) || 5900;
var un = document.getElementById('vnc-username').value;
if (un) body.username = un;
var pw = document.getElementById('vnc-password').value;
if (pw) body.password = pw;
} else if (sessionType.value === 'rdp') {
body.session_type = 'rdp';
body.hostname = document.getElementById('rdp-hostname').value;
body.port = parseInt(document.getElementById('rdp-port').value) || 3389;
var un = document.getElementById('rdp-username').value;
if (un) body.username = un;
var pw = document.getElementById('rdp-password').value;
if (pw) body.password = pw;
var dom = document.getElementById('rdp-domain').value;
if (dom) body.domain = dom;
var sec = document.getElementById('rdp-security').value;
if (sec) body.security = sec;
if (document.getElementById('rdp-ignore-cert').checked) body.ignore_cert = true;
} else {
body.session_type = 'ssh';
body.hostname = document.getElementById('hostname').value;
body.port = parseInt(document.getElementById('port').value) || 22;
var un = document.getElementById('username').value;
if (un) body.username = un;
if (document.getElementById('generate-keypair').checked) {
body.generate_keypair = true;
} else {
var pw = document.getElementById('password').value;
if (pw) body.password = pw;
var pk = document.getElementById('private-key').value.trim();
if (pk) body.private_key = pk;
}
}
// Jump hosts (multi-hop, all session types)
var hops = hopList.filter(function(h) { return h.hostname && h.hostname.trim(); })
.map(function(h) {
var hop = { hostname: h.hostname.trim(), port: h.port || 22, username: h.username || '' };
if (h.password) hop.password = h.password;
if (h.private_key) hop.private_key = h.private_key;
return hop;
});
if (hops.length > 0) body.jump_hosts = hops;
var banner = document.getElementById('banner').value;
if (banner) body.banner = banner;
// Send browser dimensions so RDP sessions start at correct resolution
var bw = window.innerWidth, bh = window.innerHeight;
if (bw > 0 && bh > 0) {
body.width = bw;
body.height = bh;
body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
}
fetch('/api/sessions', {
method: 'POST',
headers: apiHeaders({ 'Content-Type': 'application/json' }),
credentials: 'same-origin',
body: JSON.stringify(body),
})
.then(function(res) {
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
return res.json();
})
.then(function(data) {
window.open(data.client_url, '_blank');
hopList = [];
renderHops();
refreshSessions();
})
.catch(function(err) {
errorEl.textContent = err.message;
})
.finally(function() {
btn.disabled = false;
btn.textContent = 'Connect';
});
});
function h(tag, text, attrs) {
var el = document.createElement(tag);
if (text != null) el.textContent = text;
if (attrs) for (var k in attrs) el.setAttribute(k, attrs[k]);
return el;
}
function refreshSessions() {
// Admins get everyone's sessions; non-admins see only their own (see #102).
fetch('/api/sessions?all=true', { headers: apiHeaders(), credentials: 'same-origin' })
.then(function(res) {
if (!res.ok) throw new Error('auth');
return res.json();
})
.then(function(sessions) {
var tbody = document.getElementById('session-list');
var empty = document.getElementById('session-empty');
var counts = document.getElementById('session-counts');
tbody.innerHTML = '';
var activeCount = 0;
var ownActiveCount = 0;
sessions.forEach(function(s) {
var tr = document.createElement('tr');
var shortId = s.session_id.substring(0, 8);
var hostCol = s.session_type === 'web' ? (s.url || '') : s.hostname;
var isActive = s.status === 'active';
var isOwn = myName && s.created_by === myName;
var canShadow = isActive && !isOwn && myRole === 'admin';
if (isActive) activeCount++;
if (isActive && isOwn) ownActiveCount++;
if (!isOwn) tr.classList.add('row-other');
var tdId = document.createElement('td');
var idLink = h('a', shortId, { href: s.client_url, target: '_blank' });
tdId.appendChild(idLink);
tr.appendChild(tdId);
tr.appendChild(h('td', s.session_type));
tr.appendChild(h('td', hostCol));
tr.appendChild(h('td', s.username));
tr.appendChild(h('td', s.created_by || '', { 'class': isOwn ? 'owner-self' : 'owner-other' }));
tr.appendChild(h('td', s.status, { 'class': 'status-' + s.status }));
tr.appendChild(h('td', String(s.active_connections)));
var tdOpen = document.createElement('td');
if (isActive && isOwn) {
tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
} else if (canShadow) {
tdOpen.appendChild(h('a', 'watch', {
href: '#',
'class': 'shadow-link',
'data-shadow': s.session_id,
'data-read-only': 'true',
title: 'Join this session as a view-only viewer (10 min token, audit-logged)'
}));
tdOpen.appendChild(document.createTextNode(' '));
tdOpen.appendChild(h('a', 'shadow', {
href: '#',
'class': 'shadow-link',
'data-shadow': s.session_id,
title: 'Join this session with keyboard and mouse control (10 min token, audit-logged)'
}));
}
tr.appendChild(tdOpen);
// Delete: owner can always delete own; admin can delete any.
var canDelete = isOwn || myRole === 'admin';
var tdDel = document.createElement('td');
if (canDelete) {
tdDel.appendChild(h('button', 'delete', { 'class': 'btn-small', 'data-delete': s.session_id }));
}
tr.appendChild(tdDel);
tbody.appendChild(tr);
});
// Count indicator: "12 active · 3 yours" for admins seeing others,
// "3 active" when caller only sees their own.
if (activeCount === 0) {
empty.style.display = '';
empty.textContent = myRole === 'admin'
? 'No active sessions.'
: 'No active sessions. Use Connections to connect to a saved entry, or the form above to start an ad-hoc session.';
counts.textContent = '';
} else {
empty.style.display = 'none';
if (myRole === 'admin' && activeCount !== ownActiveCount) {
counts.textContent = activeCount + ' active · ' + ownActiveCount + ' yours';
} else {
counts.textContent = activeCount + ' active';
}
}
});
}
document.getElementById('session-list').addEventListener('click', function(e) {
if (e.target.getAttribute('data-delete')) {
var id = e.target.getAttribute('data-delete');
fetch('/api/sessions/' + id, { method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin' })
.then(function() { refreshSessions(); });
}
if (e.target.getAttribute('data-shadow')) {
e.preventDefault();
var id = e.target.getAttribute('data-shadow');
var link = e.target;
var originalText = link.textContent;
link.textContent = 'minting...';
link.classList.add('shadow-link-busy');
var shadowHeaders = apiHeaders();
shadowHeaders['Content-Type'] = 'application/json';
fetch('/api/sessions/' + id + '/shadow', {
method: 'POST',
headers: shadowHeaders,
credentials: 'same-origin',
body: JSON.stringify({ read_only: link.getAttribute('data-read-only') === 'true' })
})
.then(function(res) {
if (!res.ok) return res.text().then(function(t) { throw new Error(t || 'shadow failed'); });
return res.json();
})
.then(function(data) {
if (data.url) window.open(data.url, '_blank');
})
.catch(function(err) {
document.getElementById('error').textContent = 'Shadow failed: ' + err.message;
})
.finally(function() {
link.textContent = originalText;
link.classList.remove('shadow-link-busy');
});
}
});
refreshSessions();
setInterval(refreshSessions, 5000);
</script>
</body>
</html>