Files
Varaverk/Plugin/unraid/pages/auth.php
T

2632 lines
143 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
//
// STACK SWITCHING
// AUTH_STACK in master.conf decides which panels exist. Proxies and Certs belong to Nginx
// Proxy Manager and are drawn for every stack; Users and Access Control are the identity
// stack's and are drawn only for one that Varaverk can drive. api/auth.php applies the same
// rule to every action, so a tab left open across a switch cannot write to the old stack.
//
// 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; }
/* ── Stack indicator ─────────────────────────────────────────────────────── */
/* Which stack the tab is driving. Always visible, because every panel below it means something
different depending on this, and it is a conf value nothing else on the page reveals. */
.vv-au-stackchip{ font-size:10px;padding:2px 8px;border-radius:3px;background:#12181f;
border:1px solid #1e2a38;color:#5c9fd4;white-space:nowrap; }
.vv-au-stacknote{ font-size:11px;color:#997;background:#1a1400;border:1px solid #3a2d00;
border-radius:4px;padding:8px 12px;margin-bottom:10px;line-height:1.55; }
.vv-au-stacknote b { color:#ffb74d;display:block;margin-bottom:2px; }
.vv-au-stacknote.bad { background:#1a0d0d;border-color:#3a1a1a;color:#a77; }
.vv-au-stacknote.bad b { color:#ef5350; }
/* ── 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;display:flex;flex-direction:column;gap:1px; }
/* The real name under the display name, only when the two differ. */
.vv-au-user-real { font-size:9px;color:#4a4a4a;font-weight:normal; }
/* ── Avatars ─────────────────────────────────────────────────────────────── */
/* Fixed square with a flex-shrink guard: the rows are a flex layout and an image left to its own
intrinsic size drags every name on the page out of alignment while it loads. */
.vv-au-av { width:22px;height:22px;border-radius:3px;object-fit:cover;flex-shrink:0;
background:#111;border:1px solid #222;display:inline-block; }
.vv-au-av.none { font-size:9px;color:#4a4a4a;text-align:center;line-height:22px;font-weight:bold;
letter-spacing:.02em; }
.vv-au-av.big { width:88px;height:88px;border-radius:5px;line-height:88px;font-size:26px; }
/* ── Editor sections ─────────────────────────────────────────────────────── */
/* Below the field block and its Save, so the split between "staged until Save details" and
"applies when you press its own button" is a visible line rather than a convention. */
.vv-au-sect { margin-top:6px;padding-top:10px;border-top:1px solid #222; }
.vv-au-sect-h { font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;
margin:10px 0 5px; }
.vv-au-sect-h:first-child { margin-top:0; }
/* Wider only where it has to be — the editor now carries a photo beside a form. The other dialogs
on this page are single columns and would just get airier. */
.vv-au-modal.wide { max-width:620px; }
.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; }
/* nginx config is whitespace-significant and read in blocks — proportional text at 11px makes a
location block unreadable, and wrapping hides where a brace closes. */
.vv-au-nginx { font-family:monospace;font-size:11px;line-height:1.45;white-space:pre;
overflow-wrap:normal;overflow-x:auto;tab-size:2;resize:vertical;min-height:180px; }
.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; }
/* The host column is the flexible one in the proxy table, so a long list of domains has to wrap
inside it rather than push the figure columns out of line. */
.vv-au-domain { font-size:12px;color:#bbb;font-weight:bold;overflow-wrap:anywhere; }
.vv-au-fwd { font-size:10px;color:#444;font-family:monospace;margin-top:1px;overflow-wrap:anywhere; }
/* ── Proxy row marks and traffic ─────────────────────────────────────────── */
/* Small marks rather than four more columns. Each one is a fact about the host that had no
column at all — most importantly whether an auth_request block is in front of it. */
.vv-au-marks { display:flex;flex-wrap:wrap;gap:3px;align-items:center; }
.vv-au-m { font-size:9px;padding:1px 5px;border-radius:2px;background:#141414;
border:1px solid #232323;color:#666;white-space:nowrap; }
.vv-au-m.dim { color:#3a3a3a;border-color:#1c1c1c; }
/* The one mark worth finding at a glance: it is the difference between a service the household
can reach and one the whole internet can. */
.vv-au-m.auth { background:#1a0d2a;border-color:#2a1a4a;color:#9c6ff7;font-weight:600; }
/* Requests and errors are their own columns now. Right-aligned so the magnitudes line up down the
page — the whole point of the errors column is spotting the one host that is all failures. */
.vv-au-num { text-align:right;white-space:nowrap; }
.vv-au-stat-n { font-size:13px;font-weight:700;color:#bbb;line-height:1.15; }
.vv-au-stat-n.dim { color:#2e2e2e;font-weight:normal;font-size:12px; }
.vv-au-stat-n.ok { color:#2d4a2d; }
.vv-au-stat-n.warn { color:#ff9800; }
.vv-au-stat-n.bad { color:#ef5350; }
/* The unit rides with the number rather than in the header alone: a column of bare figures next
to another column of bare figures is two things you have to look up to tell apart. */
.vv-au-stat-u { font-size:9px;font-weight:400;color:#4a4a4a;text-transform:uppercase;
letter-spacing:.05em;margin-left:4px; }
.vv-au-stat-s { font-size:9px;color:#3a3a3a;margin-top:1px; }
.vv-au-stat-s.warn { color:#8a6a2a; }
.vv-au-stat-s.bad { color:#a34; }
/* Healthy uptime is deliberately quiet — a page where thirty green numbers shout is a page where
the two red ones do not. */
.vv-au-stat-n.ok2 { color:#4a7a4a; }
/* Fixed columns for the proxy table. With auto layout each figure column sized itself to its own
widest cell, so the three of them were three different widths and every column shifted whenever
the numbers changed. Declared once here and the host column takes whatever is left.
Percentages, not pixels, and no minimum width. A pixel minimum makes the table wider than the
page on a narrow window, and a table that cannot fit is a table that pushes its container around
— which is a layout problem everywhere else on the page in exchange for alignment in one card.
Proportions give the same thing the fixed widths gave: three figure columns identical to each
other and steady from row to row, at any width. */
.vv-au-tbl.fixed { table-layout:fixed; }
/* With fixed layout a cell keeps its width and its content simply overhangs, so anything that
cannot shrink has to be clipped here rather than allowed to run into the next column. The strip
below is the case that matters: sixty bars at a fixed 3px each do not care how narrow the window
is. Losing the oldest few minutes off the left of it is a fair price for a table that never
deforms — the percentage beside it is the precise figure anyway. */
.vv-au-tbl.fixed td { overflow:hidden; }
.vv-au-c-flags { width:14%; }
.vv-au-c-stat { width:17%; }
.vv-au-c-tog { width:5%; }
.vv-au-c-act { width:6%; }
/* ── Why ─────────────────────────────────────────────────────────────────── */
/* On the figure's own line, not under the strip: the strip is 179px of fixed-width bars and is what
sets this column's width, so anything below it would widen every row for the four that need it. */
.vv-au-upl { display:flex;align-items:center;justify-content:flex-end;gap:6px; }
.vv-au-why { font-size:9px;padding:1px 5px;border-radius:2px;background:#1f1200;
border:1px solid #3a2000;color:#ff9800;cursor:pointer;line-height:1.4; }
.vv-au-why:hover{ background:#2a1900;color:#ffb74d; }
.vv-au-why:disabled { opacity:.4;cursor:default; }
.vv-au-why-wait { font-size:11px;color:#666;padding:14px 2px;line-height:1.5; }
/* The findings are the answer, so they get the top of the dialog and enough weight to be read as
sentences rather than as another row of status. */
.vv-au-find { font-size:11px;line-height:1.5;padding:8px 10px;border-radius:3px;margin-bottom:6px;
border-left:2px solid #333;background:#141414;color:#888; }
.vv-au-find.bad { border-left-color:#ef5350;color:#d88; }
.vv-au-find.warn{ border-left-color:#ff9800;color:#b98a4a; }
.vv-au-find.info{ border-left-color:#2a4a6a;color:#777; }
.vv-au-why-grid { display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:12px 0 4px;font-size:11px; }
.vv-au-why-k { color:#444;white-space:nowrap; }
.vv-au-why-v { color:#999;overflow-wrap:anywhere; }
.vv-au-why-sec { font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;
margin:14px 0 6px;border-top:1px solid #222;padding-top:10px; }
.vv-au-why-h { margin-bottom:10px; }
.vv-au-why-hd { font-size:11px;color:#999;display:flex;justify-content:space-between;gap:10px; }
.vv-au-why-bad { font-size:10px;color:#8a6a2a;margin:2px 0 3px; }
.vv-au-why-ev { font-size:10px;color:#666;margin-top:2px; }
.vv-au-why-note { font-size:10px;color:#4a4a4a;margin-top:2px;line-height:1.45; }
/* ── Access simulator ────────────────────────────────────────────────────── */
.vv-au-sim { padding:12px 14px;margin-bottom:10px; }
.vv-au-sim-row { display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
.vv-au-sim-row .vv-au-label { margin-bottom:0; }
.vv-au-sim-row .vv-au-select { width:auto;min-width:150px;flex:0 1 auto; }
.vv-au-sim-path { width:110px;flex:0 0 auto; }
/* One word, because that is the question. Sized to be readable from the second screen. */
.vv-au-sim-verdict { font-size:20px;font-weight:700;letter-spacing:.02em;margin:14px 0 10px; }
.vv-au-sim-verdict.ok { color:#4a7a4a; }
.vv-au-sim-verdict.warn { color:#ff9800; }
.vv-au-sim-verdict.bad { color:#ef5350; }
.vv-au-sim-t { display:flex;gap:8px;font-size:10px;color:#555;padding:3px 0;align-items:baseline; }
.vv-au-sim-t.hit{ color:#999; }
.vv-au-sim-n { color:#333;min-width:14px; }
.vv-au-sim-t.hit .vv-au-sim-n { color:#4a7a4a; }
.vv-au-sim-l { min-width:130px;color:#666; }
.vv-au-sim-t.hit .vv-au-sim-l { color:#bbb;font-weight:600; }
.vv-au-sim-w { color:#444;overflow-wrap:anywhere; }
.vv-au-sim-t.hit .vv-au-sim-w { color:#777; }
/* ── Renewal triage ──────────────────────────────────────────────────────── */
/* Root causes carry the marker; consequences are dimmed. Sorting these by count would put rate
limiting first every time, and rate limiting is the one thing here never worth fixing directly. */
.vv-au-tri-row { display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #1a1a1a;align-items:baseline; }
.vv-au-tri-row:last-of-type { border-bottom:none; }
.vv-au-tri-row.root .vv-au-tri-n { color:#ff9800; }
.vv-au-tri-n { font-size:15px;font-weight:700;color:#555;min-width:34px;text-align:right; }
.vv-au-tri-w { font-size:11px;color:#888;line-height:1.5; }
.vv-au-tri-d { font-size:10px;color:#4a4a4a;font-family:monospace;margin-top:2px;overflow-wrap:anywhere; }
.vv-au-ok { color:#4a7a4a; }
.vv-au-bad { color:#ef5350; }
.vv-au-warn { color:#ff9800; }
.vv-au-dim { color:#444; }
/* One bar per probe, last hour. The shape is the part a percentage throws away: one long outage
and sixty scattered blips are the same 50% and completely different problems. */
.vv-au-spark { display:flex;gap:1px;justify-content:flex-end;align-items:flex-end;height:11px;margin-top:3px; }
.vv-au-spark i { width:2px;height:100%;background:#2d5a2d;border-radius:1px;flex-shrink:0; }
.vv-au-spark i.d{ background:#ef5350; }
/* A disabled host is still listed — it is a thing you might re-enable — but it should not read
as part of what is currently serving. */
.vv-au-off td { opacity:.42; }
.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; }
/* ── Cert history ────────────────────────────────────────────────────────── */
.vv-au-tot { display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:8px; }
.vv-au-tot-b { background:#161616;border:1px solid #222;border-radius:5px;padding:8px 10px;text-align:center; }
.vv-au-tot-n { font-size:19px;font-weight:700;color:#bbb;line-height:1.1; }
.vv-au-tot-n.bad { color:#ef5350; }
.vv-au-tot-l { font-size:9px;color:#444;text-transform:uppercase;letter-spacing:.05em;margin-top:2px; }
/* Fixed columns rather than flex: forty rows of counters only read as a table if the numbers line
up down the page, and a domain name is the one part whose width varies. */
.vv-au-hist-row { display:grid;grid-template-columns:1fr 34px 34px 74px 84px auto;gap:6px;
align-items:center;padding:4px 12px;border-bottom:1px solid #1a1a1a;font-size:11px; }
.vv-au-hist-row:last-child { border-bottom:none; }
.vv-au-hist-row:hover { background:#141414; }
.vv-au-hist-row.retired { background:#150c0c; }
.vv-au-hist-row.removed { opacity:.45; }
.vv-au-hist-dom { color:#bbb;font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.vv-au-hist-n { text-align:right;font-weight:600;font-size:11px; }
.vv-au-hist-n.ok { color:#4caf50; }
.vv-au-hist-n.bad{ color:#ef5350; }
.vv-au-hist-n.dim{ color:#333; }
.vv-au-hist-t { color:#666;font-size:10px;text-align:right; }
.vv-au-hist-e { color:#444;font-size:10px;font-family:monospace;text-align:right; }
.vv-au-hist-note { font-size:9px;color:#3a3a3a;font-weight:normal;text-transform:none;letter-spacing:0; }
.vv-au-strike { font-size:9px;color:#ff9800;background:#1f1200;border:1px solid #3a2800;
border-radius:2px;padding:0 5px;white-space:nowrap; }
.vv-au-dot { width:7px;height:7px;border-radius:50%;flex-shrink:0;display:inline-block; }
</style>
<?php
require_once __DIR__ . '/../include/auth.php';
$isOwner = vv_is_owner();
// AUTH_STACK decides which panels exist at all. Read once here and used for both the tabs and the
// panels, so a tab can never be drawn for a panel the stack does not carry.
$stackDef= vv_auth_stack_def();
$panels = $stackDef['panels'];
$first = $panels[0] ?? 'proxies';
$tabs = ['proxies' => 'Proxies', 'users' => 'Users &amp; Groups',
'acl' => 'Access Control', 'certs' => 'Certs'];
?>
<div class="vv-au-toolbar">
<span class="vv-au-title">Auth Stack</span>
<?php foreach ($tabs as $id => $label): if (!in_array($id, $panels, true)) continue; ?>
<button class="vv-au-tab<?= $id === $first ? ' active' : '' ?>" data-tab="<?= $id ?>"><?= $label ?></button>
<?php endforeach; ?>
<span class="vv-au-stackchip" title="AUTH_STACK in master.conf — Settings below"><?= htmlspecialchars($stackDef['label']) ?></span>
<button class="vv-au-btn" id="vv-au-refresh" title="Refresh current tab">&#8635; Refresh</button>
</div>
<?php if (!vv_auth_stack_valid()): ?>
<!-- A value that is not one of the known stacks. Named rather than silently corrected, because
the tab is now showing a different stack than the conf asks for and that is worth knowing. -->
<div class="vv-au-stacknote bad">
<b>AUTH_STACK is set to "<?= htmlspecialchars(trim(vv_conf_vars()['AUTH_STACK'] ?? '')) ?>", which is not a stack this page knows.</b>
Showing <?= htmlspecialchars($stackDef['label']) ?> instead. Fix it in Auth settings, below.
</div>
<?php elseif (!$stackDef['ready']): ?>
<!-- Selected but not implemented. The panels that do work are still drawn — proxy hosts and
certificates belong to Nginx Proxy Manager, not to the identity stack — and the two that
cannot are absent with the reason stated, rather than present and broken. -->
<div class="vv-au-stacknote">
<b><?= htmlspecialchars($stackDef['label']) ?> is selected, and Varaverk cannot manage it yet.</b>
<?= htmlspecialchars($stackDef['summary']) ?>
<span style="color:#4a4a4a">Needed: <?= htmlspecialchars($stackDef['needs'] ?? '') ?></span>
</div>
<?php endif; ?>
<!-- ── Proxies ─────────────────────────────────────────────────────────────── -->
<?php if (in_array("proxies",$panels,true)): ?>
<div class="vv-au-panel<?= $first==="proxies" ? " 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 fixed" id="vv-au-proxy-tbl" style="display:none">
<colgroup>
<col><col class="vv-au-c-flags"><col class="vv-au-c-stat"><col class="vv-au-c-stat">
<col class="vv-au-c-stat"><col class="vv-au-c-tog"><col class="vv-au-c-act">
</colgroup>
<thead><tr>
<th>Host</th><th>Flags</th><th style="text-align:right">Uptime</th><th style="text-align:right">Requests</th><th style="text-align:right">Errors</th><th>On</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>
<?php endif; ?>
<!-- ── Users & Groups ─────────────────────────────────────────────────────── -->
<?php if (in_array("users",$panels,true)): ?>
<div class="vv-au-panel<?= $first==="users" ? " active" : "" ?>" 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>
<?php endif; ?>
<!-- ── Access Control ─────────────────────────────────────────────────────── -->
<?php if (in_array("acl",$panels,true)): ?>
<div class="vv-au-panel<?= $first==="acl" ? " active" : "" ?>" 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>
<!-- Whether one person can open one URL is decided by three objects on three different tabs: the
NPM host (is it handed to Authelia at all), the rule list below (which rule wins, in file
order), and the LDAP group the rule names. This asks the question directly instead of making
someone hold all three in their head — and it reads the Authelia instance the chosen host
actually talks to, which is not always the one this tab edits. -->
<div class="vv-au-card vv-au-sim">
<div class="vv-au-sim-row">
<span class="vv-au-label">Can</span>
<select class="vv-au-select" id="vv-au-sim-user"></select>
<span class="vv-au-label">open</span>
<select class="vv-au-select" id="vv-au-sim-dom"></select>
<input class="vv-au-input vv-au-sim-path" id="vv-au-sim-path" value="/" title="Path — rules carrying a resources pattern only apply to the paths they name">
<button class="vv-au-btn prim" id="vv-au-sim-run">Test</button>
</div>
<div id="vv-au-sim-out"></div>
</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>
<?php endif; ?>
<!-- ── Certs ───────────────────────────────────────────────────────────────── -->
<?php if (in_array("certs",$panels,true)): ?>
<div class="vv-au-panel<?= $first==="certs" ? " active" : "" ?>" id="vv-au-panel-certs">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-cert-ts"></span>
<!-- The counts on this tab say a domain stopped renewing. certbot's logs say why, and they are
639 MB of Python tracebacks — which is why the last answer to that question was somebody
reading them by hand. This reads the newest runs and names the categories. -->
<button class="vv-au-btn" id="vv-au-cert-triage">Why renewals fail</button>
<button class="vv-au-btn prim" id="vv-au-cert-run">Refresh</button>
</div>
<div id="vv-au-cert-triage-out"></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>
<!-- Everything below is history rather than current state, and comes from
DB_DIR/cert_history.json which Tools/cert_history.sh writes. The grid above answers "is
this cert about to expire"; this answers "has this domain ever been trouble". -->
<div id="vv-au-hist-totals" style="margin-top:14px"></div>
<div class="vv-au-ug-grid" style="margin-top:10px;grid-template-columns:2fr 1fr">
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Domain history</span>
<span class="vv-au-hist-note" id="vv-au-hist-when"></span>
</div>
<div id="vv-au-hist-list"><div class="vv-au-loading">Loading…</div></div>
</div>
<div class="vv-au-card">
<div class="vv-au-card-h"><span class="vv-au-card-title">DDNS</span></div>
<div id="vv-au-ddns"><div class="vv-au-loading">Loading…</div></div>
</div>
</div>
</div>
<?php endif; ?>
<!-- ── 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', 'Auth Stack|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 = [];
// Keyed by proxy-host id, from Tools/npm_access_stats.sh. Empty until it has run once.
let _proxyStats = {};
// Keyed by hostname, from Tools/uptime_probe.sh. Empty until the probe has run once.
let _uptime = {};
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 = '';
// Seeded from PHP rather than hardcoded to 'proxies'. Which panels exist is AUTH_STACK's decision,
// and a stack whose first panel is not Proxies would boot with _activeTab naming a tab that is not
// on the page — so Refresh would reload nothing and the first render would be empty.
let _activeTab = <?= json_encode($first) ?>;
// ── Helpers ───────────────────────────────────────────────────────────────────
function _esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// params is optional and encoded rather than concatenated — the only caller passing one passes an
// id, but a query string built by hand is how a value with an & in it silently becomes two.
function _get(action, cb, params) {
const qs = params ? '&' + new URLSearchParams(params).toString() : '';
fetch(API + '?action=' + action + qs)
.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) }));
}
// wide is for the one dialog that carries a photo beside a form. Reset on every call rather than
// only set, or a narrow dialog opened after the editor would inherit its width.
// Attach only if the element is there. AUTH_STACK decides which panels the page renders, so on a
// stack that does not carry Users or Access Control those containers genuinely do not exist —
// and a bare getElementById(...).addEventListener would throw on load and take every listener
// after it down with it, including the ones for the panels that do exist.
function _on(id, ev, fn) {
const el = document.getElementById(id);
if (el) el.addEventListener(ev, fn);
return !!el;
}
function _modal(html, wide) {
const m = document.getElementById('vv-au-modal');
m.innerHTML = html;
m.classList.toggle('wide', !!wide);
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);
});
});
_on('vv-au-refresh', '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();
}
// Stats are optional decoration — the list must render whether or not the aggregator has run,
// so this neither blocks _check() nor fails the load.
_get('npm_stats', r => { _proxyStats = (r && r.ok && r.hosts) ? r.hosts : {}; if (proxiesLoaded) _renderProxies(); });
_get('npm_uptime', r => { _uptime = (r && r.ok && r.domains) ? r.domains : {}; if (proxiesLoaded) _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 enabled = p.enabled;
const st = _proxyStats[String(p.id)] || null;
// Everything the host is doing that used to be invisible, as one row of small marks rather
// than four more columns: whether it is protected by an auth_request block, whether SSL is
// forced, and whether it carries custom nginx at all. 25 of these have config the page could
// not previously show, and the auth ones are the important case.
const adv = (p.advanced_config || '').trim();
const guarded = /auth_request/.test(adv);
const marks = [
hasSsl ? `<span class="vv-au-badge ssl" title="${_esc(_certName(p.certificate_id))}">SSL</span>`
: `<span class="vv-au-badge nossl">none</span>`,
p.ssl_forced ? '<span class="vv-au-m" title="HTTP redirected to HTTPS">force</span>' : '',
guarded ? '<span class="vv-au-m auth" title="auth_request — behind Authelia">auth</span>' : '',
adv && !guarded ? '<span class="vv-au-m" title="has Custom Nginx Configuration">nginx</span>' : '',
p.http2_support ? '<span class="vv-au-m dim" title="HTTP/2">h2</span>' : '',
p.hsts_enabled ? '<span class="vv-au-m dim" title="HSTS">hsts</span>' : '',
p.block_exploits ? '<span class="vv-au-m dim" title="Block common exploits">blk</span>' : '',
].filter(Boolean).join('');
// Two columns, not one run of numbers. Requests and errors answer different questions — "is
// anything using this" and "is it working" — and a host serving thirty-five thousand requests
// that are all failures read as a busy host when the two sat side by side.
//
// Only shown once the aggregator has run. A dash is honest; a zero would read as "nobody has
// ever visited this" when it means "nothing has counted yet".
const bad = st ? (st.s4xx || 0) + (st.s5xx || 0) : 0;
const pct = (st && st.requests) ? (bad / st.requests * 100) : 0;
// Banded rather than a gradient: the question is which of "fine", "worth a look" and "this is
// not working" applies, and three answers do not need a hundred shades.
const pctCls = pct >= 50 ? ' bad' : (pct >= 5 ? ' warn' : '');
// Every figure carries its own word. A bare "15m" next to a byte count is a number you have to
// hover to identify, and the sub-line is the one place there is room to just say it.
const reqCell = st
? `<div class="vv-au-stat-n">${_fmtNum(st.requests)}<span class="vv-au-stat-u">requests</span></div>
<div class="vv-au-stat-s">${_fmtBytes(st.bytes)} sent${st.last_seen ? ' · last request ' + _ago(st.last_seen) + ' ago' : ' · never hit'}</div>`
: '<div class="vv-au-stat-n dim">—</div>';
const errCell = !st ? '<div class="vv-au-stat-n dim">—</div>'
: (bad === 0
? `<div class="vv-au-stat-n ok">0<span class="vv-au-stat-u">errors</span></div>`
: `<div class="vv-au-stat-n${pctCls}" title="${_fmtNum(st.s4xx)} client (4xx), ${_fmtNum(st.s5xx)} server (5xx)">${_fmtNum(bad)}<span class="vv-au-stat-u">errors</span></div>
<div class="vv-au-stat-s${pctCls}">${pct >= 10 ? Math.round(pct) : pct.toFixed(1)}% of requests</div>`);
return `<tr class="${enabled ? '' : 'vv-au-off'}">
<td>
<div class="vv-au-domain">${_esc(domains)}</div>
<div class="vv-au-fwd">→ ${_esc(fwd)}</div>
</td>
<td><div class="vv-au-marks">${marks}</div></td>
<td class="vv-au-num">${_upCell(p)}</td>
<td class="vv-au-num">${reqCell}</td>
<td class="vv-au-num">${errCell}</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('');
}
// Uptime for the host, from Tools/uptime_probe.sh. A proxy host can carry several domains, so the
// worst of them is what the row reports — a host is only as reachable as its least reachable name,
// and averaging would hide one dead domain behind three healthy ones.
function _upCell(p) {
const doms = (p.domain_names || []).map(d => String(d).toLowerCase()).filter(d => !d.includes('*'));
const recs = doms.map(d => _uptime[d]).filter(Boolean);
if (!recs.length) return '<div class="vv-au-stat-n dim">—</div>';
const worst = recs.reduce((a, b) => ((a.h24 ?? 101) <= (b.h24 ?? 101) ? a : b));
const down = recs.some(r => r.state === 'down');
const pct = worst.h24;
// Banded on the same three-state rule as the error column: fine, worth a look, not working.
const cls = down ? ' bad' : (pct === null ? ' dim' : (pct >= 99.5 ? ' ok2' : (pct >= 95 ? ' warn' : ' bad')));
// Sixty samples is the last hour at a one-minute cadence. Drawn as bars rather than a number
// because the shape — one long outage or sixty scattered blips — is the part a percentage loses.
const bars = (worst.samples || []).map(s =>
`<i class="${s ? '' : 'd'}"></i>`).join('');
const title = down
? 'DOWN — ' + (worst.last_detail || 'no response')
: 'up' + (worst.last_ms ? ' · ' + worst.last_ms + 'ms' : '');
// Offered only where there is something to explain. A why? on all thirty-five rows is a button
// nobody reads; on the four that are not fine it is the next thing you were going to do anyway.
//
// 96 rather than 100 because a probe every minute makes 99.9% an ordinary week — one missed
// sample in a day is 99.93 and means nothing. Below 96 is roughly an hour lost in a day, which is
// always something. Anything currently down qualifies whatever its percentage says.
const needWhy = down || (pct !== null && pct < 96);
const why = needWhy
? `<button class="vv-au-why" data-why="${p.id}" title="Go and look at what is behind this figure">why?</button>`
: '';
return `<div class="vv-au-upl">${why}<div class="vv-au-stat-n${cls}" title="${_esc(title)}">${pct === null ? '—' : (pct >= 99.95 ? '100' : pct.toFixed(pct >= 99 ? 2 : 1))}<span class="vv-au-stat-u">% 24h</span></div></div>
<div class="vv-au-spark" title="last hour, one bar per minute">${bars}</div>`;
}
// ── Why ───────────────────────────────────────────────────────────────────────
// Held so the assistant hand-off has something to send without asking the server twice. Cleared
// with the dialog, because a brief describing a probe from ten minutes ago is worse than no brief.
let _why = null;
function _whyModal(id, btn) {
const p = _proxies.find(x => x.id === id);
const name = (p?.domain_names || []).join(', ') || ('host ' + id);
_why = null;
// Disabled for the duration. The check takes seconds and opens sockets, and a second press while
// the first is in flight probes the host twice to answer the same question.
if (btn) btn.disabled = true;
// Named while it works, because it genuinely takes a few seconds and a silent dialog reads as a
// hang. What it is about to do is listed rather than hidden behind a spinner — this opens
// connections, and an operator watching a limping host should know the page is about to touch it.
_modal(`<h3>Why — ${_esc(name)}</h3>
<div class="vv-au-why-wait">Looking… connecting to the service behind this host, asking the
domain itself, and reading the probe history.</div>`, true);
_get('npm_why', r => {
if (btn) btn.disabled = false;
if (!r || !r.ok) {
_modal(`<h3>Why — ${_esc(name)}</h3>
<div class="vv-au-err show">${_esc(r?.error || 'The check failed')}</div>
<div class="vv-au-modal-acts"><button class="vv-au-btn" id="wm-close">Close</button></div>`, true);
document.getElementById('wm-close').onclick = _closeModal;
return;
}
_why = r;
_modal(_whyHtml(r, name), true);
document.getElementById('wm-close').onclick = _closeModal;
const ask = document.getElementById('wm-ask');
if (ask) ask.onclick = () => _whyAsk(name);
}, { id });
}
// Hand the measurements to the assistant. The dialog closes first: the chat card is at the foot of
// the page and an answer streaming in behind an overlay is an answer nobody sees.
function _whyAsk(name) {
const chat = window.__vvAiChat && window.__vvAiChat['vv-au-ai'];
if (!chat || !_why) return;
if (chat.busy()) { vvAlert('The assistant is still answering the previous question.'); return; }
const brief = _whyBrief(_why, name);
_closeModal();
// retarget, so the thread is about this host and not appended to whatever was asked before it.
// Same reasoning as the Watchdog tab: a troubleshooting thread about one host must not bleed into
// a question about another.
chat.retarget('troubleshoot', name, 'now looking at ' + name);
const input = document.getElementById('vv-au-ai-input');
if (input) input.value = brief;
chat.send();
const card = document.getElementById('vv-au-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function _whyHtml(r, name) {
const lvl = { bad: 'bad', warn: 'warn', info: 'info' };
// The findings first and everything else under them. The evidence is what makes the answer
// checkable, but it is not the answer, and a dialog that opens on a table of status codes makes
// the reader do the work the check just did.
const findings = (r.findings || []).map(f =>
`<div class="vv-au-find ${lvl[f.level] || 'info'}">${_esc(f.text)}</div>`).join('');
const up = r.upstream || {};
const tcp = up.tcp || {};
const rows = [];
rows.push(['Forwards to', _esc(r.forward)
+ (up.container ? ` <span class="vv-au-m${up.container.running ? '' : ' auth'}">${_esc(up.container.name)} · ${_esc(up.container.status)}</span>` : '')]);
// The address actually knocked on, not the one written in NPM. They differ whenever the forward
// host is a docker name, and a result attributed to the wrong address is worse than no result.
rows.push([tcp.ok ? 'Port open' : 'Port', (tcp.ok
? `<span class="vv-au-ok">${_esc(up.probed || '')}:${up.port}</span> <span class="vv-au-dim">${tcp.ms}ms</span>`
: `<span class="vv-au-bad">${_esc(up.probed || '')}:${up.port} — ${_esc(tcp.err || 'no answer')}</span>`)
+ (up.note ? `<div class="vv-au-why-note">${_esc(up.note)}</div>` : '')]);
if (up.http)
rows.push(['Service answers', up.http.code
? `<span class="${up.http.code >= 500 ? 'vv-au-bad' : 'vv-au-ok'}">HTTP ${up.http.code}</span> <span class="vv-au-dim">${up.http.ms}ms</span>`
: `<span class="vv-au-bad">${_esc(up.http.err || 'no response')}</span>`]);
// Both halves of the certificate: what NPM records and what the handshake did. They disagree more
// often than they should — a cert with a month left still fails if the chain being served is wrong.
if (r.cert) {
const d = r.cert.days_left;
rows.push(['Certificate', `${_esc(r.cert.name)} <span class="${d !== null && d < 0 ? 'vv-au-bad' : (d !== null && d <= 14 ? 'vv-au-warn' : 'vv-au-dim')}">`
+ (d === null ? 'no expiry recorded' : (d < 0 ? `expired ${Math.abs(d)}d ago` : `${d}d left`)) + '</span>']);
}
if (r.guarded) rows.push(['Protection', '<span class="vv-au-dim">behind Authelia (auth_request)</span>']);
if (!r.enabled) rows.push(['State', '<span class="vv-au-bad">disabled in NPM</span>']);
for (const [d, l] of Object.entries(r.live || {})) {
const okNow = l.code > 0 && l.code < 500;
rows.push([_esc(d), (okNow ? `<span class="vv-au-ok">HTTP ${l.code}</span> <span class="vv-au-dim">${l.ms}ms</span>`
: `<span class="vv-au-bad">${l.code ? 'HTTP ' + l.code : _esc(l.err || 'no answer')}</span>`)
+ (l.insecure ? ` <span class="vv-au-warn">— answers ${l.insecure.code} without certificate verification</span>` : '')]);
}
const t = r.traffic;
if (t) rows.push(['Logged traffic', `${_fmtNum(t.requests || 0)} requests · ${_fmtNum(t.s4xx || 0)} 4xx · ${_fmtNum(t.s5xx || 0)} 5xx`
+ (t.last_seen ? ` <span class="vv-au-dim">· last ${_ago(t.last_seen)} ago</span>` : ' <span class="vv-au-dim">· never hit</span>')]);
const grid = rows.map(([k, v]) =>
`<div class="vv-au-why-k">${k}</div><div class="vv-au-why-v">${v}</div>`).join('');
// The recorded history, per domain. Kept last and kept short: it is the part that says whether
// this is happening now or already over, which only matters once you know what "this" is.
const hist = Object.entries(r.history || {}).map(([d, h]) => {
const ev = (h.events || []).map(e =>
`<div class="vv-au-why-ev"><span class="${e.to === 'up' ? 'vv-au-ok' : 'vv-au-bad'}">${_esc(String(e.to).toUpperCase())}</span>
<span class="vv-au-dim">${_ago(e.ts)} ago</span> ${_esc(e.detail || '')}</div>`).join('');
const bad = (h.bad_hours || []).map(b =>
`${String(b.hour).slice(8, 10)}:00 <span class="vv-au-dim">${b.up}/${b.total}</span>`).join(' · ');
return `<div class="vv-au-why-h">
<div class="vv-au-why-hd">${_esc(d)}
<span class="vv-au-dim">${h.h1 === null ? '—' : h.h1 + '% 1h'} · ${h.h24 === null ? '—' : h.h24 + '% 24h'} · ${h.d30 === null ? '—' : h.d30 + '% 30d'}</span></div>
${bad ? `<div class="vv-au-why-bad">Hours that lost samples: ${bad}</div>` : ''}
${ev || '<div class="vv-au-why-ev vv-au-dim">no state changes recorded</div>'}
</div>`;
}).join('');
// Offered only where there is a model to ask. The findings above are the page's own reading and
// stand without it — the assistant is for the step after, which is what to do about it.
const ask = document.getElementById('vv-au-ai-chat')
? '<button class="vv-au-btn prim" id="wm-ask">Ask the assistant</button>' : '';
return `<h3>Why — ${_esc(name)}</h3>
${findings}
<div class="vv-au-why-grid">${grid}</div>
${hist ? `<div class="vv-au-why-sec">Recorded history</div>${hist}` : ''}
<div class="vv-au-modal-acts">${ask}<button class="vv-au-btn" id="wm-close">Close</button></div>`;
}
// The brief the assistant is given. Deliberately the same facts the dialog just showed, written out
// rather than summarised: a model asked "why is this host down" with no evidence answers from the
// general shape of the question, and the whole point of the check above is that it went and looked.
function _whyBrief(r, name) {
const L = [];
L.push(`Proxy host ${name} on this machine is not at full uptime. Here is what the Auth tab just measured.`);
L.push(`Forwards to ${r.forward}${r.guarded ? ', behind Authelia via auth_request' : ''}${r.enabled ? '' : '. The host is DISABLED in NPM'}.`);
const up = r.upstream || {}, tcp = up.tcp || {};
if (up.note) L.push(`Note on the check itself: ${up.note}`);
L.push(`TCP to ${up.probed}:${up.port} — ${tcp.ok ? 'open in ' + tcp.ms + 'ms' : 'failed: ' + (tcp.err || 'no reason')}.`);
if (up.container) L.push(`That target is container ${up.container.name}, currently ${up.container.status}.`);
if (up.http) L.push(`Asked the service directly: ${up.http.code ? 'HTTP ' + up.http.code : 'no response (' + (up.http.err || '') + ')'}.`);
if (r.cert) L.push(`Certificate ${r.cert.name}: ${r.cert.days_left === null ? 'no expiry recorded' : r.cert.days_left + ' days left'}.`);
for (const [d, l] of Object.entries(r.live || {}))
L.push(`Through the proxy, https://${d}/ — ${l.code ? 'HTTP ' + l.code + ' in ' + l.ms + 'ms' : 'no answer: ' + (l.err || '')}`
+ (l.insecure ? `; with certificate verification off it answers ${l.insecure.code}` : '') + '.');
const t = r.traffic;
if (t) L.push(`Access log totals: ${t.requests || 0} requests, ${t.s4xx || 0} 4xx, ${t.s5xx || 0} 5xx.`);
for (const [d, h] of Object.entries(r.history || {})) {
L.push(`${d}: ${h.h1 === null ? '?' : h.h1}% in the last hour, ${h.h24 === null ? '?' : h.h24}% over 24h, ${h.d30 === null ? '?' : h.d30}% over 30 days; currently ${h.state || 'unknown'}, last probe said "${h.last_detail || '?'}".`);
for (const e of (h.events || []).slice(0, 5))
L.push(` went ${e.to} ${_ago(e.ts)} ago — ${e.detail || ''}`);
}
L.push('');
L.push('What is the most likely cause, and what should I check or change first? '
+ 'If the evidence above already settles it, say so rather than listing possibilities.');
return L.join('\n');
}
function _certName(id) {
const c = _certs.find(x => String(x.id) === String(id));
return c ? (c.nice_name || (c.domain_names || []).join(', ')) : 'certificate ' + id;
}
// Compact because these sit in a column beside a domain name: 664,673 is wider than the name it
// belongs to and the exact figure is in the title attribute either way.
function _fmtNum(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(n < 1e7 ? 1 : 0) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0) + 'k';
return String(n);
}
// Decimal, matching every network tool this will be compared against. Memory is the binary one.
function _fmtBytes(b) {
b = Number(b) || 0;
const u = ['B','kB','MB','GB','TB'];
let i = 0;
while (b >= 1000 && i < u.length - 1) { b /= 1000; i++; }
return (b < 10 && i ? b.toFixed(1) : Math.round(b)) + ' ' + u[i];
}
function _ago(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (s < 90) return s + 's';
if (s < 5400) return Math.round(s / 60) + 'm';
if (s < 172800)return Math.round(s / 3600) + 'h';
return Math.round(s / 86400) + 'd';
}
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-tog-row">
<span class="vv-au-tog${(!p||p.http2_support!==false)?' on':''}" id="pm-http2"></span>
<span class="vv-au-tog-lbl">HTTP/2 Support</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.hsts_enabled?' on':''}" id="pm-hsts"></span>
<span class="vv-au-tog-lbl">HSTS Enabled</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.hsts_subdomains?' on':''}" id="pm-hsts-sub"></span>
<span class="vv-au-tog-lbl">HSTS Subdomains</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${(!p||p.enabled!==false)?' on':''}" id="pm-enabled"></span>
<span class="vv-au-tog-lbl">Enabled</span>
</div>
<!-- The field this dialog used to send as an empty string on every save. Twenty-five of the
thirty-five hosts here carry one, and on the protected ones it is the auth_request block
that puts Authelia in front of the site — so an edit to a port silently removed the
authentication from it. -->
<div class="vv-au-adv-toggle" id="pm-adv-toggle">${(p?.advanced_config||'').trim() ? '▼' : '▶'} Custom Nginx Configuration${(p?.advanced_config||'').trim() ? ' (in use)' : ''}</div>
<div class="vv-au-adv-section${(p?.advanced_config||'').trim() ? ' open' : ''}" id="pm-adv">
<textarea class="vv-au-input vv-au-nginx" id="pm-advanced" rows="12" spellcheck="false"
placeholder="location / { ... }">${_esc(p?.advanced_config || '')}</textarea>
<div class="vv-au-hint">Pasted into the server block verbatim. NPM rejects the save if nginx
will not load it, and the error comes back here.</div>
</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>`, true);
// Wire inline toggles
document.querySelectorAll('#vv-au-modal .vv-au-tog').forEach(t => {
t.addEventListener('click', () => t.classList.toggle('on'));
});
document.getElementById('pm-adv-toggle').addEventListener('click', () => {
const sec = document.getElementById('pm-adv');
const open = sec.classList.toggle('open');
document.getElementById('pm-adv-toggle').textContent =
(open ? '▼' : '▶') + ' Custom Nginx Configuration';
});
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; }
// NPM's update replaces the whole host, so anything not sent here is not "left alone" — it is
// reset. This object used to hardcode advanced_config to '', http2 and both HSTS flags to
// false, meta to {} and locations to [], and enabled to true. Editing a port therefore removed
// the site's auth_request block, turned off HTTP/2 and HSTS, dropped its custom locations, and
// switched a deliberately disabled host back on.
//
// Everything the dialog does not offer is carried from the host it is editing; only a genuinely
// new host gets defaults.
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: document.getElementById('pm-http2').classList.contains('on'),
hsts_enabled: document.getElementById('pm-hsts').classList.contains('on'),
hsts_subdomains: document.getElementById('pm-hsts-sub').classList.contains('on'),
enabled: document.getElementById('pm-enabled').classList.contains('on'),
advanced_config: document.getElementById('pm-advanced').value,
// Not represented in this dialog at all. Preserved rather than blanked — an access list is a
// deliberate restriction and a location block is routing, and losing either quietly is worse
// than not being able to edit it here.
access_list_id: p ? (p.access_list_id ?? 0) : 0,
meta: p ? (p.meta ?? {}) : {},
locations: p ? (p.locations ?? []) : [],
};
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
_on('vv-au-panel-proxies', '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;
}
// Why
const whyBtn = e.target.closest('[data-why]');
if (whyBtn) { _whyModal(parseInt(whyBtn.dataset.why), whyBtn); 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(done) {
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';
// done() fires on every path including failure and the empty list. An open editor waits on it
// to redraw its sections, and a refresh that silently never called back would leave the dialog
// showing the state from before the change with no indication anything had happened.
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; if (done) done(); return; }
_users = r.users || [];
if (!_users.length) { empty.style.display = 'block'; if (done) done(); 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('');
// Only fetched for the users who have one — has_avatar comes from the attribute names, so
// the list costs nothing for the 27 people here without a photo. _avatarBust changes after
// an upload so the browser reloads rather than showing the cached previous face.
const av = u.has_avatar
? `<img class="vv-au-av" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(u.id)}&v=${_avatarBust}" alt="">`
: `<span class="vv-au-av none">${_esc(_initials(u))}</span>`;
// The real name, when it differs from the display name. 31 of 33 users here have first and
// last set and none of it was on the page.
const real = [u.firstName, u.lastName].filter(Boolean).join(' ');
const sub = (real && real !== (u.displayName||'')) ? real : '';
return `<div class="vv-au-user-row">
${av}
<span class="vv-au-user-name">${_esc(u.displayName||u.id)}${sub ? `<span class="vv-au-user-real">${_esc(sub)}</span>` : ''}</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-photo="${_esc(u.id)}" title="Set or remove photo">🖼</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('');
if (done) done();
});
}
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>
<!-- lldap's full editable set for a user is display_name, mail, first_name, last_name and
avatar. These two were the ones with nowhere to go, so the only way to correct a name was
to open lldap's own WebUI. -->
<div style="display:flex;gap:8px">
<div class="vv-au-field" style="flex:1">
<label class="vv-au-label">First Name</label>
<input class="vv-au-input" id="um-first" value="${_esc(u?.firstName||'')}" placeholder="John">
</div>
<div class="vv-au-field" style="flex:1">
<label class="vv-au-label">Last Name</label>
<input class="vv-au-input" id="um-last" value="${_esc(u?.lastName||'')}" placeholder="Doe">
</div>
</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>` : ''}
${u ? `<div class="vv-au-hint" style="margin-bottom:10px">
created ${_esc(_fmtDate(u.creationDate))} &middot; uuid <span style="font-family:monospace">${_esc(u.uuid||'—')}</span>
</div>` : ''}
<div class="vv-au-err" id="um-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="um-cancel">${u ? 'Close' : 'Cancel'}</button>
<button class="vv-au-btn prim" id="um-save">${u ? 'Save details' : 'Create'}</button>
</div>
<!-- Everything else lldap can do to this user, in the place you already have open. The row
buttons stay for one-click access from the list; this is for when you are already in here
and the alternative was closing the dialog to reach a different one.
These sections apply on their own buttons rather than on Save, because each is a separate
lldap mutation with its own failure — batching them behind one button would mean reporting
"saved" for a password that took and a group that did not. Save details covers exactly the
fields above it, which is what its label says. -->
${u ? `
<div class="vv-au-sect" id="um-more">
<div class="vv-au-sect-h">Groups</div>
<div id="um-groups"></div>
<div class="vv-au-sect-h">Password</div>
<div style="display:flex;gap:6px;align-items:flex-start">
<input class="vv-au-input" id="um-pw" type="password" autocomplete="new-password"
placeholder="New password" style="flex:1">
<button class="vv-au-btn" id="um-pw-set">Set</button>
</div>
<div class="vv-au-hint" id="um-pw-msg"></div>
<div class="vv-au-sect-h">Photo</div>
<div style="display:flex;gap:10px;align-items:center">
<div id="um-ph-preview"></div>
<div style="flex:1;min-width:0">
<input class="vv-au-input" type="file" id="um-ph-file" accept="image/*">
<div class="vv-au-hint" id="um-ph-msg">Any image; cropped square, ${VV_AVATAR_PX}px, saved as JPEG.</div>
<div style="display:flex;gap:6px;margin-top:5px">
<button class="vv-au-btn prim" id="um-ph-set" disabled>Set photo</button>
<button class="vv-au-btn danger" id="um-ph-rm" ${u.has_avatar ? '' : 'disabled'}>Remove</button>
</div>
</div>
</div>
</div>` : ''}`, !!u);
if (u) _userModalExtras(u);
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 first= (document.getElementById('um-first').value||'').trim();
const last = (document.getElementById('um-last').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 details':'Create'; return; }
// A create is finished when it succeeds, so the dialog closes. An edit is not — the sections
// below are the reason it is worth staying in, and closing on the first Save would put the
// operator straight back to reopening it.
if (!u) { _closeModal(); _loadUsers(); return; }
_showModalErr('um-err', '');
btn.disabled = false; btn.textContent = 'Saved ✓';
setTimeout(() => { btn.textContent = 'Save details'; }, 2500);
_loadUsers();
};
// first_name/last_name are always sent from this form, including empty, because the form does
// offer them — an empty box here means "clear it", which the endpoint turns into a proper
// attribute removal rather than an empty string.
if (u) _post({ action:'lldap_update_user', uid:id, email, display_name:name, first_name:first, last_name:last }, done);
else _post({ action:'lldap_create_user', uid:id, email, display_name:name, password:pass, first_name:first, last_name:last }, done);
};
}
// ── The editor's live sections ────────────────────────────────────────────────
// Groups, password and photo, wired inside the open Edit User dialog. Each acts immediately and
// reports in place; none of them closes the dialog, because the reason they are here at all is
// that having to leave the editor to reach them was the complaint.
//
// The background lists are refreshed too, so the row behind the dialog is not left showing the
// groups or the photo the user had a moment ago.
function _userModalExtras(u) {
const uid = u.id;
// Re-read from _users after a refresh rather than trusting the copy captured when the dialog
// opened — a group added here changes the object the next redraw has to render from.
const cur = () => _users.find(x => x.id === uid) || u;
const drawGroups = () => {
const me = cur();
const mine = me.groups || [];
const gids = new Set(mine.map(g => g.id));
const free = _groups.filter(g => !gids.has(g.id));
document.getElementById('um-groups').innerHTML =
`<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">
${mine.length ? mine.map(g => `<span class="vv-au-badge grp" style="cursor:pointer"
data-um-rmgrp="${g.id}" title="Remove from ${_esc(g.displayName)}">${_esc(g.displayName)} ✕</span>`).join('')
: '<span class="vv-au-hint">No groups</span>'}
</div>
${free.length ? `<div style="display:flex;gap:6px">
<select class="vv-au-select" id="um-grp-pick" style="flex:1">
${free.map(g => `<option value="${g.id}">${_esc(g.displayName)}</option>`).join('')}
</select>
<button class="vv-au-btn" id="um-grp-add">Add</button>
</div>` : '<div class="vv-au-hint">In every group</div>'}`;
const add = document.getElementById('um-grp-add');
if (add) add.onclick = () => {
const gid = parseInt(document.getElementById('um-grp-pick').value);
add.disabled = true;
_post({ action:'lldap_add_to_group', uid, gid }, r => {
if (!r.ok) { _showModalErr('um-err', r.error||'Could not add to group'); add.disabled = false; return; }
refresh(drawGroups);
});
};
document.querySelectorAll('[data-um-rmgrp]').forEach(el => el.onclick = async () => {
const gid = parseInt(el.dataset.umRmgrp);
const g = _groups.find(x => x.id === gid);
if (!await vvConfirm('Remove from group "' + (g?.displayName||gid) + '"?')) return;
_post({ action:'lldap_remove_from_group', uid, gid }, r => {
if (!r.ok) { _showModalErr('um-err', r.error||'Could not remove from group'); return; }
refresh(drawGroups);
});
});
};
const drawPhoto = () => {
const me = cur();
document.getElementById('um-ph-preview').innerHTML = me.has_avatar
? `<img class="vv-au-av big" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(uid)}&v=${_avatarBust}" alt="">`
: `<span class="vv-au-av big none">${_esc(_initials(me))}</span>`;
const rm = document.getElementById('um-ph-rm');
if (rm) rm.disabled = !me.has_avatar;
};
// Both lists, because a group change moves membership on either side of it. The dialog stays
// open throughout — only the section that changed is redrawn.
const refresh = after => {
let left = 2;
const done = () => { if (--left === 0) after && after(); };
_loadUsers(done); _loadGroups(done);
};
drawGroups();
drawPhoto();
// ── Password ──
const pwBtn = document.getElementById('um-pw-set');
pwBtn.onclick = () => {
const inp = document.getElementById('um-pw'), msg = document.getElementById('um-pw-msg');
const pass = inp.value;
if (!pass) { msg.textContent = 'Enter a password first.'; msg.style.color = '#ef5350'; return; }
pwBtn.disabled = true; pwBtn.textContent = 'Setting…';
_post({ action:'lldap_set_password', uid, password: pass }, r => {
pwBtn.disabled = false; pwBtn.textContent = 'Set';
if (!r.ok) { msg.textContent = r.error || 'Failed'; msg.style.color = '#ef5350'; return; }
// Cleared on success so the new password is not left sitting in a field behind an open
// dialog, and so a second click cannot silently set it again.
inp.value = '';
msg.textContent = 'Password changed ✓'; msg.style.color = '#4caf50';
setTimeout(() => { msg.textContent = ''; msg.style.color = ''; }, 4000);
});
};
// ── Photo ──
let pending = null;
document.getElementById('um-ph-file').addEventListener('change', async e => {
const f = e.target.files && e.target.files[0];
const msg = document.getElementById('um-ph-msg');
if (!f) return;
try {
pending = await _toJpegBase64(f);
msg.textContent = `${f.name} → ${Math.round(pending.length * 3 / 4 / 1024)} KB JPEG`;
msg.style.color = '';
document.getElementById('um-ph-preview').innerHTML =
`<img class="vv-au-av big" src="data:image/jpeg;base64,${pending}" alt="">`;
document.getElementById('um-ph-set').disabled = false;
} catch (err) {
pending = null;
document.getElementById('um-ph-set').disabled = true;
msg.textContent = err.message || 'Could not read that image'; msg.style.color = '#ef5350';
}
});
document.getElementById('um-ph-set').onclick = () => {
if (!pending) return;
const btn = document.getElementById('um-ph-set'), msg = document.getElementById('um-ph-msg');
btn.disabled = true; btn.textContent = 'Saving…';
_post({ action:'lldap_set_avatar', uid, avatar: pending }, r => {
btn.textContent = 'Set photo';
if (!r.ok) { btn.disabled = false; msg.textContent = r.error||'Upload failed'; msg.style.color = '#ef5350'; return; }
pending = null;
document.getElementById('um-ph-file').value = '';
_avatarBust = Date.now();
msg.textContent = 'Photo saved ✓'; msg.style.color = '#4caf50';
refresh(drawPhoto);
});
};
document.getElementById('um-ph-rm').onclick = async () => {
if (!await vvConfirm('Remove this photo?')) return;
const btn = document.getElementById('um-ph-rm'), msg = document.getElementById('um-ph-msg');
btn.disabled = true;
_post({ action:'lldap_remove_avatar', uid }, r => {
if (!r.ok) { btn.disabled = false; msg.textContent = r.error||'Removal failed'; msg.style.color = '#ef5350'; return; }
_avatarBust = Date.now();
msg.textContent = 'Photo removed ✓'; msg.style.color = '#4caf50';
refresh(drawPhoto);
});
};
}
// ── Photo ─────────────────────────────────────────────────────────────────────
// Bumped after every upload or removal. The avatar endpoint is a plain GET the browser caches, so
// without a changing parameter the row would keep drawing the previous face after a change.
let _avatarBust = Date.now();
function _initials(u) {
const s = ((u.firstName||'') + ' ' + (u.lastName||'')).trim() || u.displayName || u.id || '';
return s.split(/\s+/).filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('') || '?';
}
function _fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return isNaN(d) ? String(iso).slice(0,10) : d.toISOString().slice(0,10);
}
// lldap types the avatar attribute JPEG_PHOTO and refuses anything else, so a PNG or a HEIC out of
// a phone would be rejected on arrival. Everything is drawn to a canvas and re-encoded as JPEG
// here instead, which means any format the browser can open is a valid import — and the same pass
// bounds the size. The originals on this directory run to 273 KB for a picture shown at 22px;
// 256px at q0.85 lands around 15 KB, and this value is read back on every user query forever.
const VV_AVATAR_PX = 256, VV_AVATAR_Q = 0.85;
function _toJpegBase64(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onerror = () => reject(new Error('Could not read that file'));
fr.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('That file is not an image the browser can open'));
img.onload = () => {
// Square, centre-cropped. An avatar is drawn in a square slot everywhere it appears, and
// letterboxing it here would bake the padding into the stored image.
const side = Math.min(img.width, img.height);
const sx = (img.width - side) / 2, sy = (img.height - side) / 2;
const px = Math.min(VV_AVATAR_PX, side);
const cv = document.createElement('canvas');
cv.width = cv.height = px;
const cx = cv.getContext('2d');
// White rather than transparent: JPEG has no alpha, and a transparent PNG flattened onto
// the default black reads as a photo of nothing.
cx.fillStyle = '#fff'; cx.fillRect(0, 0, px, px);
cx.drawImage(img, sx, sy, side, side, 0, 0, px, px);
resolve(cv.toDataURL('image/jpeg', VV_AVATAR_Q).replace(/^data:image\/jpeg;base64,/, ''));
};
img.src = fr.result;
};
fr.readAsDataURL(file);
});
}
function _photoModal(uid) {
const u = _users.find(x => x.id === uid);
if (!u) return;
const cur = u.has_avatar
? `<img class="vv-au-av big" src="${API}?action=lldap_avatar&uid=${encodeURIComponent(uid)}&v=${_avatarBust}" alt="">`
: `<span class="vv-au-av big none">${_esc(_initials(u))}</span>`;
_modal(`<h3>Photo — ${_esc(u.displayName||uid)}</h3>
<div style="display:flex;gap:14px;align-items:center;margin-bottom:12px">
<div id="ph-preview">${cur}</div>
<div style="flex:1;min-width:0">
<input class="vv-au-input" type="file" id="ph-file" accept="image/*">
<div class="vv-au-hint">Any image the browser can open. Centre-cropped square, resized to
${VV_AVATAR_PX}px and converted to JPEG — lldap stores nothing else.</div>
<div class="vv-au-hint" id="ph-size"></div>
</div>
</div>
<div class="vv-au-err" id="ph-err"></div>
<div class="vv-au-modal-acts">
${u.has_avatar ? '<button class="vv-au-btn danger" id="ph-remove" style="margin-right:auto">Remove photo</button>' : ''}
<button class="vv-au-btn" id="ph-cancel">Cancel</button>
<button class="vv-au-btn prim" id="ph-save" disabled>Save</button>
</div>`);
let pending = null;
document.getElementById('ph-file').addEventListener('change', async e => {
const f = e.target.files && e.target.files[0];
if (!f) return;
try {
pending = await _toJpegBase64(f);
// Rounded from the base64 length, which is the payload that actually travels and gets stored.
const kb = Math.round(pending.length * 3 / 4 / 1024);
document.getElementById('ph-size').textContent = `${f.name} → ${kb} KB JPEG`;
document.getElementById('ph-preview').innerHTML =
`<img class="vv-au-av big" src="data:image/jpeg;base64,${pending}" alt="">`;
document.getElementById('ph-save').disabled = false;
_showModalErr('ph-err', '');
} catch (err) {
pending = null;
document.getElementById('ph-save').disabled = true;
_showModalErr('ph-err', err.message || 'Could not read that image');
}
});
document.getElementById('ph-cancel').onclick = _closeModal;
const rm = document.getElementById('ph-remove');
if (rm) rm.onclick = async () => {
if (!await vvConfirm('Remove this photo?')) return;
rm.disabled = true;
_post({ action:'lldap_remove_avatar', uid }, r => {
if (!r.ok) { _showModalErr('ph-err', r.error||'Removal failed'); rm.disabled = false; return; }
_avatarBust = Date.now(); _closeModal(); _loadUsers();
});
};
document.getElementById('ph-save').onclick = () => {
if (!pending) return;
const btn = document.getElementById('ph-save');
btn.disabled = true; btn.textContent = 'Saving…';
// Through _post, so URLSearchParams and never FormData — a multipart POST to this plugin's
// endpoints hangs with no status ever returned, which is exactly the shape an upload invites.
_post({ action:'lldap_set_avatar', uid, avatar: pending }, r => {
if (!r.ok) { _showModalErr('ph-err', r.error||'Upload failed'); btn.disabled=false; btn.textContent='Save'; return; }
_avatarBust = Date.now(); _closeModal(); _loadUsers();
});
};
}
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
_on('vv-au-panel-users', '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 photoBtn = e.target.closest('[data-user-photo]');
if (photoBtn) { _photoModal(photoBtn.dataset.userPhoto); 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(done) {
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'; if (done) done(); return; }
_groups = r.groups || [];
if (!_groups.length) { empty.style.display = 'block'; if (done) done(); 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" data-group-ren="${g.id}" title="Rename group">✎</button>
<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('');
if (done) done();
});
}
// Group event delegation
_on('vv-au-panel-users', '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 renGrp = e.target.closest('[data-group-ren]');
if (renGrp) {
e.stopPropagation();
const id = parseInt(renGrp.dataset.groupRen);
const grp = _groups.find(g => g.id === id);
const name = await vvPrompt('Rename group', grp?.displayName || '');
if (name === null) return;
const trimmed = String(name).trim();
if (!trimmed || trimmed === grp?.displayName) return;
// Worth stopping on: the Access Control rules match groups by name, so a rename that the rules
// do not follow leaves every rule naming a group nobody is in — which fails closed, silently,
// for whoever was in it.
//
// Fetched rather than read from _rules, because _rules is only populated once the Access
// Control tab has been opened. Renaming a group from a fresh page load would otherwise find an
// empty list and report no rules affected, which is the reassuring answer and the wrong one.
const rules = _rules.length ? _rules : await new Promise(res =>
_get('authelia_rules', r => res((r && r.ok && r.rules) ? r.rules : [])));
const used = rules.filter(r => _normList(r.subject).some(s =>
_normList(s).some(v => String(v) === 'group:' + grp?.displayName)));
if (used.length && !await vvConfirm(
`"${grp.displayName}" is named by ${used.length} access-control rule${used.length>1?'s':''}. ` +
`Renaming it here does not update ${used.length>1?'them':'it'} — you will need to edit ` +
`${used.length>1?'those rules':'that rule'} on the Access Control tab too. Continue?`)) return;
_post({ action:'lldap_rename_group', id, name: trimmed }, r => {
if (!r.ok) { vvAlert('Rename failed: ' + (r.error||'unknown error')); return; }
_loadGroups(); _loadUsers();
});
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 simulator ──────────────────────────────────────────────────────────
// The dropdowns come from the other two panels' data, which this panel does not otherwise load —
// AUTH_STACK decides which panels exist and _loadTab only fetches what the open one needs, so both
// lists are fetched here rather than assumed to be sitting in the page already.
function _simFill() {
const dom = document.getElementById('vv-au-sim-dom');
const user = document.getElementById('vv-au-sim-user');
if (!dom || !user) return;
const fillDom = () => {
// Wildcards are dropped: they are not a hostname anyone opens, so simulating one answers a
// question nobody can ask. Disabled hosts stay in — "why does this not work" is often "it is
// switched off", and hiding them hides the answer.
const seen = new Set();
_proxies.forEach(p => (p.domain_names || []).forEach(d => { if (!String(d).includes('*')) seen.add(String(d).toLowerCase()); }));
dom.innerHTML = [...seen].sort().map(d => `<option value="${_esc(d)}">${_esc(d)}</option>`).join('')
|| '<option value="">no proxy hosts</option>';
};
const fillUser = () => {
user.innerHTML = _users.slice().sort((a, b) => String(a.id).localeCompare(String(b.id)))
.map(u => `<option value="${_esc(u.id)}">${_esc(u.id)}</option>`).join('')
|| '<option value="">no users</option>';
};
if (_proxies.length) fillDom();
else _get('npm_proxies', r => { if (r.ok) { _proxies = r.proxies || []; fillDom(); } });
if (_users.length) fillUser();
else _get('lldap_users', r => { if (r.ok) { _users = r.users || []; fillUser(); } });
}
// Held for the assistant hand-off, same as the proxy check.
let _sim = null;
function _simRun() {
const out = document.getElementById('vv-au-sim-out');
const btn = document.getElementById('vv-au-sim-run');
const dom = document.getElementById('vv-au-sim-dom').value;
const uid = document.getElementById('vv-au-sim-user').value;
const path = document.getElementById('vv-au-sim-path').value || '/';
if (!dom) return;
_sim = null;
btn.disabled = true;
out.innerHTML = '<div class="vv-au-why-wait">Reading the proxy host, the rules of the Authelia it talks to, and the directory…</div>';
_get('access_check', r => {
btn.disabled = false;
if (!r || !r.ok) { out.innerHTML = `<div class="vv-au-err show">${_esc(r?.error || 'The check failed')}</div>`; return; }
_sim = r;
out.innerHTML = _simHtml(r);
const ask = document.getElementById('vv-au-sim-ask');
if (ask) ask.onclick = () => _simAsk(r);
}, { domain: dom, uid, path });
}
function _simHtml(r) {
// The verdict as one word, because that is the question. Bypass is coloured as a warning rather
// than a success — reaching a page without being asked to log in is only good news if it was
// meant to be public, and the findings below say which case this is.
const pol = r.policy || 'unknown';
const cls = pol === 'deny' ? 'bad' : (pol === 'bypass' ? 'warn' : 'ok');
const verdict = `<div class="vv-au-sim-verdict ${cls}">${_esc(pol)}</div>`;
const findings = (r.findings || []).map(f =>
`<div class="vv-au-find ${_esc(f.level || 'info')}">${_esc(f.text)}</div>`).join('');
// Every rule, including the ones that did nothing. Which rule you expected to win and why it was
// stepped over is the part that is impossible to see from the cards below.
const trace = (r.trace || []).map(t =>
`<div class="vv-au-sim-t${t.applied ? ' hit' : ''}">
<span class="vv-au-sim-n">${t.n}</span>
<span class="vv-au-sim-l">${_esc(t.label)}</span>
<span class="vv-au-sim-w">${_esc(t.why)}${t.applied ? ' → ' + _esc(t.policy || '') : ''}</span>
</div>`).join('');
const groups = (r.groups || []).length
? (r.groups || []).map(g => `<span class="vv-au-m">${_esc(g)}</span>`).join(' ')
: '<span class="vv-au-dim">no groups</span>';
const ask = document.getElementById('vv-au-ai-chat')
? '<button class="vv-au-btn prim" id="vv-au-sim-ask">Ask the assistant</button>' : '';
return `${verdict}${findings}
<div class="vv-au-why-grid">
<div class="vv-au-why-k">Groups</div><div class="vv-au-why-v">${groups}</div>
${r.forward ? `<div class="vv-au-why-k">Forwards to</div><div class="vv-au-why-v">${_esc(r.forward)}</div>` : ''}
${r.authelia && r.authelia.container ? `<div class="vv-au-why-k">Decided by</div><div class="vv-au-why-v">${_esc(r.authelia.container)}<div class="vv-au-why-note">${_esc(r.authelia.config || '')}</div></div>` : ''}
${r.default_policy ? `<div class="vv-au-why-k">Default policy</div><div class="vv-au-why-v">${_esc(r.default_policy)}</div>` : ''}
</div>
${trace ? `<div class="vv-au-why-sec">Every rule, in file order</div>${trace}` : ''}
${ask ? `<div class="vv-au-modal-acts">${ask}</div>` : ''}`;
}
function _simAsk(r) {
const chat = window.__vvAiChat && window.__vvAiChat['vv-au-ai'];
if (!chat) return;
if (chat.busy()) { vvAlert('The assistant is still answering the previous question.'); return; }
const L = [];
L.push(`On this machine, can ${r.uid || 'anyone'} open https://${r.domain}${r.path}? Here is what the Auth tab worked out.`);
L.push(`The proxy host ${r.served ? 'exists' : 'does not exist'}${r.served && !r.enabled ? ' but is disabled' : ''}${r.forward ? ', forwarding to ' + r.forward : ''}.`);
L.push(`${r.uid || 'The user'} is in these LDAP groups: ${(r.groups || []).join(', ') || 'none'}.`);
if (r.authelia && r.authelia.container)
L.push(`Authentication is decided by ${r.authelia.container}, config ${r.authelia.config || 'not found'}${r.authelia.is_configured ? '' : ' — which is not the instance this tab edits'}.`);
L.push(`Rules were walked in file order:`);
for (const t of (r.trace || []))
L.push(` ${t.n}. ${t.label} — ${t.applied ? 'APPLIED, policy ' + (t.policy || '') : 'skipped: ' + t.why}`);
L.push(`Default policy is ${r.default_policy || 'unknown'}. The resulting policy is ${r.policy || 'unknown'}.`);
L.push('');
L.push('Is that the intended outcome? If not, say which rule to change and how — be specific about '
+ 'rule order, because Authelia stops at the first rule that matches on every axis.');
chat.retarget('troubleshoot', r.domain, 'now looking at access to ' + r.domain);
const input = document.getElementById('vv-au-ai-input');
if (input) input.value = L.join('\n');
chat.send();
const card = document.getElementById('vv-au-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── Access Control ────────────────────────────────────────────────────────────
function _loadAcl() {
_simFill();
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.
_on('vv-au-panel-acl', '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.
_on('vv-au-panel-acl', 'keydown', e => {
// Enter in the path box runs the simulation. Checked before the domain-input handler below,
// which would otherwise never see it anyway but reads as if it might.
if (e.target.id === 'vv-au-sim-path' && e.key === 'Enter') { e.preventDefault(); _simRun(); return; }
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
_on('vv-au-panel-acl', 'click', async e => {
if (e.target.id === 'vv-au-sim-run') { _simRun(); return; }
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.
_on('vv-au-ac-defpol', 'change', () => _aclMarkDirty(true));
// ── Close modal on overlay click ──────────────────────────────────────────────
_on('vv-au-overlay', '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('');
}
// ── History, totals and DDNS ─────────────────────────────────────────────────
// Read-only: the counters advance when Tools/cert_history.sh runs, not when this page is opened.
// A page that wrote the history it displays would count a refresh as an observation.
function _loadCertHistory() {
const list = document.getElementById('vv-au-hist-list');
const tot = document.getElementById('vv-au-hist-totals');
const ddns = document.getElementById('vv-au-ddns');
if (!list) return;
fetch(CERT_API + '?action=history')
.then(r => r.json())
.then(d => {
if (!d.ok) throw new Error(d.error || 'history unavailable');
const t = d.totals || {};
if (!t.tracked) {
tot.innerHTML = '';
list.innerHTML = '<div class="vv-au-empty">No history yet — run '
+ '<code style="color:#5c9fd4">Tools/cert_history.sh</code> once to start tracking.</div>';
ddns.innerHTML = '';
return;
}
// Failures are only red when there are any: a zero in an alarm colour trains you to ignore
// the colour rather than the number.
tot.innerHTML = `<div class="vv-au-tot">
${_totBox(t.tracked, 'domains tracked')}
${_totBox(t.active, 'active')}
${_totBox(t.renewals, 'renewals seen')}
${_totBox(t.failures, 'failures', t.failures ? 'bad' : '')}
${_totBox(t.retired, 'retired', t.retired ? 'bad' : '')}
${_totBox(t.checks, 'checks')}
${_totBox(t.oldest_span || '—', 'longest tracked')}
</div>`;
const when = document.getElementById('vv-au-hist-when');
if (when) when.textContent = d.last_pass
? 'last pass ' + new Date(d.last_pass * 1000).toLocaleString()
: 'never run';
list.innerHTML = (d.rows || []).map(r => {
const cls = r.state === 'retired' ? ' retired' : (r.state === 'removed' ? ' removed' : '');
// Strikes are only worth showing while they are accruing; a retired domain already says so.
const strike = (r.strikes && r.state === 'active')
? `<span class="vv-au-strike">${r.strikes}/${d.strike_limit} strikes</span>` : '';
const tag = r.state === 'retired' ? '<span class="vv-au-badge deny">retired</span>'
: r.state === 'removed' ? '<span class="vv-au-badge nossl">removed</span>' : '';
return `<div class="vv-au-hist-row${cls}">
<span class="vv-au-hist-dom" title="${_esc(r.domain)}">${_esc(r.domain)}</span>
<span class="vv-au-hist-n ok" title="renewals observed">${r.renewals}</span>
<span class="vv-au-hist-n ${r.failures ? 'bad' : 'dim'}" title="times found expired">${r.failures}</span>
<span class="vv-au-hist-t" title="first seen ${_esc(new Date(r.first_seen*1000).toISOString().slice(0,10))}">${_esc(r.tracked)}</span>
<span class="vv-au-hist-e" title="current expiry">${_esc(r.expires || '—')}</span>
${strike}${tag}
</div>`;
}).join('') || '<div class="vv-au-empty">Nothing tracked yet.</div>';
ddns.innerHTML = (d.ddns || []).length
? d.ddns.map(c => `<div class="vv-au-user-row">
<span class="vv-au-dot" style="background:${c.running ? '#4caf50' : '#ef5350'}"></span>
<span class="vv-au-user-name" style="flex:1">${_esc(c.name)}</span>
<span class="vv-au-user-email">${_esc(c.status)}</span>
</div>`).join('')
: '<div class="vv-au-empty">No DDNS containers configured.</div>';
})
.catch(e => {
list.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed')}</div>`;
});
}
function _totBox(n, label, cls) {
return `<div class="vv-au-tot-b"><div class="vv-au-tot-n ${cls || ''}">${_esc(String(n))}</div>`
+ `<div class="vv-au-tot-l">${_esc(label)}</div></div>`;
}
function _loadCerts(onDone) {
const grid = document.getElementById('vv-au-cert-grid');
grid.innerHTML = '<div class="vv-au-loading">Loading…</div>';
_loadCertHistory();
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();
});
}
_on('vv-au-cert-run', 'click', function() {
const btn = this;
btn.disabled = true; btn.textContent = 'Loading…';
_loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });
});
// ── Renewal triage ────────────────────────────────────────────────────────────
let _triage = null;
_on('vv-au-cert-triage', 'click', function() {
const btn = this, out = document.getElementById('vv-au-cert-triage-out');
btn.disabled = true;
out.innerHTML = '<div class="vv-au-why-wait">Reading certbot\'s logs…</div>';
fetch(CERT_API + '?action=triage').then(r => r.json()).then(r => {
btn.disabled = false;
if (!r || !r.ok) { out.innerHTML = `<div class="vv-au-err show">${_esc(r?.error || 'Triage failed')}</div>`; return; }
_triage = r;
out.innerHTML = _triageHtml(r);
const ask = document.getElementById('vv-au-triage-ask');
if (ask) ask.onclick = () => _triageAsk(r);
}).catch(e => { btn.disabled = false; out.innerHTML = `<div class="vv-au-err show">${_esc(String(e))}</div>`; });
});
function _triageHtml(r) {
if (!r.total)
return `<div class="vv-au-card vv-au-sim"><div class="vv-au-find info">No renewal failures in the
last ${r.files_read} certbot runs. Nothing is failing to renew right now.</div></div>`;
// Causes first and consequences after, marked as such. Sorted by count would put rate limiting
// at the top of every one of these, and rate limiting is almost never the thing to fix.
const cats = (r.categories || []).map(c => `
<div class="vv-au-tri-row${c.root ? ' root' : ''}">
<span class="vv-au-tri-n">${c.count}</span>
<span class="vv-au-tri-w">${_esc(c.what)}${c.root ? '' : ' <span class="vv-au-dim">— a consequence, not a cause</span>'}
${(c.domains || []).length ? `<div class="vv-au-tri-d">${(c.domains || []).map(d => _esc(d)).join(' · ')}</div>` : ''}
</span>
</div>`).join('');
const reading = (r.reading || []).map(l => `<div class="vv-au-find info">${_esc(l)}</div>`).join('');
const ask = document.getElementById('vv-au-ai-chat')
? '<div class="vv-au-modal-acts"><button class="vv-au-btn prim" id="vv-au-triage-ask">Ask the assistant</button></div>' : '';
return `<div class="vv-au-card vv-au-sim">
<div class="vv-au-why-sec" style="margin-top:0;border-top:none;padding-top:0">
${r.total} of the last ${r.files_read} certbot runs failed${r.unclassified ? ` · ${r.unclassified} matched nothing known` : ''}
</div>
${cats}${reading}${ask}
</div>`;
}
function _triageAsk(r) {
const chat = window.__vvAiChat && window.__vvAiChat['vv-au-ai'];
if (!chat) return;
if (chat.busy()) { vvAlert('The assistant is still answering the previous question.'); return; }
const L = [];
L.push(`Certificate renewals on this machine. ${r.total} of the last ${r.files_read} certbot runs failed. `
+ `Categories, counted per run rather than per log line:`);
for (const c of (r.categories || []))
L.push(` ${c.count} — ${c.what}${c.root ? ' [root cause]' : ' [consequence]'}`
+ ((c.domains || []).length ? ` — ${c.domains.join(', ')}` : ''));
for (const l of (r.reading || [])) L.push(l);
L.push('');
L.push('What do I fix first, and in what order? Be concrete about which hostnames to add DNS for, '
+ 'delete, or remove from NPM — and say whether the rate limit clears on its own once that is done.');
chat.retarget('troubleshoot', 'certificates', 'now looking at certificate renewals');
const input = document.getElementById('vv-au-ai-input');
if (input) input.value = L.join('\n');
chat.send();
const card = document.getElementById('vv-au-ai-card');
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── 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 ──────────────────────────────────────────────────────────────────────
// Through _loadTab so the opening panel follows AUTH_STACK, rather than calling _loadProxies()
// directly and reaching into elements a different stack does not render.
_loadTab(_activeTab);
})();
</script>