ZDDC/tables/js/main.js
ZDDC 7c0b66590c feat(server,shared): tell denied users who can — subtly, before wasted effort
When a user lacks permission, the app should (a) not let them do data entry it
will reject and (b) subtly say who can. General mechanism + the key gates.

Server — compute & expose "who can <verb> here":
- zddc.WhoCan(chain, verb) → Authority{Roles, People}: the acl.permissions
  grantees holding the verb across the cascade (roles + their members) plus the
  admins (who bypass). New whocan.go + whocan_test.go.
- AccessView gains path_who_can (profilehandler.go), populated only for verbs the
  caller LACKS and only when they can read the path (mirrors .zddc readability),
  so one cap.at() answers "can I?" and "if not, who?".
- writeForbiddenWho enriches the 403 body with who_can for the missing verb
  (errors.go); authorizeAction uses it (fileapi.go) as the safety net for denials
  that weren't pre-checked.

Shared — shared/cap.js:
- cap.whoCan(view, verb) + cap.denyHint(view, verb) → {text, title}, role-first
  ("Only the document controller can create here") with the people in the tooltip.
- handleForbidden appends the hint (from the 403 body, else the cached view), so
  every tool that already routes 403s through it (form save, tables save, browse)
  now explains who can — for free.

Key gates:
- Browse party-create (the reported bug): pre-check create authority on ssr/ and
  the slot BEFORE opening the picker — if the user can do neither, show the hint
  instead of the form; if only existing parties are usable, disable "+ New party"
  with the who-can hint. The post-hoc 403 catch now names who can too.
- Tables +Add row disabled state shows the who-can hint.

Plus: subtle /_apps/{browse,archive,classifier}.html links in the landing footer.

Tests: Go WhoCan unit test (role/person split, admin bypass, dedupe); cap.spec.js
(denyHint role-first/people/fallback, whoCan, handleForbidden enrichment) — 5
green; Go handler+zddc+policy suites green. (Pre-existing stale browse toolbar
test browse.spec.js:274 unaffected.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:58:20 -05:00

285 lines
13 KiB
JavaScript

(function (app) {
'use strict';
async function init() {
// Both apps (table + form) ship in the same bundle. Skip if
// mode dispatcher said this isn't our mode — form-mode requests
// are handled by formApp.
if (window.zddcMode === 'form') {
return;
}
const ctx = await app.modules.context.load();
app.context = ctx;
const titleEl = document.getElementById('table-title');
if (ctx.title && titleEl) {
titleEl.textContent = ctx.title;
document.title = 'ZDDC — ' + ctx.title;
}
const descEl = document.getElementById('table-description');
if (descEl && ctx.description) {
descEl.textContent = ctx.description;
descEl.hidden = false;
}
const tableEl = document.getElementById('table-root');
const theadEl = tableEl.querySelector('thead');
const tbodyEl = tableEl.querySelector('tbody');
const emptyEl = document.getElementById('table-empty');
const countEl = document.getElementById('table-rowcount');
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv');
const saveBtn = document.getElementById('table-save');
// Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to <dir>/form.html and swaps the
// synthetic row id for the server's response. The button shows
// whenever the page is a real table view (http(s) + a table
// context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s).
// Save: explicit flush of every dirty row. The button is
// hidden until a draft exists; save.onDraftsChanged() (called
// from editor.setDraft / clearDraftField) toggles visibility +
// updates the count label. Backstop for the row-blur trigger,
// which only fires when the user navigates to a different
// ROW in the table — clicking outside the grid entirely never
// fired a save without this.
if (saveBtn) {
saveBtn.addEventListener('click', function () {
const save = app.modules.save;
if (save && typeof save.flushAll === 'function') {
save.flushAll();
}
});
}
// Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing
// phase so we beat the browser's "Save Page As" default.
window.addEventListener('keydown', function (ev) {
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
const save = app.modules.save;
if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) {
ev.preventDefault();
save.flushAll();
}
}
});
// Auto-save when focus leaves the grid entirely (the user
// clicked a header link, the URL bar, etc. without moving to
// another row first). focusout fires for cell-to-cell moves
// too — relatedTarget being outside #table-root distinguishes.
//
// Deferred to next tick (setTimeout 0): the editor's commit
// path tears down its input element and then refocuses the
// owning cell. The remove fires focusout BEFORE the refocus
// runs, with relatedTarget=null (body briefly), so the naive
// sync check would mis-detect a "left the grid" event and
// fire flushAll redundantly alongside the selection-change
// save. Checking document.activeElement on the next tick
// gives the refocus time to settle.
const tableRoot = document.getElementById('table-root');
if (tableRoot) {
tableRoot.addEventListener('focusout', function (ev) {
const next = ev.relatedTarget;
if (next && tableRoot.contains(next)) return;
setTimeout(function () {
if (tableRoot.contains(document.activeElement)) return;
const save = app.modules.save;
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
save.flushAll();
}
}, 0);
});
}
// Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every
// table that loaded with columns, regardless of HTTP/file://.
if (exportBtn) {
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
if (hasCols) {
exportBtn.hidden = false;
exportBtn.addEventListener('click', function () {
const exp = app.modules.exportCsv;
if (exp && typeof exp.invoke === 'function') {
exp.invoke();
}
});
}
}
if (addRowBtn) {
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
// ctx.addable === false suppresses the affordance entirely.
// Used by project-rollup tables where the row's party
// affiliation is ambiguous (add at the per-party path).
const allowAdd = ctx.addable !== false;
if (onHttp && hasCols && allowAdd) {
addRowBtn.hidden = false;
addRowBtn.removeAttribute('href');
addRowBtn.setAttribute('role', 'button');
addRowBtn.setAttribute('tabindex', '0');
addRowBtn.style.cursor = 'pointer';
const handleAdd = function (ev) {
ev.preventDefault();
const addRow = app.modules.addRow;
if (addRow && typeof addRow.invoke === 'function') {
addRow.invoke();
}
};
addRowBtn.addEventListener('click', handleAdd);
addRowBtn.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
});
// Permission gate: fetch the path-scoped verbs for the
// current directory and disable + Add row when the
// cascade denies create. Async — the button shows up
// optimistically and disables a tick later if the
// server says no, which is the same race window every
// path-scoped fetch has. Server still gates the POST,
// so the worst case is a 403 toast on click.
if (window.zddc && window.zddc.cap) {
window.zddc.cap.at(location.pathname).then(function (view) {
if (!view) return;
var verbs = view.path_verbs || '';
if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true');
// Tell them who can (subtly): role-first text + people in the tooltip.
var hint = window.zddc.cap.denyHint ? window.zddc.cap.denyHint(view, 'c') : null;
addRowBtn.title = hint ? (hint.text + (hint.title ? ' (' + hint.title + ')' : '')) : "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) {
if (addRowBtn.classList.contains('is-disabled')) {
ev.preventDefault();
ev.stopPropagation();
}
}, true);
}
});
}
}
}
// "Add from archive" — shown only on the project MDL rollup (own gating).
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
app.modules.mdlFromArchive.setup(ctx);
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
const state = app.state;
state.rows = allRows;
state.sort = app.modules.sort.defaultsFromContext(ctx);
state.filter = {};
// Seed default filters from context.defaults.filter (per-column).
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
const seeded = ctx.defaults.filter[col.field];
if (seeded == null) {
continue;
}
// Filter UI is uniformly text-contains. If the spec
// seeds an array (legacy enum-style), coerce to a
// comma-joined contains string — partial match on any
// listed value still narrows the table sensibly.
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
state.filter[col.field] = { kind: 'contains', value: seedStr };
}
}
function anyFilterActive() {
const filters = app.modules.filters;
const keys = Object.keys(state.filter);
for (let i = 0; i < keys.length; i++) {
if (!filters.isEmpty(state.filter[keys[i]])) {
return true;
}
}
return false;
}
function paint() {
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
app.modules.render.body(tbodyEl, sorted, columns);
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
if (emptyEl) {
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
}
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
// Restore the editor's selection across re-paints so a sort
// or filter change doesn't dump the user out of the cell
// they were on. Selected coords clamp to the new bounds in
// setSelected; if the row vanished (filter excluded it),
// we land on the last valid cell instead of clearing.
const editor = app.modules.editor;
if (editor) {
editor.attachToTable();
if (state.selected) {
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
// Row context menu re-attaches each paint — renderBody wipes
// the tbody, taking listeners with it.
const rowOps = app.modules.rowOps;
if (rowOps && typeof rowOps.attach === 'function') {
rowOps.attach();
}
// Re-apply Phase-3 dirty-row markers — tbody.innerHTML='' in
// renderBody wiped them.
const save = app.modules.save;
if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows();
}
// Refresh the Save button visibility + count after every
// paint — save flow may have settled drafts in the meantime.
if (save && typeof save.updateSaveButton === 'function') {
save.updateSaveButton();
}
}
// Public re-paint entry point so other modules (save.useMine /
// save.reload) can request a refresh after they mutate row state.
app.repaint = paint;
function onHeaderClick(field, shiftKey) {
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
paint();
}
function onFilterChange(field, value) {
state.filter[field] = value;
paint();
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
state.filter = {};
paint();
});
}
paint();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(window.tablesApp);