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>/.' +
'