From 7c0b66590cd34c909225c8373d2a7259c1aa2dbb Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 12 Jun 2026 14:58:20 -0500 Subject: [PATCH] =?UTF-8?q?feat(server,shared):=20tell=20denied=20users=20?= =?UTF-8?q?who=20can=20=E2=80=94=20subtly,=20before=20wasted=20effort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user lacks permission, the app should (a) not let them do data entry it will reject and (b) subtly say who can. General mechanism + the key gates. Server — compute & expose "who can here": - zddc.WhoCan(chain, verb) → Authority{Roles, People}: the acl.permissions grantees holding the verb across the cascade (roles + their members) plus the admins (who bypass). New whocan.go + whocan_test.go. - AccessView gains path_who_can (profilehandler.go), populated only for verbs the caller LACKS and only when they can read the path (mirrors .zddc readability), so one cap.at() answers "can I?" and "if not, who?". - writeForbiddenWho enriches the 403 body with who_can for the missing verb (errors.go); authorizeAction uses it (fileapi.go) as the safety net for denials that weren't pre-checked. Shared — shared/cap.js: - cap.whoCan(view, verb) + cap.denyHint(view, verb) → {text, title}, role-first ("Only the document controller can create here") with the people in the tooltip. - handleForbidden appends the hint (from the 403 body, else the cached view), so every tool that already routes 403s through it (form save, tables save, browse) now explains who can — for free. Key gates: - Browse party-create (the reported bug): pre-check create authority on ssr/ and the slot BEFORE opening the picker — if the user can do neither, show the hint instead of the form; if only existing parties are usable, disable "+ New party" with the who-can hint. The post-hoc 403 catch now names who can too. - Tables +Add row disabled state shows the who-can hint. Plus: subtle /_apps/{browse,archive,classifier}.html links in the landing footer. Tests: Go WhoCan unit test (role/person split, admin bypass, dedupe); cap.spec.js (denyHint role-first/people/fallback, whoCan, handleForbidden enrichment) — 5 green; Go handler+zddc+policy suites green. (Pre-existing stale browse toolbar test browse.spec.js:274 unaffected.) Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/events.js | 42 ++++++++++++-- landing/css/landing.css | 15 +++++ landing/template.html | 9 +++ playwright.config.js | 4 ++ shared/cap.js | 57 ++++++++++++++++++- tables/js/main.js | 4 +- tests/cap.spec.js | 66 +++++++++++++++++++++ zddc/internal/handler/errors.go | 20 +++++++ zddc/internal/handler/fileapi.go | 2 +- zddc/internal/handler/profilehandler.go | 30 ++++++++++ zddc/internal/zddc/whocan.go | 76 +++++++++++++++++++++++++ zddc/internal/zddc/whocan_test.go | 63 ++++++++++++++++++++ 12 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 tests/cap.spec.js create mode 100644 zddc/internal/zddc/whocan.go create mode 100644 zddc/internal/zddc/whocan_test.go diff --git a/browse/js/events.js b/browse/js/events.js index 90bf600..ee52c78 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -792,6 +792,13 @@ function openPartyPicker(opts) { return new Promise(function (resolve) { var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; + // The "+ New party" affordance is gated on create authority over ssr/ + // (pre-checked in createInAggregator). When denied, disable it and say + // who can — role-first text inline, the specific people in the tooltip. + var newPartyAllowed = opts.canNewParty !== false; + var newPartyNote = newPartyAllowed ? '(registers a new party)' + : (opts.newPartyHint && opts.newPartyHint.text) || 'You can’t register a new party here.'; + var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || ''; var overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; var box = document.createElement('div'); @@ -810,10 +817,10 @@ 'Pick the party this ' + kindWord + ' belongs to — it lands under ' + escapeHtml(opts.slot) + '/<party>/.' + '

' + '
' + - (partyList || 'No parties yet — create one below.') + - '' + + (partyList || 'No parties yet.') + + '' + '
' + ' + + +