diff --git a/archive/build.sh b/archive/build.sh index 721a515..266d19f 100755 --- a/archive/build.sh +++ b/archive/build.sh @@ -64,6 +64,7 @@ concat_files \ "js/app.js" \ "../shared/help.js" \ "../shared/elevation.js" \ + "../shared/cap.js" \ > "$js_raw" # Escape ' "$js_raw" # Escape ' "$js_raw" diff --git a/shared/cap.js b/shared/cap.js new file mode 100644 index 0000000..369b960 --- /dev/null +++ b/shared/cap.js @@ -0,0 +1,154 @@ +// shared/cap.js — client-side capability helpers for permission gating. +// +// Three small helpers, exposed under window.zddc.cap, that wrap the +// server's verbs / /.profile/access?path / 403 missing_verb surface: +// +// zddc.cap.at(path) — Promise. Fetches +// /.profile/access?path= and +// memoises per-path for the session. +// Used by tools to gate top-of-page +// affordances (Publish, +Add row, +// +New folder) on PathVerbs. +// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string +// "rwcda"-subset) for the listed verb. +// Transition: falls back to +// node.writable for 'w' when verbs +// is absent, so the legacy field still +// drives gating on old listings. +// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response, +// parses the JSON body for +// missing_verb and renders a toast. +// Offers "Elevate" when the path's +// /.profile/access?path= reports a +// path_can_elevate_grant covering the +// missing verb. +// +// Tools using this module must concat shared/cap.js AFTER shared/ +// toast.js (toast dependency) and shared/elevation.js (cookie shape). +(function () { + 'use strict'; + + if (!window.zddc) window.zddc = {}; + if (window.zddc.cap) return; + + var pathCache = new Map(); // path → AccessView (or null sentinel) + + async function fetchAccess(path) { + try { + var url = '/.profile/access'; + if (path) url += '?path=' + encodeURIComponent(path); + var resp = await fetch(url, { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin', + cache: 'no-cache' + }); + if (!resp.ok) return null; + return await resp.json(); + } catch (_e) { + return null; + } + } + + // at(path) — fetch path-scoped access view, memoised per path + // within the page session. Cache is page-scoped: any elevation + // toggle forces a hard reload (see shared/elevation.js), which + // resets the cache so stale-after-elevation isn't a concern. Pass + // null/undefined for the global view (no ?path=). + async function at(path) { + var key = path || ''; + if (pathCache.has(key)) return pathCache.get(key); + var view = await fetchAccess(path); + pathCache.set(key, view); + return view; + } + + // has(node, verb) — check a per-entry verbs string for a single + // verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a'). + // Transition shim: when node.verbs is absent, fall back to + // node.writable for 'w' so the legacy field keeps editor save + // buttons working on old listings — drop this fallback once every + // tool's loader sets node.verbs unconditionally. + function has(node, verb) { + if (!node) return false; + if (typeof node.verbs === 'string') { + return node.verbs.indexOf(verb) !== -1; + } + if (verb === 'w' && typeof node.writable === 'boolean') { + return node.writable; + } + return false; + } + + // VERB_LABELS — human-readable phrases for the 403 toast. "create" + // covers both new-file PUT and mkdir; "admin" includes .zddc edits. + var VERB_LABELS = { + r: 'read', + w: 'write', + c: 'create', + d: 'delete', + a: 'edit access rules' + }; + + // handleForbidden(resp, opts) — render a 403 toast naming the + // missing verb. opts.path (optional) is the URL the failed request + // hit; when provided, the helper consults /.profile/access?path= to + // decide whether to offer an Elevate action. opts.context is an + // optional string prefix shown before the verb message ("Save", + // "Delete", etc.) — purely cosmetic. + // + // Best-effort: when the body isn't JSON or missing_verb is + // absent, falls back to a plain "Forbidden" toast. Returns the + // Promise so callers can await before chaining. + async function handleForbidden(resp, opts) { + opts = opts || {}; + var missing = ''; + try { + var body = await resp.clone().json(); + if (body && typeof body.missing_verb === 'string') { + missing = body.missing_verb; + } + } catch (_e) { /* non-JSON body */ } + + var prefix = opts.context ? (opts.context + ': ') : ''; + var verbLabel = VERB_LABELS[missing] || missing || ''; + var msg; + if (verbLabel) { + msg = prefix + 'You do not have ' + verbLabel + ' access here.'; + } else { + msg = prefix + 'Forbidden.'; + } + + // Optional elevate offer: only when the caller supplied a + // path AND the path-scoped access view reports an elevation + // grant covering the missing verb. Render as a clickable + // action appended to the toast message; clicking sets the + // elevation cookie and reloads, matching the header toggle. + var canOffer = false; + if (opts.path && missing) { + var view = await at(opts.path); + if (view && typeof view.path_can_elevate_grant === 'string' + && view.path_can_elevate_grant.indexOf(missing) !== -1) { + canOffer = true; + } + } + + var toastFn = (window.zddc && window.zddc.toast) || function () {}; + var el = toastFn(msg, 'error', { durationMs: 8000 }); + if (canOffer && el && el.appendChild) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'zddc-toast__action'; + btn.textContent = 'Elevate'; + btn.addEventListener('click', function (ev) { + ev.stopPropagation(); // don't dismiss the toast + if (window.zddc.elevation && window.zddc.elevation.setElevated) { + window.zddc.elevation.setElevated(true); + window.location.reload(); + } + }); + el.appendChild(btn); + } + } + + window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden }; +})(); diff --git a/shared/toast.css b/shared/toast.css index 449adc9..fdd3fac 100644 --- a/shared/toast.css +++ b/shared/toast.css @@ -38,3 +38,22 @@ from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } } + +/* Inline action button appended to a toast by zddc.cap.handleForbidden + when an Elevate path is offered. Stops click propagation on its own + so clicking the button doesn't also dismiss the toast. */ +.zddc-toast__action { + display: inline-block; + margin-left: 0.75rem; + padding: 0.25rem 0.75rem; + background: var(--accent, var(--text)); + color: var(--bg); + border: none; + border-radius: var(--radius); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; +} +.zddc-toast__action:hover { + filter: brightness(1.1); +} diff --git a/tables/build.sh b/tables/build.sh index 2b2a667..d0dbfba 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -42,6 +42,7 @@ concat_files \ "../shared/logo.js" \ "../shared/help.js" \ "../shared/elevation.js" \ + "../shared/cap.js" \ "../shared/context-menu.js" \ "js/mode.js" \ "js/app.js" \ diff --git a/transmittal/build.sh b/transmittal/build.sh index 544134e..9a3da19 100755 --- a/transmittal/build.sh +++ b/transmittal/build.sh @@ -87,6 +87,7 @@ concat_files \ "js/focus.js" \ "../shared/help.js" \ "../shared/elevation.js" \ + "../shared/cap.js" \ "js/main.js" \ > "$js_raw"