feat(browse): verb-aware gating on rename/delete + editor save

Browse's row context menu and in-place editors now consult the
server-computed verbs string (via window.zddc.cap.has) before
enabling write/delete affordances:

  - Rename… disables when the entry's verbs lacks 'w'.
  - Delete… disables when verbs lacks 'd'.
  - Markdown editor mounts read-only when verbs lacks 'w'.
  - YAML editor mounts read-only when verbs lacks 'w' for regular
    files, 'a' for the .zddc placeholder (matches the file API's
    ActionAdmin gate at that URL).

Disabled menu items carry a tooltip naming the missing access
("You don't have write access to this item.") so the user discovers
which permission is missing rather than just seeing a greyed row.
shared/context-menu.js gains a `tooltip` field (string or fn(ctx))
that sets the row's title attribute.

canMutate() stays as the source-side gate (server vs FS-API
reachability, zip-member / virtual filtering); verbs gate composes
on top. Server-side ACL still has the final say if a stale client
ever tries the action.

cap.has() falls back to node.writable for 'w' when verbs is absent,
so offline FS-API mode keeps working without a server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 08:45:11 -05:00
parent b5b3c92905
commit fbfb8d15a1
4 changed files with 58 additions and 16 deletions

View file

@ -895,16 +895,43 @@
{ separator: true }, { separator: true },
// ── Rename + Delete (the permission-gated pair) ── // ── 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 zddc.cap.has(node, verb)
// applies the server-computed cascade verbs. If either
// bars the action, the item disables with a tooltip
// explaining the reason — server-side ACL still has the
// final say on the actual PUT/DELETE if a stale client
// somehow tries.
{ {
label: 'Rename…', label: 'Rename…',
disabled: function (c) { return !canMutate(c); }, disabled: function (c) {
if (!canMutate(c)) return true;
if (!window.zddc.cap) return false;
return !window.zddc.cap.has(c.node, 'w');
},
tooltip: function (c) {
if (!canMutate(c)) return '';
if (!window.zddc.cap || window.zddc.cap.has(c.node, 'w')) return '';
return "You don't have write access to this item.";
},
action: function (c) { renameNode(c.node); } action: function (c) { renameNode(c.node); }
}, },
{ {
label: 'Delete…', label: 'Delete…',
icon: '🗑', icon: '🗑',
danger: true, danger: true,
disabled: function (c) { return !canMutate(c); }, disabled: function (c) {
if (!canMutate(c)) return true;
if (!window.zddc.cap) return false;
return !window.zddc.cap.has(c.node, 'd');
},
tooltip: function (c) {
if (!canMutate(c)) return '';
if (!window.zddc.cap || window.zddc.cap.has(c.node, 'd')) return '';
return "You don't have delete access to this item.";
},
action: function (c) { deleteNode(c.node); } action: function (c) { deleteNode(c.node); }
}, },
{ separator: true }, { separator: true },

View file

@ -304,11 +304,14 @@
function canSave(node) { function canSave(node) {
if (isZipMemberNode(node)) return false; if (isZipMemberNode(node)) return false;
// Server-computed authority gate. The listing's `writable` // Server-computed authority gate. The listing's verbs string
// bit reflects what a PUT would do — false here means the // tells us whether a PUT to this entry would be allowed —
// file API would 403 the save, so we mount in read-only // false here means the file API would 403, so we mount in
// mode rather than letting the user type and lose changes. // read-only mode rather than letting the user type and lose
if (node.url && window.app.state.source === 'server' && !node.writable) return false; // 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.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true; if (node.url && window.app.state.source === 'server') return true;
return false; return false;

View file

@ -82,10 +82,15 @@
// user home, canonical-folder virtuals) is just a tree // user home, canonical-folder virtuals) is just a tree
// affordance, not a writable file. // affordance, not a writable file.
if (node.virtual && node.name !== '.zddc') return false; if (node.virtual && node.name !== '.zddc') return false;
// Server-computed authority gate. Mirrors the markdown editor's // Server-computed authority gate. The virtual .zddc entry
// check — listing's `writable` bit is the same decision the // requires the admin verb 'a' (matches fileapi.go's
// file API would reach on PUT. // ActionAdmin gate at the .zddc URL); regular YAML files
if (node.url && window.app.state.source === 'server' && !node.writable) return false; // 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.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true; if (node.url && window.app.state.source === 'server') return true;
return false; return false;

View file

@ -9,8 +9,11 @@
// //
// `items` is an array (or a function returning an array, evaluated // `items` is an array (or a function returning an array, evaluated
// against `context` at open-time). Each entry is one of: // 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. // — 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, ... } // { label, checked, action, ... }
// — toggle item; `checked` may be a bool or a fn(ctx). Renders // — toggle item; `checked` may be a bool or a fn(ctx). Renders
// a ✓ in the gutter when truthy. // a ✓ in the gutter when truthy.
@ -21,10 +24,10 @@
// are collapsed automatically so callers can build items // are collapsed automatically so callers can build items
// conditionally without managing dividers. // conditionally without managing dividers.
// //
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may // Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
// be a function — each is invoked with the context object so callers // `items` may be a function — each is invoked with the context object
// can render fully context-aware menus from a single declarative // so callers can render fully context-aware menus from a single
// config. // declarative config.
// //
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a // Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
// submenu, ArrowLeft / Escape backs up one level (or closes if // submenu, ArrowLeft / Escape backs up one level (or closes if
@ -146,6 +149,10 @@
row.classList.add('is-disabled'); row.classList.add('is-disabled');
row.setAttribute('aria-disabled', 'true'); row.setAttribute('aria-disabled', 'true');
} }
if ('tooltip' in item) {
var tip = resolve(item.tooltip, ctx);
if (tip) row.title = String(tip);
}
row.setAttribute('role', row.setAttribute('role',
hasSub ? 'menuitem' hasSub ? 'menuitem'
: (isToggle ? 'menuitemcheckbox' : 'menuitem')); : (isToggle ? 'menuitemcheckbox' : 'menuitem'));