Make access-control domains an editable list, and stop saves eating the rule labels
The block is rebuilt from the parsed model on every save and the parser discarded comments, so one press of Save deleted the five ## lines that are the only thing in the file saying what each rule is for — which inline editing was about to make far easier to press.
This commit is contained in:
+162
-19
@@ -134,10 +134,26 @@ require_once dirname(__DIR__) . '/include/ai_chat.php';
|
||||
.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-lbl { color:#5a6a4a;text-transform:none;letter-spacing:0;font-style:italic;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0; }
|
||||
/* One row per domain, each an input. Chips read well and cannot be edited; this is a list you
|
||||
work in, so the row is the field rather than a label that opens a dialog somewhere else. */
|
||||
.vv-au-domlist { display:flex;flex-direction:column;gap:2px; }
|
||||
.vv-au-domrow { display:flex;align-items:center;gap:4px; }
|
||||
.vv-au-dominput { flex:1;min-width:0;background:#0f1419;border:1px solid #1e2a33;border-radius:2px;
|
||||
color:#7c9fb8;font-family:monospace;font-size:10px;padding:2px 5px;outline:none; }
|
||||
.vv-au-dominput:focus { border-color:#2d5a8a;background:#111820;color:#a8cde0; }
|
||||
.vv-au-dominput.wild { color:#c9a227;border-color:#3a2800; }
|
||||
/* An empty row would be written out as a blank domain, so it is marked while it is still on
|
||||
screen rather than silently dropped at save time. */
|
||||
.vv-au-dominput.blank { border-color:#3a1a1a;background:#1a0d0d; }
|
||||
.vv-au-domadd { font-size:10px;color:#4a7a4a;background:none;border:1px dashed #23331f;border-radius:2px;
|
||||
padding:2px 6px;cursor:pointer;margin-top:4px;width:100%;text-align:center; }
|
||||
.vv-au-domadd:hover { color:#7ac77a;border-color:#2d5a2d;background:#0d1a0d; }
|
||||
/* The unsaved marker. Inline editing makes it very easy to change three cards and walk away, and
|
||||
nothing here reaches Authelia until Save & Restart is pressed. */
|
||||
.vv-au-btn.dirty { border-color:#3a2800;background:#1f1200;color:#ffb74d; }
|
||||
.vv-au-dirty-note{ font-size:10px;color:#ffb74d;margin-right:8px; }
|
||||
.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; }
|
||||
@@ -262,7 +278,8 @@ $isOwner = vv_is_owner();
|
||||
<?php if (!$isOwner): ?>
|
||||
<span style="font-size:10px;color:#3a2a1a;">default policy editable on HOST1</span>
|
||||
<?php endif; ?>
|
||||
<button class="vv-au-btn green" id="vv-au-ac-save" style="margin-left:auto">Save & Restart Authelia</button>
|
||||
<span class="vv-au-dirty-note" id="vv-au-ac-dirty" style="margin-left:auto"></span>
|
||||
<button class="vv-au-btn green" id="vv-au-ac-save">Save & Restart Authelia</button>
|
||||
</div>
|
||||
<div class="vv-au-sec-bar">
|
||||
<span class="vv-au-sec-title" id="vv-au-acl-count"></span>
|
||||
@@ -344,6 +361,9 @@ const IS_OWNER = <?= $isOwner ? 'true' : 'false' ?>;
|
||||
let _proxies = [], _certs = [];
|
||||
let _users = [], _groups = [];
|
||||
let _rules = [], _defaultPolicy = 'deny';
|
||||
// The trailing comment on the default_policy line, carried so a save puts it back. The block is
|
||||
// rebuilt from this model, so anything not held here is deleted by the next save.
|
||||
let _defaultNote = '';
|
||||
let _activeTab = 'proxies';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -894,6 +914,10 @@ function _loadAcl() {
|
||||
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
|
||||
_rules = r.rules || [];
|
||||
_defaultPolicy = r.default_policy || 'deny';
|
||||
_defaultNote = r.default_note || '';
|
||||
// Freshly loaded is by definition not modified — this also resets the marker after a reload
|
||||
// that followed an abandoned edit.
|
||||
_aclMarkDirty(false);
|
||||
|
||||
const sel = document.getElementById('vv-au-ac-defpol');
|
||||
if (sel) sel.value = _defaultPolicy;
|
||||
@@ -917,9 +941,16 @@ function _renderAcl() {
|
||||
if (emptyEl) emptyEl.style.display = _rules.length ? 'none' : 'block';
|
||||
|
||||
body.innerHTML = _rules.map((rule, i) => {
|
||||
const doms = _normDomain(rule.domain);
|
||||
// Normalised in place so a domain has a stable index to edit against. Authelia allows the
|
||||
// scalar form and this config uses it, but "rule 1's third domain" has to mean something for
|
||||
// an inline editor to address it at all.
|
||||
if (!Array.isArray(rule.domain)) rule.domain = _normDomain(rule.domain);
|
||||
const doms = rule.domain;
|
||||
const subjs = _normSubject(rule.subject);
|
||||
const sfx = _commonSuffix(doms);
|
||||
// The comment the operator wrote above this rule in configuration.yml. It is the only thing in
|
||||
// the file that says what a rule is for — the rule itself is a group id and thirteen hostnames
|
||||
// — so it belongs on the card rather than only in the file it came from.
|
||||
const label = _normList(rule._label).map(s => String(s).replace(/^#+\s*/, '')).join(' · ');
|
||||
|
||||
// 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.
|
||||
@@ -927,12 +958,18 @@ function _renderAcl() {
|
||||
? 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>`;
|
||||
// Full domain in every field, not the shortened form the chips used. A shortened value in an
|
||||
// editable box is a value that has to be reassembled before it means anything, and this is the
|
||||
// page where a wrong domain hands a service to the wrong group.
|
||||
const domHtml = doms.map((d, j) => {
|
||||
const full = String(d);
|
||||
const cls = (full.includes('*') ? ' wild' : '') + (full.trim() === '' ? ' blank' : '');
|
||||
return `<div class="vv-au-domrow">
|
||||
<input class="vv-au-dominput${cls}" data-rule="${i}" data-dom="${j}"
|
||||
value="${_esc(full)}" spellcheck="false" autocomplete="off"
|
||||
placeholder="host.example.com">
|
||||
<button class="vv-au-icon-btn del" data-dom-del="${i}:${j}" title="Remove this domain">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Both were invisible in the table — there was no column for them — so a rule narrowed to one
|
||||
@@ -962,9 +999,10 @@ function _renderAcl() {
|
||||
<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>` : ''}
|
||||
${label ? `<span class="vv-au-rule-lbl" title="comment in configuration.yml">${_esc(label)}</span>` : ''}
|
||||
</div>
|
||||
<div class="vv-au-doms">${domHtml}</div>
|
||||
<div class="vv-au-domlist">${domHtml}</div>
|
||||
<button class="vv-au-domadd" data-dom-add="${i}" type="button">+ add domain</button>
|
||||
${extra}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -1053,27 +1091,127 @@ function _ruleModal(idx) {
|
||||
else if (subjects.length) newRule.subject = subjects.length === 1 ? subjects[0] : subjects;
|
||||
if (networks.length) newRule.networks = networks;
|
||||
if (resources.length) newRule.resources = resources;
|
||||
// The comment lines above this rule in configuration.yml — ## Admin Only and the rest. The
|
||||
// dialog does not show them and rebuilds the rule from scratch, so without this, editing a
|
||||
// rule's policy would delete the only line in the file that says what the rule is for.
|
||||
if (rule && rule._label) newRule._label = rule._label;
|
||||
|
||||
if (idx !== null) _rules[idx] = newRule;
|
||||
else _rules.push(newRule);
|
||||
|
||||
_closeModal();
|
||||
_aclMarkDirty(true);
|
||||
_renderAcl();
|
||||
};
|
||||
}
|
||||
|
||||
// ── Unsaved state ────────────────────────────────────────────────────────────
|
||||
// Nothing on this tab reaches Authelia until Save & Restart is pressed — every edit mutates the
|
||||
// in-memory rules and re-renders. That was tolerable when the only ways to change anything were a
|
||||
// modal and a delete confirmation; with the domain rows editable in place it is far too easy to
|
||||
// change three cards, switch tabs and lose the lot with no indication anything was pending.
|
||||
let _aclDirty = false;
|
||||
function _aclMarkDirty(on) {
|
||||
_aclDirty = on;
|
||||
const btn = document.getElementById('vv-au-ac-save');
|
||||
if (btn) btn.classList.toggle('dirty', on);
|
||||
const note = document.getElementById('vv-au-ac-dirty');
|
||||
if (note) note.textContent = on ? 'unsaved changes' : '';
|
||||
}
|
||||
|
||||
// Writes straight into the model and deliberately does not re-render: the element being typed in
|
||||
// is inside the markup a render would replace, which would drop focus on the first keystroke.
|
||||
document.getElementById('vv-au-panel-acl').addEventListener('input', e => {
|
||||
const inp = e.target.closest('.vv-au-dominput');
|
||||
if (!inp) return;
|
||||
const r = parseInt(inp.dataset.rule), d = parseInt(inp.dataset.dom);
|
||||
if (!_rules[r] || !Array.isArray(_rules[r].domain)) return;
|
||||
_rules[r].domain[d] = inp.value;
|
||||
inp.classList.toggle('blank', inp.value.trim() === '');
|
||||
inp.classList.toggle('wild', inp.value.includes('*'));
|
||||
_aclMarkDirty(true);
|
||||
});
|
||||
|
||||
// Enter adds a row underneath and moves to it, so a list of fourteen can be typed straight
|
||||
// through instead of returning to the add button between each one.
|
||||
document.getElementById('vv-au-panel-acl').addEventListener('keydown', e => {
|
||||
const inp = e.target.closest('.vv-au-dominput');
|
||||
if (!inp || e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const r = parseInt(inp.dataset.rule), d = parseInt(inp.dataset.dom);
|
||||
if (!_rules[r]) return;
|
||||
_rules[r].domain.splice(d + 1, 0, '');
|
||||
_aclMarkDirty(true);
|
||||
_renderAcl();
|
||||
_focusDomain(r, d + 1);
|
||||
});
|
||||
|
||||
function _focusDomain(r, d) {
|
||||
const el = document.querySelector(`.vv-au-dominput[data-rule="${r}"][data-dom="${d}"]`);
|
||||
if (el) { el.focus(); el.select(); }
|
||||
}
|
||||
|
||||
// ACL event delegation
|
||||
document.getElementById('vv-au-panel-acl').addEventListener('click', async e => {
|
||||
if (e.target.id === 'vv-au-rule-add') { _ruleModal(null); return; }
|
||||
|
||||
const domAdd = e.target.closest('[data-dom-add]');
|
||||
if (domAdd) {
|
||||
const r = parseInt(domAdd.dataset.domAdd);
|
||||
if (!_rules[r]) return;
|
||||
_rules[r].domain.push('');
|
||||
_aclMarkDirty(true);
|
||||
_renderAcl();
|
||||
_focusDomain(r, _rules[r].domain.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const domDel = e.target.closest('[data-dom-del]');
|
||||
if (domDel) {
|
||||
const [r, d] = domDel.dataset.domDel.split(':').map(Number);
|
||||
if (!_rules[r]) return;
|
||||
// A rule with no domain matches nothing and Authelia will not load it. Refused here rather
|
||||
// than allowed and caught at save, so the answer arrives while the rule is still on screen.
|
||||
if (_rules[r].domain.length <= 1) {
|
||||
vvAlert('A rule needs at least one domain. Delete the whole rule instead, with the ✕ in its header.');
|
||||
return;
|
||||
}
|
||||
_rules[r].domain.splice(d, 1);
|
||||
_aclMarkDirty(true);
|
||||
_renderAcl();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.target.id === 'vv-au-ac-save') {
|
||||
const dp = document.getElementById('vv-au-ac-defpol').value;
|
||||
const btn = document.getElementById('vv-au-ac-save');
|
||||
|
||||
// Trimmed and emptied out here rather than on every keystroke, so a row can legitimately be
|
||||
// blank while it is being typed into. A rule left with no domain at all is refused instead of
|
||||
// written, because Authelia will not load the file and the failure would land at container
|
||||
// restart — with everything behind it already down.
|
||||
const payload = _rules.map(rule => {
|
||||
const out = Object.assign({}, rule);
|
||||
const doms = _normList(rule.domain).map(d => String(d).trim()).filter(Boolean);
|
||||
out.domain = doms.length === 1 ? doms[0] : doms;
|
||||
return out;
|
||||
});
|
||||
const empty = payload.findIndex(r => !_normList(r.domain).length);
|
||||
if (empty !== -1) {
|
||||
vvAlert('Rule ' + (empty + 1) + ' has no domains left. Give it one, or delete the rule.');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true; btn.textContent = 'Saving…';
|
||||
_post({ action:'authelia_save', rules: JSON.stringify(_rules), default_policy: dp }, r => {
|
||||
_post({ action:'authelia_save', rules: JSON.stringify(payload), default_policy: dp,
|
||||
default_note: _defaultNote }, r => {
|
||||
btn.disabled = false; btn.textContent = 'Save & Restart Authelia';
|
||||
if (!r.ok) { vvAlert('Save failed: ' + (r.error||'unknown error')); return; }
|
||||
// brief visual confirmation
|
||||
// The model on screen is the model on disk now — including the trimming just applied, which
|
||||
// is why the rules are replaced rather than left as typed.
|
||||
_rules = payload;
|
||||
_aclMarkDirty(false);
|
||||
_renderAcl();
|
||||
btn.textContent = 'Saved ✓';
|
||||
setTimeout(() => { btn.textContent = 'Save & Restart Authelia'; }, 2000);
|
||||
});
|
||||
@@ -1088,6 +1226,7 @@ document.getElementById('vv-au-panel-acl').addEventListener('click', async e =>
|
||||
const i = parseInt(delBtn.dataset.ruleDel);
|
||||
if (!await vvConfirm('Delete this rule?')) return;
|
||||
_rules.splice(i, 1);
|
||||
_aclMarkDirty(true);
|
||||
_renderAcl();
|
||||
return;
|
||||
}
|
||||
@@ -1095,17 +1234,21 @@ document.getElementById('vv-au-panel-acl').addEventListener('click', async e =>
|
||||
const upBtn = e.target.closest('[data-rule-up]');
|
||||
if (upBtn) {
|
||||
const i = parseInt(upBtn.dataset.ruleUp);
|
||||
if (i > 0) { [_rules[i-1], _rules[i]] = [_rules[i], _rules[i-1]]; _renderAcl(); }
|
||||
if (i > 0) { [_rules[i-1], _rules[i]] = [_rules[i], _rules[i-1]]; _aclMarkDirty(true); _renderAcl(); }
|
||||
return;
|
||||
}
|
||||
|
||||
const dnBtn = e.target.closest('[data-rule-dn]');
|
||||
if (dnBtn) {
|
||||
const i = parseInt(dnBtn.dataset.ruleDn);
|
||||
if (i < _rules.length-1) { [_rules[i], _rules[i+1]] = [_rules[i+1], _rules[i]]; _renderAcl(); }
|
||||
if (i < _rules.length-1) { [_rules[i], _rules[i+1]] = [_rules[i+1], _rules[i]]; _aclMarkDirty(true); _renderAcl(); }
|
||||
}
|
||||
});
|
||||
|
||||
// The default policy is the one control here that is not a rule, and it is the most consequential
|
||||
// one on the tab — it decides what happens to every hostname no rule names.
|
||||
document.getElementById('vv-au-ac-defpol').addEventListener('change', () => _aclMarkDirty(true));
|
||||
|
||||
// ── Close modal on overlay click ──────────────────────────────────────────────
|
||||
document.getElementById('vv-au-overlay').addEventListener('click', e => {
|
||||
if (e.target === document.getElementById('vv-au-overlay')) _closeModal();
|
||||
|
||||
Reference in New Issue
Block a user