Compare commits

..

No commits in common. "605f4ab3e02b6b06329d6681519ca250d7ebc1ee" and "3d553ce9d43c66237c42b33272e1c3f21eb4b969" have entirely different histories.

27 changed files with 188 additions and 919 deletions

View file

@ -792,13 +792,6 @@
function openPartyPicker(opts) { function openPartyPicker(opts) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; 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 cant register a new party here.';
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
var overlay = document.createElement('div'); 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;'; 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'); var box = document.createElement('div');
@ -817,10 +810,10 @@
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' + 'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' + '</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' + '<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet.</em>') + (partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' + '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' + '<input type="radio" name="pp-party" value="__new__">' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' + '<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
'</div>' + '</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' + '<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' + '<label for="pp-newparty">New party name</label><br>' +
@ -877,28 +870,8 @@
async function createInAggregator(agg, kind) { async function createInAggregator(agg, kind) {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (!up) return; if (!up) return;
var cap = window.zddc && window.zddc.cap;
var ssrPath = '/' + agg.project + '/ssr/';
var slotPath = '/' + agg.project + '/' + agg.slot + '/';
// Pre-check create authority BEFORE any data entry: registering a new
// party needs `c` on ssr/, creating under an existing party needs `c` on
// the slot. If the user can do neither, don't open the form just to deny
// them — say (subtly) who can. If they can only use existing parties,
// open the picker with the "+ New party" option disabled + explained.
var canNewParty = true, ssrView = null, slotView = null;
if (cap && cap.at) {
slotView = await cap.at(slotPath);
ssrView = await cap.at(ssrPath);
var canSlot = !slotView || cap.has({ verbs: slotView.path_verbs }, 'c');
canNewParty = !ssrView || cap.has({ verbs: ssrView.path_verbs }, 'c');
if (slotView && !canSlot && !canNewParty) {
statusError(cap.denyHint(slotView, 'c').text);
return;
}
}
var parties = await fetchParties(agg.project); var parties = await fetchParties(agg.project);
var newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null; var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties, canNewParty: canNewParty, newPartyHint: newPartyHint });
if (!choice) return; if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding // Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf. // needed for the party segment; makeDir/makeFile encode the leaf.
@ -921,10 +894,7 @@
} catch (e) { } catch (e) {
var msg = (e && e.message) || String(e); var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) { if (/\b403\b/.test(msg)) {
// Name who can — best-effort, for the path the denial came from. statusError('Not allowed — registering a new party requires the document-controller role.');
var denied = choice.isNew ? ssrPath : ('/' + agg.project + '/' + agg.slot + '/' + choice.party + '/');
var v = (cap && cap.at) ? await cap.at(denied) : null;
statusError(v && cap.denyHint ? cap.denyHint(v, 'c').text : 'Not allowed — you dont have create access here.');
} else if (/\b409\b/.test(msg)) { } else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).'); statusError('Unknown party — register it first (document controller).');
} else { } else {

View file

@ -26,7 +26,6 @@ concat_files \
"../shared/profile-menu.css" \ "../shared/profile-menu.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"css/base.css" \ "css/base.css" \
"../shared/seltable.css" \
"css/layout.css" \ "css/layout.css" \
"css/spreadsheet.css" \ "css/spreadsheet.css" \
> "$css_temp" > "$css_temp"

View file

@ -628,10 +628,47 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.scratch-match__tn { font-family: var(--mono, monospace); } .scratch-match__tn { font-family: var(--mono, monospace); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; } .scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and /* ── MDL-from-archive overlay ───────────────────────────────────────────── */
shared with the tables tool); only the classifier-specific catalog bits .mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
(.seltable__extra, .mdl-rev__input, .fromlist-*, .src-badge, #mdlTree) are .mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; }
here. */ .mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; }
.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
.mdl-overlay__close:hover { color: var(--text); }
.mdl-overlay__status { padding: 0.4rem 1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__table { flex: 1; min-height: 0; }
.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* ── Shared selectable + autofilter table (seltable) ────────────────────── */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__filter {
flex: 1; min-width: 8rem; padding: 0.3rem 0.5rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.85rem;
}
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
/* Per-column filter inputs: fill the column (min-width:0-ish) so they never
force a column wider than its header/cells. */
.seltable__table thead tr.seltable__filters th { padding: 0.08rem 0.3rem; }
.seltable__colfilter {
width: 100%; min-width: 2rem; box-sizing: border-box;
padding: 0.1rem 0.3rem; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.72rem; font-weight: 400; letter-spacing: 0; text-transform: none;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── Copy destination dialog ────────────────────────────────────────────── */ /* ── Copy destination dialog ────────────────────────────────────────────── */
.copy-choice__backdrop { .copy-choice__backdrop {

View file

@ -378,6 +378,15 @@
return out; return out;
} }
// Files currently placed in a node (reverse lookup over all source files).
function filesInNode(nodeId, axis, allFiles) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
return (allFiles || []).filter(function (f) {
var a = state.assignments[srcKeyForFile(f)];
return a && a[field] === nodeId;
});
}
// Per-file classification state for the left-tree markers. // Per-file classification state for the left-tree markers.
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none' // 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
function fileState(file) { function fileState(file) {
@ -415,11 +424,7 @@
transmittalTree: state.transmittalTree, transmittalTree: state.transmittalTree,
outputName: state.outputName, outputName: state.outputName,
config: state.config, config: state.config,
// Strip the transient row→keys hint (`placed`) — it's rebuilt as mdlList: state.mdlList,
// drops happen and would otherwise bloat every autosave.
mdlList: state.mdlList.map(function (r) {
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
}),
}; };
} }
function load(obj) { function load(obj) {
@ -853,7 +858,7 @@
getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; }, getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse // derive + reverse
deriveTarget: deriveTarget, deriveTarget: deriveTarget, filesInNode: filesInNode,
fileState: fileState, stats: stats, fileState: fileState, stats: stats,
// persistence // persistence
serialize: serialize, load: load, reset: reset, serialize: serialize, load: load, reset: reset,

View file

@ -55,14 +55,7 @@
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () { if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
var list = C().getMdlList(); if (!C().getMdlList().length) return;
if (!list.length) return;
// Warn before stranding files that still need a revision: they stay
// assigned (on a "pending" leaf under By tracking number), but the
// row you'd use to finish them here is about to disappear.
var pending = 0;
list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; });
if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder under By tracking number), but the list row to finish them here goes away. Clear anyway?')) return;
C().clearMdlList(); C().clearMdlList();
window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info'); window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info');
}); });
@ -608,11 +601,11 @@
}); });
} }
// "From a list" loader: "Load…" opens a multi-select directory tree (scoped // Load the catalog: "Load…" opens a multi-select directory tree (scoped to
// to the served context); every ticked directory is walked recursively into // the served context); every ticked directory is walked recursively into the
// the union of existing files + MDL deliverables, deduped by tracking number // union of existing files + MDL deliverables, deduped by tracking number to
// to one row at the latest revision. Writes/alters nothing — the revision // one row at the latest revision. Writes/alters nothing — the revision cell
// cell is classifier-local and starts blank. // is classifier-local and starts blank.
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; }
// The newest combined "<rev> (<status>)" string in a set, by revision token. // The newest combined "<rev> (<status>)" string in a set, by revision token.

View file

@ -472,21 +472,6 @@ body {
text-decoration: underline; text-decoration: underline;
} }
/* Subtle standalone-tools footer. */
.landing-apps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 2rem auto 1rem;
padding: 0 1rem;
max-width: 60rem;
font-size: 0.82rem;
color: var(--text-muted, #6b7280);
}
.landing-apps__label { color: var(--text-muted, #6b7280); }
.landing-apps .browse-link { margin-top: 0; }
#projectView ol { #projectView ol {
padding-left: 1.5rem; padding-left: 1.5rem;
} }

View file

@ -195,15 +195,6 @@
</div><!-- /projectView --> </div><!-- /projectView -->
</main> </main>
<!-- Subtle links to the standalone tools (run against your own local files,
no server). Served at /_apps/<tool>.html by zddc-server. -->
<footer class="landing-apps">
<span class="landing-apps__label">Standalone tools for your own files:</span>
<a class="browse-link" href="/_apps/browse.html">Browse</a>
<a class="browse-link" href="/_apps/archive.html">Archive</a>
<a class="browse-link" href="/_apps/classifier.html">Classifier</a>
</footer>
<!-- Help Panel --> <!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title"> <aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header"> <div class="help-panel__header">

View file

@ -95,10 +95,6 @@ export default defineConfig({
name: 'tables', name: 'tables',
testMatch: 'tables.spec.js', testMatch: 'tables.spec.js',
}, },
{
name: 'cap',
testMatch: 'cap.spec.js',
},
{ {
name: 'tables-mdl', name: 'tables-mdl',
testMatch: 'tables-mdl.spec.js', testMatch: 'tables-mdl.spec.js',

View file

@ -98,48 +98,6 @@
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -153,9 +111,8 @@
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
body = await resp.clone().json(); var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -170,16 +127,6 @@
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -212,5 +159,5 @@
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})(); })();

View file

@ -152,9 +152,7 @@
if (verbs.indexOf('c') === -1) { if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled'); addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true'); addRowBtn.setAttribute('aria-disabled', 'true');
// Tell them who can (subtly): role-first text + people in the tooltip. addRowBtn.title = "You don't have create access in this folder.";
var hint = window.zddc.cap.denyHint ? window.zddc.cap.denyHint(view, 'c') : null;
addRowBtn.title = hint ? (hint.text + (hint.title ? ' (' + hint.title + ')' : '')) : "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the // Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission. // tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) { addRowBtn.addEventListener('click', function (ev) {

View file

@ -271,10 +271,11 @@ test.describe('Browse menu — context & tiers', () => {
expect(res.rwd).toContain('Delete…'); expect(res.rwd).toContain('Delete…');
}); });
// New folder / New file are not toolbar buttons — they live in the test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => {
// row/pane context menu (see the "keyboard menu key and kebab" test).
test('toolbar Sort and Show-hidden drive state', async ({ page }) => {
await openWithTree(page); await openWithTree(page);
await expect(page.locator('#newFolderBtn')).toBeVisible();
await expect(page.locator('#newFileBtn')).toBeVisible();
await page.locator('#sortSelect').selectOption('date:-1'); await page.locator('#sortSelect').selectOption('date:-1');
expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 }); expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 });

View file

@ -1,66 +0,0 @@
import { test, expect } from '@playwright/test';
import * as path from 'path';
// shared/cap.js — the "who can?" helpers (denyHint / whoCan) + handleForbidden
// enrichment. cap.js is bundled into every server-mode tool; tables.html is a
// convenient host. Pure helpers run fine on a file:// page (cap.at short-circuits
// offline, but denyHint/whoCan/handleForbidden don't need the network).
const HOST = 'file://' + path.resolve('tables/dist/tables.html');
async function load(page) {
await page.goto(HOST, { waitUntil: 'load' });
await page.waitForFunction(() => window.zddc && window.zddc.cap && window.zddc.cap.denyHint);
}
test.describe('cap.js — who-can hints', () => {
test('denyHint is role-first with people as the tooltip detail', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => {
const view = { path_who_can: { c: { roles: ['document_controller'], people: ['alice@example.com', 'bob@example.com'] } } };
return window.zddc.cap.denyHint(view, 'c');
});
expect(h.text).toBe('Only the document controller can create here.'); // role-first, humanized
expect(h.title).toBe('alice@example.com, bob@example.com'); // people in the tooltip
});
test('denyHint names people when no role grants the verb', async ({ page }) => {
await load(page);
const h = await page.evaluate(() =>
window.zddc.cap.denyHint({ path_who_can: { w: { people: ['sam@example.com'] } } }, 'w'));
expect(h.text).toBe('Ask sam@example.com to write here.');
});
test('denyHint falls back to "an administrator" when nobody is named', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => window.zddc.cap.denyHint({ path_who_can: {} }, 'd'));
expect(h.text).toBe('Ask an administrator to delete here.');
});
test('whoCan reads either a path view or a 403 body', async ({ page }) => {
await load(page);
const r = await page.evaluate(() => {
const fromView = window.zddc.cap.whoCan({ path_who_can: { c: { roles: ['r1'] } } }, 'c');
const fromBody = window.zddc.cap.whoCan({ missing_verb: 'c', who_can: { roles: ['r2'] } }, 'c');
const miss = window.zddc.cap.whoCan({ path_who_can: { w: {} } }, 'c');
return { fromView: fromView && fromView.roles[0], fromBody: fromBody && fromBody.roles[0], miss };
});
expect(r.fromView).toBe('r1');
expect(r.fromBody).toBe('r2');
expect(r.miss).toBeNull();
});
test('handleForbidden appends the who-can hint from the 403 body', async ({ page }) => {
await load(page);
const msg = await page.evaluate(async () => {
let captured = '';
window.zddc.toast = (m) => { captured = m; return document.createElement('div'); };
const body = JSON.stringify({ error: 'Forbidden', missing_verb: 'c', who_can: { roles: ['document_controller'], people: ['alice@example.com'] } });
const resp = new Response(body, { status: 403, headers: { 'Content-Type': 'application/json' } });
await window.zddc.cap.handleForbidden(resp, { context: 'Create' });
return captured;
});
expect(msg).toContain('You do not have create access here.');
expect(msg).toContain('Only the document controller can create here.'); // who-can appended
});
});

View file

@ -1408,7 +1408,7 @@ test('proposeMatches finds a row whose tracking number is in the filename', asyn
expect(r[1].conf).toBeCloseTo(0.8); expect(r[1].conf).toBeCloseTo(0.8);
}); });
test('From a list: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => { test('By existing: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => {
await page.click('#modeClassifyBtn'); await page.click('#modeClassifyBtn');
const r = await page.evaluate(async () => { const r = await page.evaluate(async () => {
const tt = window.app.modules.targetTree; const tt = window.app.modules.targetTree;
@ -1459,7 +1459,7 @@ test('From a list: walkDirInto unions files + mdl deliverables, deduped to the l
expect(r.latestModifierWins).toBe('A+B1 (IFC)'); // A < A+B1 expect(r.latestModifierWins).toBe('A+B1 (IFC)'); // A < A+B1
}); });
test('From a list: _detectScope routes by URL/protocol', async ({ page }) => { test('By existing: _detectScope routes by URL/protocol', async ({ page }) => {
const r = await page.evaluate(() => { const r = await page.evaluate(() => {
const tt = window.app.modules.targetTree; const tt = window.app.modules.targetTree;
return { return {
@ -1475,7 +1475,7 @@ test('From a list: _detectScope routes by URL/protocol', async ({ page }) => {
expect(r.offlineHttp).toBe('local'); expect(r.offlineHttp).toBe('local');
}); });
test('From a list: dir-picker resolves the topmost ticked directories only', async ({ page }) => { test('By existing: dir-picker resolves the topmost ticked directories only', async ({ page }) => {
const r = await page.evaluate(() => { const r = await page.evaluate(() => {
const dp = window.app.modules.dirPicker; const dp = window.app.modules.dirPicker;
const childOfA = { handle: 'A/x', checked: true, children: [] }; const childOfA = { handle: 'A/x', checked: true, children: [] };

View file

@ -22,13 +22,12 @@ test.describe('shared/toast.js', () => {
expect(exposed).toBe(true); expect(exposed).toBe(true);
}); });
test('renders a toast with the level class and ARIA role', async ({ page }) => { test('renders a single toast with the level class and ARIA role', async ({ page }) => {
const after = await page.evaluate(() => { const after = await page.evaluate(() => {
window.zddc.toast('Saved.', 'success'); window.zddc.toast('Saved.', 'success');
const el = document.querySelector('.zddc-toast'); const el = document.querySelector('.zddc-toast');
return el && { return el && {
// The message lives in its own span (the toast also holds a × button). text: el.textContent,
text: el.querySelector('.zddc-toast__msg').textContent,
level: [...el.classList].find(c => c.startsWith('zddc-toast--')), level: [...el.classList].find(c => c.startsWith('zddc-toast--')),
role: el.getAttribute('role'), role: el.getAttribute('role'),
live: el.getAttribute('aria-live'), live: el.getAttribute('aria-live'),
@ -51,24 +50,18 @@ test.describe('shared/toast.js', () => {
expect(probe).toEqual({ role: 'alert', live: 'assertive' }); expect(probe).toEqual({ role: 'alert', live: 'assertive' });
}); });
test('toasts stack, and a "Clear all" control appears at 2+', async ({ page }) => { test('a second toast replaces the first (single-toast policy)', async ({ page }) => {
const r = await page.evaluate(() => { const count = await page.evaluate(() => {
window.zddc.toast('one', 'error'); // sticky so it stays for the count window.zddc.toast('one', 'info');
window.zddc.toast('two', 'error'); window.zddc.toast('two', 'info');
return { return document.querySelectorAll('.zddc-toast').length;
count: document.querySelectorAll('.zddc-toast').length,
clearAll: !!document.querySelector('.zddc-toasts__clear'),
};
}); });
expect(r.count).toBe(2); // stack, not replace expect(count).toBe(1);
expect(r.clearAll).toBe(true); // "Clear all" surfaces when 2+ are stacked
}); });
test('the × button dismisses a toast; clicking the body does not', async ({ page }) => { test('clicking dismisses immediately', async ({ page }) => {
await page.evaluate(() => window.zddc.toast('keep me', 'error')); // sticky await page.evaluate(() => window.zddc.toast('click me', 'info'));
await page.locator('.zddc-toast .zddc-toast__msg').click(); // selecting text ≠ dismiss await page.locator('.zddc-toast').click();
await expect(page.locator('.zddc-toast')).toHaveCount(1);
await page.locator('.zddc-toast .zddc-toast__close').click(); // × dismisses
await expect(page.locator('.zddc-toast')).toHaveCount(0); await expect(page.locator('.zddc-toast')).toHaveCount(0);
}); });
}); });

View file

@ -162,23 +162,19 @@ test.describe('/.tokens self-service token UI', () => {
}); });
test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => { test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => {
page.on('dialog', d => d.accept());
const xssDesc = `<img src=x onerror="window.__xss=1">`; const xssDesc = `<img src=x onerror="window.__xss=1">`;
await page.goto(`${server.baseURL}/.tokens`); await page.goto(`${server.baseURL}/.tokens`);
// Create via the apiActions modal (the inline #desc form is long gone). await page.fill('#desc', xssDesc);
await page.locator('#api-create-btn').click(); await page.click('button[type="submit"]');
await expect(page.locator('.api-modal')).toBeVisible(); // Wait for the row to appear in the table.
await page.locator('.api-modal input').first().fill(xssDesc); await expect(page.locator('#tokens tbody')).toContainText('<img');
await page.locator('.api-modal button[type="submit"]').click(); // The literal <img> tag should NOT have been parsed as HTML —
await expect(page.locator('.api-modal__secret')).toBeVisible(); // window.__xss must remain undefined.
await page.locator('.api-modal button:has-text("Done")').click(); const xssFired = await page.evaluate(() => window.__xss === 1);
await page.waitForLoadState('networkidle'); expect(xssFired).toBe(false);
// The description renders as a row — as TEXT, not parsed HTML. // And the on-disk text content of the cell should contain the
const row = page.locator('#table-root tbody tr', { hasText: 'img src' }); // literal angle brackets, proving they were escaped.
await expect(row).toBeVisible(); const rowText = await page.locator('#tokens tbody tr', { hasText: 'img src' }).textContent();
// The <img> must NOT have been parsed (its onerror never fires)… expect(rowText).toContain('<img');
expect(await page.evaluate(() => window.__xss === 1)).toBe(false);
// …and the literal angle brackets survive in the cell text.
expect(await row.textContent()).toContain('<img');
}); });
}); });

View file

@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
@ -11523,48 +11523,6 @@ window.app.modules.filtering = {
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -11578,9 +11536,8 @@ window.app.modules.filtering = {
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
body = await resp.clone().json(); var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -11595,16 +11552,6 @@ window.app.modules.filtering = {
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -11637,7 +11584,7 @@ window.app.modules.filtering = {
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})(); })();
</script> </script>

View file

@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -7022,48 +7022,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -7077,9 +7035,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
body = await resp.clone().json(); var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -7094,16 +7051,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -7136,7 +7083,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})(); })();
// shared/icons.js — minimal outline SVG sprite for ZDDC tools. // shared/icons.js — minimal outline SVG sprite for ZDDC tools.
@ -16262,13 +16209,6 @@ window.__ZDDC_SCHEMA__ = {
function openPartyPicker(opts) { function openPartyPicker(opts) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; 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 cant register a new party here.';
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
var overlay = document.createElement('div'); 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;'; 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'); var box = document.createElement('div');
@ -16287,10 +16227,10 @@ window.__ZDDC_SCHEMA__ = {
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' + 'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' + '</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' + '<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet.</em>') + (partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' + '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' + '<input type="radio" name="pp-party" value="__new__">' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' + '<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
'</div>' + '</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' + '<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' + '<label for="pp-newparty">New party name</label><br>' +
@ -16347,28 +16287,8 @@ window.__ZDDC_SCHEMA__ = {
async function createInAggregator(agg, kind) { async function createInAggregator(agg, kind) {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (!up) return; if (!up) return;
var cap = window.zddc && window.zddc.cap;
var ssrPath = '/' + agg.project + '/ssr/';
var slotPath = '/' + agg.project + '/' + agg.slot + '/';
// Pre-check create authority BEFORE any data entry: registering a new
// party needs `c` on ssr/, creating under an existing party needs `c` on
// the slot. If the user can do neither, don't open the form just to deny
// them — say (subtly) who can. If they can only use existing parties,
// open the picker with the "+ New party" option disabled + explained.
var canNewParty = true, ssrView = null, slotView = null;
if (cap && cap.at) {
slotView = await cap.at(slotPath);
ssrView = await cap.at(ssrPath);
var canSlot = !slotView || cap.has({ verbs: slotView.path_verbs }, 'c');
canNewParty = !ssrView || cap.has({ verbs: ssrView.path_verbs }, 'c');
if (slotView && !canSlot && !canNewParty) {
statusError(cap.denyHint(slotView, 'c').text);
return;
}
}
var parties = await fetchParties(agg.project); var parties = await fetchParties(agg.project);
var newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null; var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties, canNewParty: canNewParty, newPartyHint: newPartyHint });
if (!choice) return; if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding // Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf. // needed for the party segment; makeDir/makeFile encode the leaf.
@ -16391,10 +16311,7 @@ window.__ZDDC_SCHEMA__ = {
} catch (e) { } catch (e) {
var msg = (e && e.message) || String(e); var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) { if (/\b403\b/.test(msg)) {
// Name who can — best-effort, for the path the denial came from. statusError('Not allowed — registering a new party requires the document-controller role.');
var denied = choice.isNew ? ssrPath : ('/' + agg.project + '/' + agg.slot + '/' + choice.party + '/');
var v = (cap && cap.at) ? await cap.at(denied) : null;
statusError(v && cap.denyHint ? cap.denyHint(v, 'c').text : 'Not allowed — you dont have create access here.');
} else if (/\b409\b/.test(msg)) { } else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).'); statusError('Unknown party — register it first (document controller).');
} else { } else {

View file

@ -1168,54 +1168,6 @@ body.is-elevated::after {
/* Toast notifications come from shared/toast.css (.zddc-toast); the /* Toast notifications come from shared/toast.css (.zddc-toast); the
classifier-local .toast block was promoted there. */ classifier-local .toast block was promoted there. */
/* ── Shared selectable + autofilter table (seltable) + its hosting overlay ───
Used by the tables tool's "Add from archive". The classifier carries an
equivalent copy inline in its layout.css for the catalog. */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
.seltable__colfilter {
width: 100%; min-width: 2rem; box-sizing: border-box; padding: 0.15rem 0.35rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
.mdlarch-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
}
.mdlarch-overlay__box {
display: flex; flex-direction: column; min-height: 0;
width: min(960px, 95vw); height: min(80vh, 760px);
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
.mdlarch-overlay__close:hover { color: var(--text); }
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }
/* Classifier layout — tokens from shared/base.css */ /* Classifier layout — tokens from shared/base.css */
/* .empty-state / .empty-state__inner / .welcome-list live in /* .empty-state / .empty-state__inner / .welcome-list live in
@ -1846,10 +1798,47 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.scratch-match__tn { font-family: var(--mono, monospace); } .scratch-match__tn { font-family: var(--mono, monospace); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; } .scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; }
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and /* ── MDL-from-archive overlay ───────────────────────────────────────────── */
shared with the tables tool); only the classifier-specific catalog bits .mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
(.seltable__extra, .mdl-rev__input, .fromlist-*, .src-badge, #mdlTree) are .mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; }
here. */ .mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; }
.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
.mdl-overlay__close:hover { color: var(--text); }
.mdl-overlay__status { padding: 0.4rem 1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__table { flex: 1; min-height: 0; }
.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* ── Shared selectable + autofilter table (seltable) ────────────────────── */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__filter {
flex: 1; min-width: 8rem; padding: 0.3rem 0.5rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.85rem;
}
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
/* Per-column filter inputs: fill the column (min-width:0-ish) so they never
force a column wider than its header/cells. */
.seltable__table thead tr.seltable__filters th { padding: 0.08rem 0.3rem; }
.seltable__colfilter {
width: 100%; min-width: 2rem; box-sizing: border-box;
padding: 0.1rem 0.3rem; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.72rem; font-weight: 400; letter-spacing: 0; text-transform: none;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── Copy destination dialog ────────────────────────────────────────────── */ /* ── Copy destination dialog ────────────────────────────────────────────── */
.copy-choice__backdrop { .copy-choice__backdrop {
@ -2418,7 +2407,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -7789,6 +7778,15 @@ X.B(E,Y);return E}return J}())
return out; return out;
} }
// Files currently placed in a node (reverse lookup over all source files).
function filesInNode(nodeId, axis, allFiles) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
return (allFiles || []).filter(function (f) {
var a = state.assignments[srcKeyForFile(f)];
return a && a[field] === nodeId;
});
}
// Per-file classification state for the left-tree markers. // Per-file classification state for the left-tree markers.
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none' // 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
function fileState(file) { function fileState(file) {
@ -7826,11 +7824,7 @@ X.B(E,Y);return E}return J}())
transmittalTree: state.transmittalTree, transmittalTree: state.transmittalTree,
outputName: state.outputName, outputName: state.outputName,
config: state.config, config: state.config,
// Strip the transient row→keys hint (`placed`) — it's rebuilt as mdlList: state.mdlList,
// drops happen and would otherwise bloat every autosave.
mdlList: state.mdlList.map(function (r) {
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
}),
}; };
} }
function load(obj) { function load(obj) {
@ -8264,7 +8258,7 @@ X.B(E,Y);return E}return J}())
getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; }, getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse // derive + reverse
deriveTarget: deriveTarget, deriveTarget: deriveTarget, filesInNode: filesInNode,
fileState: fileState, stats: stats, fileState: fileState, stats: stats,
// persistence // persistence
serialize: serialize, load: load, reset: reset, serialize: serialize, load: load, reset: reset,
@ -11051,14 +11045,7 @@ X.B(E,Y);return E}return J}())
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () { if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
var list = C().getMdlList(); if (!C().getMdlList().length) return;
if (!list.length) return;
// Warn before stranding files that still need a revision: they stay
// assigned (on a "pending" leaf under By tracking number), but the
// row you'd use to finish them here is about to disappear.
var pending = 0;
list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; });
if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder under By tracking number), but the list row to finish them here goes away. Clear anyway?')) return;
C().clearMdlList(); C().clearMdlList();
window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info'); window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info');
}); });
@ -11604,11 +11591,11 @@ X.B(E,Y);return E}return J}())
}); });
} }
// "From a list" loader: "Load…" opens a multi-select directory tree (scoped // Load the catalog: "Load…" opens a multi-select directory tree (scoped to
// to the served context); every ticked directory is walked recursively into // the served context); every ticked directory is walked recursively into the
// the union of existing files + MDL deliverables, deduped by tracking number // union of existing files + MDL deliverables, deduped by tracking number to
// to one row at the latest revision. Writes/alters nothing — the revision // one row at the latest revision. Writes/alters nothing — the revision cell
// cell is classifier-local and starts blank. // is classifier-local and starts blank.
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; }
// The newest combined "<rev> (<status>)" string in a set, by revision token. // The newest combined "<rev> (<status>)" string in a set, by revision token.
@ -15737,48 +15724,6 @@ X.B(E,Y);return E}return J}())
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -15792,9 +15737,8 @@ X.B(E,Y);return E}return J}())
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
body = await resp.clone().json(); var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -15809,16 +15753,6 @@ X.B(E,Y);return E}return J}())
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -15851,7 +15785,7 @@ X.B(E,Y);return E}return J}())
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})(); })();
</script> </script>

View file

@ -1609,21 +1609,6 @@ body {
text-decoration: underline; text-decoration: underline;
} }
/* Subtle standalone-tools footer. */
.landing-apps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 2rem auto 1rem;
padding: 0 1rem;
max-width: 60rem;
font-size: 0.82rem;
color: var(--text-muted, #6b7280);
}
.landing-apps__label { color: var(--text-muted, #6b7280); }
.landing-apps .browse-link { margin-top: 0; }
#projectView ol { #projectView ol {
padding-left: 1.5rem; padding-left: 1.5rem;
} }
@ -1793,7 +1778,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -1966,15 +1951,6 @@ body {
</div><!-- /projectView --> </div><!-- /projectView -->
</main> </main>
<!-- Subtle links to the standalone tools (run against your own local files,
no server). Served at /_apps/<tool>.html by zddc-server. -->
<footer class="landing-apps">
<span class="landing-apps__label">Standalone tools for your own files:</span>
<a class="browse-link" href="/_apps/browse.html">Browse</a>
<a class="browse-link" href="/_apps/archive.html">Archive</a>
<a class="browse-link" href="/_apps/classifier.html">Classifier</a>
</footer>
<!-- Help Panel --> <!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title"> <aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header"> <div class="help-panel__header">
@ -3396,48 +3372,6 @@ body {
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -3451,9 +3385,8 @@ body {
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
body = await resp.clone().json(); var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -3468,16 +3401,6 @@ body {
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -3510,7 +3433,7 @@ body {
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})(); })();
(function() { (function() {

View file

@ -2770,7 +2770,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;
@ -14071,48 +14071,6 @@ X.B(E,Y);return E}return J}())
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -14126,9 +14084,8 @@ X.B(E,Y);return E}return J}())
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
body = await resp.clone().json(); var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -14143,16 +14100,6 @@ X.B(E,Y);return E}return J}())
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -14185,7 +14132,7 @@ X.B(E,Y);return E}return J}())
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})(); })();
(function (app) { (function (app) {

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947 archive=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
transmittal=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947 transmittal=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
classifier=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947 classifier=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
landing=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947 landing=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
form=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947 form=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
tables=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947 tables=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5
browse=v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947 browse=v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5

View file

@ -5,7 +5,6 @@ import (
"net/http" "net/http"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
// writeForbidden emits a 403 JSON response naming the missing verb. Used // writeForbidden emits a 403 JSON response naming the missing verb. Used
@ -35,25 +34,6 @@ func writeForbidden(w http.ResponseWriter, action string) {
_, _ = w.Write(body) _, _ = w.Write(body)
} }
// writeForbiddenWho is writeForbidden enriched with a "who_can" Authority for
// the missing verb, computed from the deny site's policy chain. Lets the toast
// tell the user who to ask even when the action wasn't pre-checked (a race, or
// a path the client didn't gate). The pre-check path (AccessView.PathWhoCan) is
// the primary surface; this is the safety net.
func writeForbiddenWho(w http.ResponseWriter, action string, chain zddc.PolicyChain) {
verb := verbForAction(action)
body := map[string]any{"error": "Forbidden", "missing_verb": verb}
if vs, ok := zddc.ParseVerbSet(verb); ok {
if a := zddc.WhoCan(chain, vs); !a.Empty() {
body["who_can"] = a
}
}
raw, _ := json.Marshal(body)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(raw)
}
// verbForAction maps a policy.Action constant to its single-character // verbForAction maps a policy.Action constant to its single-character
// verb. Mirrors policy.actionVerb but emits the wire-format letter // verb. Mirrors policy.actionVerb but emits the wire-format letter
// rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a" // rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a"

View file

@ -152,7 +152,7 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
decider := DeciderFromContext(r) decider := DeciderFromContext(r)
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action) allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
if !allowed { if !allowed {
writeForbiddenWho(w, action, chain) // name who CAN, so the toast can explain writeForbidden(w, action)
return false return false
} }
return true return true

View file

@ -181,15 +181,6 @@ type AccessView struct {
// the "which roles do I hold here?" answer the browse hovercard // the "which roles do I hold here?" answer the browse hovercard
// surfaces. Elevation-independent (role membership, not admin). // surfaces. Elevation-independent (role membership, not admin).
PathRoles []string `json:"path_roles,omitempty"` PathRoles []string `json:"path_roles,omitempty"`
// PathWhoCan answers "if I can't, who can?" for the gated verbs the caller
// LACKS at this path (keyed by verb letter: "c"/"w"/"d"). Each entry names
// the roles + people that hold the verb, so a denied affordance can show a
// subtle "ask a document controller" hint instead of letting the user act
// and bounce off a 403. Populated only when the caller can READ the path
// (mirrors .zddc readability — no new disclosure), and only for verbs not
// already in PathVerbs. Omitted (nil) when the caller can do everything or
// can't read here.
PathWhoCan map[string]zddc.Authority `json:"path_who_can,omitempty"`
} }
// enumerateAccess builds an AccessView for the given caller. Used by the // enumerateAccess builds an AccessView for the given caller. Used by the
@ -265,27 +256,6 @@ func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg c
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery) verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
view.PathVerbs = verbs.String() view.PathVerbs = verbs.String()
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
// "If I can't, who can?" — only for verbs the caller LACKS, and only when
// they can read here (the .zddc that backs this is read-ACL-governed, so a
// reader could see it anyway). Lets a denied affordance explain itself
// up-front instead of letting the user act and bounce off a 403.
if verbs.Has(zddc.VerbR) {
who := map[string]zddc.Authority{}
for _, gv := range []struct {
letter string
v zddc.VerbSet
}{{"c", zddc.VerbC}, {"w", zddc.VerbW}, {"d", zddc.VerbD}} {
if verbs.Has(gv.v) {
continue
}
if a := zddc.WhoCan(chain, gv.v); !a.Empty() {
who[gv.letter] = a
}
}
if len(who) > 0 {
view.PathWhoCan = who
}
}
// Which cascade roles the caller holds at this path — the answer to // Which cascade roles the caller holds at this path — the answer to
// "the system thinks I'm a document_controller here, right?". // "the system thinks I'm a document_controller here, right?".
view.PathRoles = zddc.RolesForPrincipalInChain(chain, p.Email) view.PathRoles = zddc.RolesForPrincipalInChain(chain, p.Email)

View file

@ -1770,7 +1770,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-13 17:17:09 · 51f5947</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 16:07:31 · f66b9c5</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3592,48 +3592,6 @@ body.is-elevated::after {
a: 'edit access rules' a: 'edit access rules'
}; };
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the // handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request // missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to // hit; when provided, the helper consults /.profile/access?path= to
@ -3647,9 +3605,8 @@ body.is-elevated::after {
async function handleForbidden(resp, opts) { async function handleForbidden(resp, opts) {
opts = opts || {}; opts = opts || {};
var missing = ''; var missing = '';
var body = null;
try { try {
body = await resp.clone().json(); var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') { if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb; missing = body.missing_verb;
} }
@ -3664,16 +3621,6 @@ body.is-elevated::after {
msg = prefix + 'Forbidden.'; msg = prefix + 'Forbidden.';
} }
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a // Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation // path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable // grant covering the missing verb. Render as a clickable
@ -3706,7 +3653,7 @@ body.is-elevated::after {
} }
} }
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint }; window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})(); })();
// shared/context-menu.js — generic context-menu framework exposed on // shared/context-menu.js — generic context-menu framework exposed on
@ -8191,9 +8138,7 @@ body.is-elevated::after {
if (verbs.indexOf('c') === -1) { if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled'); addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true'); addRowBtn.setAttribute('aria-disabled', 'true');
// Tell them who can (subtly): role-first text + people in the tooltip. addRowBtn.title = "You don't have create access in this folder.";
var hint = window.zddc.cap.denyHint ? window.zddc.cap.denyHint(view, 'c') : null;
addRowBtn.title = hint ? (hint.text + (hint.title ? ' (' + hint.title + ')' : '')) : "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the // Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission. // tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) { addRowBtn.addEventListener('click', function (ev) {

View file

@ -1,76 +0,0 @@
package zddc
import "sort"
// Authority answers "who can do this here?" for a user who was denied an action.
// It is split so the UI can lead with the ROLE ("ask a document controller") and
// keep the specific People (admins + role members + direct email grants) as
// secondary detail. Both lists are sorted + de-duplicated.
//
// Safe to surface to anyone who can read the path: the .zddc cascade it is
// derived from is already governed by directory read-ACL, so this exposes
// nothing a reader couldn't already see in the .zddc itself.
type Authority struct {
Roles []string `json:"roles,omitempty"`
People []string `json:"people,omitempty"`
}
// Empty reports whether no authority was found (no one is named — the caller
// should fall back to "ask an administrator").
func (a Authority) Empty() bool { return len(a.Roles) == 0 && len(a.People) == 0 }
// WhoCan returns the principals that hold `verb` at the chain: every
// acl.permissions grantee across the cascade whose verb set includes it, plus
// every level's admins (who bypass the ACL entirely). Role grantees are recorded
// as Roles AND expanded to their member patterns in People; direct email/glob
// grantees go to People. Pure and side-effect-free.
func WhoCan(chain PolicyChain, verb VerbSet) Authority {
roleSet := map[string]struct{}{}
peopleSet := map[string]struct{}{}
add := func(principal string, levelIdx int) {
if principal == "" {
return
}
if IsPrincipalRole(principal) {
// A bare name is a role only if the cascade actually defines it;
// otherwise it's a wildcard pattern (e.g. "*") handled as a person.
if _, defined := lookupRoleMembers(chain, levelIdx, principal); defined {
roleSet[principal] = struct{}{}
for _, m := range RoleMembers(chain, levelIdx, principal) {
if m != "" {
peopleSet[m] = struct{}{}
}
}
return
}
}
peopleSet[principal] = struct{}{}
}
for i, level := range chain.Levels {
for principal, verbStr := range level.ACL.Permissions {
if v, _ := ParseVerbSet(verbStr); v.Has(verb) {
add(principal, i)
}
}
// Admins bypass the ACL clamp, so they can always do this verb.
for _, principal := range level.Admins {
add(principal, i)
}
}
return Authority{Roles: sortedSet(roleSet), People: sortedSet(peopleSet)}
}
func sortedSet(m map[string]struct{}) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}

View file

@ -1,63 +0,0 @@
package zddc
import (
"reflect"
"testing"
)
func TestWhoCan(t *testing.T) {
chain := PolicyChain{
Levels: []ZddcFile{
{
ACL: ACLRules{Permissions: map[string]string{
"document_controller": "rwcda",
"carol@example.com": "rwc",
"*@example.com": "r", // read only — must NOT show up for 'c'
}},
Roles: map[string]Role{
"document_controller": {Members: []string{"alice@example.com", "bob@example.com"}},
},
Admins: []string{"super@example.com"},
},
},
HasAnyFile: true,
}
// create: the role (rwcda) + a direct grant (carol rwc) + the admin (bypass).
// The read-only *@example.com pattern is excluded.
got := WhoCan(chain, VerbC)
if want := []string{"document_controller"}; !reflect.DeepEqual(got.Roles, want) {
t.Errorf("VerbC Roles = %v, want %v", got.Roles, want)
}
if want := []string{"alice@example.com", "bob@example.com", "carol@example.com", "super@example.com"}; !reflect.DeepEqual(got.People, want) {
t.Errorf("VerbC People = %v, want %v", got.People, want)
}
// admin verb: only the role (rwcda) + admin bypass; carol (rwc) lacks 'a'.
got = WhoCan(chain, VerbA)
if wcContains(got.People, "carol@example.com") {
t.Errorf("VerbA People = %v, should not include carol (no 'a')", got.People)
}
if !wcContains(got.People, "super@example.com") {
t.Errorf("VerbA People = %v, want the admin", got.People)
}
// read: granted to the *@example.com pattern (a People entry, not a role).
if got = WhoCan(chain, VerbR); !wcContains(got.People, "*@example.com") {
t.Errorf("VerbR People = %v, want to include *@example.com", got.People)
}
// empty chain → nobody named.
if a := WhoCan(PolicyChain{}, VerbC); !a.Empty() {
t.Errorf("empty chain: got %+v, want Empty()", a)
}
}
func wcContains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}