All gates re-verified green. Report: - **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`). - **Completion criteria:** - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off` - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only` - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4 - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config" - suite green, coverage >90%, ruff+pyright clean — ✅ - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green) - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run) - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references. - **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating. - **Next pending phase:** `191_source_name_labels_tags`.
706 lines
29 KiB
JavaScript
706 lines
29 KiB
JavaScript
/* Brain of Reese — Settings view module (phase 168, task 05): the
|
|
* admin settings panel — "edit anything in .env and save it to the
|
|
* database. The database value should override the env var. The
|
|
* change should take effect on clicking 'save'" (TODO.md L3).
|
|
*
|
|
* The phase-76 shell-view-module contract (the theme.js / tokens.js
|
|
* shape): the router (assets/router.js) lazy-imports this module on
|
|
* FIRST show of #view-settings and calls mount(root) ONCE (mount-once,
|
|
* hide-forever — root is the view's <section id="view-settings">,
|
|
* every DOM lookup scoped to it). The initSharedHeader() call never
|
|
* happens here: in the shell the shared header boots exactly once,
|
|
* via the chat module (app.js) at shell boot.
|
|
*
|
|
* What the panel does:
|
|
*
|
|
* • gate — fetchIsAdmin() (the SAME cached /api/whoami promise
|
|
* header.js exports, zero extra requests): admin hides
|
|
* #settings-gate and reveals #settings-content; anonymous /
|
|
* token-user keeps the gate (the #nav-settings link is already
|
|
* hidden by header.js — the gate is the DIRECT-URL case, the
|
|
* #tokens-gate pattern). No /api/admin/settings request is ever
|
|
* made outside the admin branch (the router 403s them — the
|
|
* phase-79 contract).
|
|
* • load — GET /api/admin/settings → render the 75-row catalogue
|
|
* in the SERVER's order (GROUPS order — the stable order the
|
|
* E2E pins against): one <tr> per setting (the group band rows
|
|
* are created per group) + the ship-rebuilt no-match row, all
|
|
* through createElement + textContent (this file never builds
|
|
* HTML — the XSS-safe-by-construction house rule). A failed
|
|
* fetch keeps the last rendered catalogue (never a blanked panel)
|
|
* and shows the house error line.
|
|
* • rows — the Setting cell (the label in a .cell-clip ellipsis
|
|
* — the phase-156 contract with the full text in the control's
|
|
* aria-label — + the BOR_* name in <code class="settings-env">
|
|
* + the literal-text `restart required` .settings-flag for the
|
|
* boot-wired fields (text, never color alone, B5, with the
|
|
* title/aria-describedby copy explaining when it applies)),
|
|
* the Value cell (the control by the server's `type`: int/float
|
|
* → <input type=number> (step="any" for float), bool → a
|
|
* true/false <select>, json → a spellcheck=false <textarea>
|
|
* (the one list[str] field, K2), str → <input type=text> (a
|
|
* <textarea rows=2> for the CSV/path fields whose value exceeds
|
|
* 40 chars), secret → <input type=password
|
|
* autocomplete=new-password placeholder="•••••• (stored)"> with
|
|
* an aria-label naming the env var — an EMPTY secret input
|
|
* means "leave unchanged" (never an accidental wipe); every
|
|
* control is id=set-<key> with a <label for>), the Source cell
|
|
* (the literal-text DB / env / default badge — the color is
|
|
* decoration, B5), and the Actions cell (the per-row Reset —
|
|
* disabled with a title when there is nothing to reset or the
|
|
* field is read-only, LOCKED A3).
|
|
* • dirty tracking — module-state Map<key, value>: an input/
|
|
* change marks the row (.is-dirty + the "N unsaved changes"
|
|
* hint) and enables #settings-save; a row that returns to its
|
|
* served value un-marks.
|
|
* • Save — the §7.4 never-stale lifecycle (disabled + aria-busy +
|
|
* "Saving…", re-enabled on success AND failure — a click can
|
|
* never leave the button stuck): for each dirty row in DOM
|
|
* order PUT /api/admin/settings/<key> with { value }; STOP AT
|
|
* THE FIRST FAILURE (K4) — its server detail lands in
|
|
* #settings-error (role=alert), the catalogue re-GETs so the
|
|
* badges show the truth, the remaining rows' edits are
|
|
* re-applied to the re-rendered controls (they stay in the form
|
|
* for correction), and focus goes to the offending input. All
|
|
* good → one #settings-status line `Saved N settings.` (then
|
|
* role=status) — a saved restart-required row appends "—
|
|
* applies on the next restart" (the owner is never told
|
|
* "saved" and then sees nothing change) — + a re-GET so every
|
|
* badge/value is the settled server state.
|
|
* • Reset — per row: DELETE /api/admin/settings/<key> → the
|
|
* status line `BOR_X is back to the environment value.` + a
|
|
* re-GET.
|
|
* • Clear all overrides — the house two-step inline confirm
|
|
* (the history-confirm-* classes, focus moves to the confirm
|
|
* button, Escape / No cancels) → DELETE /api/admin/settings →
|
|
* the status line `Cleared N overrides.` (N from the
|
|
* response) + a re-GET.
|
|
* • the live brand re-apply (phase 190, task 03 — A2, the second half
|
|
* of "takes effect on clicking save") — when a save / Reset / Clear
|
|
* all touched one of the three brand strings (`app_name`,
|
|
* `input_placeholder`, `footer_text` — the panel's own keys), the
|
|
* OPEN document is re-branded from a fresh `GET /api/config`
|
|
* through `window.applyBrandFromConfig` (the ONE pass brand.js owns
|
|
* and the boot runs — this file never writes the title / brand /
|
|
* footer / placeholder itself). No brand key in the batch → no
|
|
* extra request; a failed re-apply leaves the document exactly as
|
|
* it was (never a blanked brand); the catalogue re-GET stays the
|
|
* catalogue's own refresh. The Theme tab (theme.js) runs the SAME
|
|
* shared pass after its own save / reset.
|
|
* • search — #settings-filter: a case-insensitive substring over
|
|
* the label + BOR_* name cells, applied on input with NO fetch
|
|
* (the phase-101 tokens D4 contract) and RE-APPLIED after every
|
|
* load (the query lives in the input — it survives re-shows,
|
|
* the phase-77 contract). A group with no visible rows hides
|
|
* its group row; zero matches with a non-empty query → one
|
|
* no-match row `No settings match "<query>"` (textContent).
|
|
* • the kill-switch banner — while overlay_enabled is false
|
|
* (BOR_SETTINGS_DB=0, D6) the #settings-disabled alert explains
|
|
* that saving stores the value but it will not take effect
|
|
* (the mechanism is off, not the panel).
|
|
*
|
|
* Every value is rendered with textContent — this file never builds
|
|
* HTML (the XSS-safe-by-construction house rule; a full-file source
|
|
* pin enforces it).
|
|
*/
|
|
|
|
import { fetchIsAdmin } from "./header.js";
|
|
|
|
export async function mount(root) {
|
|
/* ---------- view elements (the view's section, scoped to root) ---------- */
|
|
const gateEl = root.querySelector("#settings-gate");
|
|
const contentEl = root.querySelector("#settings-content");
|
|
const disabledBanner = root.querySelector("#settings-disabled");
|
|
const statusEl = root.querySelector("#settings-status");
|
|
const errorEl = root.querySelector("#settings-error");
|
|
const filter = root.querySelector("#settings-filter");
|
|
const tbody = root.querySelector("#settings-tbody");
|
|
const saveBtn = root.querySelector("#settings-save");
|
|
const clearAllBtn = root.querySelector("#settings-clear-all");
|
|
const dirtyHint = root.querySelector("#settings-dirty");
|
|
|
|
const SAVE_LABEL = "Save settings";
|
|
|
|
/* Module state (the phase-76 mount-once contract — it survives
|
|
every switch): the last settled catalogue (the row meta for the
|
|
save's restart check), the pending edits (key → the value being
|
|
edited), and the in-flight save flag (the §7.4 guard). */
|
|
let lastItems = [];
|
|
const dirty = new Map();
|
|
let saveInFlight = false;
|
|
let clearAllEsc = null; // the armed Escape handler (null = disarmed)
|
|
|
|
/* The no-match row (rebuilt into the tbody on every render — the
|
|
<td> text is JS-filled; it is never a data row). */
|
|
const noMatchRow = document.createElement("tr");
|
|
noMatchRow.className = "settings-no-match";
|
|
noMatchRow.hidden = true;
|
|
const noMatchTd = document.createElement("td");
|
|
noMatchTd.colSpan = 4;
|
|
noMatchRow.appendChild(noMatchTd);
|
|
|
|
/* ---------- feedback lines (never stale, §7.4) ---------- */
|
|
|
|
function announce(message) {
|
|
if (statusEl) statusEl.textContent = message;
|
|
}
|
|
|
|
function showError(message) {
|
|
if (!errorEl) return;
|
|
errorEl.textContent = message; // textContent — the server detail is data
|
|
errorEl.hidden = false;
|
|
}
|
|
|
|
function clearError() {
|
|
if (!errorEl) return;
|
|
errorEl.textContent = "";
|
|
errorEl.hidden = true;
|
|
}
|
|
|
|
/* FastAPI error bodies: a string detail (the house 422s — the
|
|
detail names the offending field) or the validation-error array
|
|
(the first entry's msg). Same extraction as theme.js. */
|
|
async function apiDetail(r, fallback) {
|
|
try {
|
|
const data = await r.json();
|
|
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
|
return String(data.detail[0].msg);
|
|
}
|
|
if (typeof data.detail === "string" && data.detail) return data.detail;
|
|
} catch {
|
|
/* non-JSON error body */
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/* ---------- catalogue render (createElement + textContent only) ---------- */
|
|
|
|
const dataRows = () =>
|
|
tbody ? [...tbody.querySelectorAll("tr.settings-row")] : [];
|
|
|
|
function makeControl(item) {
|
|
let control;
|
|
if (item.secret) {
|
|
/* LOCKED A4: the secret is write-only — the input starts
|
|
EMPTY (the plaintext never leaves the server); an empty
|
|
value means "leave unchanged" (never an accidental wipe). */
|
|
control = document.createElement("input");
|
|
control.type = "password";
|
|
control.setAttribute("autocomplete", "new-password");
|
|
control.placeholder = "•••••• (stored)";
|
|
} else if (item.type === "int") {
|
|
control = document.createElement("input");
|
|
control.type = "number";
|
|
control.value = item.value;
|
|
} else if (item.type === "float") {
|
|
control = document.createElement("input");
|
|
control.type = "number";
|
|
control.step = "any";
|
|
control.value = item.value;
|
|
} else if (item.type === "bool") {
|
|
control = document.createElement("select");
|
|
for (const opt of ["true", "false"]) {
|
|
const option = document.createElement("option");
|
|
option.value = opt;
|
|
option.textContent = opt;
|
|
control.appendChild(option);
|
|
}
|
|
control.value = item.value;
|
|
} else if (item.type === "json") {
|
|
/* The one list[str] field (K2) — edited as a JSON array in a
|
|
textarea (the server's env-native string form, verbatim). */
|
|
control = document.createElement("textarea");
|
|
control.spellcheck = false;
|
|
control.rows = 2;
|
|
control.value = item.value;
|
|
} else if (item.type === "str" && (item.value || "").length > 40) {
|
|
/* A CSV / path field with a long value — the two-row
|
|
textarea (no single-line clip for 200-char lists). */
|
|
control = document.createElement("textarea");
|
|
control.rows = 2;
|
|
control.value = item.value;
|
|
} else {
|
|
control = document.createElement("input");
|
|
control.type = "text";
|
|
control.setAttribute("autocomplete", "off");
|
|
control.value = item.value;
|
|
}
|
|
control.id = "set-" + item.key;
|
|
control.classList.add("settings-control");
|
|
/* The full (unclipped) label stays reachable: the .cell-clip
|
|
ellipsis is visual only (the phase-156 contract), and the
|
|
secret's aria-label names the env var (the house pattern for
|
|
table-hosted inputs — the Setting cell's label is the
|
|
visible <label for>). */
|
|
control.setAttribute(
|
|
"aria-label",
|
|
item.secret ? item.env_var : `${item.label} — ${item.env_var}`,
|
|
);
|
|
return control;
|
|
}
|
|
|
|
function makeRow(item) {
|
|
const tr = document.createElement("tr");
|
|
tr.className = "settings-row";
|
|
tr.dataset.key = item.key;
|
|
|
|
/* The Setting cell: the label (the .cell-clip ellipsis — the
|
|
full text is the label's own textContent + the control's
|
|
aria-label), the BOR_* name (presentation only, D1), and —
|
|
when boot-wired (LOCKED A4) — the literal-text flag with
|
|
the when-it-applies copy (title + the shared
|
|
aria-describedby target). */
|
|
const nameTd = document.createElement("td");
|
|
nameTd.className = "settings-name-cell";
|
|
const label = document.createElement("label");
|
|
label.htmlFor = "set-" + item.key;
|
|
const clip = document.createElement("span");
|
|
clip.className = "cell-clip";
|
|
clip.textContent = item.label;
|
|
label.appendChild(clip);
|
|
const env = document.createElement("code");
|
|
env.className = "settings-env";
|
|
env.textContent = item.env_var;
|
|
nameTd.append(label, env);
|
|
if (item.restart_required) {
|
|
const flag = document.createElement("span");
|
|
flag.className = "settings-flag";
|
|
flag.textContent = "restart required"; // text, never color alone (B5)
|
|
flag.title =
|
|
"Applies on the next restart — the app reads this setting once at boot";
|
|
flag.setAttribute("aria-describedby", "settings-restart-desc");
|
|
nameTd.appendChild(flag);
|
|
}
|
|
tr.appendChild(nameTd);
|
|
|
|
/* The Value cell: the control by type (see makeControl). */
|
|
const valueTd = document.createElement("td");
|
|
valueTd.className = "settings-value-cell";
|
|
const control = makeControl(item);
|
|
valueTd.appendChild(control);
|
|
tr.appendChild(valueTd);
|
|
|
|
/* The Source cell: the literal-text badge (db → "DB", env,
|
|
default — the color is decoration, B5). */
|
|
const srcTd = document.createElement("td");
|
|
const src = document.createElement("span");
|
|
src.className =
|
|
"settings-src " +
|
|
({ db: "is-db", env: "is-env", default: "is-default" })[item.source];
|
|
src.textContent = item.source === "db" ? "DB" : item.source;
|
|
srcTd.appendChild(src);
|
|
tr.appendChild(srcTd);
|
|
|
|
/* The Actions cell: the per-row Reset — disabled with a title
|
|
when there is NOTHING to reset (no saved override) or the
|
|
field is READ-ONLY (LOCKED A3: the engine is built from it /
|
|
the dim is fixed at table creation). */
|
|
const actTd = document.createElement("td");
|
|
actTd.className = "settings-actions-cell";
|
|
const reset = document.createElement("button");
|
|
reset.type = "button";
|
|
reset.className = "settings-reset";
|
|
reset.textContent = "Reset";
|
|
reset.setAttribute("aria-label", `Reset ${item.env_var}`);
|
|
if (!item.overridable) {
|
|
reset.disabled = true;
|
|
reset.title = `read-only — ${item.env_var} is not settable from the database`;
|
|
} else if (!item.has_override) {
|
|
reset.disabled = true;
|
|
reset.title = "no saved override — the environment value is in effect";
|
|
}
|
|
reset.addEventListener("click", () => void resetRow(item.key, item.env_var));
|
|
actTd.appendChild(reset);
|
|
tr.appendChild(actTd);
|
|
|
|
/* Dirty tracking (input + change — the select/change pair). */
|
|
control.addEventListener("input", () => markDirty(item, control));
|
|
control.addEventListener("change", () => markDirty(item, control));
|
|
return tr;
|
|
}
|
|
|
|
/* GET /api/admin/settings → render the groups in the server's
|
|
order (re-entrant: the tbody is REPLACED — never a duplicate
|
|
set — and the no-match row is re-appended). A FAILED load
|
|
returns before the render (the last catalogue stays) and shows
|
|
the house error line. */
|
|
function render(items) {
|
|
if (!tbody) return;
|
|
const rows = [];
|
|
let currentGroup = null;
|
|
for (const item of items) {
|
|
if (item.group !== currentGroup) {
|
|
currentGroup = item.group;
|
|
const groupRow = document.createElement("tr");
|
|
groupRow.className = "settings-group-row";
|
|
const th = document.createElement("th");
|
|
th.colSpan = 4;
|
|
th.scope = "colgroup";
|
|
th.textContent = item.group;
|
|
groupRow.appendChild(th);
|
|
rows.push(groupRow);
|
|
}
|
|
rows.push(makeRow(item));
|
|
}
|
|
rows.push(noMatchRow);
|
|
tbody.replaceChildren(...rows);
|
|
}
|
|
|
|
async function loadSettings() {
|
|
let r;
|
|
try {
|
|
r = await fetch("/api/admin/settings");
|
|
} catch {
|
|
showError("Couldn't load the settings — is the app reachable?");
|
|
return false;
|
|
}
|
|
if (!r.ok) {
|
|
showError("Couldn't load the settings — try again.");
|
|
return false;
|
|
}
|
|
let data;
|
|
try {
|
|
data = await r.json();
|
|
} catch {
|
|
showError("Couldn't load the settings — try again.");
|
|
return false;
|
|
}
|
|
clearError();
|
|
lastItems = data.items;
|
|
/* The kill-switch banner (D6): the mechanism is off, not the
|
|
panel — a save stores the value but it will not take effect. */
|
|
if (disabledBanner) disabledBanner.hidden = data.overlay_enabled;
|
|
render(data.items);
|
|
/* A failed batch keeps the remaining edits in the form (K4):
|
|
re-apply every pending value to the re-rendered control and
|
|
re-mark the row. */
|
|
for (const [key, value] of dirty) {
|
|
const control = root.querySelector("#set-" + key);
|
|
if (!control) continue;
|
|
control.value = value;
|
|
const row = control.closest("tr");
|
|
if (row) row.classList.add("is-dirty");
|
|
}
|
|
applyFilter();
|
|
updateDirtyUI();
|
|
return true;
|
|
}
|
|
|
|
/* ---------- dirty tracking (module state) ---------- */
|
|
|
|
function markDirty(item, control) {
|
|
/* The served value: the effective value in env-native string
|
|
form — for a MASKED field it is "" (an empty input means
|
|
"leave unchanged", never an accidental wipe). */
|
|
const served = item.secret ? "" : item.value ?? "";
|
|
const value = control.value;
|
|
if (value === served) {
|
|
dirty.delete(item.key);
|
|
} else {
|
|
dirty.set(item.key, value);
|
|
}
|
|
updateDirtyUI();
|
|
}
|
|
|
|
function updateDirtyUI() {
|
|
for (const row of dataRows()) {
|
|
row.classList.toggle("is-dirty", dirty.has(row.dataset.key));
|
|
}
|
|
if (dirtyHint) {
|
|
dirtyHint.textContent = dirty.size
|
|
? `${dirty.size} unsaved changes`
|
|
: "";
|
|
}
|
|
if (saveBtn && !saveInFlight) saveBtn.disabled = dirty.size === 0;
|
|
}
|
|
|
|
/* ---------- the client-side search (phase-101 D4 contract) ----------
|
|
* Case-insensitive substring over the label + BOR_* name cells,
|
|
* applied on `input` with NO fetch and re-applied after every
|
|
* load (the query lives in the input — it survives re-shows).
|
|
* A group with no visible rows hides its group row; zero matches
|
|
* with a non-empty query → the one no-match row (textContent). */
|
|
function applyFilter() {
|
|
const query = (filter ? filter.value : "").trim().toLowerCase();
|
|
const children = tbody ? [...tbody.children] : [];
|
|
let visible = 0;
|
|
let currentGroupRow = null;
|
|
const groupVisible = new Map();
|
|
for (const tr of children) {
|
|
if (tr === noMatchRow) continue; // the state row is never a data row
|
|
if (tr.classList.contains("settings-group-row")) {
|
|
currentGroupRow = tr;
|
|
groupVisible.set(tr, false);
|
|
continue;
|
|
}
|
|
const nameCell = tr.querySelector(".settings-name-cell");
|
|
const labelEl = nameCell ? nameCell.querySelector("label") : null;
|
|
const envEl = nameCell
|
|
? nameCell.querySelector(".settings-env")
|
|
: null;
|
|
const haystack =
|
|
((labelEl ? labelEl.textContent : "") +
|
|
" " +
|
|
(envEl ? envEl.textContent : "")).toLowerCase();
|
|
const show = query === "" || haystack.includes(query);
|
|
tr.hidden = !show;
|
|
if (show) visible += 1;
|
|
if (currentGroupRow) groupVisible.set(currentGroupRow, true);
|
|
}
|
|
for (const [groupRow, hasVisible] of groupVisible) {
|
|
groupRow.hidden = !hasVisible;
|
|
}
|
|
const noMatch = query !== "" && visible === 0;
|
|
noMatchRow.hidden = !noMatch;
|
|
if (noMatch) {
|
|
noMatchTd.textContent = `No settings match "${
|
|
filter ? filter.value.trim() : ""
|
|
}".`;
|
|
}
|
|
}
|
|
|
|
/* ---------- the live brand re-apply (phase 190, task 03 — A2) ----------
|
|
* The phase-168 header contract is "the change should take effect on
|
|
* clicking 'save'" — for the three BRAND strings that means the OPEN
|
|
* document too, not just the catalogue. One pass, ONE owner:
|
|
* brand.js exports the boot's own function as
|
|
* window.applyBrandFromConfig, and this helper feeds it a fresh
|
|
* GET /api/config (the same no-store request the boot makes).
|
|
*
|
|
* • gated on the key — a save / Reset of any of the ~72 other rows
|
|
* costs NO extra request and no DOM churn; Clear all passes all
|
|
* three because clearing hands every one of them back to the
|
|
* Theme-tab row or the environment;
|
|
* • the §7.4 guard, EXTENDED not forked — the save itself is already
|
|
* inside `saveInFlight` (this helper is awaited before that flag
|
|
* drops, so a second Save can never start mid-re-apply) and the
|
|
* one flag below covers the settle paths that share the pass, so a
|
|
* second save / Reset / Clear all never stacks a second fetch;
|
|
* • never a blanked brand — a non-2xx or an unreachable app returns
|
|
* WITHOUT touching the DOM (the phase-168 "never a blanked panel"
|
|
* precedent): the open page keeps its last applied name, footer
|
|
* and placeholder, and the save's own status / error line stays
|
|
* the visibility. */
|
|
const BRAND_KEYS = ["app_name", "input_placeholder", "footer_text"];
|
|
let brandReapplyInFlight = false;
|
|
|
|
async function reapplyBrand(keys) {
|
|
if (!keys.some((key) => BRAND_KEYS.includes(key))) return; // not a brand row
|
|
if (brandReapplyInFlight) return; // one re-apply at a time (§7.4)
|
|
if (typeof window.applyBrandFromConfig !== "function") return; // brand.js is the owner
|
|
brandReapplyInFlight = true;
|
|
try {
|
|
const r = await fetch("/api/config", { cache: "no-store" });
|
|
if (!r.ok) return; // the brand keeps its last applied state
|
|
window.applyBrandFromConfig(await r.json());
|
|
} catch {
|
|
/* unreachable app or a non-JSON body — nothing is written (the
|
|
brand never blanks; the settle path's own line says it) */
|
|
} finally {
|
|
brandReapplyInFlight = false;
|
|
}
|
|
}
|
|
|
|
/* ---------- Save (dirty rows only — stop at the first failure,
|
|
K4; the §7.4 never-stale lifecycle) ---------- */
|
|
async function saveSettings() {
|
|
if (!dirty.size || saveInFlight) return;
|
|
saveInFlight = true;
|
|
if (saveBtn) {
|
|
saveBtn.disabled = true;
|
|
saveBtn.setAttribute("aria-busy", "true");
|
|
saveBtn.textContent = "Saving…";
|
|
}
|
|
clearError();
|
|
const saved = [];
|
|
let failure = null;
|
|
for (const row of dataRows()) {
|
|
const key = row.dataset.key;
|
|
if (!key || !dirty.has(key)) continue;
|
|
const value = dirty.get(key);
|
|
try {
|
|
const r = await fetch(`/api/admin/settings/${key}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ value }),
|
|
});
|
|
if (!r.ok) {
|
|
failure = { key, r };
|
|
break; // stop at the first failure (K4)
|
|
}
|
|
saved.push(key);
|
|
dirty.delete(key);
|
|
} catch {
|
|
failure = { key, r: null };
|
|
break; // the app is unreachable — stop
|
|
}
|
|
}
|
|
if (failure) {
|
|
/* The 422 detail (it names the field) or the house network
|
|
line; the catalogue re-GETs first (the badges show the
|
|
truth, the remaining edits are re-applied — K4), and the
|
|
error is shown AFTER the re-GET (the re-GET's clearError
|
|
must not wipe it — the previous success line is cleared
|
|
with it, so the stale "Saved N settings." never lingers,
|
|
the §7.4 never-stale contract). Focus goes to the
|
|
offending input. */
|
|
await loadSettings();
|
|
if (statusEl) statusEl.textContent = "";
|
|
showError(
|
|
failure.r
|
|
? await apiDetail(failure.r, "Couldn't save the settings — try again.")
|
|
: "Couldn't save the settings — is the app reachable?",
|
|
);
|
|
const input = root.querySelector("#set-" + failure.key);
|
|
if (input) input.focus();
|
|
/* A brand row that DID land before the failure changed the app
|
|
(K4 saves the prefix) — the open document re-reads it too. */
|
|
await reapplyBrand(saved);
|
|
} else {
|
|
/* One status line (role=status) — a saved restart-required
|
|
row says so (the owner is never told "saved" and then sees
|
|
nothing change). */
|
|
const anyRestart = saved.some(
|
|
(key) => (lastItems.find((i) => i.key === key) || {}).restart_required,
|
|
);
|
|
announce(
|
|
`Saved ${saved.length} settings.` +
|
|
(anyRestart ? " — applies on the next restart" : ""),
|
|
);
|
|
await loadSettings(); // every badge/value is the settled state
|
|
/* …and the brand strings apply to the OPEN document (no reload) —
|
|
additive to the catalogue re-GET above, still inside the
|
|
saveInFlight guard (phase 190, task 03). */
|
|
await reapplyBrand(saved);
|
|
}
|
|
saveInFlight = false;
|
|
if (saveBtn) {
|
|
saveBtn.removeAttribute("aria-busy");
|
|
saveBtn.textContent = SAVE_LABEL;
|
|
saveBtn.disabled = dirty.size === 0;
|
|
}
|
|
}
|
|
|
|
/* ---------- the per-row Reset (DELETE the row → env wins) ---------- */
|
|
async function resetRow(key, envVar) {
|
|
let r;
|
|
try {
|
|
r = await fetch(`/api/admin/settings/${key}`, { method: "DELETE" });
|
|
} catch {
|
|
announce(`Couldn't reset ${envVar} — is the app reachable?`);
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
announce(`Couldn't reset ${envVar} — try again.`);
|
|
return;
|
|
}
|
|
announce(`${envVar} is back to the environment value.`);
|
|
await loadSettings(); // the badge falls back to env/default
|
|
await reapplyBrand([key]); // …and the open document falls back too
|
|
}
|
|
|
|
/* ---------- Clear all overrides (the house two-step inline
|
|
confirm — the history-confirm-* classes; focus to Yes; Escape
|
|
/ No cancels) ---------- */
|
|
function restoreClearAll() {
|
|
if (clearAllEsc) {
|
|
document.removeEventListener("keydown", clearAllEsc, true);
|
|
clearAllEsc = null;
|
|
}
|
|
const wrap = root.querySelector(".settings-confirm");
|
|
if (wrap) wrap.replaceWith(clearAllBtn);
|
|
if (clearAllBtn) clearAllBtn.focus();
|
|
}
|
|
|
|
async function confirmClearAll(yesBtn, restore) {
|
|
yesBtn.disabled = true; // no double-fire while the request is in flight
|
|
let cleared = null;
|
|
try {
|
|
const r = await fetch("/api/admin/settings", { method: "DELETE" });
|
|
if (!r.ok) {
|
|
announce("Couldn't clear the overrides — try again.");
|
|
restore();
|
|
return;
|
|
}
|
|
const data = await r.json();
|
|
cleared = data.cleared;
|
|
} catch {
|
|
announce("Couldn't clear the overrides — is the app reachable?");
|
|
restore();
|
|
return;
|
|
}
|
|
restore();
|
|
announce(`Cleared ${cleared} overrides.`);
|
|
await loadSettings(); // every badge falls back to env/default
|
|
await reapplyBrand(BRAND_KEYS); // every brand string may have moved
|
|
}
|
|
|
|
function showClearAllConfirm() {
|
|
if (!clearAllBtn) return;
|
|
const wrap = document.createElement("span");
|
|
wrap.className = "settings-confirm";
|
|
const label = document.createElement("span");
|
|
label.className = "history-confirm-text";
|
|
label.textContent = "Clear all overrides?";
|
|
const yes = document.createElement("button");
|
|
yes.type = "button";
|
|
yes.className = "history-confirm-yes";
|
|
yes.textContent = "Yes";
|
|
const no = document.createElement("button");
|
|
no.type = "button";
|
|
no.className = "history-confirm-no";
|
|
no.textContent = "No";
|
|
yes.addEventListener("click", () => void confirmClearAll(yes, restoreClearAll));
|
|
no.addEventListener("click", restoreClearAll);
|
|
wrap.append(label, yes, no);
|
|
clearAllBtn.replaceWith(wrap);
|
|
yes.focus(); // the confirm pair takes over the focus
|
|
/* Escape cancels (the task contract — the capture phase so the
|
|
page's own Esc handlers never race it). */
|
|
clearAllEsc = (e) => {
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
restoreClearAll();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", clearAllEsc, true);
|
|
}
|
|
|
|
/* ---------- view boot (phase 168 task 05) ----------
|
|
* The shared header is NOT booted here — in the shell it runs
|
|
* exactly once, via the chat module (app.js) at shell boot. The
|
|
* gate reads fetchIsAdmin() — the SAME cached whoami promise the
|
|
* header uses (zero extra requests). Anonymous / token-user: the
|
|
* gate in, the content out — and NO /api/admin/settings request
|
|
* at all (the router 403s them — the phase-79 contract). */
|
|
if (!(await fetchIsAdmin())) {
|
|
if (gateEl) gateEl.hidden = false;
|
|
if (contentEl) contentEl.hidden = true;
|
|
return;
|
|
}
|
|
if (gateEl) gateEl.hidden = true;
|
|
if (contentEl) contentEl.hidden = false;
|
|
|
|
/* Bindings — armed in the ADMIN branch only (like the refresh
|
|
listener): Save (the §7.4 lifecycle), Clear all (the two-step
|
|
confirm), the client-side search (NO fetch). */
|
|
if (saveBtn) saveBtn.addEventListener("click", () => void saveSettings());
|
|
if (clearAllBtn) clearAllBtn.addEventListener("click", showClearAllConfirm);
|
|
if (filter) filter.addEventListener("input", () => applyFilter());
|
|
|
|
/* Phase 77 (the re-show refresh contract): a user-initiated
|
|
re-show of this already-mounted view makes the router dispatch
|
|
bor:view-refresh on the section — re-run the load then (the
|
|
tab always shows the settled server state when re-shown; the
|
|
search query survives — it lives in the input). Armed ONLY
|
|
here, after the whoami gate passed. `started` flips true once
|
|
the first loadSettings() is made, so the listener can only
|
|
ever re-run a load the mount already did. */
|
|
let started = false;
|
|
root.addEventListener("bor:view-refresh", () => {
|
|
if (started) void loadSettings();
|
|
});
|
|
started = true;
|
|
void loadSettings(); // the first load (the catalogue)
|
|
}
|