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.
This commit is contained in:
Gmer4Lfe
2026-08-15 00:04:43 -04:00
parent bb7c112a2d
commit b22d034933
+134 -39
View File
@@ -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();
<span class="vv-au-sec-title" id="vv-au-acl-count"></span>
<button class="vv-au-btn prim" id="vv-au-rule-add">+ Add Rule</button>
</div>
<div class="vv-au-card">
<div class="vv-au-loading" id="vv-au-acl-loading">Loading…</div>
<table class="vv-au-tbl" id="vv-au-acl-tbl" style="display:none">
<thead><tr>
<th style="width:24px">#</th>
<th>Domain</th><th>Policy</th><th>Subject</th><th></th>
</tr></thead>
<tbody id="vv-au-acl-body"></tbody>
</table>
<div class="vv-au-empty" id="vv-au-acl-empty" style="display:none">No rules configured.</div>
</div>
<!-- A card per rule rather than a table row. The domain list is the whole point of a rule here
and it is 13 or 14 entries on most of them, which in one table cell is a comma-joined run of
four hundred characters that says nothing at a glance. Cards give it room to wrap, and put
the group — the thing you are actually looking for — in the heading. -->
<div class="vv-au-acl-grid" id="vv-au-acl-body" style="display:none"></div>
<div class="vv-au-card"><div class="vv-au-empty" id="vv-au-acl-empty" style="display:none">No rules configured.</div></div>
</div>
<!-- ── Certs ───────────────────────────────────────────────────────────────── -->
@@ -361,16 +381,38 @@ function _policyBadge(p) {
return `<span class="vv-au-badge ${_esc(p)}">${_esc(p)}</span>`;
}
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 ? '<span style="width:18px;display:inline-block"></span>' :
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 => `<span class="vv-au-badge grp">${_esc(_subjLabel(s))}</span>`).join('')
: '<span class="vv-au-rule-any">anyone</span>';
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 `<span class="vv-au-dom${wild}" title="${_esc(full)}">${_esc(short)}</span>`;
}).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) ? `<div class="vv-au-rule-x">
${nets.length ? `<div><b>networks</b> <code>${_esc(nets.join(', '))}</code></div>` : ''}
${res.length ? `<div><b>resources</b> <code>${_esc(res.join(', '))}</code></div>` : ''}
</div>` : '';
const upBtn = i === 0 ? '<span style="width:16px;display:inline-block"></span>' :
`<button class="vv-au-icon-btn" data-rule-up="${i}" title="Move up">↑</button>`;
const dnBtn = i === _rules.length-1 ? '<span style="width:18px;display:inline-block"></span>' :
const dnBtn = i === _rules.length-1 ? '<span style="width:16px;display:inline-block"></span>' :
`<button class="vv-au-icon-btn" data-rule-dn="${i}" title="Move down">↓</button>`;
return `<tr>
<td class="vv-au-rule-num">${i+1}</td>
<td><div class="vv-au-domain">${_esc(domains)}</div></td>
<td>${_policyBadge(rule.policy)}</td>
<td style="font-size:10px;color:#555">${_esc(subjects||'—')}</td>
<td>
return `<div class="vv-au-rule">
<div class="vv-au-rule-h">
<span class="vv-au-rule-ord">${i+1}</span>
<span class="vv-au-rule-subj">${subjHtml}</span>
${_policyBadge(rule.policy)}
<div class="vv-au-rule-acts">
${upBtn}${dnBtn}
<button class="vv-au-icon-btn" data-rule-edit="${i}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-rule-del="${i}" title="Delete">✕</button>
</div>
</td>
</tr>`;
</div>
<div class="vv-au-rule-b">
<div class="vv-au-rule-meta">
<span>${doms.length} domain${doms.length !== 1 ? 's' : ''}</span>
${sfx ? `<span class="vv-au-rule-sfx">${_esc(sfx)}</span>` : ''}
</div>
<div class="vv-au-doms">${domHtml}</div>
${extra}
</div>
</div>`;
}).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 =>
`<option value="${p}"${(rule?.policy||'two_factor')===p?' selected':''}>${p}</option>`
@@ -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;