From b22d03493338a82926f7dc75f31d59232defdcc1 Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sat, 15 Aug 2026 00:04:43 -0400 Subject: [PATCH] Give each access-control rule its own card, and stop the scalar forms throwing Most rules carry thirteen or fourteen domains, which in one table cell was a four-hundred character run that answered no question at a glance; the group is what you are looking for and it is now the heading. --- Plugin/unraid/pages/auth.php | 175 +++++++++++++++++++++++++++-------- 1 file changed, 135 insertions(+), 40 deletions(-) diff --git a/Plugin/unraid/pages/auth.php b/Plugin/unraid/pages/auth.php index dd6d187..6cca551 100644 --- a/Plugin/unraid/pages/auth.php +++ b/Plugin/unraid/pages/auth.php @@ -118,6 +118,30 @@ require_once dirname(__DIR__) . '/include/ai_chat.php'; .vv-au-ac-defpol select { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#888;font-size:11px;padding:2px 6px; } .vv-au-rule-num { color:#333;font-size:10px;width:24px;text-align:right;flex-shrink:0; } .vv-au-rule-acts { display:flex;gap:3px;white-space:nowrap; } + +/* ── ACL rule cards ──────────────────────────────────────────────────────── */ +/* Grid, not flex rows — every card the same width whatever its domain count, so the columns line + up down the page instead of each row negotiating its own. Same reason the Monitor uses one. */ +.vv-au-acl-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:10px;align-items:start; } +.vv-au-rule { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; } +/* Order is semantics here — Authelia takes the first rule whose domain and subject both match — + so the number stays loud even though cards read less sequentially than rows did. */ +.vv-au-rule-h { display:flex;align-items:center;gap:7px;padding:7px 10px;background:#111;border-bottom:1px solid #1e1e1e; } +.vv-au-rule-ord { font-size:10px;font-weight:700;color:#3a3a3a;min-width:14px; } +.vv-au-rule-subj { flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:3px; } +.vv-au-rule-any { font-size:10px;color:#b8860b;font-style:italic; } +.vv-au-rule-b { padding:8px 10px; } +.vv-au-rule-meta { font-size:9px;color:#3a3a3a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px; + display:flex;justify-content:space-between;gap:8px; } +.vv-au-rule-sfx { color:#4a4a4a;text-transform:none;letter-spacing:0;font-family:monospace; } +.vv-au-doms { display:flex;flex-wrap:wrap;gap:3px; } +.vv-au-dom { font-size:10px;padding:1px 5px;border-radius:2px;background:#0f1419;border:1px solid #1e2a33; + color:#7c9fb8;font-family:monospace;white-space:nowrap; } +.vv-au-dom.wild { background:#1a1000;border-color:#3a2800;color:#c9a227; } +.vv-au-rule-x { margin-top:7px;padding-top:6px;border-top:1px solid #1c1c1c;font-size:10px;color:#555; + display:flex;flex-direction:column;gap:2px; } +.vv-au-rule-x b { color:#3a3a3a;font-weight:normal;text-transform:uppercase;font-size:9px;letter-spacing:.06em; } +.vv-au-rule-x code { color:#8a7fb8;font-family:monospace;word-break:break-all; } .vv-au-icon-btn { font-size:12px;color:#444;cursor:pointer;padding:1px 3px;border-radius:2px;background:none;border:none;line-height:1; } .vv-au-icon-btn:hover { color:#bbb;background:#222; } .vv-au-icon-btn.del:hover { color:#ef5350;background:#1a0808; } @@ -244,17 +268,13 @@ $isOwner = vv_is_owner(); -
-
Loading…
- - - - - - - - -
+
Loading…
+ + +
@@ -361,16 +381,38 @@ function _policyBadge(p) { return `${_esc(p)}`; } -function _normSubject(val) { - if (!val) return []; - if (Array.isArray(val)) return val; - return [val]; +// Every list-shaped field in an Authelia rule is "a string or a list of them", and the config +// on this host uses both forms in the same file — one rule's domain is a bare string, another's +// is a list of fourteen. Two identical helpers existed for domain and subject; networks and +// resources had none and were read with a bare .join(), so `resources: "^\/web.*"` written as a +// scalar threw a TypeError and the Edit dialog for that rule never opened at all. +function _normList(val) { + if (val === null || val === undefined || val === '') return []; + return Array.isArray(val) ? val : [val]; } +const _normSubject = _normList; +const _normDomain = _normList; -function _normDomain(val) { - if (!val) return []; - if (Array.isArray(val)) return val; - return [val]; +// A subject entry is itself allowed to be a list, which Authelia reads as "all of these", so it +// renders joined by + rather than flattened into siblings that would read as alternatives. +function _subjLabel(s) { return Array.isArray(s) ? s.join(' + ') : String(s); } + +// The shared tail of a rule's domains, so fourteen chips can read npm, npm2, main instead of +// spending two thirds of every chip restating gmer4lfe.com. Returned only when it is at least two +// labels deep and every domain keeps something in front of it — stripping a bare .com would make +// the chips longer to read, not shorter, and stripping everything would leave one blank. +function _commonSuffix(list) { + if (list.length < 2) return ''; + const parts = list.map(d => String(d).split('.')); + let n = 0; + for (;;) { + const idx = parts.map(p => p.length - 1 - n); + if (idx.some(i => i < 1)) break; + const seg = parts[0][idx[0]]; + if (!parts.every((p, k) => p[idx[k]] === seg)) break; + n++; + } + return n >= 2 ? '.' + parts[0].slice(parts[0].length - n).join('.') : ''; } // ── Tab switching ───────────────────────────────────────────────────────────── @@ -841,10 +883,10 @@ document.getElementById('vv-au-panel-users').addEventListener('click', async e = // ── Access Control ──────────────────────────────────────────────────────────── function _loadAcl() { const loading = document.getElementById('vv-au-acl-loading'); - const tbl = document.getElementById('vv-au-acl-tbl'); + const grid = document.getElementById('vv-au-acl-body'); const empty = document.getElementById('vv-au-acl-empty'); loading.style.display = 'block'; - tbl.style.display = 'none'; + grid.style.display = 'none'; empty.style.display = 'none'; _get('authelia_rules', r => { @@ -857,7 +899,7 @@ function _loadAcl() { if (sel) sel.value = _defaultPolicy; if (!_rules.length) { empty.style.display = 'block'; return; } - tbl.style.display = 'table'; + grid.style.display = 'grid'; _renderAcl(); }); } @@ -867,35 +909,78 @@ function _renderAcl() { count.textContent = _rules.length + ' rule' + (_rules.length !== 1 ? 's' : ''); const body = document.getElementById('vv-au-acl-body'); + // Owned here, not only by the loader. Adding the first rule to an empty config, or deleting the + // last one, re-renders without going back to the endpoint — so whichever of the two is showing + // has to be decided by the render that knows the new count. + body.style.display = _rules.length ? 'grid' : 'none'; + const emptyEl = document.getElementById('vv-au-acl-empty'); + if (emptyEl) emptyEl.style.display = _rules.length ? 'none' : 'block'; + body.innerHTML = _rules.map((rule, i) => { - const domains = _normDomain(rule.domain).join(', '); - const subjects = _normSubject(rule.subject).join(', '); - const upBtn = i === 0 ? '' : + const doms = _normDomain(rule.domain); + const subjs = _normSubject(rule.subject); + const sfx = _commonSuffix(doms); + + // No subject means the rule applies to everyone who reaches that domain, which is the single + // most consequential thing a rule can say and read as an empty cell in the table it replaced. + const subjHtml = subjs.length + ? subjs.map(s => `${_esc(_subjLabel(s))}`).join('') + : 'anyone'; + + const domHtml = doms.map(d => { + const full = String(d); + const short = (sfx && full.endsWith(sfx)) ? full.slice(0, -sfx.length) : full; + // A wildcard covers everything under it, so it is worth spotting among its neighbours. + const wild = full.includes('*') ? ' wild' : ''; + return `${_esc(short)}`; + }).join(''); + + // Both were invisible in the table — there was no column for them — so a rule narrowed to one + // path or one subnet looked identical to one that was not. + const nets = _normList(rule.networks), res = _normList(rule.resources); + const extra = (nets.length || res.length) ? `
+ ${nets.length ? `
networks ${_esc(nets.join(', '))}
` : ''} + ${res.length ? `
resources ${_esc(res.join(', '))}
` : ''} +
` : ''; + + const upBtn = i === 0 ? '' : ``; - const dnBtn = i === _rules.length-1 ? '' : + const dnBtn = i === _rules.length-1 ? '' : ``; - return ` - ${i+1} -
${_esc(domains)}
- ${_policyBadge(rule.policy)} - ${_esc(subjects||'—')} - + + return `
+
+ ${i+1} + ${subjHtml} + ${_policyBadge(rule.policy)}
${upBtn}${dnBtn}
- - `; +
+
+
+ ${doms.length} domain${doms.length !== 1 ? 's' : ''} + ${sfx ? `${_esc(sfx)}` : ''} +
+
${domHtml}
+ ${extra} +
+
`; }).join(''); } function _ruleModal(idx) { const rule = idx !== null ? _rules[idx] : null; - const domains = _normDomain(rule?.domain).join(', '); - const subjects = _normSubject(rule?.subject).join(', '); - const nets = (rule?.networks||[]).join(', '); - const resources= (rule?.resources||[]).join(', '); + // All four through the same normaliser. networks and resources used to be read with a bare + // .join(), which threw on the scalar form Authelia allows and left the dialog unopenable. + const domains = _normList(rule?.domain).join(', '); + const subjects = _normList(rule?.subject).map(_subjLabel).join(', '); + // Captured before the field is drawn, so "did the operator change this?" is answerable at save. + const subjects_initial = subjects; + const nets = _normList(rule?.networks).join(', '); + const resources= _normList(rule?.resources).join(', '); const policyOpts = ['bypass','one_factor','two_factor','deny'].map(p => `` @@ -947,7 +1032,8 @@ function _ruleModal(idx) { const domains = document.getElementById('rm-domain').value.split(',').map(s=>s.trim()).filter(Boolean); if (!domains.length) { _showModalErr('rm-err','Domain required'); return; } - const subjects = document.getElementById('rm-subject').value.split(',').map(s=>s.trim()).filter(Boolean); + const subjRaw = document.getElementById('rm-subject').value; + const subjects = subjRaw.split(',').map(s=>s.trim()).filter(Boolean); const networks = document.getElementById('rm-networks').value.split(',').map(s=>s.trim()).filter(Boolean); const resources = document.getElementById('rm-resources').value.split(',').map(s=>s.trim()).filter(Boolean); @@ -955,7 +1041,16 @@ function _ruleModal(idx) { domain: domains.length === 1 ? domains[0] : domains, policy: document.getElementById('rm-policy').value, }; - if (subjects.length) newRule.subject = subjects.length === 1 ? subjects[0] : subjects; + // Authelia lets a subject entry be a list, meaning "in all of these groups". A text box split + // on commas cannot express that — reading it back would turn one AND into two ORs and widen + // who the rule admits. So an untouched field writes the original structure back verbatim, and + // only a field the operator actually edited is re-parsed. Nothing on this host uses the nested + // form today; this is here so that changing a policy on a rule that does cannot quietly + // rewrite who it applies to. + if (rule && subjRaw === subjects_initial) { + if (rule.subject !== undefined) newRule.subject = rule.subject; + } + else if (subjects.length) newRule.subject = subjects.length === 1 ? subjects[0] : subjects; if (networks.length) newRule.networks = networks; if (resources.length) newRule.resources = resources;