feat(tables): gate +Add row on path verbs.c + cap-toast on 403

Two server-aligned signals on save paths:

  - +Add row button: fetches /.profile/access?path=<current dir> via
    zddc.cap.at() once on load; if path_verbs doesn't include 'c'
    the button disables with a tooltip ("You don't have create
    access in this folder."). Async race-window is the same as any
    other path-scoped fetch — server still gates the POST so a
    stale client gets a 403 toast on click rather than a silent
    accept.

  - 403 on save/create: previously fell into the generic
    "http-error" bucket with a console warn; now branches into
    zddc.cap.handleForbidden which renders an error toast naming the
    missing verb. When the path-scoped view reports an elevation
    grant covering that verb, the toast appends an Elevate button.

Per-row writability stays computed server-side for now — tables
walks rows via FS-API-style handles that don't surface the listing
verbs string. A follow-on pass can switch the row walk to raw
listing entries and gate row.editable on each entry's verbs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 08:48:02 -05:00
parent fbfb8d15a1
commit 34208a5bd7
2 changed files with 49 additions and 0 deletions

View file

@ -125,6 +125,33 @@
addRowBtn.addEventListener('keydown', function (ev) { addRowBtn.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(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);
}
});
}
} }
} }

View file

@ -316,6 +316,17 @@
return { status: 'invalid', errors: errs }; 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. // Other status — generic error.
console.warn('[tables] save returned', resp.status); console.warn('[tables] save returned', resp.status);
setRowState(rowId, 'errored'); setRowState(rowId, 'errored');
@ -395,6 +406,17 @@
return { status: 'invalid', errors: errs }; 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); console.warn('[tables] createRow returned', resp.status);
setRowState(rowId, 'errored'); setRowState(rowId, 'errored');
return { status: 'http-error', code: resp.status }; return { status: 'http-error', code: resp.status };