Files
rustguac/static/admin.html
T
Jarosław Dutka c25ee93b16 oidc: add option to disable automatic account creation
With `[oidc] auto_create_users = false`, OIDC login no longer creates
an account for a user who isn't in the database yet. Only users an
admin has added beforehand can log in. The option defaults to true, so
existing deployments behave as before.

An admin can add users ahead of their first login with any of:
- CLI: `rustguac add-user --email <email> --role <role> [--name <name>]`
- API: `POST /api/users` (admin only), returns 201, or 409 if the user exists
- Admin page: an "Add User" form under the users table

A user added this way keeps the role the admin gave them; default_role
is not applied. Group-to-role mappings still apply on every login.

A rejected SSO login now redirects to the login page with an error code
instead of returning JSON. This covers a user with no account
(sso_error=no_account) and a disabled account (sso_error=disabled).
SSO errors are now shown under the SSO button: they used to go into the
API key form, which is hidden when SSO is on, so "SSO login failed" was
never visible. The "provider unavailable" notice uses the same element,
and all SSO messages are defined in one place in index.html.

(cherry picked from commit 9bfdc52155)
2026-10-18 13:23:14 +11:00

699 lines
37 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>
<div class="add-form">
<input type="text" id="new-user-email" placeholder="user@example.com" style="width:200px;">
<input type="text" id="new-user-name" placeholder="Name (optional)">
<select id="new-user-role">
<option value="viewer">viewer</option>
<option value="operator" selected>operator</option>
<option value="poweruser">poweruser</option>
<option value="admin">admin</option>
</select>
<button class="btn-primary" id="add-user-btn">Add User</button>
</div>
<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() {
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);
});
});
}
document.getElementById('add-user-btn').addEventListener('click', function() {
var email = document.getElementById('new-user-email').value.trim();
var name = document.getElementById('new-user-name').value.trim();
var role = document.getElementById('new-user-role').value;
if (!email) { showError('Email is required'); return; }
fetch('/api/users', {
method: 'POST',
headers: apiHeaders({'Content-Type': 'application/json'}),
credentials: 'same-origin',
body: JSON.stringify({email: email, name: name, 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-user-email').value = '';
document.getElementById('new-user-name').value = '';
loadUsers();
})
.catch(function() {});
});
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>