feat(browse): guided "Manage access" dialog — task-first, no raw YAML
First of the intent-driven actions that replace raw-YAML editing as the front door. Right-click a folder (or the pane) → "Manage access…" → a dialog of people + friendly levels; the raw .zddc editor is demoted to "Edit raw policy (.zddc)…" as the advanced escape. Both gated by the same admin authority. - browse/js/manage-access.js: reads the folder's on-disk .zddc, presents principals as View / Contribute / Edit / Manage (admins: membership), plus an "inherit from parent" toggle (uncheck = make private). Save maps levels back to verbs (r / rc / rwc / admins:), merges ONLY the access bits into the doc (every other key preserved), and PUTs. Unrecognised verb strings show as "Custom" and are preserved untouched. - Menu: "Manage access…" (guided) is now primary; "Edit raw policy (.zddc)…" is the escape hatch. - Self-contained modal + CSS; refreshes the listing on save. Sets the pattern for the remaining operator tasks (role members, display label, project convert vars, register a party). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d44e1b01bf
commit
74ffefa191
4 changed files with 345 additions and 3 deletions
|
|
@ -47,6 +47,7 @@ concat_files \
|
||||||
"css/tree.css" \
|
"css/tree.css" \
|
||||||
"css/preview-yaml.css" \
|
"css/preview-yaml.css" \
|
||||||
"css/history.css" \
|
"css/history.css" \
|
||||||
|
"css/manage-access.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
||||||
# JS files: shared canonical helpers, then browse modules.
|
# JS files: shared canonical helpers, then browse modules.
|
||||||
|
|
@ -82,6 +83,7 @@ concat_files \
|
||||||
"$schema_rel" \
|
"$schema_rel" \
|
||||||
"js/util.js" \
|
"js/util.js" \
|
||||||
"js/yaml-complete.js" \
|
"js/yaml-complete.js" \
|
||||||
|
"js/manage-access.js" \
|
||||||
"js/conflict.js" \
|
"js/conflict.js" \
|
||||||
"js/menu-model.js" \
|
"js/menu-model.js" \
|
||||||
"js/loader.js" \
|
"js/loader.js" \
|
||||||
|
|
|
||||||
86
browse/css/manage-access.css
Normal file
86
browse/css/manage-access.css
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/* manage-access.js — guided "who can do what here" dialog. */
|
||||||
|
.ma-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.ma-box {
|
||||||
|
background: var(--bg-elevated, var(--bg, #fff));
|
||||||
|
color: var(--text, #222);
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32);
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
width: min(34rem, 94vw);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.ma-title { margin: 0 0 0.2rem; font-size: 1.15rem; }
|
||||||
|
.ma-sub {
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted, #777);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.ma-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.ma-row { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.ma-who {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font: inherit;
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg, #fff);
|
||||||
|
color: var(--text, #222);
|
||||||
|
}
|
||||||
|
.ma-level {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.35rem 0.4rem;
|
||||||
|
font: inherit;
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg, #fff);
|
||||||
|
color: var(--text, #222);
|
||||||
|
}
|
||||||
|
.ma-del {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ma-del:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.06)); color: var(--danger, #c14242); }
|
||||||
|
.ma-add {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
border: 1px dashed var(--border, #bbb);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary, #2868c8);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.ma-add:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); }
|
||||||
|
.ma-inherit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.ma-err { color: var(--danger, #c14242); font-size: 0.82rem; margin: 0.5rem 0 0; min-height: 0; }
|
||||||
|
.ma-err:empty { display: none; }
|
||||||
|
.ma-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
237
browse/js/manage-access.js
Normal file
237
browse/js/manage-access.js
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
// manage-access.js — guided "who can do what here" dialog. A task-first
|
||||||
|
// front door for a folder's .zddc acl: the user picks people + friendly access
|
||||||
|
// levels; we read the on-disk .zddc, merge ONLY the access bits (preserving
|
||||||
|
// every other key), and PUT it. No YAML, no schema knowledge required. The raw
|
||||||
|
// editor stays as the "Advanced" escape hatch.
|
||||||
|
//
|
||||||
|
// Friendly level → verbs (r read, w overwrite, c create, d delete, a admin):
|
||||||
|
// View → r Contribute → rc
|
||||||
|
// Edit → rwc Manage → admins: membership (not a verb string)
|
||||||
|
// "Custom" preserves a hand-written verb string we don't recognise.
|
||||||
|
(function (app) {
|
||||||
|
'use strict';
|
||||||
|
if (!app || !app.modules) return;
|
||||||
|
var util = app.modules.util;
|
||||||
|
|
||||||
|
var LEVELS = [
|
||||||
|
{ id: 'view', label: 'View', hint: 'read only', verbs: 'r' },
|
||||||
|
{ id: 'contribute', label: 'Contribute', hint: 'read + add new files', verbs: 'rc' },
|
||||||
|
{ id: 'edit', label: 'Edit', hint: 'read, overwrite, add', verbs: 'rwc' },
|
||||||
|
{ id: 'manage', label: 'Manage', hint: 'full config + (elevated) bypass', verbs: null }
|
||||||
|
];
|
||||||
|
function verbsOfLevel(id) {
|
||||||
|
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i].verbs;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function levelOfVerbs(verbs) {
|
||||||
|
verbs = String(verbs || '');
|
||||||
|
if (verbs.indexOf('a') !== -1) return 'manage';
|
||||||
|
if (verbs.indexOf('w') !== -1) return 'edit';
|
||||||
|
if (verbs.indexOf('c') !== -1) return 'contribute';
|
||||||
|
if (verbs.indexOf('r') !== -1) return 'view';
|
||||||
|
return 'custom'; // empty (explicit deny) or non-standard
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirUrl(dir) {
|
||||||
|
var u = dir || '/';
|
||||||
|
if (u.charAt(0) !== '/') u = '/' + u;
|
||||||
|
if (u.charAt(u.length - 1) !== '/') u += '/';
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(dir) {
|
||||||
|
if (!app.state || app.state.source !== 'server') {
|
||||||
|
toast('Access management needs the server.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var base = dirUrl(dir);
|
||||||
|
var zddcUrl = base + '.zddc';
|
||||||
|
var data = {}, etag = null;
|
||||||
|
try {
|
||||||
|
var r = await fetch(zddcUrl, { credentials: 'same-origin' });
|
||||||
|
if (r.ok) {
|
||||||
|
etag = r.headers.get('ETag');
|
||||||
|
var txt = await r.text();
|
||||||
|
try { data = (window.jsyaml && window.jsyaml.load(txt)) || {}; } catch (_e) { data = {}; }
|
||||||
|
} else if (r.status !== 404) {
|
||||||
|
throw new Error('HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast('Could not read access rules: ' + (e.message || e), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
|
||||||
|
|
||||||
|
// Build the principal → level model from admins (Manage) + acl.permissions.
|
||||||
|
var acl = (data.acl && typeof data.acl === 'object') ? data.acl : {};
|
||||||
|
var perms = (acl.permissions && typeof acl.permissions === 'object') ? acl.permissions : {};
|
||||||
|
var admins = Array.isArray(data.admins) ? data.admins : [];
|
||||||
|
var rows = [];
|
||||||
|
var seen = {};
|
||||||
|
admins.forEach(function (p) {
|
||||||
|
if (typeof p === 'string' && !seen[p]) { seen[p] = 1; rows.push({ principal: p, level: 'manage', custom: '' }); }
|
||||||
|
});
|
||||||
|
Object.keys(perms).forEach(function (p) {
|
||||||
|
if (seen[p]) return;
|
||||||
|
seen[p] = 1;
|
||||||
|
var lvl = levelOfVerbs(perms[p]);
|
||||||
|
rows.push({ principal: p, level: lvl, custom: lvl === 'custom' ? String(perms[p] || '') : '' });
|
||||||
|
});
|
||||||
|
var inherit = acl.inherit !== false;
|
||||||
|
|
||||||
|
renderModal(base, zddcUrl, data, etag, rows, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, kind) { if (window.zddc && window.zddc.toast) window.zddc.toast(msg, kind || 'info'); }
|
||||||
|
|
||||||
|
function renderModal(base, zddcUrl, data, etag, rows, inherit) {
|
||||||
|
var overlay = el('div', 'ma-overlay');
|
||||||
|
var box = el('div', 'ma-box');
|
||||||
|
overlay.appendChild(box);
|
||||||
|
|
||||||
|
box.appendChild(el('h2', 'ma-title', 'Manage access'));
|
||||||
|
var sub = el('p', 'ma-sub', 'Who can do what in ' + base + ' — changes here only.');
|
||||||
|
box.appendChild(sub);
|
||||||
|
|
||||||
|
var list = el('div', 'ma-list');
|
||||||
|
box.appendChild(list);
|
||||||
|
|
||||||
|
function addRow(model) {
|
||||||
|
var row = el('div', 'ma-row');
|
||||||
|
var who = el('input', 'ma-who');
|
||||||
|
who.type = 'text';
|
||||||
|
who.value = model.principal || '';
|
||||||
|
who.placeholder = 'email or *@domain or role name';
|
||||||
|
who.addEventListener('input', function () { model.principal = who.value.trim(); });
|
||||||
|
|
||||||
|
var sel = el('select', 'ma-level');
|
||||||
|
LEVELS.forEach(function (lv) {
|
||||||
|
var o = el('option', null, lv.label + ' — ' + lv.hint);
|
||||||
|
o.value = lv.id;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
if (model.level === 'custom') {
|
||||||
|
var o2 = el('option', null, 'Custom (' + model.custom + ')');
|
||||||
|
o2.value = 'custom';
|
||||||
|
sel.appendChild(o2);
|
||||||
|
}
|
||||||
|
sel.value = model.level;
|
||||||
|
sel.addEventListener('change', function () { model.level = sel.value; });
|
||||||
|
|
||||||
|
var del = el('button', 'ma-del', '✕');
|
||||||
|
del.type = 'button';
|
||||||
|
del.title = 'Remove';
|
||||||
|
del.addEventListener('click', function () { row.remove(); model._removed = true; });
|
||||||
|
|
||||||
|
row.appendChild(who);
|
||||||
|
row.appendChild(sel);
|
||||||
|
row.appendChild(del);
|
||||||
|
list.appendChild(row);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
rows.forEach(addRow);
|
||||||
|
|
||||||
|
var addBtn = el('button', 'ma-add', '+ Add person or group');
|
||||||
|
addBtn.type = 'button';
|
||||||
|
addBtn.addEventListener('click', function () {
|
||||||
|
var m = { principal: '', level: 'view', custom: '' };
|
||||||
|
rows.push(m);
|
||||||
|
addRow(m);
|
||||||
|
});
|
||||||
|
box.appendChild(addBtn);
|
||||||
|
|
||||||
|
// Inherit / make-private.
|
||||||
|
var inhWrap = el('label', 'ma-inherit');
|
||||||
|
var inhBox = el('input');
|
||||||
|
inhBox.type = 'checkbox';
|
||||||
|
inhBox.checked = inherit;
|
||||||
|
inhWrap.appendChild(inhBox);
|
||||||
|
inhWrap.appendChild(el('span', null, ' Inherit access from parent folders'));
|
||||||
|
box.appendChild(inhWrap);
|
||||||
|
|
||||||
|
var err = el('p', 'ma-err');
|
||||||
|
box.appendChild(err);
|
||||||
|
|
||||||
|
var actions = el('div', 'ma-actions');
|
||||||
|
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel');
|
||||||
|
cancel.type = 'button';
|
||||||
|
var save = el('button', 'btn btn-sm btn-primary', 'Save');
|
||||||
|
save.type = 'button';
|
||||||
|
actions.appendChild(cancel);
|
||||||
|
actions.appendChild(save);
|
||||||
|
box.appendChild(actions);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) close(); });
|
||||||
|
cancel.addEventListener('click', close);
|
||||||
|
|
||||||
|
save.addEventListener('click', function () {
|
||||||
|
err.textContent = '';
|
||||||
|
// Rebuild perms + admins from the live rows (skip removed/blank).
|
||||||
|
var perms = {}, admins = [], bad = false;
|
||||||
|
rows.forEach(function (m) {
|
||||||
|
if (m._removed) return;
|
||||||
|
var p = (m.principal || '').trim();
|
||||||
|
if (!p) return;
|
||||||
|
if (m.level === 'manage') {
|
||||||
|
if (admins.indexOf(p) === -1) admins.push(p);
|
||||||
|
} else if (m.level === 'custom') {
|
||||||
|
perms[p] = m.custom; // preserve the hand-written string
|
||||||
|
} else {
|
||||||
|
perms[p] = verbsOfLevel(m.level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge into the existing doc, preserving every unmanaged key.
|
||||||
|
var out = {};
|
||||||
|
Object.keys(data).forEach(function (k) { out[k] = data[k]; });
|
||||||
|
var acl = (out.acl && typeof out.acl === 'object') ? Object.assign({}, out.acl) : {};
|
||||||
|
if (Object.keys(perms).length) acl.permissions = perms; else delete acl.permissions;
|
||||||
|
if (!inhBox.checked) acl.inherit = false; else delete acl.inherit;
|
||||||
|
if (Object.keys(acl).length) out.acl = acl; else delete out.acl;
|
||||||
|
if (admins.length) out.admins = admins; else delete out.admins;
|
||||||
|
|
||||||
|
var content;
|
||||||
|
try { content = window.jsyaml.dump(out); }
|
||||||
|
catch (e2) { err.textContent = 'Could not serialize: ' + (e2.message || e2); return; }
|
||||||
|
|
||||||
|
save.disabled = true;
|
||||||
|
save.textContent = 'Saving…';
|
||||||
|
var node = { url: zddcUrl, name: '.zddc', ext: '' };
|
||||||
|
util.saveFile(node, content, 'application/yaml; charset=utf-8', etag ? { etag: etag } : {})
|
||||||
|
.then(function () {
|
||||||
|
toast('Access updated for ' + base, 'success');
|
||||||
|
var ev = app.modules.events;
|
||||||
|
if (ev && ev.refreshListing) { try { ev.refreshListing(); } catch (_e) { /* ignore */ } }
|
||||||
|
close();
|
||||||
|
})
|
||||||
|
.catch(function (e3) {
|
||||||
|
save.disabled = false;
|
||||||
|
save.textContent = 'Save';
|
||||||
|
if (e3 && e3.status === 412) {
|
||||||
|
err.textContent = 'These rules changed on the server since you opened this. Close and reopen to get the latest, then redo your change.';
|
||||||
|
} else {
|
||||||
|
err.textContent = 'Save failed: ' + (e3 && e3.message ? e3.message : e3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
var first = box.querySelector('.ma-who');
|
||||||
|
if (first) first.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.modules.manageAccess = { open: open };
|
||||||
|
})(window.app);
|
||||||
|
|
@ -352,13 +352,30 @@
|
||||||
|
|
||||||
// ── admin / sub-admin tier ──
|
// ── admin / sub-admin tier ──
|
||||||
{
|
{
|
||||||
// HIDDEN unless the user can actually edit access rules here
|
// Guided "who can do what here" dialog — the front door for access.
|
||||||
// (admin verb 'a', or subtree/site admin) — not shown greyed.
|
// HIDDEN unless the user can administer here (admin verb 'a', or
|
||||||
|
// subtree/site admin).
|
||||||
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
||||||
label: 'Edit access rules…',
|
label: 'Manage access…',
|
||||||
appliesTo: function (ctx) {
|
appliesTo: function (ctx) {
|
||||||
if (!isServer()) return false; // server-only tier
|
if (!isServer()) return false; // server-only tier
|
||||||
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||||||
|
return typeOk && manageAccessGate(ctx).enabled
|
||||||
|
&& !!(window.app.modules.manageAccess);
|
||||||
|
},
|
||||||
|
action: function (ctx) {
|
||||||
|
var m = window.app.modules.manageAccess;
|
||||||
|
if (m) m.open(ctx.dir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The raw-YAML escape hatch — same authority gate, demoted to
|
||||||
|
// "advanced" since the guided dialog covers the common case.
|
||||||
|
id: 'edit-zddc-raw', group: 'admin', surfaces: ['row', 'pane'],
|
||||||
|
label: 'Edit raw policy (.zddc)…',
|
||||||
|
appliesTo: function (ctx) {
|
||||||
|
if (!isServer()) return false;
|
||||||
|
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||||||
return typeOk && manageAccessGate(ctx).enabled;
|
return typeOk && manageAccessGate(ctx).enabled;
|
||||||
},
|
},
|
||||||
action: function (ctx) { openZddcEditor(ctx.dir); }
|
action: function (ctx) { openZddcEditor(ctx.dir); }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue