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…
-
-
-
#
-
Domain
Policy
Subject
-
-
-
-
No rules configured.
-
+
Loading…
+
+
+
No rules configured.
@@ -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) ? `
+ `;
}).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;