Files
Varaverk/Plugin/unraid/pages/auth.php
T
Gmer4Lfe 81aeb0619b Reach the rest of what lldap exposes — photos, real names, group rename
lldap lets you edit five things about a user and one about a group; the page reached two of
them, so correcting a surname or a group's name still meant opening the container's own WebUI.
2026-08-15 11:45:23 -04:00

1556 lines
80 KiB
PHP

<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// Auth tab. Manages the stack sitting in front of every protected hostname — NPM proxy
// hosts and certificates, LLDAP users and groups, and Authelia access-control rules.
//
// OPERATIONAL MODEL
// The most consequential page in the UI. A wrong edit here does not render badly, it locks
// people out of every service or exposes one that should be protected.
//
// DESIGN PRINCIPLES
// Requires include/auth.php directly rather than going through an endpoint for everything.
// Server-rendered for the initial state, with api/auth.php handling mutations. The
// credentials involved never reach the browser either way.
//
// Only the owner host edits auth config.
// Changes are made here and reach the partner through Critical-Data sync, not by the
// browser writing to two hosts. One source of truth, one direction of travel.
//
// Authelia rules are edited as a block, preserving the rest of the YAML untouched.
//
// OPERATIONAL SAFEGUARDS
// Every destructive action is confirmed — deleting a user, removing a proxy host, or
// rewriting the rule set are all one click away from locking out a household.
//
// Rule writes are atomic and refuse a missing config. include/auth.php writes to a temp
// file and renames; a missing configuration.yml is an error, never a freshly created file
// with a permissive default policy.
//
// Certificate state is read-only here. Renewal is owned by the cert monitor.
//
// All values render escaped — usernames, domains and rule fields are attacker-adjacent
// strings by definition on this page.
//
// RENDERS
// NPM proxy host list and editor, certificate status
// LLDAP user and group management
// Authelia access-control rules and default policy
//
// DEPENDS ON
// include/auth.php required directly for initial render
// api/auth.php mutations
// api/cert.php certificate status
// api/confform.php inline conf edits → include/confui.php
require_once dirname(__DIR__) . '/include/confui.php';
require_once dirname(__DIR__) . '/include/ai_chat.php';
?>
<style>
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.vv-au-toolbar { display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap; }
.vv-au-title { font-size:13px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
.vv-au-tab { font-size:11px;padding:3px 12px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#666;cursor:pointer;white-space:nowrap; }
.vv-au-tab:hover{ background:#222;color:#aaa; }
.vv-au-tab.active { background:#0d1f2a;border-color:#1a3a5a;color:#5c9fd4; }
.vv-au-btn { font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a2a2a;background:#1a1a1a;color:#888;cursor:pointer;white-space:nowrap; }
.vv-au-btn:hover{ background:#222;color:#bbb; }
.vv-au-btn.prim { border-color:#1a3a5a;background:#0d1f2a;color:#5c9fd4; }
.vv-au-btn.warn { border-color:#3a2000;background:#1f1200;color:#ff9800; }
.vv-au-btn.danger{border-color:#3a1a1a;background:#200d0d;color:#ef5350; }
.vv-au-btn.green{ border-color:#1a3a1a;background:#0d1f0d;color:#4caf50; }
.vv-au-btn:disabled { opacity:.4;cursor:default; }
/* ── Panels ──────────────────────────────────────────────────────────────── */
.vv-au-panel { display:none; }
.vv-au-panel.active { display:block; }
/* ── Tables ──────────────────────────────────────────────────────────────── */
.vv-au-tbl { width:100%;border-collapse:collapse;font-size:11px; }
.vv-au-tbl th { text-align:left;padding:5px 10px;font-size:10px;color:#444;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid #1e1e1e;white-space:nowrap; }
.vv-au-tbl td { padding:7px 10px;border-bottom:1px solid #161616;vertical-align:middle; }
.vv-au-tbl tr:hover td { background:#141414; }
.vv-au-tbl tr:last-child td { border-bottom:none; }
/* ── Badges ──────────────────────────────────────────────────────────────── */
.vv-au-badge { font-size:10px;padding:1px 6px;border-radius:2px;white-space:nowrap;display:inline-block; }
.vv-au-badge.ssl { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.nossl { background:#111;border:1px solid #222;color:#444; }
.vv-au-badge.on { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.off { background:#1a0a0a;border:1px solid #2a1a1a;color:#555; }
.vv-au-badge.bypass { background:#0a1a2a;border:1px solid #1a3a5a;color:#5c9fd4; }
.vv-au-badge.one_factor { background:#1a1a0a;border:1px solid #3a3a1a;color:#cddc39; }
.vv-au-badge.two_factor { background:#0d1f0d;border:1px solid #1a3a1a;color:#4caf50; }
.vv-au-badge.deny { background:#1a0a0a;border:1px solid #3a1a1a;color:#ef5350; }
.vv-au-badge.grp { background:#1a0d2a;border:1px solid #2a1a4a;color:#9c6ff7;margin:1px 2px; }
.vv-au-badge.host { background:#111;border:1px solid #1e1e1e;color:#444; }
/* ── Cards ───────────────────────────────────────────────────────────────── */
.vv-au-card { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; }
.vv-au-card-h { display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid #1e1e1e;background:#111; }
.vv-au-card-title { font-size:11px;font-weight:bold;color:#666;text-transform:uppercase;letter-spacing:.06em;flex:1; }
/* ── Users / Groups split ────────────────────────────────────────────────── */
.vv-au-ug-grid { display:grid;grid-template-columns:3fr 2fr;gap:12px; }
@media (max-width:700px) { .vv-au-ug-grid { grid-template-columns:1fr; } }
.vv-au-user-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;display:flex;align-items:center;gap:8px;flex-wrap:wrap; }
.vv-au-user-row:last-child { border-bottom:none; }
.vv-au-user-row:hover { background:#141414; }
.vv-au-user-name { font-size:12px;color:#bbb;font-weight:bold;min-width:80px;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; }
.vv-au-user-email{ font-size:10px;color:#444;flex:1; }
.vv-au-user-acts { display:flex;gap:4px;margin-left:auto;flex-shrink:0; }
.vv-au-grp-row { padding:8px 12px;border-bottom:1px solid #1a1a1a;cursor:pointer; }
.vv-au-grp-row:last-child { border-bottom:none; }
.vv-au-grp-row:hover { background:#141414; }
.vv-au-grp-name { font-size:12px;color:#bbb;font-weight:bold; }
.vv-au-grp-cnt { font-size:10px;color:#444;margin-top:2px; }
.vv-au-grp-acts { float:right;display:flex;gap:4px;margin-top:1px; }
.vv-au-grp-members { padding:6px 12px 10px 20px;background:#0f0f0f;border-bottom:1px solid #1a1a1a; display:none; }
.vv-au-grp-members.open { display:block; }
.vv-au-grp-member { font-size:11px;color:#666;padding:2px 0;display:flex;align-items:center;gap:6px; }
.vv-au-grp-member-rm { font-size:11px;color:#3a1a1a;cursor:pointer;padding:0 2px; }
.vv-au-grp-member-rm:hover { color:#ef5350; }
/* ── Access Control ──────────────────────────────────────────────────────── */
.vv-au-ac-defpol { display:flex;align-items:center;gap:10px;padding:8px 12px;background:#111;border:1px solid #222;border-radius:4px;margin-bottom:10px;font-size:11px;color:#555; }
.vv-au-ac-defpol select { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#888;font-size:11px;padding:2px 6px; }
.vv-au-rule-num { color:#333;font-size:10px;width:24px;text-align:right;flex-shrink:0; }
.vv-au-rule-acts { display:flex;gap:3px;white-space:nowrap; }
/* ── ACL rule cards ──────────────────────────────────────────────────────── */
/* Grid, not flex rows — every card the same width whatever its domain count, so the columns line
up down the page instead of each row negotiating its own. Same reason the Monitor uses one. */
.vv-au-acl-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:10px;align-items:start; }
.vv-au-rule { background:#161616;border:1px solid #222;border-radius:6px;overflow:hidden; }
/* Order is semantics here — Authelia takes the first rule whose domain and subject both match —
so the number stays loud even though cards read less sequentially than rows did. */
.vv-au-rule-h { display:flex;align-items:center;gap:7px;padding:7px 10px;background:#111;border-bottom:1px solid #1e1e1e; }
.vv-au-rule-ord { font-size:10px;font-weight:700;color:#3a3a3a;min-width:14px; }
.vv-au-rule-subj { flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:3px; }
.vv-au-rule-any { font-size:10px;color:#b8860b;font-style:italic; }
.vv-au-rule-b { padding:8px 10px; }
.vv-au-rule-meta { font-size:9px;color:#3a3a3a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:5px;
display:flex;justify-content:space-between;gap:8px; }
.vv-au-rule-sfx { color:#4a4a4a;text-transform:none;letter-spacing:0;font-family:monospace; }
.vv-au-rule-lbl { color:#5a6a4a;text-transform:none;letter-spacing:0;font-style:italic;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0; }
/* One row per domain, each an input. Chips read well and cannot be edited; this is a list you
work in, so the row is the field rather than a label that opens a dialog somewhere else. */
.vv-au-domlist { display:flex;flex-direction:column;gap:2px; }
.vv-au-domrow { display:flex;align-items:center;gap:4px; }
.vv-au-dominput { flex:1;min-width:0;background:#0f1419;border:1px solid #1e2a33;border-radius:2px;
color:#7c9fb8;font-family:monospace;font-size:10px;padding:2px 5px;outline:none; }
.vv-au-dominput:focus { border-color:#2d5a8a;background:#111820;color:#a8cde0; }
.vv-au-dominput.wild { color:#c9a227;border-color:#3a2800; }
/* An empty row would be written out as a blank domain, so it is marked while it is still on
screen rather than silently dropped at save time. */
.vv-au-dominput.blank { border-color:#3a1a1a;background:#1a0d0d; }
.vv-au-domadd { font-size:10px;color:#4a7a4a;background:none;border:1px dashed #23331f;border-radius:2px;
padding:2px 6px;cursor:pointer;margin-top:4px;width:100%;text-align:center; }
.vv-au-domadd:hover { color:#7ac77a;border-color:#2d5a2d;background:#0d1a0d; }
/* The unsaved marker. Inline editing makes it very easy to change three cards and walk away, and
nothing here reaches Authelia until Save & Restart is pressed. */
.vv-au-btn.dirty { border-color:#3a2800;background:#1f1200;color:#ffb74d; }
.vv-au-dirty-note{ font-size:10px;color:#ffb74d;margin-right:8px; }
.vv-au-rule-x { margin-top:7px;padding-top:6px;border-top:1px solid #1c1c1c;font-size:10px;color:#555;
display:flex;flex-direction:column;gap:2px; }
.vv-au-rule-x b { color:#3a3a3a;font-weight:normal;text-transform:uppercase;font-size:9px;letter-spacing:.06em; }
.vv-au-rule-x code { color:#8a7fb8;font-family:monospace;word-break:break-all; }
.vv-au-icon-btn { font-size:12px;color:#444;cursor:pointer;padding:1px 3px;border-radius:2px;background:none;border:none;line-height:1; }
.vv-au-icon-btn:hover { color:#bbb;background:#222; }
.vv-au-icon-btn.del:hover { color:#ef5350;background:#1a0808; }
/* ── Section toolbar (within panel) ─────────────────────────────────────── */
.vv-au-sec-bar { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-au-sec-title{ font-size:11px;color:#555;flex:1; }
/* ── Toggle switch ───────────────────────────────────────────────────────── */
.vv-au-tog { width:28px;height:16px;border-radius:8px;background:#1e1e1e;border:1px solid #2a2a2a;position:relative;cursor:pointer;display:inline-block;flex-shrink:0; }
.vv-au-tog.on { background:#1a3a1a;border-color:#2d5a2d; }
.vv-au-tog::after { content:'';position:absolute;top:2px;left:2px;width:10px;height:10px;border-radius:50%;background:#444;transition:left .12s,background .12s; }
.vv-au-tog.on::after { left:14px;background:#4caf50; }
/* ── Modal ───────────────────────────────────────────────────────────────── */
.vv-au-overlay { position:fixed;inset:0;background:#0009;z-index:9000;display:none;align-items:center;justify-content:center; }
.vv-au-overlay.open { display:flex; }
.vv-au-modal { background:#181818;border:1px solid #2a2a2a;border-radius:6px;padding:18px 20px;min-width:360px;max-width:520px;width:90vw;max-height:85vh;overflow-y:auto; }
.vv-au-modal h3 { font-size:12px;font-weight:bold;color:#888;text-transform:uppercase;letter-spacing:.06em;margin:0 0 14px; }
.vv-au-field { margin-bottom:10px; }
.vv-au-label { display:block;font-size:10px;color:#555;margin-bottom:3px;text-transform:uppercase;letter-spacing:.04em; }
.vv-au-input { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
.vv-au-input:focus { border-color:#1a3a5a; }
.vv-au-select { width:100%;box-sizing:border-box;background:#0d0d0d;border:1px solid #2a2a2a;border-radius:3px;color:#bbb;font-size:11px;padding:5px 8px;outline:none; }
.vv-au-hint { font-size:10px;color:#333;margin-top:2px; }
.vv-au-modal-acts { display:flex;justify-content:flex-end;gap:8px;margin-top:16px; }
.vv-au-tog-row { display:flex;align-items:center;gap:8px;margin-bottom:10px; }
.vv-au-tog-lbl { font-size:11px;color:#666; }
.vv-au-adv-toggle { font-size:10px;color:#333;cursor:pointer;margin-bottom:8px; }
.vv-au-adv-toggle:hover { color:#666; }
.vv-au-adv-section { display:none; }
.vv-au-adv-section.open { display:block; }
.vv-au-err { font-size:11px;color:#ef5350;margin-top:8px;display:none; }
.vv-au-err.show { display:block; }
/* ── Misc ────────────────────────────────────────────────────────────────── */
.vv-au-empty { padding:24px;text-align:center;font-size:11px;color:#333; }
.vv-au-domain { font-size:12px;color:#bbb;font-weight:bold; }
.vv-au-fwd { font-size:10px;color:#444; }
.vv-au-loading { color:#333;font-size:11px;padding:16px;text-align:center; }
/* ── Certs panel ─────────────────────────────────────────────────────────── */
.vv-au-cert-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px; }
.vv-au-cert-days { font-size:28px;font-weight:700;line-height:1;margin:6px 0 2px; }
.vv-au-cert-bar { height:3px;border-radius:2px;background:#1a1a1a;overflow:hidden;margin-top:8px; }
.vv-au-cert-fill { height:100%;border-radius:2px;transition:width .3s; }
</style>
<?php
require_once __DIR__ . '/../include/auth.php';
$isOwner = vv_is_owner();
?>
<div class="vv-au-toolbar">
<span class="vv-au-title">Auth Stack</span>
<button class="vv-au-tab active" data-tab="proxies">Proxies</button>
<button class="vv-au-tab" data-tab="users">Users &amp; Groups</button>
<button class="vv-au-tab" data-tab="acl">Access Control</button>
<button class="vv-au-tab" data-tab="certs">Certs</button>
<button class="vv-au-btn" id="vv-au-refresh" title="Refresh current tab">&#8635; Refresh</button>
</div>
<!-- ── Proxies ─────────────────────────────────────────────────────────────── -->
<div class="vv-au-panel active" id="vv-au-panel-proxies">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-proxy-count"></span>
<button class="vv-au-btn prim" id="vv-au-proxy-add">+ Add Proxy</button>
</div>
<div class="vv-au-card">
<div class="vv-au-loading" id="vv-au-proxy-loading">Loading…</div>
<table class="vv-au-tbl" id="vv-au-proxy-tbl" style="display:none">
<thead><tr>
<th>Domain</th><th>Forward</th><th>SSL</th><th>Status</th><th></th>
</tr></thead>
<tbody id="vv-au-proxy-body"></tbody>
</table>
<div class="vv-au-empty" id="vv-au-proxy-empty" style="display:none">No proxy hosts configured.</div>
</div>
</div>
<!-- ── Users & Groups ─────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-users">
<div class="vv-au-ug-grid">
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Users</span>
<button class="vv-au-btn prim" id="vv-au-user-add">+ Add User</button>
</div>
<div class="vv-au-loading" id="vv-au-users-loading">Loading…</div>
<div id="vv-au-users-list"></div>
<div class="vv-au-empty" id="vv-au-users-empty" style="display:none">No users found.</div>
</div>
<div class="vv-au-card">
<div class="vv-au-card-h">
<span class="vv-au-card-title">Groups</span>
<button class="vv-au-btn prim" id="vv-au-group-add">+ Add Group</button>
</div>
<div class="vv-au-loading" id="vv-au-groups-loading">Loading…</div>
<div id="vv-au-groups-list"></div>
<div class="vv-au-empty" id="vv-au-groups-empty" style="display:none">No groups found.</div>
</div>
</div>
</div>
<!-- ── Access Control ─────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-acl">
<div class="vv-au-ac-defpol" id="vv-au-ac-defpol-bar">
<span>Default policy:</span>
<select id="vv-au-ac-defpol" <?= $isOwner ? '' : 'disabled' ?>>
<option value="deny">deny</option>
<option value="two_factor">two_factor</option>
<option value="one_factor">one_factor</option>
<option value="bypass">bypass</option>
</select>
<?php if (!$isOwner): ?>
<span style="font-size:10px;color:#3a2a1a;">default policy editable on HOST1</span>
<?php endif; ?>
<span class="vv-au-dirty-note" id="vv-au-ac-dirty" style="margin-left:auto"></span>
<button class="vv-au-btn green" id="vv-au-ac-save">Save &amp; Restart Authelia</button>
</div>
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-acl-count"></span>
<button class="vv-au-btn prim" id="vv-au-rule-add">+ Add Rule</button>
</div>
<div class="vv-au-loading" id="vv-au-acl-loading">Loading…</div>
<!-- A card per rule rather than a table row. The domain list is the whole point of a rule here
and it is 13 or 14 entries on most of them, which in one table cell is a comma-joined run of
four hundred characters that says nothing at a glance. Cards give it room to wrap, and put
the group — the thing you are actually looking for — in the heading. -->
<div class="vv-au-acl-grid" id="vv-au-acl-body" style="display:none"></div>
<div class="vv-au-card"><div class="vv-au-empty" id="vv-au-acl-empty" style="display:none">No rules configured.</div></div>
</div>
<!-- ── Certs ───────────────────────────────────────────────────────────────── -->
<div class="vv-au-panel" id="vv-au-panel-certs">
<div class="vv-au-sec-bar">
<span class="vv-au-sec-title" id="vv-au-cert-ts"></span>
<button class="vv-au-btn prim" id="vv-au-cert-run">Refresh</button>
</div>
<div class="vv-au-cert-grid" id="vv-au-cert-grid">
<div class="vv-au-loading">Loading…</div>
</div>
<div id="vv-au-cert-cfg" style="margin-top:10px;font-size:10px;color:#3a3a3a;"></div>
</div>
<!-- ── Modal overlay ──────────────────────────────────────────────────────── -->
<div class="vv-au-overlay" id="vv-au-overlay">
<div class="vv-au-modal" id="vv-au-modal"></div>
</div>
<?php if (vv_ai_ui_on()): ?>
<div class="vv-card" id="vv-au-ai-card" style="margin-top:12px;">
<?php
// The factory, the profile registry and the store are three separate emits and none implies
// the others — omitting any renders a chat that looks complete and dies on the first click.
vv_ai_profiles_script();
vv_ai_chat_store_script();
vv_ai_chat_assets();
// Scoped to the tab. This page's vocabulary is the part of the stack least likely to be in
// anyone's head — a proxy host, a forward target, an Authelia policy and an LDAP group are four
// different objects that all end up deciding whether one person can open one URL, and the
// question is nearly always "which of these is stopping me".
vv_ai_chat_markup('vv-au-ai', [
'profile' => 'varaverk',
'compact' => true,
'title' => 'Assistant',
'scopeLabel' => 'Auth',
'empty' => 'Ask about a proxy host, a rule, a group, or why a login is being refused.',
'placeholder' => 'Ask about what is on this page…',
]); ?>
</div>
<?php endif; ?>
<?php
// The three services this page drives, plus the cert thresholds behind the Certs panel. They had
// no card at all until now, so the credentials that make the whole page work were reachable only
// from the Settings catch-all or over SSH — and an empty NPM_USER renders here as a failed login,
// which sends you looking for a password rather than a blank field.
//
// The match mirrors VV_UI_SECTION_SURFACES in confform.php and has to keep mirroring it: that
// constant is what tells the assistant where to send someone, and this is what the page actually
// draws. The two disagreeing means being given directions to a card that is not there.
//
// Both passwords render masked and are logged by name only — vv_conf_key_is_secret() matches
// PASS, so the same key cannot be redacted in the audit log and legible in the form.
vv_conf_ui_card('vv-cf-auth', 'NginxProxyManager|lldap|Authelia|Certificate Monitor', 'Auth settings');
?>
<script>
(function () {
'use strict';
const API = '/plugins/varaverk/api/auth.php';
const CERT_API = '/plugins/varaverk/api/cert.php';
const IS_OWNER = <?= $isOwner ? 'true' : 'false' ?>;
// ── State ─────────────────────────────────────────────────────────────────────
let _proxies = [], _certs = [];
let _users = [], _groups = [];
let _rules = [], _defaultPolicy = 'deny';
// The trailing comment on the default_policy line, carried so a save puts it back. The block is
// rebuilt from this model, so anything not held here is deleted by the next save.
let _defaultNote = '';
let _activeTab = 'proxies';
// ── Helpers ───────────────────────────────────────────────────────────────────
function _esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _get(action, cb) {
fetch(API + '?action=' + action)
.then(r => r.json()).then(cb)
.catch(e => cb({ ok: false, error: String(e) }));
}
function _post(params, cb) {
const fd = new URLSearchParams();
for (const [k, v] of Object.entries(params)) fd.append(k, v);
fetch(API, { method: 'POST', body: fd })
.then(r => r.json()).then(cb)
.catch(e => cb({ ok: false, error: String(e) }));
}
function _modal(html) {
document.getElementById('vv-au-modal').innerHTML = html;
document.getElementById('vv-au-overlay').classList.add('open');
}
function _closeModal() {
document.getElementById('vv-au-overlay').classList.remove('open');
}
function _togHtml(id, on, title) {
return `<span class="vv-au-tog${on?' on':''}" data-tog="${_esc(id)}" title="${_esc(title)}"></span>`;
}
function _policyBadge(p) {
return `<span class="vv-au-badge ${_esc(p)}">${_esc(p)}</span>`;
}
// Every list-shaped field in an Authelia rule is "a string or a list of them", and the config
// on this host uses both forms in the same file — one rule's domain is a bare string, another's
// is a list of fourteen. Two identical helpers existed for domain and subject; networks and
// resources had none and were read with a bare .join(), so `resources: "^\/web.*"` written as a
// scalar threw a TypeError and the Edit dialog for that rule never opened at all.
function _normList(val) {
if (val === null || val === undefined || val === '') return [];
return Array.isArray(val) ? val : [val];
}
const _normSubject = _normList;
const _normDomain = _normList;
// A subject entry is itself allowed to be a list, which Authelia reads as "all of these", so it
// renders joined by + rather than flattened into siblings that would read as alternatives.
function _subjLabel(s) { return Array.isArray(s) ? s.join(' + ') : String(s); }
// The shared tail of a rule's domains, so fourteen chips can read npm, npm2, main instead of
// spending two thirds of every chip restating gmer4lfe.com. Returned only when it is at least two
// labels deep and every domain keeps something in front of it — stripping a bare .com would make
// the chips longer to read, not shorter, and stripping everything would leave one blank.
function _commonSuffix(list) {
if (list.length < 2) return '';
const parts = list.map(d => String(d).split('.'));
let n = 0;
for (;;) {
const idx = parts.map(p => p.length - 1 - n);
if (idx.some(i => i < 1)) break;
const seg = parts[0][idx[0]];
if (!parts.every((p, k) => p[idx[k]] === seg)) break;
n++;
}
return n >= 2 ? '.' + parts[0].slice(parts[0].length - n).join('.') : '';
}
// ── Tab switching ─────────────────────────────────────────────────────────────
document.querySelectorAll('.vv-au-tab').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
document.querySelectorAll('.vv-au-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vv-au-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('vv-au-panel-' + tab).classList.add('active');
_activeTab = tab;
_loadTab(tab);
});
});
document.getElementById('vv-au-refresh').addEventListener('click', () => _loadTab(_activeTab));
function _loadTab(tab) {
if (tab === 'proxies') _loadProxies();
if (tab === 'users') { _loadUsers(); _loadGroups(); }
if (tab === 'acl') _loadAcl();
if (tab === 'certs') _loadCerts();
}
// ── Proxies ───────────────────────────────────────────────────────────────────
function _loadProxies() {
const loading = document.getElementById('vv-au-proxy-loading');
const tbl = document.getElementById('vv-au-proxy-tbl');
const empty = document.getElementById('vv-au-proxy-empty');
loading.style.display = 'block';
tbl.style.display = 'none';
empty.style.display = 'none';
// Load certs and proxies in parallel
let certsLoaded = false, proxiesLoaded = false;
function _check() {
if (!certsLoaded || !proxiesLoaded) return;
loading.style.display = 'none';
if (!_proxies.length) { empty.style.display = 'block'; return; }
tbl.style.display = 'table';
_renderProxies();
}
_get('npm_certs', r => { _certs = r.certs || []; certsLoaded = true; _check(); });
_get('npm_proxies', r => {
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350">'+_esc(r.error)+'</span>'; return; }
_proxies = r.proxies || [];
proxiesLoaded = true;
_check();
});
}
function _renderProxies() {
const count = document.getElementById('vv-au-proxy-count');
count.textContent = _proxies.length + ' host' + (_proxies.length !== 1 ? 's' : '');
const body = document.getElementById('vv-au-proxy-body');
body.innerHTML = _proxies.map(p => {
const domains = (p.domain_names || []).join(', ');
const fwd = p.forward_scheme + '://' + p.forward_host + ':' + p.forward_port;
const hasSsl = p.certificate_id && p.certificate_id !== '0';
const sslBadge = hasSsl
? `<span class="vv-au-badge ssl">SSL</span>`
: `<span class="vv-au-badge nossl">none</span>`;
const enabled = p.enabled;
return `<tr>
<td><div class="vv-au-domain">${_esc(domains)}</div></td>
<td><div class="vv-au-fwd">${_esc(fwd)}</div></td>
<td>${sslBadge}</td>
<td>${_togHtml('proxy-' + p.id, enabled, enabled ? 'Enabled — click to disable' : 'Disabled — click to enable')}</td>
<td style="text-align:right">
<div class="vv-au-rule-acts">
<button class="vv-au-icon-btn" data-proxy-edit="${p.id}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-proxy-del="${p.id}" title="Delete">✕</button>
</div>
</td>
</tr>`;
}).join('');
}
function _proxyModal(id) {
const p = id ? _proxies.find(x => x.id === id) : null;
const certOptions = _certs.map(c =>
`<option value="${c.id}"${p && p.certificate_id == c.id ? ' selected' : ''}>${_esc(c.nice_name || c.domain_names?.join(', '))}</option>`
).join('');
_modal(`<h3>${p ? 'Edit Proxy' : 'Add Proxy'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Domain Names</label>
<input class="vv-au-input" id="pm-domains" value="${_esc((p?.domain_names||[]).join(', '))}" placeholder="example.com, *.example.com">
<div class="vv-au-hint">Comma-separated</div>
</div>
<div style="display:grid;grid-template-columns:1fr 2fr 80px;gap:8px">
<div class="vv-au-field">
<label class="vv-au-label">Scheme</label>
<select class="vv-au-select" id="pm-scheme">
<option${(!p||p.forward_scheme==='http')?' selected':''}>http</option>
<option${(p?.forward_scheme==='https')?' selected':''}>https</option>
</select>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Forward Host</label>
<input class="vv-au-input" id="pm-host" value="${_esc(p?.forward_host||'')}" placeholder="192.168.1.100">
</div>
<div class="vv-au-field">
<label class="vv-au-label">Port</label>
<input class="vv-au-input" id="pm-port" type="number" value="${_esc(p?.forward_port||80)}">
</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">SSL Certificate</label>
<select class="vv-au-select" id="pm-cert">
<option value="0">None</option>
${certOptions}
</select>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.ssl_forced?' on':''}" id="pm-ssl-forced"></span>
<span class="vv-au-tog-lbl">Force SSL</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${p?.block_exploits?' on':''}" id="pm-block-exploits"></span>
<span class="vv-au-tog-lbl">Block Common Exploits</span>
</div>
<div class="vv-au-tog-row">
<span class="vv-au-tog${(!p||p.allow_websocket_upgrade!==false)?' on':''}" id="pm-websocket"></span>
<span class="vv-au-tog-lbl">WebSocket Support</span>
</div>
<div class="vv-au-err" id="pm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="pm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="pm-save">${p ? 'Save' : 'Create'}</button>
</div>`);
// Wire inline toggles
document.querySelectorAll('#vv-au-modal .vv-au-tog').forEach(t => {
t.addEventListener('click', () => t.classList.toggle('on'));
});
document.getElementById('pm-cancel').onclick = _closeModal;
document.getElementById('pm-save').onclick = () => {
const domains = document.getElementById('pm-domains').value.split(',').map(s=>s.trim()).filter(Boolean);
if (!domains.length) { _showModalErr('pm-err', 'Domain required'); return; }
const host = document.getElementById('pm-host').value.trim();
if (!host) { _showModalErr('pm-err', 'Forward host required'); return; }
const data = {
domain_names: domains,
forward_scheme: document.getElementById('pm-scheme').value,
forward_host: host,
forward_port: parseInt(document.getElementById('pm-port').value) || 80,
certificate_id: parseInt(document.getElementById('pm-cert').value) || 0,
ssl_forced: document.getElementById('pm-ssl-forced').classList.contains('on'),
block_exploits: document.getElementById('pm-block-exploits').classList.contains('on'),
allow_websocket_upgrade: document.getElementById('pm-websocket').classList.contains('on'),
http2_support: false,
hsts_enabled: false,
hsts_subdomains: false,
meta: {},
locations: [],
advanced_config: '',
enabled: true,
};
const btn = document.getElementById('pm-save');
btn.disabled = true; btn.textContent = 'Saving…';
const done = r => {
if (!r.ok) { _showModalErr('pm-err', r.error||'Save failed'); btn.disabled=false; btn.textContent = p?'Save':'Create'; return; }
_closeModal(); _loadProxies();
};
if (p) _post({ action:'npm_update', id: p.id, data: JSON.stringify(data) }, done);
else _post({ action:'npm_create', data: JSON.stringify(data) }, done);
};
}
function _showModalErr(id, msg) {
const el = document.getElementById(id);
if (el) { el.textContent = msg; el.classList.add('show'); }
}
// Proxy event delegation
document.getElementById('vv-au-panel-proxies').addEventListener('click', async e => {
// Add button
if (e.target.id === 'vv-au-proxy-add') { _proxyModal(null); return; }
// Toggle
const tog = e.target.closest('[data-tog^="proxy-"]');
if (tog) {
const id = parseInt(tog.dataset.tog.split('-')[1]);
const was = tog.classList.contains('on');
tog.classList.toggle('on');
_post({ action:'npm_toggle', id, enabled: was?'0':'1' }, r => {
if (!r.ok) { tog.classList.toggle('on'); }
});
return;
}
// Edit
const editBtn = e.target.closest('[data-proxy-edit]');
if (editBtn) { _proxyModal(parseInt(editBtn.dataset.proxyEdit)); return; }
// Delete
const delBtn = e.target.closest('[data-proxy-del]');
if (delBtn) {
const id = parseInt(delBtn.dataset.proxyDel);
const p = _proxies.find(x => x.id === id);
if (!await vvConfirm('Delete proxy for ' + (p?.domain_names||['this host']).join(', ') + '?')) return;
_post({ action:'npm_delete', id }, r => { if (r.ok) _loadProxies(); });
}
});
// ── Users ─────────────────────────────────────────────────────────────────────
function _loadUsers() {
const loading = document.getElementById('vv-au-users-loading');
const list = document.getElementById('vv-au-users-list');
const empty = document.getElementById('vv-au-users-empty');
loading.style.display = 'block';
list.innerHTML = '';
empty.style.display = 'none';
_get('lldap_users', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_users = r.users || [];
if (!_users.length) { empty.style.display = 'block'; return; }
list.innerHTML = _users.map(u => {
const grpBadges = (u.groups||[]).map(g => `<span class="vv-au-badge grp" title="Click to remove" data-rm-from-group="${_esc(u.id)}" data-gid="${g.id}">${_esc(g.displayName)}</span>`).join('');
// 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('');
});
}
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">Cancel</button>
<button class="vv-au-btn prim" id="um-save">${u ? 'Save' : 'Create'}</button>
</div>`);
document.getElementById('um-cancel').onclick = _closeModal;
document.getElementById('um-save').onclick = () => {
const id = (document.getElementById('um-uid').value||'').trim();
const name = (document.getElementById('um-name').value||'').trim();
const email= (document.getElementById('um-email').value||'').trim();
const 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':'Create'; return; }
_closeModal(); _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);
};
}
// ── 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
document.getElementById('vv-au-panel-users').addEventListener('click', async e => {
if (e.target.id === 'vv-au-user-add') { _userModal(null); return; }
const editBtn = e.target.closest('[data-user-edit]');
if (editBtn) { _userModal(editBtn.dataset.userEdit); return; }
const passBtn = e.target.closest('[data-user-pass]');
if (passBtn) { _passModal(passBtn.dataset.userPass); return; }
const 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() {
const loading = document.getElementById('vv-au-groups-loading');
const list = document.getElementById('vv-au-groups-list');
const empty = document.getElementById('vv-au-groups-empty');
loading.style.display = 'block';
list.innerHTML = '';
empty.style.display = 'none';
_get('lldap_groups', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_groups = r.groups || [];
if (!_groups.length) { empty.style.display = 'block'; return; }
list.innerHTML = _groups.map(g => {
const members = (g.users||[]).map(u =>
`<div class="vv-au-grp-member">
<span>${_esc(u.displayName||u.id)}</span>
<span class="vv-au-grp-member-rm" data-rm-user="${_esc(u.id)}" data-gid="${g.id}" title="Remove from group">✕</span>
</div>`
).join('');
return `<div class="vv-au-grp-row" data-grp="${g.id}">
<div class="vv-au-grp-acts">
<button class="vv-au-icon-btn" 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('');
});
}
// Group event delegation
document.getElementById('vv-au-panel-users').addEventListener('click', async e => {
if (e.target.id === 'vv-au-group-add') {
_modal(`<h3>Add Group</h3>
<div class="vv-au-field">
<label class="vv-au-label">Group Name</label>
<input class="vv-au-input" id="gm-name" placeholder="admins" autofocus>
</div>
<div class="vv-au-err" id="gm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="gm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="gm-save">Create</button>
</div>`);
document.getElementById('gm-cancel').onclick = _closeModal;
document.getElementById('gm-save').onclick = () => {
const name = (document.getElementById('gm-name').value||'').trim();
if (!name) { _showModalErr('gm-err','Name required'); return; }
const btn = document.getElementById('gm-save');
btn.disabled = true; btn.textContent = 'Creating…';
_post({ action:'lldap_create_group', name }, r => {
if (!r.ok) { _showModalErr('gm-err', r.error||'Failed'); btn.disabled=false; btn.textContent='Create'; return; }
_closeModal(); _loadGroups();
});
};
return;
}
const grpRow = e.target.closest('[data-grp]');
if (grpRow && !e.target.closest('button') && !e.target.closest('.vv-au-icon-btn')) {
const panel = document.getElementById('gm-' + grpRow.dataset.grp);
if (panel) panel.classList.toggle('open');
return;
}
const 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 Control ────────────────────────────────────────────────────────────
function _loadAcl() {
const loading = document.getElementById('vv-au-acl-loading');
const grid = document.getElementById('vv-au-acl-body');
const empty = document.getElementById('vv-au-acl-empty');
loading.style.display = 'block';
grid.style.display = 'none';
empty.style.display = 'none';
_get('authelia_rules', r => {
loading.style.display = 'none';
if (!r.ok) { loading.innerHTML = '<span style="color:#ef5350;padding:10px;display:block">'+_esc(r.error)+'</span>'; loading.style.display='block'; return; }
_rules = r.rules || [];
_defaultPolicy = r.default_policy || 'deny';
_defaultNote = r.default_note || '';
// Freshly loaded is by definition not modified — this also resets the marker after a reload
// that followed an abandoned edit.
_aclMarkDirty(false);
const sel = document.getElementById('vv-au-ac-defpol');
if (sel) sel.value = _defaultPolicy;
if (!_rules.length) { empty.style.display = 'block'; return; }
grid.style.display = 'grid';
_renderAcl();
});
}
function _renderAcl() {
const count = document.getElementById('vv-au-acl-count');
count.textContent = _rules.length + ' rule' + (_rules.length !== 1 ? 's' : '');
const body = document.getElementById('vv-au-acl-body');
// Owned here, not only by the loader. Adding the first rule to an empty config, or deleting the
// last one, re-renders without going back to the endpoint — so whichever of the two is showing
// has to be decided by the render that knows the new count.
body.style.display = _rules.length ? 'grid' : 'none';
const emptyEl = document.getElementById('vv-au-acl-empty');
if (emptyEl) emptyEl.style.display = _rules.length ? 'none' : 'block';
body.innerHTML = _rules.map((rule, i) => {
// Normalised in place so a domain has a stable index to edit against. Authelia allows the
// scalar form and this config uses it, but "rule 1's third domain" has to mean something for
// an inline editor to address it at all.
if (!Array.isArray(rule.domain)) rule.domain = _normDomain(rule.domain);
const doms = rule.domain;
const subjs = _normSubject(rule.subject);
// The comment the operator wrote above this rule in configuration.yml. It is the only thing in
// the file that says what a rule is for — the rule itself is a group id and thirteen hostnames
// — so it belongs on the card rather than only in the file it came from.
const label = _normList(rule._label).map(s => String(s).replace(/^#+\s*/, '')).join(' · ');
// No subject means the rule applies to everyone who reaches that domain, which is the single
// most consequential thing a rule can say and read as an empty cell in the table it replaced.
const subjHtml = subjs.length
? subjs.map(s => `<span class="vv-au-badge grp">${_esc(_subjLabel(s))}</span>`).join('')
: '<span class="vv-au-rule-any">anyone</span>';
// Full domain in every field, not the shortened form the chips used. A shortened value in an
// editable box is a value that has to be reassembled before it means anything, and this is the
// page where a wrong domain hands a service to the wrong group.
const domHtml = doms.map((d, j) => {
const full = String(d);
const cls = (full.includes('*') ? ' wild' : '') + (full.trim() === '' ? ' blank' : '');
return `<div class="vv-au-domrow">
<input class="vv-au-dominput${cls}" data-rule="${i}" data-dom="${j}"
value="${_esc(full)}" spellcheck="false" autocomplete="off"
placeholder="host.example.com">
<button class="vv-au-icon-btn del" data-dom-del="${i}:${j}" title="Remove this domain">✕</button>
</div>`;
}).join('');
// Both were invisible in the table — there was no column for them — so a rule narrowed to one
// path or one subnet looked identical to one that was not.
const nets = _normList(rule.networks), res = _normList(rule.resources);
const extra = (nets.length || res.length) ? `<div class="vv-au-rule-x">
${nets.length ? `<div><b>networks</b> <code>${_esc(nets.join(', '))}</code></div>` : ''}
${res.length ? `<div><b>resources</b> <code>${_esc(res.join(', '))}</code></div>` : ''}
</div>` : '';
const upBtn = i === 0 ? '<span style="width:16px;display:inline-block"></span>' :
`<button class="vv-au-icon-btn" data-rule-up="${i}" title="Move up">↑</button>`;
const dnBtn = i === _rules.length-1 ? '<span style="width:16px;display:inline-block"></span>' :
`<button class="vv-au-icon-btn" data-rule-dn="${i}" title="Move down">↓</button>`;
return `<div class="vv-au-rule">
<div class="vv-au-rule-h">
<span class="vv-au-rule-ord">${i+1}</span>
<span class="vv-au-rule-subj">${subjHtml}</span>
${_policyBadge(rule.policy)}
<div class="vv-au-rule-acts">
${upBtn}${dnBtn}
<button class="vv-au-icon-btn" data-rule-edit="${i}" title="Edit">✎</button>
<button class="vv-au-icon-btn del" data-rule-del="${i}" title="Delete">✕</button>
</div>
</div>
<div class="vv-au-rule-b">
<div class="vv-au-rule-meta">
<span>${doms.length} domain${doms.length !== 1 ? 's' : ''}</span>
${label ? `<span class="vv-au-rule-lbl" title="comment in configuration.yml">${_esc(label)}</span>` : ''}
</div>
<div class="vv-au-domlist">${domHtml}</div>
<button class="vv-au-domadd" data-dom-add="${i}" type="button">+ add domain</button>
${extra}
</div>
</div>`;
}).join('');
}
function _ruleModal(idx) {
const rule = idx !== null ? _rules[idx] : null;
// All four through the same normaliser. networks and resources used to be read with a bare
// .join(), which threw on the scalar form Authelia allows and left the dialog unopenable.
const domains = _normList(rule?.domain).join(', ');
const subjects = _normList(rule?.subject).map(_subjLabel).join(', ');
// Captured before the field is drawn, so "did the operator change this?" is answerable at save.
const subjects_initial = subjects;
const nets = _normList(rule?.networks).join(', ');
const resources= _normList(rule?.resources).join(', ');
const policyOpts = ['bypass','one_factor','two_factor','deny'].map(p =>
`<option value="${p}"${(rule?.policy||'two_factor')===p?' selected':''}>${p}</option>`
).join('');
_modal(`<h3>${rule ? 'Edit Rule' : 'Add Rule'}</h3>
<div class="vv-au-field">
<label class="vv-au-label">Domain</label>
<input class="vv-au-input" id="rm-domain" value="${_esc(domains)}" placeholder="*.example.com, example.com">
<div class="vv-au-hint">Comma-separated; wildcards supported</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Policy</label>
<select class="vv-au-select" id="rm-policy">${policyOpts}</select>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Subject <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-subject" value="${_esc(subjects)}" placeholder="group:admins, user:john">
<div class="vv-au-hint">Comma-separated; prefix with group: or user:</div>
</div>
<div class="vv-au-adv-toggle" id="rm-adv-toggle">▶ Advanced (networks, resources)</div>
<div class="vv-au-adv-section" id="rm-adv">
<div class="vv-au-field">
<label class="vv-au-label">Networks <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-networks" value="${_esc(nets)}" placeholder="192.168.1.0/24, 10.0.0.0/8">
<div class="vv-au-hint">Comma-separated CIDR ranges</div>
</div>
<div class="vv-au-field">
<label class="vv-au-label">Resources <span style="color:#2a2a2a">(optional)</span></label>
<input class="vv-au-input" id="rm-resources" value="${_esc(resources)}" placeholder="^/api, ^/admin">
<div class="vv-au-hint">Comma-separated regex patterns</div>
</div>
</div>
<div class="vv-au-err" id="rm-err"></div>
<div class="vv-au-modal-acts">
<button class="vv-au-btn" id="rm-cancel">Cancel</button>
<button class="vv-au-btn prim" id="rm-save">${rule ? 'Save' : 'Add Rule'}</button>
</div>`);
document.getElementById('rm-adv-toggle').addEventListener('click', () => {
const sec = document.getElementById('rm-adv');
const tog = document.getElementById('rm-adv-toggle');
const open = sec.classList.toggle('open');
tog.textContent = (open ? '▼' : '▶') + ' Advanced (networks, resources)';
});
document.getElementById('rm-cancel').onclick = _closeModal;
document.getElementById('rm-save').onclick = () => {
const domains = document.getElementById('rm-domain').value.split(',').map(s=>s.trim()).filter(Boolean);
if (!domains.length) { _showModalErr('rm-err','Domain required'); return; }
const subjRaw = document.getElementById('rm-subject').value;
const subjects = subjRaw.split(',').map(s=>s.trim()).filter(Boolean);
const networks = document.getElementById('rm-networks').value.split(',').map(s=>s.trim()).filter(Boolean);
const resources = document.getElementById('rm-resources').value.split(',').map(s=>s.trim()).filter(Boolean);
const newRule = {
domain: domains.length === 1 ? domains[0] : domains,
policy: document.getElementById('rm-policy').value,
};
// Authelia lets a subject entry be a list, meaning "in all of these groups". A text box split
// on commas cannot express that — reading it back would turn one AND into two ORs and widen
// who the rule admits. So an untouched field writes the original structure back verbatim, and
// only a field the operator actually edited is re-parsed. Nothing on this host uses the nested
// form today; this is here so that changing a policy on a rule that does cannot quietly
// rewrite who it applies to.
if (rule && subjRaw === subjects_initial) {
if (rule.subject !== undefined) newRule.subject = rule.subject;
}
else if (subjects.length) newRule.subject = subjects.length === 1 ? subjects[0] : subjects;
if (networks.length) newRule.networks = networks;
if (resources.length) newRule.resources = resources;
// The comment lines above this rule in configuration.yml — ## Admin Only and the rest. The
// dialog does not show them and rebuilds the rule from scratch, so without this, editing a
// rule's policy would delete the only line in the file that says what the rule is for.
if (rule && rule._label) newRule._label = rule._label;
if (idx !== null) _rules[idx] = newRule;
else _rules.push(newRule);
_closeModal();
_aclMarkDirty(true);
_renderAcl();
};
}
// ── Unsaved state ────────────────────────────────────────────────────────────
// Nothing on this tab reaches Authelia until Save & Restart is pressed — every edit mutates the
// in-memory rules and re-renders. That was tolerable when the only ways to change anything were a
// modal and a delete confirmation; with the domain rows editable in place it is far too easy to
// change three cards, switch tabs and lose the lot with no indication anything was pending.
let _aclDirty = false;
function _aclMarkDirty(on) {
_aclDirty = on;
const btn = document.getElementById('vv-au-ac-save');
if (btn) btn.classList.toggle('dirty', on);
const note = document.getElementById('vv-au-ac-dirty');
if (note) note.textContent = on ? 'unsaved changes' : '';
}
// Writes straight into the model and deliberately does not re-render: the element being typed in
// is inside the markup a render would replace, which would drop focus on the first keystroke.
document.getElementById('vv-au-panel-acl').addEventListener('input', e => {
const inp = e.target.closest('.vv-au-dominput');
if (!inp) return;
const r = parseInt(inp.dataset.rule), d = parseInt(inp.dataset.dom);
if (!_rules[r] || !Array.isArray(_rules[r].domain)) return;
_rules[r].domain[d] = inp.value;
inp.classList.toggle('blank', inp.value.trim() === '');
inp.classList.toggle('wild', inp.value.includes('*'));
_aclMarkDirty(true);
});
// Enter adds a row underneath and moves to it, so a list of fourteen can be typed straight
// through instead of returning to the add button between each one.
document.getElementById('vv-au-panel-acl').addEventListener('keydown', e => {
const inp = e.target.closest('.vv-au-dominput');
if (!inp || e.key !== 'Enter') return;
e.preventDefault();
const r = parseInt(inp.dataset.rule), d = parseInt(inp.dataset.dom);
if (!_rules[r]) return;
_rules[r].domain.splice(d + 1, 0, '');
_aclMarkDirty(true);
_renderAcl();
_focusDomain(r, d + 1);
});
function _focusDomain(r, d) {
const el = document.querySelector(`.vv-au-dominput[data-rule="${r}"][data-dom="${d}"]`);
if (el) { el.focus(); el.select(); }
}
// ACL event delegation
document.getElementById('vv-au-panel-acl').addEventListener('click', async e => {
if (e.target.id === 'vv-au-rule-add') { _ruleModal(null); return; }
const domAdd = e.target.closest('[data-dom-add]');
if (domAdd) {
const r = parseInt(domAdd.dataset.domAdd);
if (!_rules[r]) return;
_rules[r].domain.push('');
_aclMarkDirty(true);
_renderAcl();
_focusDomain(r, _rules[r].domain.length - 1);
return;
}
const domDel = e.target.closest('[data-dom-del]');
if (domDel) {
const [r, d] = domDel.dataset.domDel.split(':').map(Number);
if (!_rules[r]) return;
// A rule with no domain matches nothing and Authelia will not load it. Refused here rather
// than allowed and caught at save, so the answer arrives while the rule is still on screen.
if (_rules[r].domain.length <= 1) {
vvAlert('A rule needs at least one domain. Delete the whole rule instead, with the ✕ in its header.');
return;
}
_rules[r].domain.splice(d, 1);
_aclMarkDirty(true);
_renderAcl();
return;
}
if (e.target.id === 'vv-au-ac-save') {
const dp = document.getElementById('vv-au-ac-defpol').value;
const btn = document.getElementById('vv-au-ac-save');
// Trimmed and emptied out here rather than on every keystroke, so a row can legitimately be
// blank while it is being typed into. A rule left with no domain at all is refused instead of
// written, because Authelia will not load the file and the failure would land at container
// restart — with everything behind it already down.
const payload = _rules.map(rule => {
const out = Object.assign({}, rule);
const doms = _normList(rule.domain).map(d => String(d).trim()).filter(Boolean);
out.domain = doms.length === 1 ? doms[0] : doms;
return out;
});
const empty = payload.findIndex(r => !_normList(r.domain).length);
if (empty !== -1) {
vvAlert('Rule ' + (empty + 1) + ' has no domains left. Give it one, or delete the rule.');
return;
}
btn.disabled = true; btn.textContent = 'Saving…';
_post({ action:'authelia_save', rules: JSON.stringify(payload), default_policy: dp,
default_note: _defaultNote }, r => {
btn.disabled = false; btn.textContent = 'Save & Restart Authelia';
if (!r.ok) { vvAlert('Save failed: ' + (r.error||'unknown error')); return; }
// The model on screen is the model on disk now — including the trimming just applied, which
// is why the rules are replaced rather than left as typed.
_rules = payload;
_aclMarkDirty(false);
_renderAcl();
btn.textContent = 'Saved ✓';
setTimeout(() => { btn.textContent = 'Save & Restart Authelia'; }, 2000);
});
return;
}
const editBtn = e.target.closest('[data-rule-edit]');
if (editBtn) { _ruleModal(parseInt(editBtn.dataset.ruleEdit)); return; }
const delBtn = e.target.closest('[data-rule-del]');
if (delBtn) {
const i = parseInt(delBtn.dataset.ruleDel);
if (!await vvConfirm('Delete this rule?')) return;
_rules.splice(i, 1);
_aclMarkDirty(true);
_renderAcl();
return;
}
const upBtn = e.target.closest('[data-rule-up]');
if (upBtn) {
const i = parseInt(upBtn.dataset.ruleUp);
if (i > 0) { [_rules[i-1], _rules[i]] = [_rules[i], _rules[i-1]]; _aclMarkDirty(true); _renderAcl(); }
return;
}
const dnBtn = e.target.closest('[data-rule-dn]');
if (dnBtn) {
const i = parseInt(dnBtn.dataset.ruleDn);
if (i < _rules.length-1) { [_rules[i], _rules[i+1]] = [_rules[i+1], _rules[i]]; _aclMarkDirty(true); _renderAcl(); }
}
});
// The default policy is the one control here that is not a rule, and it is the most consequential
// one on the tab — it decides what happens to every hostname no rule names.
document.getElementById('vv-au-ac-defpol').addEventListener('change', () => _aclMarkDirty(true));
// ── Close modal on overlay click ──────────────────────────────────────────────
document.getElementById('vv-au-overlay').addEventListener('click', e => {
if (e.target === document.getElementById('vv-au-overlay')) _closeModal();
});
// ── Certs ─────────────────────────────────────────────────────────────────────
function _certBadgeCls(s) {
return ({OK:'ssl', WARN:'one_factor', CRIT:'deny', FAIL:'deny'})[s] || 'nossl';
}
function _certBadgeTxt(s) {
return ({OK:'healthy', WARN:'warning', CRIT:'critical', FAIL:'failed', UNKN:'not checked'})[s] || s;
}
function _certDayColor(days, warn, crit) {
if (days == null) return '#3a3a3a';
return days <= crit ? '#ef5350' : days <= warn ? '#ffb74d' : '#4caf50';
}
function _certRel(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now()/1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60) + 'm ago';
if (d < 86400) return Math.floor(d/3600) + 'h ago';
return Math.floor(d/86400) + 'd ago';
}
function _renderNpmCerts(data) {
const grid = document.getElementById('vv-au-cert-grid');
const ts = document.getElementById('vv-au-cert-ts');
const cfg = document.getElementById('vv-au-cert-cfg');
const warn = data.warn_days || 30, crit = data.crit_days || 7;
const certs = data.certs || [];
ts.textContent = certs.length + ' certificate' + (certs.length !== 1 ? 's' : '') + ' · live from NPM';
cfg.textContent = `Warn: ${warn}d · Crit: ${crit}d`;
if (!certs.length) {
grid.innerHTML = '<div class="vv-au-empty">No certificates found in NPM</div>';
return;
}
grid.innerHTML = certs.map(c => {
const s = c.status || 'UNKN';
const days = c.days;
const col = _certDayColor(days, warn, crit);
const barPct = days != null ? Math.min(Math.round(days / 90 * 100), 100) : 0;
const expStr = c.expires ? 'Expires ' + c.expires : '';
const extra = (c.domain_names || []).filter(d => d !== c.nice_name).join(', ');
return `<div class="vv-au-card" style="padding:12px 14px;">
<div class="vv-au-domain">${_esc(c.nice_name)}</div>
${extra ? `<div style="font-size:9px;color:#444;margin-bottom:2px;word-break:break-all;">${_esc(extra)}</div>` : ''}
<div class="vv-au-cert-days" style="color:${col}">${days != null ? days : '—'}</div>
<div style="font-size:9px;color:#444;margin-bottom:6px;">${days != null ? 'days remaining' : ''}</div>
<span class="vv-au-badge ${_certBadgeCls(s)}">${_certBadgeTxt(s)}</span>
<div style="font-size:9px;color:#3a3a3a;margin-top:5px;">${_esc(expStr)}</div>
${days != null ? `<div class="vv-au-cert-bar"><div class="vv-au-cert-fill" style="width:${barPct}%;background:${col};"></div></div>` : ''}
</div>`;
}).join('');
}
function _loadCerts(onDone) {
const grid = document.getElementById('vv-au-cert-grid');
grid.innerHTML = '<div class="vv-au-loading">Loading…</div>';
fetch(CERT_API + '?action=npm')
.then(r => r.json())
.then(d => {
if (d.ok) _renderNpmCerts(d);
else throw new Error(d.error || 'NPM error');
if (onDone) onDone();
})
.catch(e => {
grid.innerHTML = `<div class="vv-au-empty" style="color:#ef5350">${_esc(e.message || 'Failed to load')}</div>`;
if (onDone) onDone();
});
}
document.getElementById('vv-au-cert-run').addEventListener('click', function() {
const btn = this;
btn.disabled = true; btn.textContent = 'Loading…';
_loadCerts(() => { btn.disabled = false; btn.textContent = 'Refresh'; });
});
// ── Assistant ────────────────────────────────────────────────────────────────
// vv_ai_chat_markup() emits markup and nothing else — the instance has to be constructed here,
// as every other placement does. Without this the card renders complete and is entirely inert:
// the profile chip has no label because applyProfile() never ran, and clicking it does nothing
// because the picker was never wired.
//
// Guarded on the element because the card is behind vv_ai_ui_on(), so on a node with no reachable
// model this block finds nothing and does nothing rather than throwing on a missing prefix.
if (document.getElementById('vv-au-ai-chat')) {
VvAiChat({
prefix: 'vv-au-ai',
profile: 'varaverk',
scopeLabel: 'Auth',
scope: () => 'Auth',
// Pinned, like the other cards: without it this resumes whatever thread was last open
// anywhere, which could land the card mid-conversation under a profile this tab never offers.
resumeProfile: 'varaverk',
empty: 'Ask about a proxy host, a rule, a group, or why a login is being refused.',
});
}
// ── Boot ──────────────────────────────────────────────────────────────────────
_loadProxies();
})();
</script>