mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-26 13:39:54 +00:00
6fbf590c1d
pages: bake the caller's role into the served page, version the logo
The header flashed on every navigation: gated links (Sessions, Reports,
Tokens, Admin) were hidden in the markup and shown after /api/me
answered, the logo was revalidated on each page load, and the nav
differed between pages, so switching tabs reflowed the header.
Serve each page in five variants, one per role level, that differ only
in a data-role-level attribute on <html>; optional auth now runs on the
page routes so a logged-in request gets its variant with the navigation
already right on first paint. The ETag is per variant. The logo joins
scripts and stylesheets in content-addressed, immutable caching.
Part of #254
pages: one navigation for every page, gated by role in CSS
Every page carries the same eight links in the same order, including
the Credentials entry (which opens the dialog on the connections page
and deep-links to it from elsewhere), with data-min-role on the gated
ones instead of inline display:none. rustguac.css shows them from the
data-role-level the server writes on <html>; the pages set that
attribute themselves from /api/me for API-key users, whose page GET
carries no credential. Admin-only controls follow the same gate, so
nothing pops in after the first paint. Recordings shows "host (Tenant)"
entry names as the host over a quieter tenant line.
Part of #254
tests: one navigation on every page, gated by data-role-level
(cherry picked from commit 5eed92c6ec)
658 lines
35 KiB
HTML
658 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - Admin</title>
|
|
<link rel="stylesheet" href="/rustguac.css">
|
|
<style>
|
|
.audit-row td { padding: var(--s-2) var(--s-3); font-size: var(--fz-sm); }
|
|
</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" 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" class="active" 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">🔑 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>
|
|
|
|
<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>
|
|
|
|
<h2>Connections Audit Log</h2>
|
|
<div style="margin-bottom:0.5em;color:var(--text-muted);font-size:0.9em;">
|
|
Destructive and mutating actions against the Connections address book. Metadata only — entry field values (passwords, keys) are never logged.
|
|
</div>
|
|
<div style="margin-bottom:0.5em;">
|
|
<input type="text" id="ab-audit-email-filter" placeholder="Filter by email" style="width:200px;">
|
|
<button class="btn-primary" id="ab-audit-filter-btn" style="padding:0.3em 0.8em;">Filter</button>
|
|
</div>
|
|
<table id="ab-audit-table">
|
|
<thead><tr>
|
|
<th>Time</th><th>User</th><th>Action</th><th>Scope</th><th>Folder</th><th>Entry</th><th>IP</th><th>Details</th>
|
|
</tr></thead>
|
|
<tbody id="ab-audit-body"></tbody>
|
|
</table>
|
|
<div id="no-ab-audit" style="display:none;color:var(--text-muted);margin-top:0.5em;font-size:0.9em;">No connections 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='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+' - 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) document.documentElement.setAttribute('data-role-level', String({admin:4,poweruser:3,operator:2,viewer:1}[data.role] || 0));
|
|
if (!data || data.role !== 'admin') {
|
|
window.location.href = '/connections.html';
|
|
return;
|
|
}
|
|
document.getElementById('reports-link').style.display = '';
|
|
loadUsers();
|
|
loadMappings();
|
|
loadAllTokens();
|
|
loadAuditLog();
|
|
loadAbAuditLog();
|
|
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 ? localTime(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;
|
|
}
|
|
|
|
// Render a backend timestamp in the viewer's local time. The DB stores
|
|
// UTC via SQLite datetime('now') as "YYYY-MM-DD HH:MM:SS" with no zone
|
|
// marker, so we tag it as UTC before localizing. Already-zoned ISO
|
|
// strings (e.g. "...Z") pass through unchanged. Unparseable values fall
|
|
// back to the raw string.
|
|
function localTime(s) {
|
|
if (!s) return '';
|
|
var iso = s.indexOf('T') === -1 ? s.replace(' ', 'T') : s;
|
|
if (!/[zZ]|[+-]\d\d:?\d\d$/.test(iso)) iso += 'Z';
|
|
var d = new Date(iso);
|
|
return isNaN(d.getTime()) ? s : d.toLocaleString();
|
|
}
|
|
|
|
// ── 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(localTime(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 ? localTime(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(localTime(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);
|
|
});
|
|
|
|
// ── Connections Audit Log ──
|
|
|
|
function loadAbAuditLog(emailFilter) {
|
|
var url = '/api/admin/addressbook-audit?limit=100';
|
|
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('ab-audit-body');
|
|
tbody.innerHTML = '';
|
|
|
|
if (!entries || entries.length === 0) {
|
|
document.getElementById('no-ab-audit').style.display = '';
|
|
return;
|
|
}
|
|
document.getElementById('no-ab-audit').style.display = 'none';
|
|
|
|
entries.forEach(function(e) {
|
|
var tr = document.createElement('tr');
|
|
tr.className = 'audit-row';
|
|
tr.appendChild(td(localTime(e.created_at)));
|
|
tr.appendChild(td(e.user_email || ''));
|
|
tr.appendChild(td(e.action || ''));
|
|
tr.appendChild(td(e.scope || ''));
|
|
tr.appendChild(td(e.folder_path || ''));
|
|
tr.appendChild(td(e.entry_name || ''));
|
|
tr.appendChild(td(e.ip_addr || ''));
|
|
|
|
var detailsTd = document.createElement('td');
|
|
detailsTd.style.maxWidth = '240px';
|
|
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 connections audit log'); });
|
|
}
|
|
|
|
document.getElementById('ab-audit-filter-btn').addEventListener('click', function() {
|
|
var email = document.getElementById('ab-audit-email-filter').value.trim();
|
|
loadAbAuditLog(email || undefined);
|
|
});
|
|
|
|
// Auto-refresh
|
|
setInterval(function() { loadUsers(); loadMappings(); loadAllTokens(); }, 10000);
|
|
</script>
|
|
</body>
|
|
</html>
|