feat(shared): cap.js client helpers for permission gating
Three small helpers under window.zddc.cap, wired into every tool's
build:
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 on path_verbs / path_is_admin
/ path_can_elevate_grant.
cap.has(node, verb) — boolean. Reads the listing entry's
verbs string for the named verb.
Falls back to node.writable for 'w'
when verbs is absent (offline FS-API
listings or pre-promotion clients).
cap.handleForbidden(resp, — parses a 403 response's JSON body for
opts) missing_verb and renders an error
toast. When opts.path is supplied AND
the path-scoped access view reports
path_can_elevate_grant covering the
missing verb, the toast appends an
"Elevate" button that flips the
elevation cookie and reloads.
Browse loader.js + tree.js carry the new verbs field through to the
node objects so context-menu gating can call cap.has(node, 'w'|'d')
without changing the legacy node.writable contract. New CSS rule
.zddc-toast__action styles the inline Elevate button.
Concatenation order: cap.js comes after toast.js + elevation.js so
the dependencies (window.zddc.toast, window.zddc.elevation) are
present at module-load time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4a33aa9b3
commit
b5b3c92905
11 changed files with 197 additions and 2 deletions
|
|
@ -64,6 +64,7 @@ concat_files \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/cap.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ concat_files \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"../shared/context-menu.js" \
|
"../shared/context-menu.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/cap.js" \
|
||||||
"../shared/icons.js" \
|
"../shared/icons.js" \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,16 @@
|
||||||
// Server-computed write authority — true if the policy
|
// Server-computed write authority — true if the policy
|
||||||
// decider would allow a PUT for the calling principal.
|
// decider would allow a PUT for the calling principal.
|
||||||
// Absent / false means "save will 403"; preview editors
|
// 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,
|
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'). Empty string is the
|
||||||
|
// explicit-deny case; absence (offline FS-API mode) makes
|
||||||
|
// zddc.cap.has fall back to the writable bit for 'w'.
|
||||||
|
verbs: typeof e.verbs === 'string' ? e.verbs : '',
|
||||||
// FS-API specific (null in server mode):
|
// FS-API specific (null in server mode):
|
||||||
handle: null
|
handle: null
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,14 @@
|
||||||
// whether to mount read-only. Dropping the field here
|
// whether to mount read-only. Dropping the field here
|
||||||
// silently makes every node read-only — the actual root
|
// silently makes every node read-only — the actual root
|
||||||
// cause behind "I'm admin but the editor says read-only".
|
// 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 in the context menu reads
|
||||||
|
// this via zddc.cap.has(node, verb). Empty string = no
|
||||||
|
// verbs known / explicit deny; offline FS-API listings
|
||||||
|
// leave it empty and gating falls back to the writable
|
||||||
|
// bit through cap.has's transition shim.
|
||||||
|
verbs: typeof raw.verbs === 'string' ? raw.verbs : ''
|
||||||
};
|
};
|
||||||
state.nodes.set(id, node);
|
state.nodes.set(id, node);
|
||||||
return node;
|
return node;
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ concat_files \
|
||||||
"js/excel.js" \
|
"js/excel.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/cap.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/cap.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
"js/util.js" \
|
"js/util.js" \
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/cap.js" \
|
||||||
"js/landing.js" \
|
"js/landing.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
|
|
|
||||||
154
shared/cap.js
Normal file
154
shared/cap.js
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
// shared/cap.js — client-side capability helpers for permission gating.
|
||||||
|
//
|
||||||
|
// Three small helpers, exposed under window.zddc.cap, that wrap the
|
||||||
|
// server's verbs / /.profile/access?path / 403 missing_verb surface:
|
||||||
|
//
|
||||||
|
// zddc.cap.at(path) — Promise<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) {
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
|
|
@ -38,3 +38,22 @@
|
||||||
from { transform: translateX(0); opacity: 1; }
|
from { transform: translateX(0); opacity: 1; }
|
||||||
to { transform: translateX(100%); opacity: 0; }
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/cap.js" \
|
||||||
"../shared/context-menu.js" \
|
"../shared/context-menu.js" \
|
||||||
"js/mode.js" \
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ concat_files \
|
||||||
"js/focus.js" \
|
"js/focus.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/elevation.js" \
|
"../shared/elevation.js" \
|
||||||
|
"../shared/cap.js" \
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
> "$js_raw"
|
> "$js_raw"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue