diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 93b475c..8e27b4c 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -836,6 +836,25 @@ body.help-open .app-header { 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); +} + /* shared/elevation.css — admin-elevation toggle in the tool header. Renders only for users with admin scope (handled by elevation.js; the placeholder is `.hidden` by default). When visible, sits left @@ -2563,7 +2582,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.19 + v0.0.20
@@ -10859,6 +10878,170 @@ window.app.modules.filtering = { window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; })(); +// 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' + }; + + // 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/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index d7f6eca..23bec60 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -836,6 +836,25 @@ body.help-open .app-header { 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); +} + /* shared/logo.css — paired with shared/logo.js. The wrapping anchor inherits the logo's box and adds a subtle hover/focus affordance so it reads as clickable without altering the logo's visual weight. */ @@ -2350,7 +2369,7 @@ body {
ZDDC Browse - v0.0.19 + v0.0.20
@@ -5356,8 +5375,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // // `items` is an array (or a function returning an array, evaluated // against `context` at open-time). Each entry is one of: -// { label, action, icon?, accel?, disabled?, visible?, danger? } +// { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? } // — a normal menu item; `action(ctx)` fires on click/Enter. +// `tooltip` (string or fn(ctx)) sets the row's title attribute — +// useful for explaining WHY a disabled item is unavailable +// ("You don't have write access here", etc.). // { label, checked, action, ... } // — toggle item; `checked` may be a bool or a fn(ctx). Renders // a ✓ in the gutter when truthy. @@ -5368,10 +5390,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // are collapsed automatically so callers can build items // conditionally without managing dividers. // -// Any of `label`, `checked`, `visible`, `disabled`, and `items` may -// be a function — each is invoked with the context object so callers -// can render fully context-aware menus from a single declarative -// config. +// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and +// `items` may be a function — each is invoked with the context object +// so callers can render fully context-aware menus from a single +// declarative config. // // Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a // submenu, ArrowLeft / Escape backs up one level (or closes if @@ -5493,6 +5515,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr row.classList.add('is-disabled'); row.setAttribute('aria-disabled', 'true'); } + if ('tooltip' in item) { + var tip = resolve(item.tooltip, ctx); + if (tip) row.title = String(tip); + } row.setAttribute('role', hasSub ? 'menuitem' : (isToggle ? 'menuitemcheckbox' : 'menuitem')); @@ -5876,6 +5902,170 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; })(); +// 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' + }; + + // 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 }; +})(); + // shared/icons.js — minimal outline SVG sprite for ZDDC tools. // // Vendored from Lucide (https://lucide.dev, ISC). Only the 16 @@ -6607,8 +6797,26 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // Server-computed write authority — true if the policy // decider would allow a PUT for the calling principal. // Absent / false means "save will 403"; preview editors - // read this to mount in read-only mode. + // read this to mount in read-only mode. Superseded by + // verbs (below); kept in lockstep during the transition. writable: !!e.writable, + // Server-computed verb set: canonical "rwcda" subset the + // calling principal holds at this entry's URL. Per-entry + // gating in the context menu (Rename/Delete) reads this + // through zddc.cap.has(node, 'w'|'d'). + // + // "rw…" — zddc-server emitted explicit grant. + // "" — zddc-server emitted explicit zero grant + // (rare; usually the entry would have been + // filtered before reaching the client). + // undefined — the server didn't emit a verbs field at + // all (Caddy or any non-zddc backend). + // cap.has and the events.js gates treat + // this as "verbs unknown" and skip the + // per-entry cascade gate; canMutate + + // whatever the server enforces on the + // actual PUT/DELETE still apply. + verbs: typeof e.verbs === 'string' ? e.verbs : undefined, // FS-API specific (null in server mode): handle: null }; @@ -6821,7 +7029,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // whether to mount read-only. Dropping the field here // silently makes every node read-only — the actual root // cause behind "I'm admin but the editor says read-only". - writable: !!raw.writable + writable: !!raw.writable, + // Server-computed verb set (canonical "rwcda" subset). + // Per-entry permission gating reads this via + // zddc.cap.has(node, verb). Three states: + // "rw…" — zddc-server explicit grant + // "" — zddc-server explicit zero grant + // undefined — Caddy / FS-API listings (no verbs field). + // Per-entry gates skip the cascade check + // and fall back to canMutate / writable. + verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined }; state.nodes.set(id, node); return node; @@ -8125,11 +8342,14 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr function canSave(node) { if (isZipMemberNode(node)) return false; - // Server-computed authority gate. The listing's `writable` - // bit reflects what a PUT would do — false here means the - // file API would 403 the save, so we mount in read-only - // mode rather than letting the user type and lose changes. - if (node.url && window.app.state.source === 'server' && !node.writable) return false; + // Server-computed authority gate. The listing's verbs string + // tells us whether a PUT to this entry would be allowed — + // false here means the file API would 403, so we mount in + // read-only mode rather than letting the user type and lose + // changes. cap.has() falls back to node.writable for 'w' + // when verbs is absent (offline FS-API listings). + if (node.url && window.app.state.source === 'server' + && window.zddc.cap && !window.zddc.cap.has(node, 'w')) return false; if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; @@ -8665,10 +8885,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // user home, canonical-folder virtuals) is just a tree // affordance, not a writable file. if (node.virtual && node.name !== '.zddc') return false; - // Server-computed authority gate. Mirrors the markdown editor's - // check — listing's `writable` bit is the same decision the - // file API would reach on PUT. - if (node.url && window.app.state.source === 'server' && !node.writable) return false; + // Server-computed authority gate. The virtual .zddc entry + // requires the admin verb 'a' (matches fileapi.go's + // ActionAdmin gate at the .zddc URL); regular YAML files + // require write 'w'. cap.has falls back to node.writable for + // 'w' when verbs is absent (offline FS-API listings). + if (node.url && window.app.state.source === 'server' && window.zddc.cap) { + var needed = node.name === '.zddc' ? 'a' : 'w'; + if (!window.zddc.cap.has(node, needed)) return false; + } if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.url && window.app.state.source === 'server') return true; return false; @@ -10834,13 +11059,22 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // stage.js — Stage and Unstage workflow modals. // -// Stage: move a file from working/<…>/ into a transmittal folder under -// staging/<…>/. Modal lists existing transmittal folders in staging/ -// plus a "New transmittal folder…" option that prompts for a ZDDC- -// conforming name and mkdirs it before the move. +// After the layout reshape, working/ and staging/ live INSIDE each +// party folder: archive//working// and +// archive//staging//. Stage and Unstage are now +// per-party — the destination batch is always inside the SAME +// party's staging slot. The party context is read from the source +// file's path. // -// Unstage: move a file from staging// back to the user's -// working// home (overridable). +// Stage: move a file from archive//working/<…> into a +// transmittal folder under archive//staging/<…>. Modal lists +// existing transmittal folders in the party's staging/ plus a "New +// transmittal folder…" option that prompts for a ZDDC-conforming +// name and mkdirs it before the move. +// +// Unstage: move a file from archive//staging// +// back to the user's archive//working// home +// (overridable). // // Both reuse the existing X-ZDDC-Op: move primitive — no new composite // endpoint is needed; the client just orchestrates one POST per file @@ -10860,32 +11094,37 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } // ── Scope detection: path-shape, not cascade-content ────────────── - // A file is stageable if its containing folder lives under - // //working/<…>. Unstageable if it lives under - // //staging//<…>. Both are path-shape - // queries — content/ACL is enforced server-side. + // A file is stageable if its path matches + // //archive//working/<…>. Unstageable if it + // matches //archive//staging//<…>. + // Both are path-shape queries — content/ACL is enforced server- + // side. - function projectAndSubtree(path) { + // projectPartySlot returns { project, party, slot, rest } when + // path matches //archive///, or + // null on non-match. + function projectPartySlot(path) { var rel = path.replace(/^\/+|\/+$/g, '').split('/'); - if (rel.length < 2) return null; - return { project: rel[0], subtree: rel[1], rest: rel.slice(2) }; + if (rel.length < 4) return null; + if (rel[1].toLowerCase() !== 'archive') return null; + return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) }; } function isStageableFile(node) { if (!node || node.isDir || node.virtual) return false; var tree = window.app.modules.tree; if (!tree) return false; - var p = projectAndSubtree(tree.pathFor(node)); - return !!(p && p.subtree === 'working' && p.rest.length >= 1); + var p = projectPartySlot(tree.pathFor(node)); + return !!(p && p.slot === 'working' && p.rest.length >= 1); } function isUnstageableFile(node) { if (!node || node.isDir || node.virtual) return false; var tree = window.app.modules.tree; if (!tree) return false; - var p = projectAndSubtree(tree.pathFor(node)); - // staging// — at least one folder - // segment between staging/ and the file. - return !!(p && p.subtree === 'staging' && p.rest.length >= 2); + var p = projectPartySlot(tree.pathFor(node)); + // archive//staging// — at + // least one folder segment between staging/ and the file. + return !!(p && p.slot === 'staging' && p.rest.length >= 2); } // ── Server helpers ───────────────────────────────────────────────── @@ -10903,8 +11142,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return Array.isArray(data) ? data : []; } - async function fetchStagingFolders(project) { - var entries = await listDir('/' + project + '/staging/'); + async function fetchStagingFolders(project, party) { + var entries = await listDir( + '/' + project + '/archive/' + encodeURIComponent(party) + '/staging/'); return entries .filter(function (e) { return e && e.isDir; }) .map(function (e) { return e.name; }); @@ -11090,14 +11330,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); - var info = projectAndSubtree(srcUrl); - if (!info || info.subtree !== 'working') { - status('Stage applies only to files under working/.', 'error'); + var info = projectPartySlot(srcUrl); + if (!info || info.slot !== 'working') { + status('Stage applies only to files under archive//working/.', 'error'); return; } - var stagingBase = '/' + info.project + '/staging/'; + var stagingBase = '/' + info.project + '/archive/' + + encodeURIComponent(info.party) + '/staging/'; var folders; - try { folders = await fetchStagingFolders(info.project); } + try { folders = await fetchStagingFolders(info.project, info.party); } catch (e) { status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error'); return; @@ -11124,20 +11365,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr status((e && e.message) || 'move failed', 'error'); return; } - status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success'); + status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success'); } async function invokeUnstage(node) { var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); - var info = projectAndSubtree(srcUrl); - if (!info || info.subtree !== 'staging') { - status('Unstage applies only to files under staging/.', 'error'); + var info = projectPartySlot(srcUrl); + if (!info || info.slot !== 'staging') { + status('Unstage applies only to files under archive//staging/.', 'error'); return; } var email = await fetchSelfEmail(); - var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/'; + var defaultTarget = '/' + info.project + '/archive/' + + encodeURIComponent(info.party) + '/working/' + (email || '') + '/'; var choice; try { choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget }); @@ -12206,16 +12448,55 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr { separator: true }, // ── Rename + Delete (the permission-gated pair) ── + // + // Two gates compose: canMutate() rules out un-writable + // sources (offline FS-API without a handle, zip members, + // virtual placeholders) and — when the listing carries + // server-cascade verbs — zddc.cap.has(node, verb) applies + // the per-entry ACL. The verbs gate is server-mode only; + // file:// FS-API and plain Caddy listings have no verbs + // field, so we fall back to canMutate alone (FS-API + // enforces locally; Caddy has no PUT/DELETE either way). + // Server-side ACL still has the final say on the actual + // PUT/DELETE if a stale client tries the action. { label: 'Rename…', - disabled: function (c) { return !canMutate(c); }, + disabled: function (c) { + if (!canMutate(c)) return true; + if (!serverMode || !window.zddc.cap) return false; + // verbs===undefined → Caddy or other non-zddc + // server, no cascade signal to gate on. verbs==="" + // is zddc-server's explicit zero grant; still + // gate (disable). verbs==="rw…" → check the bit. + if (typeof c.node.verbs !== 'string') return false; + return !window.zddc.cap.has(c.node, 'w'); + }, + tooltip: function (c) { + if (!serverMode || !canMutate(c)) return ''; + if (!window.zddc.cap) return ''; + if (typeof c.node.verbs !== 'string') return ''; + if (window.zddc.cap.has(c.node, 'w')) return ''; + return "You don't have write access to this item."; + }, action: function (c) { renameNode(c.node); } }, { label: 'Delete…', icon: '🗑', danger: true, - disabled: function (c) { return !canMutate(c); }, + disabled: function (c) { + if (!canMutate(c)) return true; + if (!serverMode || !window.zddc.cap) return false; + if (typeof c.node.verbs !== 'string') return false; + return !window.zddc.cap.has(c.node, 'd'); + }, + tooltip: function (c) { + if (!serverMode || !canMutate(c)) return ''; + if (!window.zddc.cap) return ''; + if (typeof c.node.verbs !== 'string') return ''; + if (window.zddc.cap.has(c.node, 'd')) return ''; + return "You don't have delete access to this item."; + }, action: function (c) { deleteNode(c.node); } }, { separator: true }, diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index f7ca3f1..6a1ccc6 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -836,6 +836,25 @@ body.help-open .app-header { 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); +} + /* shared/elevation.css — admin-elevation toggle in the tool header. Renders only for users with admin scope (handled by elevation.js; the placeholder is `.hidden` by default). When visible, sits left @@ -1774,7 +1793,7 @@ body.is-elevated::after {
ZDDC Classifier - v0.0.19 + v0.0.20
@@ -9994,6 +10013,170 @@ X.B(E,Y);return E}return J}()) window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; })(); +// 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' + }; + + // 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/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index d5bcb96..eaa79e9 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -836,6 +836,25 @@ body.help-open .app-header { 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); +} + /* shared/elevation.css — admin-elevation toggle in the tool header. Renders only for users with admin scope (handled by elevation.js; the placeholder is `.hidden` by default). When visible, sits left @@ -1517,7 +1536,7 @@ body {
ZDDC - v0.0.19 + v0.0.20
@@ -2676,6 +2695,170 @@ body { window.zddc.elevation = { isElevated: isElevated, setElevated: setElevated }; })(); +// 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' + }; + + // 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 }; +})(); + (function() { 'use strict'; // ZDDC landing page — project picker. diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index 398eb09..6a1a727 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -840,6 +840,25 @@ body.help-open .app-header { 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); +} + /* shared/elevation.css — admin-elevation toggle in the tool header. Renders only for users with admin scope (handled by elevation.js; the placeholder is `.hidden` by default). When visible, sits left @@ -2616,7 +2635,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.19 + v0.0.20
JavaScript not available