mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-26 05:29:51 +00:00
32581f30ae
The '/' search box leaves the connections folder header for a bigger,
centred box in the brand row, inline with the logo. Results open in a dim,
blurred overlay over an untouched folder view: a centred panel with the
summary, the Type and Folder facets and the results table (fixed columns,
one line per cell, folder shown by display name over its key, Connect, new
window and open-folder gathered into one actions cell). Focusing the box or
pressing / opens it and lists every connection the user can see; typing
narrows it; Escape from anywhere, a mousedown outside the box and panel, or
opening a folder closes it (an open facet menu takes the first Escape).
static/search.js is the single implementation, used on every page
including connections, which dropped ~16k of duplicated markup, CSS and JS;
the styles live once in rustguac.css so the results look identical
everywhere. The index is cached in sessionStorage and only re-fetched when
the address book's version changes, so moving between pages is instant; the
connections page keeps the folder-specific hooks it needs (rgSelectFolder
selects a folder in place, rgSearchIndex feeds the credentials banner) and
opening a folder from another page navigates to connections with
class, and the module stands down cleanly on a page with no brand row.
Connections also gets a more generous content gutter, the folder action
buttons move down to the filter row beside Type, the folder title is inset
to align with the table, and the Connect button keeps its size and spins
in place instead of growing to 'Connecting...'.
Part of #254
tests: the global search overlay on every page
search: keep page modals above the brand-row search box
The global search lifts the brand row to z-index 300 so the box is not
dimmed by its own results overlay (200), but the page modals sat at 100, so
the search input showed through the ad-hoc session dialog and could still
be focused. Modal overlays now sit at 1000, above the search chrome and
below the themed dialogs (4000) and the embedded session (5000). A spec
pins it on the ad-hoc and folder modals.
search: never on the login page, nor for anyone the server does not recognise
The global search was included on index.html, so the login page carried
the search box, and because the index was cached in sessionStorage it
kept answering with the whole address book after logout, or to the next
login in the same tab.
- index.html no longer loads search.js at all.
- search.js only renders for an operator or above: the role the server
bakes on <html data-role-level> decides on first paint, and an API-key
user (served the anonymous variant) is confirmed through /api/me first.
Anonymous or viewer: nothing injected, cache swept.
- The cached index is keyed to the identity /api/me returns; a cache left
by a different login is dropped rather than shown.
- A 401/403 from the index or version endpoints removes the box, the
overlay and the cache; the logout link clears the cache before the
login page shows.
e2e: login page has no box and never loads search.js; anonymous and
viewer callers get nothing and a stale cache is wiped; a different
login's cache is dropped; the same login keeps its cache across pages;
logout and expiry forget it.
(cherry picked from commit b8c5c0f9ee)
664 lines
35 KiB
HTML
664 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"><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>
|
|
|
|
<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() {
|
|
rgConfirm('Delete user ' + email + '?', { danger: true, confirmText: 'Delete' }).then(function(ok) {
|
|
if (!ok) 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() {
|
|
rgConfirm('Revoke token "' + name + '" for ' + email + '?', { danger: true, confirmText: 'Revoke' }).then(function(ok) {
|
|
if (!ok) 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>
|
|
<script src="/search.js"></script>
|
|
<script src="/ui.js"></script>
|
|
</body>
|
|
</html>
|