// api-actions.js — generic "tables over an API collection" layer. // // When the injected #table-context carries an `apiActions` block, this turns // the otherwise read-only table into a managed collection backed by a REST // endpoint, WITHOUT touching the file-save/row-ops machinery (which is bound // to /*.yaml row files). It drives create + per-row delete against the // configured URLs and reloads on success (the server re-renders the fresh // list). First consumer: the self-service token page at /.tokens. // // apiActions: { // create: { url, title?, fields:[{name,label,placeholder?,type?}], secretField?, secretLabel? }, // deleteRow: { urlTemplate (with {id}), label?, confirm? } // {id} ← row's data-url // } (function (app) { 'use strict'; function cfg() { return (app && app.context && app.context.apiActions) || null; } function el(tag, attrs, text) { var e = document.createElement(tag); if (attrs) Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); }); if (text != null) e.textContent = text; return e; } // ── Create ──────────────────────────────────────────────────────────── var createMounted = false; function mountCreate(c) { if (createMounted) return; var bar = document.querySelector('.table-toolbar__left') || document.getElementById('table-toolbar'); if (!bar) return; // The native "+ Add row" posts to the form-create file endpoint, which // doesn't apply to an API collection — hide it; this button replaces it. var native = document.getElementById('table-add-row'); if (native) native.hidden = true; var btn = el('button', { type: 'button', class: 'btn btn-primary btn-sm', id: 'api-create-btn' }, '+ ' + (c.title || 'New')); btn.addEventListener('click', function () { openCreate(c); }); bar.appendChild(btn); createMounted = true; } function openCreate(c) { var overlay = el('div', { class: 'api-modal__overlay' }); var modal = el('div', { class: 'api-modal' }); modal.appendChild(el('h2', { class: 'api-modal__title' }, c.title || 'New')); var form = el('form', { class: 'api-modal__form' }); var inputs = {}; (c.fields || []).forEach(function (f) { var lab = el('label', { class: 'api-modal__field' }); lab.appendChild(el('span', null, (f.label || f.name) + (f.required ? ' *' : ''))); var inp = el('input', { type: f.type || 'text' }); if (f.placeholder) inp.setAttribute('placeholder', f.placeholder); if (f.required) inp.required = true; inputs[f.name] = inp; lab.appendChild(inp); form.appendChild(lab); }); var err = el('div', { class: 'api-modal__err', hidden: 'hidden' }); form.appendChild(err); var actions = el('div', { class: 'api-modal__actions' }); var cancel = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Cancel'); var submit = el('button', { type: 'submit', class: 'btn btn-primary btn-sm' }, 'Create'); actions.appendChild(cancel); actions.appendChild(submit); form.appendChild(actions); modal.appendChild(form); overlay.appendChild(modal); document.body.appendChild(overlay); var firstInput = form.querySelector('input'); if (firstInput) firstInput.focus(); function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } cancel.addEventListener('click', close); overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); }); form.addEventListener('submit', function (e) { e.preventDefault(); err.hidden = true; var missing = (c.fields || []).filter(function (f) { return f.required && !inputs[f.name].value.trim(); }); if (missing.length) { err.textContent = 'Required: ' + missing.map(function (f) { return f.label || f.name; }).join(', '); err.hidden = false; return; } var body = {}; (c.fields || []).forEach(function (f) { var v = inputs[f.name].value.trim(); if (!v) return; // Date fields → RFC3339 so the Go time.Time decoder accepts them. body[f.name] = (f.type === 'date') ? new Date(v + 'T00:00:00').toISOString() : v; }); // Constant fields the server requires but the user doesn't set // (e.g. project create's parent="/"). if (c.fixed) Object.keys(c.fixed).forEach(function (k) { body[k] = c.fixed[k]; }); submit.disabled = true; fetch(c.url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body) }).then(function (r) { return r.text().then(function (t) { return { ok: r.ok, status: r.status, text: t }; }); }).then(function (res) { if (!res.ok) { submit.disabled = false; err.textContent = 'Create failed: ' + res.status + ' ' + res.text; err.hidden = false; return; } close(); var secret = ''; if (c.secretField) { try { secret = (JSON.parse(res.text) || {})[c.secretField] || ''; } catch (_e) { /* ignore */ } } if (secret) showSecret(c.secretLabel || 'New secret (shown once):', secret); else location.reload(); }).catch(function (e2) { submit.disabled = false; err.textContent = 'Create failed: ' + (e2 && e2.message ? e2.message : e2); err.hidden = false; }); }); } function showSecret(label, secret) { var overlay = el('div', { class: 'api-modal__overlay' }); var modal = el('div', { class: 'api-modal' }); modal.appendChild(el('p', { class: 'api-modal__secret-label' }, label)); var box = el('div', { class: 'api-modal__secret' }, secret); modal.appendChild(box); var actions = el('div', { class: 'api-modal__actions' }); var copy = el('button', { type: 'button', class: 'btn btn-secondary btn-sm' }, 'Copy'); copy.addEventListener('click', function () { if (navigator.clipboard) navigator.clipboard.writeText(secret); copy.textContent = 'Copied'; }); var done = el('button', { type: 'button', class: 'btn btn-primary btn-sm' }, 'Done'); done.addEventListener('click', function () { location.reload(); }); actions.appendChild(copy); actions.appendChild(done); modal.appendChild(actions); overlay.appendChild(modal); document.body.appendChild(overlay); } // ── Per-row delete ────────────────────────────────────────────────────── function ensureRowDelete(d) { var tbody = document.querySelector('#table-root tbody'); if (!tbody) return; var trs = tbody.querySelectorAll('tr'); for (var i = 0; i < trs.length; i++) { var tr = trs[i]; if (tr.querySelector('.api-revoke')) continue; var id = tr.getAttribute('data-url'); if (!id) continue; var cell = tr.lastElementChild; if (!cell) continue; var b = el('button', { type: 'button', class: 'btn btn-secondary btn-sm api-revoke' }, d.label || 'Delete'); (function (rowId) { b.addEventListener('click', function () { revoke(d, rowId); }); })(id); cell.appendChild(b); } } function revoke(d, id) { if (d.confirm && !window.confirm(d.confirm)) return; var url = d.urlTemplate.replace('{id}', encodeURIComponent(id)); fetch(url, { method: 'DELETE', credentials: 'same-origin' }).then(function (r) { if (r.ok || r.status === 204) location.reload(); else r.text().then(function (t) { window.alert('Delete failed: ' + r.status + ' ' + t); }); }).catch(function (e) { window.alert('Delete failed: ' + (e && e.message ? e.message : e)); }); } // Suppress the file-model toolbar affordances that don't apply to an API // collection: native "+ Add row" (posts to the form-create file endpoint) // and "Save" (flushes dirty row files). Re-run each tick in case main.js // toggles them after us. function hideNative() { // Use inline display:none, not the [hidden] attr — the .btn display // rule overrides [hidden] and the buttons would stay visible. ['table-add-row', 'table-save'].forEach(function (id) { var b = document.getElementById(id); if (b) b.style.display = 'none'; }); } // Per-row navigation: clicking a row opens its data-url (the project / // subtree it represents) — used by the profile "Effective access" table. // Clicks on inner controls (buttons/links/inputs) are left alone. function ensureRowNav() { var tbody = document.querySelector('#table-root tbody'); if (!tbody) return; var trs = tbody.querySelectorAll('tr'); for (var i = 0; i < trs.length; i++) { var tr = trs[i]; if (tr.getAttribute('data-nav') === '1') continue; var url = tr.getAttribute('data-url'); if (!url) continue; tr.setAttribute('data-nav', '1'); tr.style.cursor = 'pointer'; (function (target) { // Capture phase: fire before the tables editor's per-cell // click handlers (which would otherwise swallow the click on // read-only rows). Inner controls (buttons/links/inputs) still // opt out. tr.addEventListener('click', function (e) { if (e.target.closest('button, a, input')) return; window.location.href = target; }, true); })(url); } } function tick() { var c = cfg(); if (!c) return; hideNative(); if (c.create) mountCreate(c.create); if (c.deleteRow) ensureRowDelete(c.deleteRow); if (c.rowNav) ensureRowNav(); } function start() { // app.context is set asynchronously by main.js (await context.load()). // Poll until it's present, then run once + observe the tbody so the // per-row buttons survive sort/filter re-renders. var tries = 0; var iv = setInterval(function () { if (cfg() || tries++ > 60) { clearInterval(iv); if (!cfg()) return; tick(); var tbody = document.querySelector('#table-root tbody'); if (tbody && window.MutationObserver) { new MutationObserver(function () { tick(); }).observe(tbody, { childList: true }); } } }, 100); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } })(window.tablesApp = window.tablesApp || {});