mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 17:47:13 +00:00
be3bea8b30
New SQLite table addressbook_audit_log records destructive and mutating actions against the connections tree: create_folder, update_folder, delete_folder, create_entry, update_entry, delete_entry. Each row captures user_email, action, scope, folder_path, optional entry_name, client IP, a small JSON details blob, and created_at. Details is deliberately headline-only: counts for delete_folder (subfolders_deleted, entries_deleted), allowed_groups_count + inherit_from_parent for folder writes, session type for entry writes. Entry field values (password, private_key, hostname) and full request bodies are never written. Audit rows live in SQLite, not Vault, so logging content would leak Vault-only secrets onto disk. The six handlers in api.rs gain ConnectInfo + HeaderMap + TrustedProxies + Db extractors so client IP can be resolved through the existing auth::client_ip helper (respecting trusted proxies) and the audit row can be written post-success. New admin-only endpoint GET /api/admin/addressbook-audit mirrors the existing token audit shape (limit + email filter, capped at 1000). admin.html gains a "Connections Audit Log" section below the token audit, auto-loaded on page show. cleanup_old_audit_log now also sweeps the new table on the same retention window as token_audit_log.
643 lines
34 KiB
HTML
643 lines
34 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">Sessions</a>
|
|
<a href="/recordings.html">Recordings</a>
|
|
<a href="/reports.html" id="reports-link" style="display:none">Reports</a>
|
|
<a href="/docs.html">Docs</a>
|
|
<a href="/tokens.html">Tokens</a>
|
|
<a href="/admin.html" class="active">Admin</a>
|
|
<span id="user-menu-wrapper"><a href="#" id="logout-item">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 !== '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 || 'never'));
|
|
|
|
// Actions
|
|
var actTd = document.createElement('td');
|
|
|
|
var toggleBtn = document.createElement('button');
|
|
toggleBtn.className = 'btn-small btn-action';
|
|
toggleBtn.textContent = u.disabled ? 'enable' : 'disable';
|
|
toggleBtn.addEventListener('click', (function(email, disabled) {
|
|
return function() {
|
|
var action = disabled ? 'enable' : 'disable';
|
|
fetch('/api/users/' + encodeURIComponent(email) + '/' + action, {
|
|
method: 'POST', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function() { loadUsers(); });
|
|
};
|
|
})(u.email, u.disabled));
|
|
actTd.appendChild(toggleBtn);
|
|
actTd.appendChild(document.createTextNode(' '));
|
|
|
|
var logoutBtn = document.createElement('button');
|
|
logoutBtn.className = 'btn-small btn-action';
|
|
logoutBtn.textContent = 'force-logout';
|
|
logoutBtn.addEventListener('click', (function(email) {
|
|
return function() {
|
|
fetch('/api/users/' + encodeURIComponent(email) + '/sessions', {
|
|
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function(res) { return res.json(); }).then(function(d) {
|
|
if (d.sessions_revoked !== undefined) showError('Revoked ' + d.sessions_revoked + ' session(s)');
|
|
});
|
|
};
|
|
})(u.email));
|
|
actTd.appendChild(logoutBtn);
|
|
actTd.appendChild(document.createTextNode(' '));
|
|
|
|
var delBtn = document.createElement('button');
|
|
delBtn.className = 'btn-small';
|
|
delBtn.textContent = 'delete';
|
|
delBtn.addEventListener('click', (function(email) {
|
|
return function() {
|
|
if (!confirm('Delete user ' + email + '?')) return;
|
|
fetch('/api/users/' + encodeURIComponent(email), {
|
|
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function() { loadUsers(); });
|
|
};
|
|
})(u.email));
|
|
actTd.appendChild(delBtn);
|
|
|
|
tr.appendChild(actTd);
|
|
tbody.appendChild(tr);
|
|
});
|
|
});
|
|
}
|
|
|
|
function td(text) {
|
|
var el = document.createElement('td');
|
|
el.textContent = text || '';
|
|
return el;
|
|
}
|
|
|
|
// ── Group Mappings ──
|
|
|
|
function loadMappings() {
|
|
fetch('/api/admin/group-mappings', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(mappings) {
|
|
var tbody = document.getElementById('mappings-body');
|
|
tbody.innerHTML = '';
|
|
mappings.forEach(function(m) {
|
|
var tr = document.createElement('tr');
|
|
tr.appendChild(td(m.oidc_group));
|
|
|
|
var roleTd = document.createElement('td');
|
|
var sel = document.createElement('select');
|
|
['viewer','operator','poweruser','admin'].forEach(function(r) {
|
|
var opt = document.createElement('option');
|
|
opt.value = r; opt.textContent = r;
|
|
if (r === m.role) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
});
|
|
sel.addEventListener('change', (function(id, group) {
|
|
return function() {
|
|
fetch('/api/admin/group-mappings/' + id, {
|
|
method: 'PUT',
|
|
headers: apiHeaders({'Content-Type': 'application/json'}),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({group: group, role: sel.value})
|
|
}).then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d){ showError(d.error); });
|
|
});
|
|
};
|
|
})(m.id, m.oidc_group));
|
|
roleTd.appendChild(sel);
|
|
tr.appendChild(roleTd);
|
|
|
|
tr.appendChild(td(m.created_at));
|
|
|
|
var actTd = document.createElement('td');
|
|
var delBtn = document.createElement('button');
|
|
delBtn.className = 'btn-small';
|
|
delBtn.textContent = 'delete';
|
|
delBtn.addEventListener('click', (function(id) {
|
|
return function() {
|
|
fetch('/api/admin/group-mappings/' + id, {
|
|
method: 'DELETE', headers: apiHeaders(), credentials: 'same-origin'
|
|
}).then(function() { loadMappings(); });
|
|
};
|
|
})(m.id));
|
|
actTd.appendChild(delBtn);
|
|
tr.appendChild(actTd);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
});
|
|
}
|
|
|
|
document.getElementById('add-mapping-btn').addEventListener('click', function() {
|
|
var group = document.getElementById('new-group').value.trim();
|
|
var role = document.getElementById('new-role').value;
|
|
if (!group) { showError('Group name is required'); return; }
|
|
|
|
fetch('/api/admin/group-mappings', {
|
|
method: 'POST',
|
|
headers: apiHeaders({'Content-Type': 'application/json'}),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({group: group, role: role})
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d){ showError(d.error); throw new Error(); });
|
|
return res.json();
|
|
})
|
|
.then(function() {
|
|
document.getElementById('new-group').value = '';
|
|
loadMappings();
|
|
})
|
|
.catch(function(){});
|
|
});
|
|
|
|
// ── User API Tokens ──
|
|
|
|
function loadAllTokens() {
|
|
fetch('/api/admin/user-tokens', { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(tokens) {
|
|
var tbody = document.getElementById('tokens-body');
|
|
tbody.innerHTML = '';
|
|
|
|
if (!tokens || tokens.length === 0) {
|
|
document.getElementById('no-tokens').style.display = '';
|
|
return;
|
|
}
|
|
document.getElementById('no-tokens').style.display = 'none';
|
|
|
|
tokens.forEach(function(t) {
|
|
var tr = document.createElement('tr');
|
|
var now = new Date().toISOString();
|
|
var expired = t.expires_at && t.expires_at < now;
|
|
if (t.disabled || expired) tr.className = 'disabled-row';
|
|
|
|
tr.appendChild(td(t.email || ''));
|
|
tr.appendChild(td(t.name));
|
|
tr.appendChild(td(t.max_role || 'none'));
|
|
tr.appendChild(td(t.expires_at ? t.expires_at.substring(0, 10) : 'never'));
|
|
tr.appendChild(td(t.created_at ? t.created_at.substring(0, 10) : ''));
|
|
tr.appendChild(td(t.last_used_at || 'never'));
|
|
|
|
var statusTd = document.createElement('td');
|
|
if (t.disabled) {
|
|
statusTd.textContent = 'disabled';
|
|
statusTd.className = 'status-disabled';
|
|
} else if (expired) {
|
|
statusTd.textContent = 'expired';
|
|
statusTd.className = 'status-expired';
|
|
} else {
|
|
statusTd.textContent = 'active';
|
|
statusTd.className = 'status-active';
|
|
}
|
|
tr.appendChild(statusTd);
|
|
|
|
var actTd = document.createElement('td');
|
|
var revokeBtn = document.createElement('button');
|
|
revokeBtn.className = 'btn-small';
|
|
revokeBtn.textContent = 'revoke';
|
|
revokeBtn.addEventListener('click', (function(id, name, email) {
|
|
return function() {
|
|
if (!confirm('Revoke token "' + name + '" for ' + email + '?')) return;
|
|
fetch('/api/admin/user-tokens/' + id, {
|
|
method: 'DELETE',
|
|
headers: apiHeaders(),
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d) { showError(d.error); });
|
|
loadAllTokens();
|
|
loadAuditLog();
|
|
});
|
|
};
|
|
})(t.id, t.name, t.email || ''));
|
|
actTd.appendChild(revokeBtn);
|
|
tr.appendChild(actTd);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
})
|
|
.catch(function(e) { showError('Failed to load tokens'); });
|
|
}
|
|
|
|
document.getElementById('admin-create-token-btn').addEventListener('click', function() {
|
|
var email = document.getElementById('token-email').value.trim();
|
|
var name = document.getElementById('token-name').value.trim();
|
|
if (!email) { showError('User email is required'); return; }
|
|
if (!name) { showError('Token name is required'); return; }
|
|
|
|
var maxRole = document.getElementById('token-max-role').value || undefined;
|
|
var expiresInput = document.getElementById('token-expires').value;
|
|
var expiresAt = expiresInput ? expiresInput + 'T23:59:59Z' : undefined;
|
|
|
|
var body = { email: email, name: name };
|
|
if (maxRole) body.max_role = maxRole;
|
|
if (expiresAt) body.expires_at = expiresAt;
|
|
|
|
var btn = document.getElementById('admin-create-token-btn');
|
|
btn.disabled = true;
|
|
|
|
fetch('/api/admin/user-tokens', {
|
|
method: 'POST',
|
|
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body)
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) return res.json().then(function(d) { showError(d.error); throw new Error(); });
|
|
return res.json();
|
|
})
|
|
.then(function(data) {
|
|
document.getElementById('token-email').value = '';
|
|
document.getElementById('token-name').value = '';
|
|
document.getElementById('token-expires').value = '';
|
|
document.getElementById('token-max-role').value = '';
|
|
|
|
document.getElementById('admin-token-plaintext').textContent = data.token;
|
|
document.getElementById('admin-token-reveal').style.display = '';
|
|
|
|
loadAllTokens();
|
|
loadAuditLog();
|
|
})
|
|
.catch(function() {})
|
|
.finally(function() { btn.disabled = false; });
|
|
});
|
|
|
|
document.getElementById('admin-copy-token-btn').addEventListener('click', function() {
|
|
var token = document.getElementById('admin-token-plaintext').textContent;
|
|
var btn = document.getElementById('admin-copy-token-btn');
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(token).then(function() {
|
|
btn.textContent = 'copied!';
|
|
setTimeout(function() { btn.textContent = 'copy to clipboard'; }, 2000);
|
|
}).catch(function() {
|
|
fallbackCopy(token, btn);
|
|
});
|
|
} else {
|
|
fallbackCopy(token, btn);
|
|
}
|
|
});
|
|
|
|
function fallbackCopy(text, btn) {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
btn.textContent = 'copied!';
|
|
setTimeout(function() { btn.textContent = 'copy to clipboard'; }, 2000);
|
|
}
|
|
|
|
document.getElementById('admin-dismiss-token-btn').addEventListener('click', function() {
|
|
document.getElementById('admin-token-reveal').style.display = 'none';
|
|
document.getElementById('admin-token-plaintext').textContent = '';
|
|
});
|
|
|
|
// ── Token Audit Log ──
|
|
|
|
function loadAuditLog(emailFilter) {
|
|
var url = '/api/admin/token-audit?limit=50';
|
|
if (emailFilter) url += '&email=' + encodeURIComponent(emailFilter);
|
|
|
|
fetch(url, { headers: apiHeaders(), credentials: 'same-origin' })
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(entries) {
|
|
var tbody = document.getElementById('audit-body');
|
|
tbody.innerHTML = '';
|
|
|
|
if (!entries || entries.length === 0) {
|
|
document.getElementById('no-audit').style.display = '';
|
|
return;
|
|
}
|
|
document.getElementById('no-audit').style.display = 'none';
|
|
|
|
entries.forEach(function(e) {
|
|
var tr = document.createElement('tr');
|
|
tr.className = 'audit-row';
|
|
tr.appendChild(td(e.created_at || ''));
|
|
tr.appendChild(td(e.user_email || ''));
|
|
tr.appendChild(td(e.token_name || ''));
|
|
tr.appendChild(td(e.action || ''));
|
|
tr.appendChild(td(e.ip_addr || ''));
|
|
|
|
var detailsTd = document.createElement('td');
|
|
detailsTd.style.maxWidth = '200px';
|
|
detailsTd.style.overflow = 'hidden';
|
|
detailsTd.style.textOverflow = 'ellipsis';
|
|
detailsTd.style.fontSize = '0.85em';
|
|
detailsTd.textContent = e.details || '';
|
|
detailsTd.title = e.details || '';
|
|
tr.appendChild(detailsTd);
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
})
|
|
.catch(function() { showError('Failed to load audit log'); });
|
|
}
|
|
|
|
document.getElementById('audit-filter-btn').addEventListener('click', function() {
|
|
var email = document.getElementById('audit-email-filter').value.trim();
|
|
loadAuditLog(email || undefined);
|
|
});
|
|
|
|
// ── 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(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>
|