diff --git a/browse/build.sh b/browse/build.sh index 99aafa5..ee7e5bf 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -47,6 +47,7 @@ concat_files \ "css/tree.css" \ "css/preview-yaml.css" \ "css/history.css" \ + "css/manage-access.css" \ > "$css_temp" # JS files: shared canonical helpers, then browse modules. @@ -82,6 +83,7 @@ concat_files \ "$schema_rel" \ "js/util.js" \ "js/yaml-complete.js" \ + "js/manage-access.js" \ "js/conflict.js" \ "js/menu-model.js" \ "js/loader.js" \ diff --git a/browse/css/manage-access.css b/browse/css/manage-access.css new file mode 100644 index 0000000..b0b23e7 --- /dev/null +++ b/browse/css/manage-access.css @@ -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; +} diff --git a/browse/js/manage-access.js b/browse/js/manage-access.js new file mode 100644 index 0000000..5874075 --- /dev/null +++ b/browse/js/manage-access.js @@ -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); diff --git a/browse/js/menu-model.js b/browse/js/menu-model.js index 1935300..024da8e 100644 --- a/browse/js/menu-model.js +++ b/browse/js/menu-model.js @@ -352,13 +352,30 @@ // ── admin / sub-admin tier ── { - // HIDDEN unless the user can actually edit access rules here - // (admin verb 'a', or subtree/site admin) — not shown greyed. + // Guided "who can do what here" dialog — the front door for access. + // HIDDEN unless the user can administer here (admin verb 'a', or + // subtree/site admin). id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'], - label: 'Edit access rules…', + label: 'Manage access…', appliesTo: function (ctx) { if (!isServer()) return false; // server-only tier 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; }, action: function (ctx) { openZddcEditor(ctx.dir); }