release: v0.0.20 lockstep
This commit is contained in:
parent
360049f482
commit
43c2879e9c
7 changed files with 1348 additions and 67 deletions
|
|
@ -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"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp">v0.0.19</span>
|
||||
<span class="build-timestamp">v0.0.20</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
@ -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<AccessView|null>. Fetches
|
||||
// /.profile/access?path=<urlpath> 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 };
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</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 {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp">v0.0.19</span>
|
||||
<span class="build-timestamp">v0.0.20</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
|
|
@ -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<AccessView|null>. Fetches
|
||||
// /.profile/access?path=<urlpath> 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/<party>/working/<email>/<file> and
|
||||
// archive/<party>/staging/<batch>/<file>. 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/<transmittal>/ back to the user's
|
||||
// working/<email>/ home (overridable).
|
||||
// Stage: move a file from archive/<party>/working/<…> into a
|
||||
// transmittal folder under archive/<party>/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/<party>/staging/<transmittal>/
|
||||
// back to the user's archive/<party>/working/<email>/ 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
|
||||
// /<project>/working/<…>. Unstageable if it lives under
|
||||
// /<project>/staging/<transmittal>/<…>. Both are path-shape
|
||||
// queries — content/ACL is enforced server-side.
|
||||
// A file is stageable if its path matches
|
||||
// /<project>/archive/<party>/working/<…>. Unstageable if it
|
||||
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
|
||||
// Both are path-shape queries — content/ACL is enforced server-
|
||||
// side.
|
||||
|
||||
function projectAndSubtree(path) {
|
||||
// projectPartySlot returns { project, party, slot, rest } when
|
||||
// path matches /<project>/archive/<party>/<slot>/<rest…>, 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/<transmittal-folder>/<file> — 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/<party>/staging/<transmittal-folder>/<file> — 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/<party>/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/<party>/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 },
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp">v0.0.19</span>
|
||||
<span class="build-timestamp">v0.0.20</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
@ -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<AccessView|null>. Fetches
|
||||
// /.profile/access?path=<urlpath> 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 };
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</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 {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp">v0.0.19</span>
|
||||
<span class="build-timestamp">v0.0.20</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -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<AccessView|null>. Fetches
|
||||
// /.profile/access?path=<urlpath> 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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp">v0.0.19</span>
|
||||
<span class="build-timestamp">v0.0.20</span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
@ -13418,6 +13437,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<AccessView|null>. Fetches
|
||||
// /.profile/access?path=<urlpath> 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 (app) {
|
||||
'use strict';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.19
|
||||
transmittal=v0.0.19
|
||||
classifier=v0.0.19
|
||||
landing=v0.0.19
|
||||
form=v0.0.19
|
||||
tables=v0.0.19
|
||||
browse=v0.0.19
|
||||
archive=v0.0.20
|
||||
transmittal=v0.0.20
|
||||
classifier=v0.0.20
|
||||
landing=v0.0.20
|
||||
form=v0.0.20
|
||||
tables=v0.0.20
|
||||
browse=v0.0.20
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1515,7 +1534,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.20-dev · 2026-05-20 20:09:54 · 703449a-dirty</span></span>
|
||||
<span class="build-timestamp">v0.0.20</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -2952,6 +2971,170 @@ body.is-elevated::after {
|
|||
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<AccessView|null>. Fetches
|
||||
// /.profile/access?path=<urlpath> 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/context-menu.js — generic context-menu framework exposed on
|
||||
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
|
||||
// menu (or any programmatically-opened menu) onto its UI without
|
||||
|
|
@ -2963,8 +3146,11 @@ body.is-elevated::after {
|
|||
//
|
||||
// `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.
|
||||
|
|
@ -2975,10 +3161,10 @@ body.is-elevated::after {
|
|||
// 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
|
||||
|
|
@ -3100,6 +3286,10 @@ body.is-elevated::after {
|
|||
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'));
|
||||
|
|
@ -5440,6 +5630,17 @@ body.is-elevated::after {
|
|||
return { status: 'invalid', errors: errs };
|
||||
}
|
||||
|
||||
if (resp.status === 403) {
|
||||
setRowState(rowId, 'errored');
|
||||
if (window.zddc && window.zddc.cap) {
|
||||
window.zddc.cap.handleForbidden(resp, {
|
||||
context: 'Save row',
|
||||
path: location.pathname
|
||||
});
|
||||
}
|
||||
return { status: 'forbidden' };
|
||||
}
|
||||
|
||||
// Other status — generic error.
|
||||
console.warn('[tables] save returned', resp.status);
|
||||
setRowState(rowId, 'errored');
|
||||
|
|
@ -5519,6 +5720,17 @@ body.is-elevated::after {
|
|||
return { status: 'invalid', errors: errs };
|
||||
}
|
||||
|
||||
if (resp.status === 403) {
|
||||
setRowState(rowId, 'errored');
|
||||
if (window.zddc && window.zddc.cap) {
|
||||
window.zddc.cap.handleForbidden(resp, {
|
||||
context: 'Add row',
|
||||
path: location.pathname
|
||||
});
|
||||
}
|
||||
return { status: 'forbidden' };
|
||||
}
|
||||
|
||||
console.warn('[tables] createRow returned', resp.status);
|
||||
setRowState(rowId, 'errored');
|
||||
return { status: 'http-error', code: resp.status };
|
||||
|
|
@ -6527,6 +6739,33 @@ body.is-elevated::after {
|
|||
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');
|
||||
addRowBtn.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7376,6 +7615,12 @@ body.is-elevated::after {
|
|||
showStatus('Please correct the errors below.', 'error');
|
||||
} else if (res.status === 403) {
|
||||
showStatus('You are not allowed to submit here.', 'error');
|
||||
if (window.zddc && window.zddc.cap) {
|
||||
window.zddc.cap.handleForbidden(res, {
|
||||
context: 'Submit',
|
||||
path: app.context.submitUrl
|
||||
});
|
||||
}
|
||||
} else if (res.status === 409) {
|
||||
showStatus('A submission with this filename already exists.', 'error');
|
||||
} else {
|
||||
|
|
@ -7476,6 +7721,29 @@ body.is-elevated::after {
|
|||
const submitBtn = document.getElementById('submit-btn');
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener('click', app.modules.post.submit);
|
||||
// Pre-flight gate: hide Submit when the cascade denies
|
||||
// create at the submission directory. Server still
|
||||
// enforces on POST — this just avoids dangling an
|
||||
// affordance that would 403. Submission directory is the
|
||||
// parent of submitUrl; fall back to the page URL when
|
||||
// submitUrl is absent (file:// / no-context mode).
|
||||
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
|
||||
const subUrl = app.context.submitUrl;
|
||||
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
|
||||
window.zddc.cap.at(dir).then(function (view) {
|
||||
if (!view) return;
|
||||
const verbs = view.path_verbs || '';
|
||||
if (verbs.indexOf('c') === -1) {
|
||||
submitBtn.hidden = true;
|
||||
const status = document.getElementById('form-status');
|
||||
if (status) {
|
||||
status.textContent = "You don't have permission to submit here.";
|
||||
status.hidden = false;
|
||||
status.classList.add('is-error');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue