Files
rustguac/static/admin.html
T
Jad Seifeddine be866264bd pages: shared facet filters and one list toolbar on every list page
reports: show entry names as host over a tenant subline

Session history and the most-used table showed "host (Tenant)" names
wrapped across three lines; they now read as the host over a quieter
tenant line, as on Connections and Recordings.

Part of #254

sessions, recordings: show the session type with the shared badge (#254)

The sessions page printed the type as plain text and the recordings page
as a coloured word; connections and reports already used .type-badge
from rustguac.css. All four now use the same badge. The Proxmox badge
colour moves into the shared stylesheet with the others.

Shared facet filters (static/filters.js) on the sessions, recordings,
reports and connections pages: a dropdown per facet (Type, Folder, User,
Owner, Status as each page has them), multi-select with counts that narrow
as other facets apply, All/None, a find box for long lists, a Clear filters
link, and selections kept per page in sessionStorage. They sit beside the
existing text search and combine with it; the buttons are compacted to the
height and padding of the other controls and the dropdown reserves room for
its scrollbar so it no longer sits over the counts.

The list pages share one toolbar: a borderless row with the search box, the
facets and the page action on the right (New ad-hoc session, Export CSV),
placeholders standardised to 'Search <noun> (fields…)'. Sessions gains a
search (user, host, owner, ID, type) and a User facet; Reports moves from
its own .filter-bar to the shared one. The redundant page titles go — the
nav already names and marks the active page — and section headings centre
their heading and action button on one line.

Connections: the folder header is sticky at the top of the entries pane, so
it stays under the nav and the active-sessions strip while a long table
scrolls. All four pages show the session type with the shared .type-badge
(the Proxmox colour moves into rustguac.css), reports and recordings show
"host (Tenant)" names as the host over a quieter tenant line, and the key
glyph before Credentials in the nav is sized to the text.

Part of #254

tests: shared facet filters and the list toolbar on every list page
2026-10-16 16:51:36 +07:00

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"><span class="nav-key">&#128273;</span> Credentials</a>
<span id="user-menu-wrapper"><a href="#" id="logout-item">Logout</a><span id="user-menu-btn" title="Preferences">&#9881; 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 &mdash; 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>