257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
// 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 <dir>/*.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 ctxObj() {
|
|
return (app && app.context) || {};
|
|
}
|
|
function cfg() {
|
|
return ctxObj().apiActions || null;
|
|
}
|
|
// Active when the table is an API collection (apiActions) OR a read-only
|
|
// server-injected view (readOnly) — either way the file-model toolbar
|
|
// buttons (+ Add row / Save) don't apply and are hidden.
|
|
function active() {
|
|
return !!(cfg() || ctxObj().readOnly);
|
|
}
|
|
|
|
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() {
|
|
if (!active()) return;
|
|
hideNative();
|
|
var c = cfg();
|
|
if (!c) return; // read-only view: native buttons hidden, nothing more
|
|
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 (active() || tries++ > 60) {
|
|
clearInterval(iv);
|
|
if (!active()) 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 || {});
|