Files
Varaverk/Plugin/unraid/pages/auth.php
T
Gmer4Lfe 693ac693a4 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.
2026-08-15 11:30:36 -04:00

1359 lines
69 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Auth tab. Manages the stack sitting in front of every protected hostname — NPM proxy
// hosts and certificates, LLDAP users and groups, and Authelia access-control rules.
//
// OPERATIONAL MODEL
// The most consequential page in the UI. A wrong edit here does not render badly, it locks
// people out of every service or exposes one that should be protected.
//
// DESIGN PRINCIPLES
// Requires include/auth.php directly rather than going through an endpoint for everything.
// Server-rendered for the initial state, with api/auth.php handling mutations. The
// credentials involved never reach the browser either way.
//
// Only the owner host edits auth config.
// Changes are made here and reach the partner through Critical-Data sync, not by the
// browser writing to two hosts. One source of truth, one direction of travel.
//
// Authelia rules are edited as a block, preserving the rest of the YAML untouched.
//
// OPERATIONAL SAFEGUARDS
// Every destructive action is confirmed — deleting a user, removing a proxy host, or
// rewriting the rule set are all one click away from locking out a household.
//
// Rule writes are atomic and refuse a missing config. include/auth.php writes to a temp
// file and renames; a missing configuration.yml is an error, never a freshly created file
// with a permissive default policy.
//
// Certificate state is read-only here. Renewal is owned by the cert monitor.
//
// All values render escaped — usernames, domains and rule fields are attacker-adjacent
// strings by definition on this page.
//
// RENDERS
// NPM proxy host list and editor, certificate status
// LLDAP user and group management
// Authelia access-control rules and default policy
//
// DEPENDS ON
// include/auth.php required directly for initial render
// api/auth.php mutations
// api/cert.php certificate status
// api/confform.php inline conf edits → include/confui.php
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
?>
<style>
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.vv-au-toolbar { display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap; }
.vv-au-title { font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
.vv-au-tab { font-size:11px;padding:3px 12px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#666;cursor:pointer;white-space:nowrap; }
.vv-au-tab:hover{ background:#222;color:#aaa; }
.vv-au-tab.active { background:#0d1f2a;border-color:#1a3a5a;color:#5c9fd4; }
.vv-au-btn { font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#888;cursor:pointer;white-space:nowrap; }
.vv-au-btn:hover{ background:#222;color:#bbb; }
.vv-au-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
.vv-au-btn.warn { border-color:#3a2000;background:#1f1200;color:#ff9800; }
.vv-au-btn.danger{border-color:#3a1a1a;background:#200d0d;color:#ef5350; }
.vv-au-btn.green{ border-color:#1a3a1a;background:#0d1f0d;color:#4caf50; }
.vv-au-btn:disabled { opacity:.4;cursor:default; }
/* ── Panels ──────────────────────────────────────────────────────────────── */
.vv-au-panel { display:none; }
.vv-au-panel.active { display:block; }
/* ── Tables ──────────────────────────────────────────────────────────────── */
.vv-au-tbl { width:100%;border-collapse:collapse;font-size:11px; }
.vv-au-tbl th { text-align:left;padding:5px 10px;font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid #1e1e1e;white-space:nowrap; }
.vv-au-tbl td { padding:7px 10px;border-bottom:1px solid #161616;vertical-align:middle; }
.vv-au-tbl tr:hover td { background:#141414; }
.vv-au-tbl tr:last-child td { border-bottom:none; }
/* ── Badges ──────────────────────────────────────────────────────────────── */
.vv-au-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;display:inline-block; }
.vv-au-badge.ssl { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.nossl { background:#111;border:1px solid #222;color:#444; }
.vv-au-badge.on { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.off { background:#1a0a0a;border:1px solid #2a1a1a;color:#555; }
.vv-au-badge.bypass { background:#0a1a2a;border:1px solid #1a3a5a;color:#5c9fd4; }
.vv-au-badge.one_factor { background:#1a1a0a;border:1px solid #3a3a1a;color:#cddc39; }
.vv-au-badge.two_factor { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.deny { background:#1a0a0a;border:1px solid #3a1a1a;color:#ef5350; }
.vv-au-badge.grp { background:#1a0d2a;border:1px solid #2a1a4a;color:#9c6ff7;margin:1px 2px; }
.vv-au-badge.host { background:#111;border:1px solid #1e1e1e;color:#444; }
/* ── Cards ───────────────────────────────────────────────────────────────── */
.vv-au-card { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; }
.vv-au-card-h { display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid #1e1e1e;background:#111; }
.vv-au-card-title { font-size:11px;font-weight:bold;color:#666;text-transform:uppercase;letter-spacing:.06em;flex:1; }
/* ── Users / Groups split ────────────────────────────────────────────────── */
.vv-au-ug-grid { display:grid;grid-template-columns:3fr 2fr;gap:12px; }
@media (max-width:700px) { .vv-au-ug-grid { grid-template-columns:1fr; } }
.vv-au-user-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
.vv-au-user-row:last-child { border-bottom:none; }
.vv-au-user-row:hover { background:#141414; }
.vv-au-user-name { font-size:12px;color:#bbb;font-weight:bold;min-width:80px; }
.vv-au-user-email{ font-size:10px;color:#444;flex:1; }
.vv-au-user-acts { display:flex;gap:4px;margin-left:auto;flex-shrink:0; }
.vv-au-grp-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;cursor:pointer; }
.vv-au-grp-row:last-child { border-bottom:none; }
.vv-au-grp-row:hover { background:#141414; }
.vv-au-grp-name { font-size:12px;color:#bbb;font-weight:bold; }
.vv-au-grp-cnt { font-size:10px;color:#444;margin-top:2px; }
.vv-au-grp-acts { float:right;display:flex;gap:4px;margin-top:1px; }
.vv-au-grp-members { padding:6px 12px 10px 20px;background:#0f0f0f;border-bottom:1px solid #1a1a1a; display:none; }
.vv-au-grp-members.open { display:block; }
.vv-au-grp-member { font-size:11px;color:#666;padding:2px 0;display:flex;align-items:center;gap:6px; }
.vv-au-grp-member-rm { font-size:11px;color:#3a1a1a;cursor:pointer;padding:0 2px; }
.vv-au-grp-member-rm:hover { color:#ef5350; }
/* ── Access Control ──────────────────────────────────────────────────────── */
.vv-au-ac-defpol { display:flex;align-items:center;gap:10px;padding:8px 12px;background:#111;border:1px solid #222;border-radius:4px;margin-bottom:10px;font-size:11px;color:#555; }
.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-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; }
.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; }
/* ── Section toolbar (within panel) ─────────────────────────────────────── */
.vv-au-sec-bar { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-au-sec-title{ font-size:11px;color:#555;flex:1; }
/* ── Toggle switch ───────────────────────────────────────────────────────── */
.vv-au-tog { width:28px;height:16px;border-radius:8px;background:#1e1e1e;border:1px solid #2a2a2a;position:relative;cursor:pointer;display:inline-block;flex-shrink:0; }
.vv-au-tog.on { background:#1a3a1a;border-color:#2d5a2d; }
.vv-au-tog::after { content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#444;transition:left .12s,background .12s; }
.vv-au-tog.on::after { left:14px;background:#4caf50; }
/* ── Modal ───────────────────────────────────────────────────────────────── */
.vv-au-overlay { position:fixed;inset:0;background:#0009;z-index:9000;display:none;align-items:center;justify-content:center; }
.vv-au-overlay.open { display:flex; }
.vv-au-modal { background:#181818;border:1px solid #2a2a2a;border-radius:6px;padding:18px 20px;min-width:360px;max-width:520px;width:90vw;max-height:85vh;overflow-y:auto; }
.vv-au-modal h3 { font-size:12px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px; }
.vv-au-field { margin-bottom:10px; }
.vv-au-label { display:block;font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.04em; }
.vv-au-input { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
.vv-au-input:focus { border-color:#1a3a5a; }
.vv-au-select { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
.vv-au-hint { font-size:10px;color:#333;margin-top:2px; }
.vv-au-modal-acts { display:flex;justify-content:flex-end;gap:8px;margin-top:16px; }
.vv-au-tog-row { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-au-tog-lbl { font-size:11px;color:#666; }
.vv-au-adv-toggle { font-size:10px;color:#333;cursor:pointer;margin-bottom:8px; }
.vv-au-adv-toggle:hover { color:#666; }
.vv-au-adv-section { display:none; }
.vv-au-adv-section.open { display:block; }
.vv-au-err { font-size:11px;color:#ef5350;margin-top:8px;display:none; }
.vv-au-err.show { display:block; }
/* ── Misc ────────────────────────────────────────────────────────────────── */
.vv-au-empty { padding:24px;text-align:center;font-size:11px;color:#333; }
.vv-au-domain { font-size:12px;color:#bbb;font-weight:bold; }
.vv-au-fwd { font-size:10px;color:#444; }
.vv-au-loading { color:#333;font-size:11px;padding:16px;text-align:center; }
/* ── Certs panel ─────────────────────────────────────────────────────────── */
.vv-au-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px; }
.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
.vv-au-cert-bar { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
</style>
<?php
require_once __DIR__ . '/../include/auth.php';
$isOwner = vv_is_owner();
?>
<div class="vv-au-toolbar">
<span class="vv-au-title">Auth Stack</span>
<button class="vv-au-tab active" data-tab="proxies">Proxies</button>
<button class="vv-au-tab" data-tab="users">Users &amp; Groups</button>
<button class="vv-au-tab" data-tab="acl">Access Control</button>
<button class="vv-au-tab" data-tab="certs">Certs</button>
<button class="vv-au-btn" id="vv-au-refresh" title="Refresh current tab">&#8635; Refresh</button>
</div>
<!-- ── Proxies ─────────────────────────────────────────────────────────────── -->
<div class="vv-au-panel active" id="vv-au-panel-proxies">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-proxy-count"></span>
<button class="vv-au-btn prim" id="vv-au-proxy-add">+ Add Proxy</button>
</div>
<div class="vv-au-card">
<div class="vv-au-loading" id="vv-au-proxy-loading">Loading…</div>
<table class="vv-au-tbl" id="vv-au-proxy-tbl" style="display:none">
<thead><tr>
<th>Domain</th><th>Forward</th><th>SSL</th><th>Status</th><th></th>
</tr></thead>
<tbody id="vv-au-proxy-body"></tbody>
</table>
<div class="vv-au-empty" id="vv-au-proxy-empty" style="display:none">No proxy hosts configured.</div>
</div>
</div>
<!-- ── Users & Groups ─────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-users">
<div class="vv-au-ug-grid">
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Users</span>
<button class="vv-au-btn prim" id="vv-au-user-add">+ Add User</button>
</div>
<div class="vv-au-loading" id="vv-au-users-loading">Loading…</div>
<div id="vv-au-users-list"></div>
<div class="vv-au-empty" id="vv-au-users-empty" style="display:none">No users found.</div>
</div>
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Groups</span>
<button class="vv-au-btn prim" id="vv-au-group-add">+ Add Group</button>
</div>
<div class="vv-au-loading" id="vv-au-groups-loading">Loading…</div>
<div id="vv-au-groups-list"></div>
<div class="vv-au-empty" id="vv-au-groups-empty" style="display:none">No groups found.</div>
</div>
</div>
</div>
<!-- ── Access Control ─────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-acl">
<div class="vv-au-ac-defpol" id="vv-au-ac-defpol-bar">
<span>Default policy:</span>
<select id="vv-au-ac-defpol" <?= $isOwner ? '' : 'disabled' ?>>
<option value="deny">deny</option>
<option value="two_factor">two_factor</option>
<option value="one_factor">one_factor</option>
<option value="bypass">bypass</option>
</select>
<?php if (!$isOwner): ?>
<span style="font-size:10px;color:#3a2a1a;">default policy editable on HOST1</span>
<?php endif; ?>
<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 &amp; Restart Authelia</button>
</div>
<div class="vv-au-sec-bar">
<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-loading" id="vv-au-acl-loading">Loading…</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 ───────────────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-certs">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-cert-ts"></span>
<button class="vv-au-btn prim" id="vv-au-cert-run">Refresh</button>
</div>
<div class="vv-au-cert-grid" id="vv-au-cert-grid">
<div class="vv-au-loading">Loading…</div>
</div>
<div id="vv-au-cert-cfg" style="margin-top:10px;font-size:10px;color:#3a3a3a;"></div>
</div>
<!-- ── Modal overlay ──────────────────────────────────────────────────────── -->
<div class="vv-au-overlay" id="vv-au-overlay">
<div class="vv-au-modal" id="vv-au-modal"></div>
</div>
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-au-ai-card" style="margin-top:12px;">
<?php
// The factory, the profile registry and the store are three separate emits and none implies
// the others — omitting any renders a chat that looks complete and dies on the first click.
vv_ai_profiles_script();
vv_ai_chat_store_script();
vv_ai_chat_assets();
// Scoped to the tab. This page's vocabulary is the part of the stack least likely to be in
// anyone's head — a proxy host, a forward target, an Authelia policy and an LDAP group are four
// different objects that all end up deciding whether one person can open one URL, and the
// question is nearly always "which of these is stopping me".
vv_ai_chat_markup('vv-au-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
'scopeLabel' => 'Auth',
'empty' => 'Ask about a proxy host, a rule, a group, or why a login is being refused.',
'placeholder' => 'Ask about what is on this page…',
]); ?>
</div>
<?php endif; ?>
<?php
// The three services this page drives, plus the cert thresholds behind the Certs panel. They had
// no card at all until now, so the credentials that make the whole page work were reachable only
// from the Settings catch-all or over SSH — and an empty NPM_USER renders here as a failed login,
// which sends you looking for a password rather than a blank field.
//
// The match mirrors VV_UI_SECTION_SURFACES in confform.php and has to keep mirroring it: that
// constant is what tells the assistant where to send someone, and this is what the page actually
// draws. The two disagreeing means being given directions to a card that is not there.
//
// Both passwords render masked and are logged by name only — vv_conf_key_is_secret() matches
// PASS, so the same key cannot be redacted in the audit log and legible in the form.
vv_conf_ui_card('vv-cf-auth', 'NginxProxyManager|lldap|Authelia|Certificate Monitor', 'Auth settings');
?>
<script>
(function () {
'use strict';
const API = '/plugins/varaverk/api/auth.php';
const CERT_API = '/plugins/varaverk/api/cert.php';
const IS_OWNER = <?= $isOwner ? 'true' : 'false' ?>;
// ── State ─────────────────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────────────
function _esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _get(action, cb) {
fetch(API + '?action=' + action)
.then(r => r.json()).then(cb)
.catch(e => cb({ ok: false, error: String(e) }));
}
function _post(params, cb) {
const fd = new URLSearchParams();
for (const [k, v] of Object.entries(params)) fd.append(k, v);
fetch(API, { method: 'POST', body: fd })
.then(r => r.json()).then(cb)
.catch(e => cb({ ok: false, error: String(e) }));
}
function _modal(html) {
document.getElementById('vv-au-modal').innerHTML = html;
document.getElementById('vv-au-overlay').classList.add('open');
}
function _closeModal() {
document.getElementById('vv-au-overlay').classList.remove('open');
}
function _togHtml(id, on, title) {
return `<span class="vv-au-tog${on?' on':''}" data-tog="${_esc(id)}" title="${_esc(title)}"></span>`;
}
function _policyBadge(p) {
return `<span class="vv-au-badge ${_esc(p)}">${_esc(p)}</span>`;
}
// 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;
// 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 ─────────────────────────────────────────────────────────────
document.querySelectorAll('.vv-au-tab').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
document.querySelectorAll('.vv-au-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vv-au-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('vv-au-panel-' + tab).classList.add('active');
_activeTab = tab;
_loadTab(tab);
});
});
document.getElementById('vv-au-refresh').addEventListener('click', () => _loadTab(_activeTab));
function _loadTab(tab) {
if (tab === 'proxies') _loadProxies();
if (tab === 'users') { _loadUsers(); _loadGroups(); }
if (tab === 'acl') _loadAcl();
if (tab === 'certs') _loadCerts();
}
// ── Proxies ───────────────────────────────────────────────────────────────────
function _loadProxies() {
const loading = document.getElementById('vv-au-proxy-loading');
const tbl = document.getElementById('vv-au-proxy-tbl');
const empty = document.getElementById('vv-au-proxy-empty');
loading.style.display = 'block';
tbl.style.display = 'none';
empty.style.display = 'none';
// Load certs and proxies in parallel
let certsLoaded = false, proxiesLoaded = false;
function _check() {
if (!certsLoaded || !proxiesLoaded) return;
loading.style.display = 'none';
if (!_proxies.length) { empty.style.display = 'block'; return; }
tbl.style.display = 'table';
_renderProxies();
}
_get('npm_certs', r => { _certs = r.certs || []; certsLoaded = true; _check(); });
_get('npm_proxies', r => {
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350">'+_esc(r.error)+'</span>'; return; }
_proxies = r.proxies || [];
proxiesLoaded = true;
_check();
});
}
function _renderProxies() {
const count = document.getElementById('vv-au-proxy-count');
count.textContent = _proxies.length + ' host' + (_proxies.length !== 1 ? 's' : '');
const body = document.getElementById('vv-au-proxy-body');
body.innerHTML = _proxies.map(p => {
const domains = (p.domain_names || []).join(', ');
const fwd = p.forward_scheme + '://' + p.forward_host + ':' + p.forward_port;
const hasSsl = p.certificate_id && p.certificate_id !== '0';
const sslBadge = hasSsl
? `<span class="vv-au-badge ssl">SSL</span>`
: `<span class="vv-au-badge nossl">none</span>`;
const enabled = p.enabled;
return `<tr>
<td><div class="vv-au-domain">${_esc(domains)}</div></td>
<td><div class="vv-au-fwd">${_esc(fwd)}</div></td>
<td>${sslBadge}</td>
<td>${_togHtml('proxy-' + p.id, enabled, enabled ? 'Enabled — click to disable' : 'Disabled — click to enable')}</td>
<td style="text-align:right">
<div class="vv-au-rule-acts">
<button class="vv-au-icon-btn" data-proxy-edit="${p.id}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-proxy-del="${p.id}" title="Delete">✕</button>
</div>
</td>
</tr>`;
}).join('');
}
function _proxyModal(id) {
const p = id ? _proxies.find(x => x.id === id) : null;
const certOptions = _certs.map(c =>
`<option value="${c.id}"${p && p.certificate_id == c.id ? ' selected' : ''}>${_esc(c.nice_name || c.domain_names?.join(', '))}</option>`
).join('');
_modal(`<h3>${p ? 'Edit Proxy' : 'Add Proxy'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Domain Names</label>
<input class="vv-au-input" id="pm-domains" value="${_esc((p?.domain_names||[]).join(', '))}" placeholder="example.com, *.example.com">
<div class="vv-au-hint">Comma-separated</div>
</div>
<div style="display:grid;grid-template-columns:1fr 2fr 80px;gap:8px">
<div class="vv-au-field">
<label class="vv-au-label">Scheme</label>
<select class="vv-au-select" id="pm-scheme">
<option${(!p||p.forward_scheme==='http')?' selected':''}>http</option>
<option${(p?.forward_scheme==='https')?' selected':''}>https</option>
</select>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Forward Host</label>
<input class="vv-au-input" id="pm-host" value="${_esc(p?.forward_host||'')}" placeholder="192.168.1.100">
</div>
<div class="vv-au-field">
<label class="vv-au-label">Port</label>
<input class="vv-au-input" id="pm-port" type="number" value="${_esc(p?.forward_port||80)}">
</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">SSL Certificate</label>
<select class="vv-au-select" id="pm-cert">
<option value="0">None</option>
${certOptions}
</select>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.ssl_forced?' on':''}" id="pm-ssl-forced"></span>
<span class="vv-au-tog-lbl">Force SSL</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.block_exploits?' on':''}" id="pm-block-exploits"></span>
<span class="vv-au-tog-lbl">Block Common Exploits</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${(!p||p.allow_websocket_upgrade!==false)?' on':''}" id="pm-websocket"></span>
<span class="vv-au-tog-lbl">WebSocket Support</span>
</div>
<div class="vv-au-err" id="pm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="pm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="pm-save">${p ? 'Save' : 'Create'}</button>
</div>`);
// Wire inline toggles
document.querySelectorAll('#vv-au-modal .vv-au-tog').forEach(t => {
t.addEventListener('click', () => t.classList.toggle('on'));
});
document.getElementById('pm-cancel').onclick = _closeModal;
document.getElementById('pm-save').onclick = () => {
const domains = document.getElementById('pm-domains').value.split(',').map(s=>s.trim()).filter(Boolean);
if (!domains.length) { _showModalErr('pm-err', 'Domain required'); return; }
const host = document.getElementById('pm-host').value.trim();
if (!host) { _showModalErr('pm-err', 'Forward host required'); return; }
const data = {
domain_names: domains,
forward_scheme: document.getElementById('pm-scheme').value,
forward_host: host,
forward_port: parseInt(document.getElementById('pm-port').value) || 80,
certificate_id: parseInt(document.getElementById('pm-cert').value) || 0,
ssl_forced: document.getElementById('pm-ssl-forced').classList.contains('on'),
block_exploits: document.getElementById('pm-block-exploits').classList.contains('on'),
allow_websocket_upgrade: document.getElementById('pm-websocket').classList.contains('on'),
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
meta: {},
locations: [],
advanced_config: '',
enabled: true,
};
const btn = document.getElementById('pm-save');
btn.disabled = true; btn.textContent = 'Saving…';
const done = r => {
if (!r.ok) { _showModalErr('pm-err', r.error||'Save failed'); btn.disabled=false; btn.textContent = p?'Save':'Create'; return; }
_closeModal(); _loadProxies();
};
if (p) _post({ action:'npm_update', id: p.id, data: JSON.stringify(data) }, done);
else _post({ action:'npm_create', data: JSON.stringify(data) }, done);
};
}
function _showModalErr(id, msg) {
const el = document.getElementById(id);
if (el) { el.textContent = msg; el.classList.add('show'); }
}
// Proxy event delegation
document.getElementById('vv-au-panel-proxies').addEventListener('click', async e => {
// Add button
if (e.target.id === 'vv-au-proxy-add') { _proxyModal(null); return; }
// Toggle
const tog = e.target.closest('[data-tog^="proxy-"]');
if (tog) {
const id = parseInt(tog.dataset.tog.split('-')[1]);
const was = tog.classList.contains('on');
tog.classList.toggle('on');
_post({ action:'npm_toggle', id, enabled: was?'0':'1' }, r => {
if (!r.ok) { tog.classList.toggle('on'); }
});
return;
}
// Edit
const editBtn = e.target.closest('[data-proxy-edit]');
if (editBtn) { _proxyModal(parseInt(editBtn.dataset.proxyEdit)); return; }
// Delete
const delBtn = e.target.closest('[data-proxy-del]');
if (delBtn) {
const id = parseInt(delBtn.dataset.proxyDel);
const p = _proxies.find(x => x.id === id);
if (!await vvConfirm('Delete proxy for ' + (p?.domain_names||['this host']).join(', ') + '?')) return;
_post({ action:'npm_delete', id }, r => { if (r.ok) _loadProxies(); });
}
});
// ── Users ─────────────────────────────────────────────────────────────────────
function _loadUsers() {
const loading = document.getElementById('vv-au-users-loading');
const list = document.getElementById('vv-au-users-list');
const empty = document.getElementById('vv-au-users-empty');
loading.style.display = 'block';
list.innerHTML = '';
empty.style.display = 'none';
_get('lldap_users', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_users = r.users || [];
if (!_users.length) { empty.style.display = 'block'; return; }
list.innerHTML = _users.map(u => {
const grpBadges = (u.groups||[]).map(g => `<span class="vv-au-badge grp" title="Click to remove" data-rm-from-group="${_esc(u.id)}" data-gid="${g.id}">${_esc(g.displayName)}</span>`).join('');
return `<div class="vv-au-user-row">
<span class="vv-au-user-name">${_esc(u.displayName||u.id)}</span>
<span class="vv-au-user-email">${_esc(u.email||'')}</span>
<div style="display:flex;gap:3px;align-items:center;flex-wrap:wrap">${grpBadges}</div>
<div class="vv-au-user-acts">
<button class="vv-au-icon-btn" data-user-grp="${_esc(u.id)}" title="Add to group">+grp</button>
<button class="vv-au-icon-btn" data-user-pass="${_esc(u.id)}" title="Change password">🔑</button>
<button class="vv-au-icon-btn" data-user-edit="${_esc(u.id)}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-user-del="${_esc(u.id)}" title="Delete">✕</button>
</div>
</div>`;
}).join('');
});
}
function _userModal(uid) {
const u = uid ? _users.find(x => x.id === uid) : null;
_modal(`<h3>${u ? 'Edit User' : 'Add User'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Username (ID)</label>
<input class="vv-au-input" id="um-uid" value="${_esc(u?.id||'')}" ${u?'readonly':''} placeholder="johndoe">
${u ? '' : '<div class="vv-au-hint">Lowercase letters, digits, hyphens — cannot be changed later</div>'}
</div>
<div class="vv-au-field">
<label class="vv-au-label">Display Name</label>
<input class="vv-au-input" id="um-name" value="${_esc(u?.displayName||'')}" placeholder="John Doe">
</div>
<div class="vv-au-field">
<label class="vv-au-label">Email</label>
<input class="vv-au-input" id="um-email" type="email" value="${_esc(u?.email||'')}" placeholder="john@example.com">
</div>
${!u ? `<div class="vv-au-field">
<label class="vv-au-label">Password</label>
<input class="vv-au-input" id="um-pass" type="password" placeholder="Initial password">
</div>` : ''}
<div class="vv-au-err" id="um-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="um-cancel">Cancel</button>
<button class="vv-au-btn prim" id="um-save">${u ? 'Save' : 'Create'}</button>
</div>`);
document.getElementById('um-cancel').onclick = _closeModal;
document.getElementById('um-save').onclick = () => {
const id = (document.getElementById('um-uid').value||'').trim();
const name = (document.getElementById('um-name').value||'').trim();
const email= (document.getElementById('um-email').value||'').trim();
const pass = document.getElementById('um-pass')?.value || '';
if (!id) { _showModalErr('um-err','Username required'); return; }
if (!email){ _showModalErr('um-err','Email required'); return; }
const btn = document.getElementById('um-save');
btn.disabled = true; btn.textContent = 'Saving…';
const done = r => {
if (!r.ok) { _showModalErr('um-err', r.error||'Save failed'); btn.disabled=false; btn.textContent=u?'Save':'Create'; return; }
_closeModal(); _loadUsers();
};
if (u) _post({ action:'lldap_update_user', uid:id, email, display_name:name }, done);
else _post({ action:'lldap_create_user', uid:id, email, display_name:name, password:pass }, done);
};
}
function _passModal(uid) {
_modal(`<h3>Change Password</h3>
<div class="vv-au-field">
<label class="vv-au-label">User</label>
<input class="vv-au-input" value="${_esc(uid)}" readonly>
</div>
<div class="vv-au-field">
<label class="vv-au-label">New Password</label>
<input class="vv-au-input" id="pw-pass" type="password" placeholder="New password" autofocus>
</div>
<div class="vv-au-err" id="pw-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="pw-cancel">Cancel</button>
<button class="vv-au-btn prim" id="pw-save">Set Password</button>
</div>`);
document.getElementById('pw-cancel').onclick = _closeModal;
document.getElementById('pw-save').onclick = () => {
const pass = document.getElementById('pw-pass').value;
if (!pass) { _showModalErr('pw-err','Password required'); return; }
const btn = document.getElementById('pw-save');
btn.disabled = true; btn.textContent = 'Saving…';
_post({ action:'lldap_set_password', uid, password:pass }, r => {
if (!r.ok) { _showModalErr('pw-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Set Password'; return; }
_closeModal();
});
};
}
function _addToGroupModal(uid) {
const user = _users.find(u => u.id === uid);
const userGids= new Set((user?.groups||[]).map(g => g.id));
const available = _groups.filter(g => !userGids.has(g.id));
if (!available.length) { vvAlert('User is already in all groups.'); return; }
const opts = available.map(g => `<option value="${g.id}">${_esc(g.displayName)}</option>`).join('');
_modal(`<h3>Add to Group</h3>
<div class="vv-au-field">
<label class="vv-au-label">User</label>
<input class="vv-au-input" value="${_esc(user?.displayName||uid)}" readonly>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Group</label>
<select class="vv-au-select" id="ag-grp">${opts}</select>
</div>
<div class="vv-au-err" id="ag-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="ag-cancel">Cancel</button>
<button class="vv-au-btn prim" id="ag-save">Add</button>
</div>`);
document.getElementById('ag-cancel').onclick = _closeModal;
document.getElementById('ag-save').onclick = () => {
const gid = parseInt(document.getElementById('ag-grp').value);
const btn = document.getElementById('ag-save');
btn.disabled = true; btn.textContent = 'Adding…';
_post({ action:'lldap_add_to_group', uid, gid }, r => {
if (!r.ok) { _showModalErr('ag-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Add'; return; }
_closeModal(); _loadUsers(); _loadGroups();
});
};
}
// User event delegation
document.getElementById('vv-au-panel-users').addEventListener('click', async e => {
if (e.target.id === 'vv-au-user-add') { _userModal(null); return; }
const editBtn = e.target.closest('[data-user-edit]');
if (editBtn) { _userModal(editBtn.dataset.userEdit); return; }
const passBtn = e.target.closest('[data-user-pass]');
if (passBtn) { _passModal(passBtn.dataset.userPass); return; }
const grpBtn = e.target.closest('[data-user-grp]');
if (grpBtn) { _addToGroupModal(grpBtn.dataset.userGrp); return; }
const delBtn = e.target.closest('[data-user-del]');
if (delBtn) {
const uid = delBtn.dataset.userDel;
const user = _users.find(u => u.id === uid);
if (!await vvConfirm('Delete user "' + (user?.displayName||uid) + '"?')) return;
_post({ action:'lldap_delete_user', uid }, r => { if (r.ok) _loadUsers(); });
return;
}
const rmBadge = e.target.closest('[data-rm-from-group]');
if (rmBadge) {
const uid = rmBadge.dataset.rmFromGroup;
const gid = parseInt(rmBadge.dataset.gid);
const grp = _groups.find(g => g.id === gid);
if (!await vvConfirm('Remove from group "' + (grp?.displayName||gid) + '"?')) return;
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
}
});
// ── Groups ────────────────────────────────────────────────────────────────────
function _loadGroups() {
const loading = document.getElementById('vv-au-groups-loading');
const list = document.getElementById('vv-au-groups-list');
const empty = document.getElementById('vv-au-groups-empty');
loading.style.display = 'block';
list.innerHTML = '';
empty.style.display = 'none';
_get('lldap_groups', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_groups = r.groups || [];
if (!_groups.length) { empty.style.display = 'block'; return; }
list.innerHTML = _groups.map(g => {
const members = (g.users||[]).map(u =>
`<div class="vv-au-grp-member">
<span>${_esc(u.displayName||u.id)}</span>
<span class="vv-au-grp-member-rm" data-rm-user="${_esc(u.id)}" data-gid="${g.id}" title="Remove from group">✕</span>
</div>`
).join('');
return `<div class="vv-au-grp-row" data-grp="${g.id}">
<div class="vv-au-grp-acts">
<button class="vv-au-icon-btn del" data-group-del="${g.id}" title="Delete group">✕</button>
</div>
<div class="vv-au-grp-name">${_esc(g.displayName)}</div>
<div class="vv-au-grp-cnt">${(g.users||[]).length} member${(g.users||[]).length!==1?'s':''}</div>
</div>
<div class="vv-au-grp-members" id="gm-${g.id}">
${members || '<div style="font-size:11px;color:#333;padding:2px 0">No members</div>'}
</div>`;
}).join('');
});
}
// Group event delegation
document.getElementById('vv-au-panel-users').addEventListener('click', async e => {
if (e.target.id === 'vv-au-group-add') {
_modal(`<h3>Add Group</h3>
<div class="vv-au-field">
<label class="vv-au-label">Group Name</label>
<input class="vv-au-input" id="gm-name" placeholder="admins" autofocus>
</div>
<div class="vv-au-err" id="gm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="gm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="gm-save">Create</button>
</div>`);
document.getElementById('gm-cancel').onclick = _closeModal;
document.getElementById('gm-save').onclick = () => {
const name = (document.getElementById('gm-name').value||'').trim();
if (!name) { _showModalErr('gm-err','Name required'); return; }
const btn = document.getElementById('gm-save');
btn.disabled = true; btn.textContent = 'Creating…';
_post({ action:'lldap_create_group', name }, r => {
if (!r.ok) { _showModalErr('gm-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Create'; return; }
_closeModal(); _loadGroups();
});
};
return;
}
const grpRow = e.target.closest('[data-grp]');
if (grpRow && !e.target.closest('button') && !e.target.closest('.vv-au-icon-btn')) {
const panel = document.getElementById('gm-' + grpRow.dataset.grp);
if (panel) panel.classList.toggle('open');
return;
}
const delGrp = e.target.closest('[data-group-del]');
if (delGrp) {
e.stopPropagation();
const id = parseInt(delGrp.dataset.groupDel);
const grp = _groups.find(g => g.id === id);
if (!await vvConfirm('Delete group "' + (grp?.displayName||id) + '"?')) return;
_post({ action:'lldap_delete_group', id }, r => { if (r.ok) _loadGroups(); });
return;
}
const rmUser = e.target.closest('[data-rm-user]');
if (rmUser) {
e.stopPropagation();
const uid = rmUser.dataset.rmUser;
const gid = parseInt(rmUser.dataset.gid);
_post({ action:'lldap_remove_from_group', uid, gid }, r => { if (r.ok) { _loadUsers(); _loadGroups(); } });
}
});
// ── Access Control ────────────────────────────────────────────────────────────
function _loadAcl() {
const loading = document.getElementById('vv-au-acl-loading');
const grid = document.getElementById('vv-au-acl-body');
const empty = document.getElementById('vv-au-acl-empty');
loading.style.display = 'block';
grid.style.display = 'none';
empty.style.display = 'none';
_get('authelia_rules', r => {
loading.style.display = 'none';
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;
if (!_rules.length) { empty.style.display = 'block'; return; }
grid.style.display = 'grid';
_renderAcl();
});
}
function _renderAcl() {
const count = document.getElementById('vv-au-acl-count');
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) => {
// 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);
// 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.
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>';
// 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
// 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:16px;display:inline-block"></span>' :
`<button class="vv-au-icon-btn" data-rule-dn="${i}" title="Move down">↓</button>`;
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>
</div>
<div class="vv-au-rule-b">
<div class="vv-au-rule-meta">
<span>${doms.length} domain${doms.length !== 1 ? 's' : ''}</span>
${label ? `<span class="vv-au-rule-lbl" title="comment in configuration.yml">${_esc(label)}</span>` : ''}
</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>`;
}).join('');
}
function _ruleModal(idx) {
const rule = idx !== null ? _rules[idx] : null;
// 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>`
).join('');
_modal(`<h3>${rule ? 'Edit Rule' : 'Add Rule'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Domain</label>
<input class="vv-au-input" id="rm-domain" value="${_esc(domains)}" placeholder="*.example.com, example.com">
<div class="vv-au-hint">Comma-separated; wildcards supported</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Policy</label>
<select class="vv-au-select" id="rm-policy">${policyOpts}</select>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Subject <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-subject" value="${_esc(subjects)}" placeholder="group:admins, user:john">
<div class="vv-au-hint">Comma-separated; prefix with group: or user:</div>
</div>
<div class="vv-au-adv-toggle" id="rm-adv-toggle">▶ Advanced (networks, resources)</div>
<div class="vv-au-adv-section" id="rm-adv">
<div class="vv-au-field">
<label class="vv-au-label">Networks <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-networks" value="${_esc(nets)}" placeholder="192.168.1.0/24, 10.0.0.0/8">
<div class="vv-au-hint">Comma-separated CIDR ranges</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Resources <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-resources" value="${_esc(resources)}" placeholder="^/api, ^/admin">
<div class="vv-au-hint">Comma-separated regex patterns</div>
</div>
</div>
<div class="vv-au-err" id="rm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="rm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="rm-save">${rule ? 'Save' : 'Add Rule'}</button>
</div>`);
document.getElementById('rm-adv-toggle').addEventListener('click', () => {
const sec = document.getElementById('rm-adv');
const tog = document.getElementById('rm-adv-toggle');
const open = sec.classList.toggle('open');
tog.textContent = (open ? '▼' : '▶') + ' Advanced (networks, resources)';
});
document.getElementById('rm-cancel').onclick = _closeModal;
document.getElementById('rm-save').onclick = () => {
const domains = document.getElementById('rm-domain').value.split(',').map(s=>s.trim()).filter(Boolean);
if (!domains.length) { _showModalErr('rm-err','Domain required'); return; }
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);
const newRule = {
domain: domains.length === 1 ? domains[0] : domains,
policy: document.getElementById('rm-policy').value,
};
// 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;
// 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(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; }
// 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);
});
return;
}
const editBtn = e.target.closest('[data-rule-edit]');
if (editBtn) { _ruleModal(parseInt(editBtn.dataset.ruleEdit)); return; }
const delBtn = e.target.closest('[data-rule-del]');
if (delBtn) {
const i = parseInt(delBtn.dataset.ruleDel);
if (!await vvConfirm('Delete this rule?')) return;
_rules.splice(i, 1);
_aclMarkDirty(true);
_renderAcl();
return;
}
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]]; _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]]; _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();
});
// ── Certs ─────────────────────────────────────────────────────────────────────
function _certBadgeCls(s) {
return ({OK:'ssl', WARN:'one_factor', CRIT:'deny', FAIL:'deny'})[s] || 'nossl';
}
function _certBadgeTxt(s) {
return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
}
function _certDayColor(days, warn, crit) {
if (days == null) return '#3a3a3a';
return days <= crit ? '#ef5350' : days <= warn ? '#ffb74d' : '#4caf50';
}
function _certRel(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now()/1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago';
return Math.floor(d/86400) + 'd ago';
}
function _renderNpmCerts(data) {
const grid = document.getElementById('vv-au-cert-grid');
const ts = document.getElementById('vv-au-cert-ts');
const cfg = document.getElementById('vv-au-cert-cfg');
const warn = data.warn_days || 30, crit = data.crit_days || 7;
const certs = data.certs || [];
ts.textContent = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM';
cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;
if (!certs.length) {
grid.innerHTML = '<div class="vv-au-empty">No certificates found in NPM</div>';
return;
}
grid.innerHTML = certs.map(c => {
const s = c.status || 'UNKN';
const days = c.days;
const col = _certDayColor(days, warn, crit);
const barPct = days != null ? Math.min(Math.round(days / 90 * 100), 100) : 0;
const expStr = c.expires ? 'Expires ' + c.expires : '';
const extra = (c.domain_names || []).filter(d => d !== c.nice_name).join(', ');
return `<div class="vv-au-card" style="padding:12px 14px;">
<div class="vv-au-domain">${_esc(c.nice_name)}</div>
${extra ? `<div style="font-size:9px;color:#444;margin-bottom:2px;word-break:break-all;">${_esc(extra)}</div>` : ''}
<div class="vv-au-cert-days" style="color:${col}">${days != null ? days : '—'}</div>
<div style="font-size:9px;color:#444;margin-bottom:6px;">${days != null ? 'days remaining' : ''}</div>
<span class="vv-au-badge ${_certBadgeCls(s)}">${_certBadgeTxt(s)}</span>
<div style="font-size:9px;color:#3a3a3a;margin-top:5px;">${_esc(expStr)}</div>
${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}
</div>`;
}).join('');
}
function _loadCerts(onDone) {
const grid = document.getElementById('vv-au-cert-grid');
grid.innerHTML = '<div class="vv-au-loading">Loading…</div>';
fetch(CERT_API + '?action=npm')
.then(r => r.json())
.then(d => {
if (d.ok) _renderNpmCerts(d);
else throw new Error(d.error || 'NPM error');
if (onDone) onDone();
})
.catch(e => {
grid.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed to load')}</div>`;
if (onDone) onDone();
});
}
document.getElementById('vv-au-cert-run').addEventListener('click', function() {
const btn = this;
btn.disabled = true; btn.textContent = 'Loading…';
_loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });
});
// ── Assistant ────────────────────────────────────────────────────────────────
// vv_ai_chat_markup() emits markup and nothing else — the instance has to be constructed here,
// as every other placement does. Without this the card renders complete and is entirely inert:
// the profile chip has no label because applyProfile() never ran, and clicking it does nothing
// because the picker was never wired.
//
// Guarded on the element because the card is behind vv_ai_ui_on(), so on a node with no reachable
// model this block finds nothing and does nothing rather than throwing on a missing prefix.
if (document.getElementById('vv-au-ai-chat')) {
VvAiChat({
prefix: 'vv-au-ai',
profile: 'varaverk',
scopeLabel: 'Auth',
scope: () => 'Auth',
// Pinned, like the other cards: without it this resumes whatever thread was last open
// anywhere, which could land the card mid-conversation under a profile this tab never offers.
resumeProfile: 'varaverk',
empty: 'Ask about a proxy host, a rule, a group, or why a login is being refused.',
});
}
// ── Boot ──────────────────────────────────────────────────────────────────────
_loadProxies();
})();
</script>