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 },
|
{ 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 },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue