mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 17:47:13 +00:00
fb0f965bb2
Logout link moved inside the float-right user-menu-wrapper so both Logout and Settings appear on the far right of the nav bar. Fixed addressbook.html Settings font size to match other pages (1.3em).
655 lines
37 KiB
HTML
655 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Admin</title>
|
|
<style>
|
|
:root { --primary: #e94560; --primary-hover: #c73652; --accent: #5bc0be; --accent-hover: #4aa3a1; --bg: #1a1a2e; --surface: #16213e; --input: #0f3460; --text: #e0e0e0; --text-muted: #aaa; --border: #333; --text-dim: #666; --text-on-primary: #fff; --btn-disabled: #555; --status-pending: #f0c040; --status-active: #5bc0be; --status-completed: #888; --status-error: #e94560; --status-expired: #666; --type-ssh-bg: #1b4332; --type-ssh-fg: #52b788; --type-rdp-bg: #3d1f00; --type-rdp-fg: #f0a050; --type-vnc-bg: #2d1b4e; --type-vnc-fg: #b07ff0; --type-web-bg: #1a1a4e; --type-web-fg: #7b8ff0; --hop-bg: #1b4332; --hop-fg: #52b788; --bg-pattern: none; }
|
|
body { font-family: monospace; background-color: var(--bg); background-image: var(--bg-pattern); background-attachment: fixed; min-height: 100vh; color: var(--text); padding: 2em; font-size: 18px; }
|
|
h1 { color: var(--primary); }
|
|
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
|
|
a { color: var(--accent); }
|
|
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
|
nav a { margin-right: 1.5em; text-decoration: none; }
|
|
nav a:hover { text-decoration: underline; }
|
|
nav .active { color: var(--primary); font-weight: bold; }
|
|
#user-menu-wrapper { position: relative; float: right; }
|
|
#user-menu-btn { cursor: pointer; font-size: 1.3em; color: var(--text-muted); }
|
|
#user-menu-btn:hover { color: var(--text); }
|
|
#user-menu { display:none; position:absolute; right:0; top:1.8em; background:var(--surface); border:1px solid var(--border); border-radius:6px; min-width:220px; z-index:50; padding:0.4em 0; }
|
|
.um-section-label { padding:0.4em 0.9em; font-size:0.8em; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.05em; }
|
|
.um-item { padding:0.5em 0.9em; cursor:pointer; display:flex; align-items:center; gap:0.6em; font-size:0.9em; }
|
|
.um-item:hover { background:var(--input); }
|
|
.um-item.active { color:var(--accent); }
|
|
.um-theme-info { display:flex; flex-direction:column; }
|
|
.um-theme-name { font-weight:bold; }
|
|
.um-theme-desc { font-size:0.8em; color:var(--text-dim); }
|
|
.um-swatch { display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); flex-shrink:0; }
|
|
.um-divider { height:1px; background:var(--border); margin:0.3em 0; }
|
|
.um-logout { color:var(--text-muted); }
|
|
.um-logout:hover { color:var(--primary); background:var(--input); }
|
|
|
|
table { border-collapse: collapse; width: 100%; margin-top: 0.5em; }
|
|
th, td { text-align: left; padding: 0.4em 0.8em; border-bottom: 1px solid var(--border); }
|
|
th { color: var(--text-muted); font-size: 0.85em; }
|
|
select {
|
|
background: var(--input); border: 1px solid var(--border); color: var(--text);
|
|
font-family: monospace; font-size: 0.9em; padding: 0.2em 0.4em; border-radius: 3px;
|
|
}
|
|
select:focus { outline: none; border-color: var(--primary); }
|
|
input[type="text"] {
|
|
background: var(--input); border: 1px solid var(--border); color: var(--text);
|
|
font-family: monospace; font-size: 0.9em; padding: 0.3em 0.5em; border-radius: 3px;
|
|
}
|
|
input[type="text"]:focus { outline: none; border-color: var(--primary); }
|
|
.btn-small {
|
|
background: none; border: none; color: var(--primary);
|
|
cursor: pointer; font-family: monospace; padding: 0; font-size: 0.9em;
|
|
}
|
|
.btn-small:hover { text-decoration: underline; }
|
|
.btn-action { color: var(--accent); }
|
|
button.btn-primary {
|
|
padding: 0.4em 1.2em; background: var(--primary); color: var(--text-on-primary); border: none;
|
|
font-family: monospace; font-size: 0.9em; border-radius: 3px; cursor: pointer;
|
|
}
|
|
button.btn-primary:hover { background: var(--primary-hover); }
|
|
.add-form {
|
|
background: var(--surface); padding: 1em 1.5em; border-radius: 6px;
|
|
display: inline-flex; gap: 0.8em; align-items: center; margin-top: 0.8em;
|
|
}
|
|
input[type="date"] {
|
|
background: var(--input); border: 1px solid var(--border); color: var(--text);
|
|
font-family: monospace; font-size: 0.9em; padding: 0.3em 0.5em; border-radius: 3px;
|
|
}
|
|
input[type="date"]:focus { outline: none; border-color: var(--primary); }
|
|
.disabled-row { opacity: 0.5; }
|
|
.status-active { color: var(--accent); }
|
|
.status-disabled { color: var(--primary); }
|
|
.status-expired { color: var(--status-expired); }
|
|
#error { color: var(--primary); margin-top: 0.5em; }
|
|
.token-reveal {
|
|
background: var(--surface); padding: 1em 1.5em; border-radius: 6px;
|
|
margin-top: 1em; border: 1px solid var(--accent);
|
|
}
|
|
.token-reveal code {
|
|
background: var(--input); padding: 0.4em 0.8em; border-radius: 3px;
|
|
font-size: 0.85em; word-break: break-all; display: block; margin: 0.5em 0;
|
|
user-select: all;
|
|
}
|
|
.token-reveal .warning { color: var(--primary); font-size: 0.85em; margin-top: 0.5em; }
|
|
.audit-row { font-size: 0.9em; }
|
|
.audit-row td { padding: 0.3em 0.8em; }
|
|
.status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.8em; margin-top: 0.5em; margin-bottom: 1.5em; }
|
|
.status-card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 0.8em 1em; }
|
|
.status-card .label { color: var(--text-muted); font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.status-card .value { color: var(--accent); font-size: 1.3em; font-weight: bold; margin-top: 0.2em; }
|
|
.status-card .detail { color: var(--text-dim); font-size: 0.8em; margin-top: 0.2em; }
|
|
.feat-tag { display: inline-block; padding: 0.1em 0.5em; border-radius: 3px; font-size: 0.8em; margin-right: 0.4em; margin-bottom: 0.3em; }
|
|
.feat-on { background: var(--type-ssh-bg); color: var(--type-ssh-fg); }
|
|
.feat-off { background: var(--border); color: var(--text-dim); }
|
|
</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>
|
|
<img id="site-logo" src="/logo.svg" style="max-height:40px;vertical-align:middle;margin-right:0.5em" alt=""><h1 style="display:inline;vertical-align:middle">rustguac</h1>
|
|
<nav>
|
|
<a href="/addressbook.html">Address Book</a>
|
|
<a href="/sessions.html" 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">Tokens</a>
|
|
<a href="/admin.html" class="active">Admin</a>
|
|
<span id="user-menu-wrapper"><a href="#" id="logout-item" style="color:var(--text-muted);text-decoration:none;margin-right:1em">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>
|
|
|
|
<h2>System Status</h2>
|
|
<div id="system-status" class="status-grid">
|
|
<div class="status-card"><div class="label">Version</div><div class="value" id="ss-version">—</div></div>
|
|
<div class="status-card"><div class="label">Active Sessions</div><div class="value" id="ss-active">—</div><div class="detail" id="ss-sessions-detail"></div></div>
|
|
<div class="status-card"><div class="label">Users</div><div class="value" id="ss-users">—</div></div>
|
|
<div class="status-card"><div class="label">Session History</div><div class="value" id="ss-history">—</div></div>
|
|
<div class="status-card"><div class="label">Recordings</div><div class="value" id="ss-recordings">—</div><div class="detail" id="ss-rec-detail"></div></div>
|
|
<div class="status-card"><div class="label">Disk Usage</div><div class="value" id="ss-disk">—</div></div>
|
|
<div class="status-card"><div class="label">Vault</div><div class="value" id="ss-vault">—</div></div>
|
|
<div class="status-card"><div class="label">Features</div><div class="value" id="ss-features" style="font-size:1em"></div></div>
|
|
</div>
|
|
|
|
<h2>Users</h2>
|
|
<table id="users-table">
|
|
<thead><tr>
|
|
<th>Email</th><th>Name</th><th>Role</th><th>Groups</th><th>Status</th><th>Last Login</th><th></th>
|
|
</tr></thead>
|
|
<tbody id="users-body"></tbody>
|
|
</table>
|
|
|
|
<h2>Group-to-Role Mappings</h2>
|
|
<p style="color:#888;font-size:0.85em;margin:0.3em 0;">OIDC groups are matched to roles on every login. The highest matching role wins.</p>
|
|
<table id="mappings-table">
|
|
<thead><tr>
|
|
<th>Group</th><th>Role</th><th>Created</th><th></th>
|
|
</tr></thead>
|
|
<tbody id="mappings-body"></tbody>
|
|
</table>
|
|
<div class="add-form">
|
|
<input type="text" id="new-group" placeholder="OIDC group name">
|
|
<select id="new-role">
|
|
<option value="viewer">viewer</option>
|
|
<option value="operator">operator</option>
|
|
<option value="poweruser">poweruser</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
<button class="btn-primary" id="add-mapping-btn">Add Mapping</button>
|
|
</div>
|
|
<h2>User API Tokens</h2>
|
|
<p style="color:#888;font-size:0.85em;margin:0.3em 0;">Tokens issued to OIDC users for API access. Admins can create tokens for operators and revoke any token.</p>
|
|
<table id="tokens-table">
|
|
<thead><tr>
|
|
<th>User</th><th>Name</th><th>Max Role</th><th>Expires</th><th>Created</th><th>Last Used</th><th>Status</th><th></th>
|
|
</tr></thead>
|
|
<tbody id="tokens-body"></tbody>
|
|
</table>
|
|
<div id="no-tokens" style="display:none;color:var(--text-muted);margin-top:0.5em;font-size:0.9em;">No user tokens.</div>
|
|
<h2 style="font-size:0.9em;">Create Token for User</h2>
|
|
<div class="add-form" id="admin-token-form">
|
|
<input type="text" id="token-email" placeholder="user@example.com" style="width:200px;">
|
|
<input type="text" id="token-name" placeholder="Token name">
|
|
<select id="token-max-role">
|
|
<option value="">No role cap</option>
|
|
<option value="viewer">viewer</option>
|
|
<option value="operator">operator</option>
|
|
<option value="poweruser">poweruser</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
<input type="date" id="token-expires" title="Expiry date (optional)">
|
|
<button class="btn-primary" id="admin-create-token-btn">Create</button>
|
|
</div>
|
|
<div id="admin-token-reveal" class="token-reveal" style="display:none">
|
|
<strong>Token created:</strong>
|
|
<code id="admin-token-plaintext"></code>
|
|
<div style="display:inline-flex;gap:0.8em;align-items:center;">
|
|
<button class="btn-small btn-action" id="admin-copy-token-btn">copy to clipboard</button>
|
|
<button class="btn-small" id="admin-dismiss-token-btn">dismiss</button>
|
|
</div>
|
|
<div class="warning">This token will not be shown again. Copy it now and give it to the user securely.</div>
|
|
</div>
|
|
|
|
<h2>Token Audit Log</h2>
|
|
<div style="margin-bottom:0.5em;">
|
|
<input type="text" id="audit-email-filter" placeholder="Filter by email" style="width:200px;">
|
|
<button class="btn-primary" id="audit-filter-btn" style="padding:0.3em 0.8em;">Filter</button>
|
|
</div>
|
|
<table id="audit-table">
|
|
<thead><tr>
|
|
<th>Time</th><th>User</th><th>Token</th><th>Action</th><th>IP</th><th>Details</th>
|
|
</tr></thead>
|
|
<tbody id="audit-body"></tbody>
|
|
</table>
|
|
<div id="no-audit" style="display:none;color:var(--text-muted);margin-top:0.5em;font-size:0.9em;">No audit events.</div>
|
|
|
|
<div id="error"></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='dark';
|
|
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||'dark';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+' - Admin';document.querySelector('h1').textContent=d.site_title;}
|
|
initTheme(d.theme);
|
|
});
|
|
|
|
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
|
|
|
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 checkAdmin() {
|
|
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 || data.role !== 'admin') {
|
|
window.location.href = '/addressbook.html';
|
|
return;
|
|
}
|
|
document.getElementById('reports-link').style.display = '';
|
|
loadUsers();
|
|
loadMappings();
|
|
loadAllTokens();
|
|
loadAuditLog();
|
|
loadSystemStatus();
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
checkAdmin();
|
|
|
|
function loadSystemStatus() {
|
|
fetch('/api/system/status', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { return r.ok ? r.json() : null; })
|
|
.then(function(d) {
|
|
if (!d) return;
|
|
document.getElementById('ss-version').textContent = 'v' + d.version;
|
|
document.getElementById('ss-active').textContent = d.sessions.active;
|
|
document.getElementById('ss-sessions-detail').textContent = d.sessions.pending + ' pending, ' + d.sessions.total_current + ' total';
|
|
document.getElementById('ss-users').textContent = d.users.count;
|
|
document.getElementById('ss-history').textContent = d.history.total_sessions.toLocaleString();
|
|
document.getElementById('ss-recordings').textContent = d.recordings.count;
|
|
document.getElementById('ss-rec-detail').textContent = d.recordings.size_mb + ' MB';
|
|
document.getElementById('ss-disk').textContent = d.recordings.disk_usage_pct + '%';
|
|
var vault = d.vault.configured ? (d.vault.connected ? 'Connected' : 'Disconnected') : 'Not configured';
|
|
document.getElementById('ss-vault').textContent = vault;
|
|
document.getElementById('ss-vault').style.color = d.vault.connected ? 'var(--accent)' : (d.vault.configured ? 'var(--primary)' : 'var(--text-dim)');
|
|
var feat = '';
|
|
feat += '<span class="feat-tag ' + (d.features.oidc ? 'feat-on' : 'feat-off') + '">OIDC</span>';
|
|
feat += '<span class="feat-tag ' + (d.features.drive ? 'feat-on' : 'feat-off') + '">Drive</span>';
|
|
feat += '<span class="feat-tag ' + (d.features.tls ? 'feat-on' : 'feat-off') + '">TLS</span>';
|
|
feat += '<span class="feat-tag ' + (d.vault.configured ? 'feat-on' : 'feat-off') + '">Vault</span>';
|
|
document.getElementById('ss-features').innerHTML = feat;
|
|
});
|
|
}
|
|
|
|
document.getElementById('logout-item').addEventListener('click', function() {
|
|
sessionStorage.removeItem('rustguac_api_key');
|
|
fetch('/auth/logout', { credentials: 'same-origin' })
|
|
.finally(function() { window.location.href = '/'; });
|
|
});
|
|
|
|
var errorEl = document.getElementById('error');
|
|
function showError(msg) { errorEl.textContent = msg; setTimeout(function(){ errorEl.textContent = ''; }, 5000); }
|
|
|
|
// ── Users ──
|
|
|
|
function loadUsers() {
|
|
fetch('/api/users', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(users) {
|
|
var tbody = document.getElementById('users-body');
|
|
tbody.innerHTML = '';
|
|
users.forEach(function(u) {
|
|
var tr = document.createElement('tr');
|
|
if (u.disabled) tr.className = 'disabled-row';
|
|
|
|
tr.appendChild(td(u.email));
|
|
tr.appendChild(td(u.name));
|
|
|
|
// Role dropdown
|
|
var roleTd = document.createElement('td');
|
|
var sel = document.createElement('select');
|
|
['viewer','operator','poweruser','admin'].forEach(function(r) {
|
|
var opt = document.createElement('option');
|
|
opt.value = r; opt.textContent = r;
|
|
if (r === u.role) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
});
|
|
sel.addEventListener('change', function() {
|
|
fetch('/api/users/' + encodeURIComponent(u.email) + '/role', {
|
|
method: 'PUT',
|
|
headers: apiHeaders({'Content-Type': 'application/json'}),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({role: sel.value})
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d){ showError(d.error); });
|
|
});
|
|
});
|
|
roleTd.appendChild(sel);
|
|
tr.appendChild(roleTd);
|
|
|
|
// Groups
|
|
var groups = u.oidc_groups || '';
|
|
var groupsTd = td(groups.split(',').filter(function(g){return g;}).join(', '));
|
|
groupsTd.style.maxWidth = '200px';
|
|
groupsTd.style.overflow = 'hidden';
|
|
groupsTd.style.textOverflow = 'ellipsis';
|
|
groupsTd.title = groups;
|
|
tr.appendChild(groupsTd);
|
|
|
|
// Status
|
|
var statusTd = document.createElement('td');
|
|
statusTd.textContent = u.disabled ? 'disabled' : 'active';
|
|
statusTd.className = u.disabled ? 'status-disabled' : 'status-active';
|
|
tr.appendChild(statusTd);
|
|
|
|
tr.appendChild(td(u.last_login_at || 'never'));
|
|
|
|
// Actions
|
|
var actTd = document.createElement('td');
|
|
|
|
var toggleBtn = document.createElement('button');
|
|
toggleBtn.className = 'btn-small btn-action';
|
|
toggleBtn.textContent = u.disabled ? 'enable' : 'disable';
|
|
toggleBtn.addEventListener('click', (function(email, disabled) {
|
|
return function() {
|
|
var action = disabled ? 'enable' : 'disable';
|
|
fetch('/api/users/' + encodeURIComponent(email) + '/' + action, {
|
|
method: 'POST', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function() { loadUsers(); });
|
|
};
|
|
})(u.email, u.disabled));
|
|
actTd.appendChild(toggleBtn);
|
|
actTd.appendChild(document.createTextNode(' '));
|
|
|
|
var logoutBtn = document.createElement('button');
|
|
logoutBtn.className = 'btn-small btn-action';
|
|
logoutBtn.textContent = 'force-logout';
|
|
logoutBtn.addEventListener('click', (function(email) {
|
|
return function() {
|
|
fetch('/api/users/' + encodeURIComponent(email) + '/sessions', {
|
|
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function(res) { return res.json(); }).then(function(d) {
|
|
if (d.sessions_revoked !== undefined) showError('Revoked ' + d.sessions_revoked + ' session(s)');
|
|
});
|
|
};
|
|
})(u.email));
|
|
actTd.appendChild(logoutBtn);
|
|
actTd.appendChild(document.createTextNode(' '));
|
|
|
|
var delBtn = document.createElement('button');
|
|
delBtn.className = 'btn-small';
|
|
delBtn.textContent = 'delete';
|
|
delBtn.addEventListener('click', (function(email) {
|
|
return function() {
|
|
if (!confirm('Delete user ' + email + '?')) return;
|
|
fetch('/api/users/' + encodeURIComponent(email), {
|
|
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function() { loadUsers(); });
|
|
};
|
|
})(u.email));
|
|
actTd.appendChild(delBtn);
|
|
|
|
tr.appendChild(actTd);
|
|
tbody.appendChild(tr);
|
|
});
|
|
});
|
|
}
|
|
|
|
function td(text) {
|
|
var el = document.createElement('td');
|
|
el.textContent = text || '';
|
|
return el;
|
|
}
|
|
|
|
// ── Group Mappings ──
|
|
|
|
function loadMappings() {
|
|
fetch('/api/admin/group-mappings', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(mappings) {
|
|
var tbody = document.getElementById('mappings-body');
|
|
tbody.innerHTML = '';
|
|
mappings.forEach(function(m) {
|
|
var tr = document.createElement('tr');
|
|
tr.appendChild(td(m.oidc_group));
|
|
|
|
var roleTd = document.createElement('td');
|
|
var sel = document.createElement('select');
|
|
['viewer','operator','poweruser','admin'].forEach(function(r) {
|
|
var opt = document.createElement('option');
|
|
opt.value = r; opt.textContent = r;
|
|
if (r === m.role) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
});
|
|
sel.addEventListener('change', (function(id, group) {
|
|
return function() {
|
|
fetch('/api/admin/group-mappings/' + id, {
|
|
method: 'PUT',
|
|
headers: apiHeaders({'Content-Type': 'application/json'}),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({group: group, role: sel.value})
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d){ showError(d.error); });
|
|
});
|
|
};
|
|
})(m.id, m.oidc_group));
|
|
roleTd.appendChild(sel);
|
|
tr.appendChild(roleTd);
|
|
|
|
tr.appendChild(td(m.created_at));
|
|
|
|
var actTd = document.createElement('td');
|
|
var delBtn = document.createElement('button');
|
|
delBtn.className = 'btn-small';
|
|
delBtn.textContent = 'delete';
|
|
delBtn.addEventListener('click', (function(id) {
|
|
return function() {
|
|
fetch('/api/admin/group-mappings/' + id, {
|
|
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function() { loadMappings(); });
|
|
};
|
|
})(m.id));
|
|
actTd.appendChild(delBtn);
|
|
tr.appendChild(actTd);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('add-mapping-btn').addEventListener('click', function() {
|
|
var group = document.getElementById('new-group').value.trim();
|
|
var role = document.getElementById('new-role').value;
|
|
if (!group) { showError('Group name is required'); return; }
|
|
|
|
fetch('/api/admin/group-mappings', {
|
|
method: 'POST',
|
|
headers: apiHeaders({'Content-Type': 'application/json'}),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({group: group, role: role})
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d){ showError(d.error); throw new Error(); });
|
|
return res.json();
|
|
})
|
|
.then(function() {
|
|
document.getElementById('new-group').value = '';
|
|
loadMappings();
|
|
})
|
|
.catch(function(){});
|
|
});
|
|
|
|
// ── User API Tokens ──
|
|
|
|
function loadAllTokens() {
|
|
fetch('/api/admin/user-tokens', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(tokens) {
|
|
var tbody = document.getElementById('tokens-body');
|
|
tbody.innerHTML = '';
|
|
|
|
if (!tokens || tokens.length === 0) {
|
|
document.getElementById('no-tokens').style.display = '';
|
|
return;
|
|
}
|
|
document.getElementById('no-tokens').style.display = 'none';
|
|
|
|
tokens.forEach(function(t) {
|
|
var tr = document.createElement('tr');
|
|
var now = new Date().toISOString();
|
|
var expired = t.expires_at && t.expires_at < now;
|
|
if (t.disabled || expired) tr.className = 'disabled-row';
|
|
|
|
tr.appendChild(td(t.email || ''));
|
|
tr.appendChild(td(t.name));
|
|
tr.appendChild(td(t.max_role || 'none'));
|
|
tr.appendChild(td(t.expires_at ? t.expires_at.substring(0, 10) : 'never'));
|
|
tr.appendChild(td(t.created_at ? t.created_at.substring(0, 10) : ''));
|
|
tr.appendChild(td(t.last_used_at || 'never'));
|
|
|
|
var statusTd = document.createElement('td');
|
|
if (t.disabled) {
|
|
statusTd.textContent = 'disabled';
|
|
statusTd.className = 'status-disabled';
|
|
} else if (expired) {
|
|
statusTd.textContent = 'expired';
|
|
statusTd.className = 'status-expired';
|
|
} else {
|
|
statusTd.textContent = 'active';
|
|
statusTd.className = 'status-active';
|
|
}
|
|
tr.appendChild(statusTd);
|
|
|
|
var actTd = document.createElement('td');
|
|
var revokeBtn = document.createElement('button');
|
|
revokeBtn.className = 'btn-small';
|
|
revokeBtn.textContent = 'revoke';
|
|
revokeBtn.addEventListener('click', (function(id, name, email) {
|
|
return function() {
|
|
if (!confirm('Revoke token "' + name + '" for ' + email + '?')) return;
|
|
fetch('/api/admin/user-tokens/' + id, {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d) { showError(d.error); });
|
|
loadAllTokens();
|
|
loadAuditLog();
|
|
});
|
|
};
|
|
})(t.id, t.name, t.email || ''));
|
|
actTd.appendChild(revokeBtn);
|
|
tr.appendChild(actTd);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
})
|
|
.catch(function(e) { showError('Failed to load tokens'); });
|
|
}
|
|
|
|
document.getElementById('admin-create-token-btn').addEventListener('click', function() {
|
|
var email = document.getElementById('token-email').value.trim();
|
|
var name = document.getElementById('token-name').value.trim();
|
|
if (!email) { showError('User email is required'); return; }
|
|
if (!name) { showError('Token name is required'); return; }
|
|
|
|
var maxRole = document.getElementById('token-max-role').value || undefined;
|
|
var expiresInput = document.getElementById('token-expires').value;
|
|
var expiresAt = expiresInput ? expiresInput + 'T23:59:59Z' : undefined;
|
|
|
|
var body = { email: email, name: name };
|
|
if (maxRole) body.max_role = maxRole;
|
|
if (expiresAt) body.expires_at = expiresAt;
|
|
|
|
var btn = document.getElementById('admin-create-token-btn');
|
|
btn.disabled = true;
|
|
|
|
fetch('/api/admin/user-tokens', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body)
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d) { showError(d.error); throw new Error(); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
document.getElementById('token-email').value = '';
|
|
document.getElementById('token-name').value = '';
|
|
document.getElementById('token-expires').value = '';
|
|
document.getElementById('token-max-role').value = '';
|
|
|
|
document.getElementById('admin-token-plaintext').textContent = data.token;
|
|
document.getElementById('admin-token-reveal').style.display = '';
|
|
|
|
loadAllTokens();
|
|
loadAuditLog();
|
|
})
|
|
.catch(function() {})
|
|
.finally(function() { btn.disabled = false; });
|
|
});
|
|
|
|
document.getElementById('admin-copy-token-btn').addEventListener('click', function() {
|
|
var token = document.getElementById('admin-token-plaintext').textContent;
|
|
var btn = document.getElementById('admin-copy-token-btn');
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(token).then(function() {
|
|
btn.textContent = 'copied!';
|
|
setTimeout(function() { btn.textContent = 'copy to clipboard'; }, 2000);
|
|
}).catch(function() {
|
|
fallbackCopy(token, btn);
|
|
});
|
|
} else {
|
|
fallbackCopy(token, btn);
|
|
}
|
|
});
|
|
|
|
function fallbackCopy(text, btn) {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
btn.textContent = 'copied!';
|
|
setTimeout(function() { btn.textContent = 'copy to clipboard'; }, 2000);
|
|
}
|
|
|
|
document.getElementById('admin-dismiss-token-btn').addEventListener('click', function() {
|
|
document.getElementById('admin-token-reveal').style.display = 'none';
|
|
document.getElementById('admin-token-plaintext').textContent = '';
|
|
});
|
|
|
|
// ── Token Audit Log ──
|
|
|
|
function loadAuditLog(emailFilter) {
|
|
var url = '/api/admin/token-audit?limit=50';
|
|
if (emailFilter) url += '&email=' + encodeURIComponent(emailFilter);
|
|
|
|
fetch(url, { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(entries) {
|
|
var tbody = document.getElementById('audit-body');
|
|
tbody.innerHTML = '';
|
|
|
|
if (!entries || entries.length === 0) {
|
|
document.getElementById('no-audit').style.display = '';
|
|
return;
|
|
}
|
|
document.getElementById('no-audit').style.display = 'none';
|
|
|
|
entries.forEach(function(e) {
|
|
var tr = document.createElement('tr');
|
|
tr.className = 'audit-row';
|
|
tr.appendChild(td(e.created_at || ''));
|
|
tr.appendChild(td(e.user_email || ''));
|
|
tr.appendChild(td(e.token_name || ''));
|
|
tr.appendChild(td(e.action || ''));
|
|
tr.appendChild(td(e.ip_addr || ''));
|
|
|
|
var detailsTd = document.createElement('td');
|
|
detailsTd.style.maxWidth = '200px';
|
|
detailsTd.style.overflow = 'hidden';
|
|
detailsTd.style.textOverflow = 'ellipsis';
|
|
detailsTd.style.fontSize = '0.85em';
|
|
detailsTd.textContent = e.details || '';
|
|
detailsTd.title = e.details || '';
|
|
tr.appendChild(detailsTd);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
})
|
|
.catch(function() { showError('Failed to load audit log'); });
|
|
}
|
|
|
|
document.getElementById('audit-filter-btn').addEventListener('click', function() {
|
|
var email = document.getElementById('audit-email-filter').value.trim();
|
|
loadAuditLog(email || undefined);
|
|
});
|
|
|
|
// Auto-refresh
|
|
setInterval(function() { loadUsers(); loadMappings(); loadAllTokens(); }, 10000);
|
|
</script>
|
|
</body>
|
|
</html>
|