Compare commits
No commits in common. "605f4ab3e02b6b06329d6681519ca250d7ebc1ee" and "3d553ce9d43c66237c42b33272e1c3f21eb4b969" have entirely different histories.
605f4ab3e0
...
3d553ce9d4
27 changed files with 188 additions and 919 deletions
|
|
@ -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 can’t 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) + '/<party>/</code>.' +
|
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</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 don’t 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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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: [] };
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 can’t 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) + '/<party>/</code>.' +
|
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</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 don’t 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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue