mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 17:47:13 +00:00
e8d19e38dd
Polish for the Sessions page now that it's the admin/poweruser
management view (share has moved to Connections, shadow landed in
stage 2).
- Page heading becomes a .section-head strip with a right-aligned
count span ("12 active · 3 yours" for admins seeing others,
"3 active" when the caller only sees their own).
- Empty state is role-aware: admins get the plain "No active
sessions", non-admins get a nudge toward Connections / the ad-hoc
form.
- Owner column is dimmed for own sessions and accent-teal bold for
others — lets admins eye-scan own vs others at a glance.
- Delete button is hidden on rows the caller can't delete (non-admin
viewing someone else's session). Backend check is still the
authoritative gate; this just tidies the UI.
711 lines
36 KiB
HTML
711 lines
36 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">⚙ 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)">▶</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">▶</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 = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// 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 = '▼';
|
|
} else {
|
|
fields.style.display = 'none';
|
|
arrow.innerHTML = '▶';
|
|
}
|
|
});
|
|
|
|
// ── Escape helpers ──
|
|
function escapeHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
function escapeAttr(s) {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ── 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">→</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">→</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', 'shadow', {
|
|
href: '#',
|
|
'class': 'shadow-link',
|
|
'data-shadow': s.session_id,
|
|
title: 'Join this session as a read-only viewer (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');
|
|
fetch('/api/sessions/' + id + '/shadow', {
|
|
method: 'POST',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
})
|
|
.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>
|