// 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 }; })();