/* =============================================================
ACCESS TAB (multi-tenant RBAC — admin-only panel)
============================================================= */
// Thin UI over /api/admin/grants and /api/admin/keys — both already
// enforced server-side by AuthorizationMiddleware's global_admin scope
// (see auth/route_policy.py). This panel has zero authority of its own;
// it's the same trust boundary as the CLI's `grant-access`/`auth
// create-key` commands, just with a form instead of a terminal.
// Only rendered/populated when state.multiTenant is true — see
// updateIdentityBar() in _script_role_gating.html for the tab's
// show/hide toggle.
function setAccessStatus(id, msg, type = '') {
const el = $(id);
if (!el) return;
el.textContent = msg;
el.className = 'key-status' + (type ? ' ' + type : '');
}
// Access Denied dialog -- shown for 403s from grant/revoke/key actions
// instead of a bare alert() or silent no-op, per logs.md's "UI just
// flashes without anything" report. Not a new trust boundary of its own:
// every one of these 403s was already being correctly enforced
// server-side (see grants_routes.py / admin_keys_routes.py); this only
// makes the existing denial visible and explains *why*, rather than
// leaving the admin to guess.
async function showAccessDenied(detailMessage) {
$('access-denied-message').textContent = detailMessage || 'This action requires a higher role than you currently hold.';
let contact = 'Contact a superadmin to have this action performed, or to be granted superadmin access yourself.';
try {
const data = await fetch('/api/admin/keys').then(r => (r.ok ? r.json() : { keys: [] }));
const superadmins = (data.keys || [])
.filter(k => k.is_superadmin && !k.revoked_at)
.map(k => k.subject);
if (superadmins.length) {
contact = `Contact a superadmin: ${superadmins.join(', ')}`;
}
} catch (e) {
// Fall back to the generic contact line above -- this is a UX nicety,
// never worth failing the whole dialog over.
}
$('access-denied-contact').textContent = contact;
$('modal-access-denied').classList.remove('hidden');
}
$('btn-access-denied-close').addEventListener('click', () => {
$('modal-access-denied').classList.add('hidden');
});
// Simple client-side filter -- both tables are already loaded in full
// (no server-side pagination), so filtering the already-rendered rows by
// subject substring is enough; no new endpoint needed. See logs.md's
// "100+ rows" scaling concern.
function wireAccessSearch(inputId, tbodyId) {
const input = $(inputId);
if (!input) return;
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase();
const tbody = $(tbodyId);
Array.from(tbody.rows).forEach(row => {
// Subject is always the 2nd column in the grants table, 1st in the
// keys table -- rather than hardcode a column index per table,
// just match against the row's full text content, which is cheap
// at this scale and immune to column-order changes.
const matches = !q || row.textContent.toLowerCase().includes(q);
row.style.display = matches ? '' : 'none';
});
});
}
wireAccessSearch('access-grants-search', 'access-grants-tbody');
wireAccessSearch('access-keys-search', 'access-keys-tbody');
async function loadAccessTab() {
// Workspace dropdown for the grant form
const wsSelect = $('access-grant-workspace');
wsSelect.innerHTML = '';
(state.workspaces || []).forEach(ws => {
const opt = document.createElement('option');
opt.value = ws.name || ws;
opt.textContent = ws.name || ws;
wsSelect.appendChild(opt);
});
// Granting the admin role itself is superadmin-only (see
// grants_routes.py's POST /grants) -- hide the option entirely for a
// plain admin rather than let them pick it and get a 403, so the UI
// doesn't imply an ability that isn't actually there.
const roleSelect = $('access-grant-role');
const adminOption = roleSelect.querySelector('option[value="admin"]');
if (adminOption) adminOption.disabled = !state.isSuperadmin;
await Promise.all([renderGrantsTable(), renderKeysTable()]);
}
async function renderGrantsTable() {
const tbody = $('access-grants-tbody');
tbody.innerHTML = '
Loading…
';
try {
const data = await fetch('/api/admin/grants').then(r => {
if (!r.ok) throw new Error('Failed to load grants');
return r.json();
});
const grants = data.grants || [];
if (!grants.length) {
tbody.innerHTML = '