mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 21:19:45 +00:00
d75446f040
Ad-hoc sessions had their own form on the Sessions page with a subset
of the options a connection entry has. "Connect to a host" now opens
the Connections entry editor in an ad-hoc mode instead, so an ad-hoc
connection has every option an entry has, now and as entries gain more.
The button sits beside the global search box on every page that has
one. It is shown to poweruser and above under the same rules as the
search box, and opens the editor in place on Connections or goes to
/connections.html#adhoc elsewhere. Ad-hoc mode has no name or folder,
Connect instead of Save, and for admins a "Save as entry" action.
POST /api/addressbook/adhoc/connect takes the entry as the editor
builds it. It resolves only the caller's own credential variables,
never lends folder defaults, and goes through apply_adhoc_policy and
the session rate limit like every other ad-hoc route.
POST /api/sessions is unchanged.
session_request_from_entry() replaces the two copies of the entry to
session request conversion, used by the Connections connect endpoint
and /api/connect, so an option added to entries works in all three.
can_connect_adhoc() is the one place that decides who may connect
ad-hoc.
The Sessions ad-hoc form is removed. loadFolders() now runs its
callback, so #open= links work again.
Refs sol1/rustguac#295
(cherry picked from commit dd40bf08ec)
338 lines
19 KiB
HTML
338 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Sessions</title>
|
|
<link rel="stylesheet" href="/rustguac.css">
|
|
<style>
|
|
#sessions { margin-top: var(--s-5); }
|
|
#error { color: var(--primary); margin: var(--s-2) 0; }
|
|
#error:empty { display: none; }
|
|
#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; }
|
|
</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"><span class="nav-key">🔑</span> Credentials</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>
|
|
|
|
<div id="sessions">
|
|
<div class="list-tools">
|
|
<input type="text" id="session-search" class="list-search" placeholder="Search sessions (user, host, owner, ID…)">
|
|
<div id="session-filters"></div>
|
|
</div>
|
|
<div id="error" role="alert"></div>
|
|
<span class="section-count" id="session-counts" style="display:none"></span>
|
|
<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 src="/filters.js"></script>
|
|
<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) {
|
|
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('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;
|
|
}
|
|
|
|
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) {
|
|
lastSessionList = sessions;
|
|
renderSessionList(sessions);
|
|
});
|
|
}
|
|
|
|
// Shared facet filters (filters.js): Type, Status, Owner. Selections
|
|
// persist in sessionStorage for this tab. Counts in the header stay
|
|
// for the full list; the table shows the filtered one.
|
|
var lastSessionList = null;
|
|
var sessionFilters = rgFilters.create({
|
|
mount: document.getElementById('session-filters'),
|
|
id: 'sessions',
|
|
facets: [
|
|
{ key: 'type', label: 'Type', value: function(s) { return s.session_type; },
|
|
render: function(v) { return '<span class="type-badge type-' + v.replace(/[^a-z0-9-]/gi, '') + '">' + v.replace(/[<>&]/g, '') + '</span>'; } },
|
|
{ key: 'user', label: 'User', value: function(s) { return s.username; } },
|
|
{ key: 'owner', label: 'Owner', value: function(s) { return s.created_by; } },
|
|
{ key: 'status', label: 'Status', value: function(s) { return s.status; } }
|
|
],
|
|
onChange: function() { if (lastSessionList) renderSessionList(lastSessionList); }
|
|
});
|
|
function sessionSearchQuery() {
|
|
return (document.getElementById('session-search').value || '').toLowerCase();
|
|
}
|
|
document.getElementById('session-search').addEventListener('input', function() {
|
|
if (lastSessionList) renderSessionList(lastSessionList);
|
|
});
|
|
|
|
function renderSessionList(allSessions) {
|
|
sessionFilters.update(allSessions);
|
|
var sessions = sessionFilters.apply(allSessions);
|
|
var q = sessionSearchQuery();
|
|
if (q) {
|
|
sessions = sessions.filter(function(s) {
|
|
var host = (s.session_type === 'web' ? s.url : s.hostname) || '';
|
|
return (s.username || '').toLowerCase().indexOf(q) >= 0
|
|
|| host.toLowerCase().indexOf(q) >= 0
|
|
|| (s.created_by || '').toLowerCase().indexOf(q) >= 0
|
|
|| (s.session_id || '').toLowerCase().indexOf(q) >= 0
|
|
|| (s.session_type || '').toLowerCase().indexOf(q) >= 0;
|
|
});
|
|
}
|
|
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);
|
|
|
|
var tdType = document.createElement('td');
|
|
tdType.appendChild(h('span', s.session_type, { 'class': 'type-badge type-' + s.session_type }));
|
|
tr.appendChild(tdType);
|
|
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 (sessions.length === 0 && allSessions.length > 0) {
|
|
empty.style.display = '';
|
|
empty.textContent = 'No sessions match the current filters.';
|
|
} else 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 Connect to a host for an ad-hoc connection.';
|
|
} else {
|
|
empty.style.display = 'none';
|
|
}
|
|
// Header counts describe the whole list, not the filtered view.
|
|
var totalActive = 0, ownActive = 0;
|
|
allSessions.forEach(function(s) {
|
|
if (s.status !== 'active') return;
|
|
totalActive++;
|
|
if (myName && s.created_by === myName) ownActive++;
|
|
});
|
|
if (totalActive === 0) {
|
|
counts.textContent = '';
|
|
} else if (myRole === 'admin' && totalActive !== ownActive) {
|
|
counts.textContent = totalActive + ' active · ' + ownActive + ' yours' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
|
|
} else {
|
|
counts.textContent = totalActive + ' active' + (sessionFilters.active() ? ' · ' + sessions.length + ' shown' : '');
|
|
}
|
|
}
|
|
|
|
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>
|
|
<script src="/search.js"></script>
|
|
</body>
|
|
</html>
|