// 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) { // file:// pages have no server to fetch /.profile/access from; // calling fetch() there logs a browser-level error before our // catch even runs. Short-circuit so offline tools (browse on // a picked folder, form opened from a file URL) silently // degrade to "no path-scoped info, fall back to existing // gating signals". if (location.protocol !== 'http:' && location.protocol !== 'https:') { return null; } 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' }; // ── "who can?" — tell a denied user who to ask, subtly ────────────────── // The PATTERN: gate an affordance on cap.has(node, verb) / a path view's // path_verbs; when the verb is ABSENT, don't just disable — render // cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or // a small inline note) so the user learns who can do it instead of acting // and bouncing off a 403. handleForbidden() does the same on a denial that // slips past a pre-check. function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); } // whoCan(view, verb) → the Authority {roles, people} the server computed for // a verb the caller lacks at view's path, or null. `view` is a /.profile/ // access?path= result (from cap.at) OR a 403 body carrying who_can. function whoCan(view, verb) { if (!view) return null; var map = view.path_who_can; if (map && map[verb]) return map[verb]; if (view.who_can && view.missing_verb === verb) return view.who_can; return null; } // denyHint(view, verb) → { text, title } for a subtle "who can" line. // Role-first: "Only the document controller can create here", with the // specific people (admins / role members) as the tooltip detail. Falls back // to naming people, then to "Ask an administrator". Returns null when the // verb is actually granted (nothing to hint) and a generic hint when no // authority is known. function denyHint(view, verb) { var a = whoCan(view, verb); var doing = VERB_LABELS[verb] || verb || 'do that'; if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) { return { text: 'Ask an administrator to ' + doing + ' here.', title: '' }; } var people = (a.people || []).slice(); var detail = people.length ? people.join(', ') : ''; if (a.roles && a.roles.length) { return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail }; } var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : ''); return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail }; } // 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 = ''; var body = null; try { 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.'; } // Append the subtle "who can" hint: prefer who_can from the 403 body, // else fall back to the path-scoped access view. So even a denial the // UI didn't pre-check still tells the user who to ask. if (missing) { var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null; if (!src && opts.path) src = await at(opts.path); var hint = src ? denyHint(src, missing) : null; if (hint && hint.text) msg += ' ' + hint.text; } // 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, whoCan: whoCan, denyHint: denyHint }; })();