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:
parent
b5b3c92905
commit
fbfb8d15a1
4 changed files with 58 additions and 16 deletions
|
|
@ -895,16 +895,43 @@
|
|||
{ 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 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…',
|
||||
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); }
|
||||
},
|
||||
{
|
||||
label: 'Delete…',
|
||||
icon: '🗑',
|
||||
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); }
|
||||
},
|
||||
{ separator: true },
|
||||
|
|
|
|||
|
|
@ -304,11 +304,14 @@
|
|||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -82,10 +82,15 @@
|
|||
// 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;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@
|
|||
//
|
||||
// `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.
|
||||
|
|
@ -21,10 +24,10 @@
|
|||
// 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
|
||||
|
|
@ -146,6 +149,10 @@
|
|||
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'));
|
||||
|
|
|
|||
Loading…
Reference in a new issue