Off by default. Once a PIN is set in Settings -> System & backup, every non-GET /api/* request (plus GET /api/ammc/config) needs a signed 30-day HttpOnly cookie, which also closes the unauthenticated /api/update/run restart trigger. A 401 opens a PIN dialog and the pending save is retried after login. Reads stay open because the public overlay hydrates through GET /api/state. Optional RC_ADMIN_ALLOWED_CIDRS restricts the same routes by client address. The PIN hash and signing secret live in data/, outside the app_state blob, so they never leak through GET /api/state, exports or backups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
5.1 KiB
JavaScript
110 lines
5.1 KiB
JavaScript
// Admin PIN UI: a login dialog shown whenever the backend answers 401 auth_required,
|
|
// and the PIN panel under Settings -> System & backup.
|
|
let dialogOpen = false;
|
|
|
|
export function installAuthFetchGuard({ onAuthRequired }) {
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = async (input, init) => {
|
|
const response = await originalFetch(input, init);
|
|
if (response.status === 401) {
|
|
const path = new URL(typeof input === "string" ? input : input.url, window.location.href).pathname;
|
|
if (path.startsWith("/api/") && !path.startsWith("/api/auth/")) {
|
|
const body = await response.clone().json().catch(() => ({}));
|
|
if (body.error === "auth_required") onAuthRequired();
|
|
}
|
|
}
|
|
return response;
|
|
};
|
|
}
|
|
|
|
async function postJson(path, body) {
|
|
const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) });
|
|
return { status: response.status, body: await response.json().catch(() => ({})) };
|
|
}
|
|
|
|
export function showPinDialog({ t, escapeHtml, onSuccess }) {
|
|
if (dialogOpen) return;
|
|
dialogOpen = true;
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "modal-overlay";
|
|
overlay.innerHTML = `
|
|
<form class="modal-card auth-dialog" id="authLoginForm">
|
|
<h3>${escapeHtml(t("auth.login_title"))}</h3>
|
|
<p class="hint">${escapeHtml(t("auth.login_hint"))}</p>
|
|
<input type="password" name="pin" inputmode="numeric" autocomplete="current-password" required />
|
|
<p class="error" id="authLoginError" hidden></p>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" type="submit">${escapeHtml(t("auth.login"))}</button>
|
|
<button class="btn" type="button" id="authLoginCancel">${escapeHtml(t("auth.cancel"))}</button>
|
|
</div>
|
|
</form>`;
|
|
document.body.appendChild(overlay);
|
|
const close = () => { overlay.remove(); dialogOpen = false; };
|
|
const error = overlay.querySelector("#authLoginError");
|
|
overlay.querySelector("input").focus();
|
|
overlay.querySelector("#authLoginCancel").addEventListener("click", close);
|
|
overlay.querySelector("form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const { status } = await postJson("/api/auth/login", { pin: new FormData(event.currentTarget).get("pin") });
|
|
if (status === 200) {
|
|
close();
|
|
onSuccess?.();
|
|
return;
|
|
}
|
|
error.textContent = t(status === 429 ? "auth.too_many" : "auth.wrong_pin");
|
|
error.hidden = false;
|
|
});
|
|
}
|
|
|
|
export async function mountAuthSettings(container, { t, escapeHtml }) {
|
|
if (!container) return;
|
|
const status = await fetch("/api/auth/status").then((res) => res.json()).catch(() => null);
|
|
if (!status) {
|
|
container.innerHTML = `<p class="error">${escapeHtml(t("auth.status_error"))}</p>`;
|
|
return;
|
|
}
|
|
const remount = () => mountAuthSettings(container, { t, escapeHtml });
|
|
const stateText = !status.enabled ? t("auth.state_off") : status.authenticated ? t("auth.state_on_logged_in") : t("auth.state_on_logged_out");
|
|
container.innerHTML = `
|
|
<p><strong>${escapeHtml(stateText)}</strong></p>
|
|
<p class="hint">${escapeHtml(t("auth.hint"))}</p>
|
|
<form id="authPinForm" class="form-grid cols-2">
|
|
<label>${escapeHtml(t(status.enabled ? "auth.new_pin_change" : "auth.new_pin"))}<input type="password" name="newPin" minlength="4" autocomplete="new-password" required /></label>
|
|
<label>${escapeHtml(t("auth.confirm_pin"))}<input type="password" name="confirmPin" minlength="4" autocomplete="new-password" required /></label>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" type="submit">${escapeHtml(t(status.enabled ? "auth.change_pin" : "auth.enable"))}</button>
|
|
${status.enabled ? `<button class="btn" type="button" id="authLogout">${escapeHtml(t("auth.logout"))}</button>
|
|
<button class="btn btn-danger" type="button" id="authDisable">${escapeHtml(t("auth.disable"))}</button>` : ""}
|
|
</div>
|
|
<p class="error" id="authPinError" hidden></p>
|
|
</form>`;
|
|
const error = container.querySelector("#authPinError");
|
|
container.querySelector("#authPinForm").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const form = new FormData(event.currentTarget);
|
|
if (form.get("newPin") !== form.get("confirmPin")) {
|
|
error.textContent = t("auth.pin_mismatch");
|
|
error.hidden = false;
|
|
return;
|
|
}
|
|
const { status: code, body } = await postJson("/api/auth/pin", { newPin: form.get("newPin") });
|
|
if (code !== 200) {
|
|
error.textContent = code === 401 ? t("auth.login_required") : body.error || t("auth.status_error");
|
|
error.hidden = false;
|
|
return;
|
|
}
|
|
remount();
|
|
});
|
|
container.querySelector("#authLogout")?.addEventListener("click", async () => { await postJson("/api/auth/logout"); remount(); });
|
|
container.querySelector("#authDisable")?.addEventListener("click", async () => {
|
|
if (!window.confirm(t("auth.disable_confirm"))) return;
|
|
const { status: code } = await postJson("/api/auth/disable");
|
|
if (code === 401) {
|
|
error.textContent = t("auth.login_required");
|
|
error.hidden = false;
|
|
return;
|
|
}
|
|
remount();
|
|
});
|
|
}
|