Files
rustguac/static/sessions.html
T
Dave Kempe f2cd9119d3 sessions: owner-only first connection, optional read-only joins
The first WebSocket to a pending session takes its guacd stream, already
logged in to the target. Any operator could claim another user's pending
session that way, and session ids are visible in reports. Now only the
session's creator may make that connection, with no admin bypass: an admin
"shadowing" a pending session used to silently become its owner and lock
the real owner out, so shadow now refuses sessions that have not started
(409). handle_ws only takes the stream when the owner check passed at
upgrade time.

POST /api/sessions accepts an admin-only `owner` field so a backend using
an API key can create a session for a user who then connects with their
own login. That user is recorded as the creator.

Share links and shadow tokens gain an optional read-only mode; the default
stays read/write. Each shareable session gets a second token
(share_url_readonly), and shadow takes {"read_only": true}. guacd enforces
it per joining user via the read-only connect arg, which was previously
hardcoded to false. The Share dialog offers both links, the Sessions page
offers watch and shadow, and docs no longer claim a read-only mode that
did not exist.
2026-10-01 21:21:35 +10:00

722 lines
37 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); }
</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">Sessions</a>
<a href="/recordings.html">Recordings</a>
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
<a href="/docs.html">Docs</a>
<a href="/tokens.html" id="tokens-link" style="display:none">Tokens</a>
<a href="/admin.html" id="admin-link" style="display:none">Admin</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>
<form id="session-form" style="display:none">
<div style="display:flex;align-items:center;gap:0.5em;cursor:pointer" id="new-session-toggle">
<span id="new-session-arrow" style="color:var(--accent)">&#9654;</span>
<strong style="color:var(--accent)">New Ad-Hoc Session</strong>
</div>
<div id="new-session-fields" style="display:none;margin-top:0.8em">
<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>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>
<button type="submit" id="connect-btn">Connect</button>
<div id="error"></div>
</div>
</form>
<div id="sessions">
<div class="section-head">
<strong>Sessions</strong>
<span class="section-count" id="session-counts"></span>
</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;
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('session-form').style.display = '';
document.getElementById('reports-link').style.display = '';
}
})
.catch(function() {});
}
if (apiKey) {
// API key users are always admin
myRole = 'admin';
document.getElementById('session-form').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' : '';
});
// Collapsible "New Ad-Hoc Session" section
document.getElementById('new-session-toggle').addEventListener('click', function() {
var fields = document.getElementById('new-session-fields');
var arrow = document.getElementById('new-session-arrow');
if (fields.style.display === 'none') {
fields.style.display = '';
arrow.innerHTML = '&#9660;';
} else {
fields.style.display = 'none';
arrow.innerHTML = '&#9654;';
}
});
// 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 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>