// 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); o.value = lv.id; o.title = lv.hint; sel.appendChild(o); }); if (model.level === 'custom') { var o2 = el('option', null, 'Custom'); o2.value = 'custom'; o2.title = 'verbs: ' + model.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); var legend = el('p', 'ma-legend', 'View = read · Contribute = add new files · Edit = overwrite + add · Manage = admin'); box.appendChild(legend); // 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);