(() => { const el = (id) => document.getElementById(id); const loginScreenEl = el("loginScreen"); const shellEl = document.querySelector(".shell"); const loginFormEl = el("loginForm"); const loginPanelPasswordEl = el("loginPanelPassword"); const loginPanelPinEl = el("loginPanelPin"); const loginKeypadEl = el("loginKeypad"); const loginPinHiddenEl = el("loginPin"); const loginErrorEl = el("loginError"); const loginPinErrorEl = el("loginPinError"); const loginTabPasswordEl = el("loginTabPassword"); const loginTabPinEl = el("loginTabPin"); const userMenuBtnEl = el("mirUserBtn"); const userMenuPanelEl = el("mirUserPanel"); const changePasswordDialogEl = el("changePasswordDialog"); const changePasswordFormEl = el("changePasswordForm"); const changePasswordErrorEl = el("changePasswordError"); let currentUser = null; let ready = false; let loginMode = "password"; let pinDigits = []; let pinSubmitting = false; const t = (key, vars) => window.I18n?.t(key, vars) ?? key; async function apiJson(path, opts = {}) { const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!res.ok) { const msg = (data && data.error) || text || res.statusText; throw new Error(msg); } return data; } function showError(msg, mode = loginMode) { const target = mode === "pin" ? loginPinErrorEl : loginErrorEl; const other = mode === "pin" ? loginErrorEl : loginPinErrorEl; if (other) { other.textContent = ""; other.setAttribute("hidden", ""); } if (!target) return; target.textContent = msg || ""; if (msg) target.removeAttribute("hidden"); else target.setAttribute("hidden", ""); } function renderPinCells() { document.querySelectorAll(".loginPinCell").forEach((cell, index) => { cell.classList.toggle("filled", index < pinDigits.length); cell.classList.toggle("active", index === pinDigits.length && pinDigits.length < 4); }); } function resetPin() { pinDigits = []; pinSubmitting = false; if (loginPinHiddenEl) loginPinHiddenEl.value = ""; renderPinCells(); showError("", "pin"); } async function submitPinFromKeypad() { if (pinSubmitting || pinDigits.length !== 4) return; pinSubmitting = true; setLoginLoading(true); showError("", "pin"); try { await loginPin(pinDigits.join("")); } catch (e) { const msg = String(e.message || ""); if (msg.includes("invalid pin") || msg.includes("401")) { showError(t("login.error.invalidPin"), "pin"); } else { showError(msg || t("login.error.invalidPinShort"), "pin"); } resetPin(); setLoginLoading(false); } } function appendPinDigit(digit) { if (pinSubmitting || pinDigits.length >= 4) return; pinDigits.push(digit); if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join(""); renderPinCells(); if (pinDigits.length === 4) submitPinFromKeypad(); } function backspacePin() { if (pinSubmitting) return; pinDigits.pop(); if (loginPinHiddenEl) loginPinHiddenEl.value = pinDigits.join(""); renderPinCells(); showError("", "pin"); } function setLoginLoading(loading) { loginScreenEl?.classList.toggle("is-loading", loading); document.querySelectorAll(".loginSubmitLabel").forEach((label) => { label.textContent = loading ? t("login.submitting") : t("login.submit"); }); } function setLoginMode(mode) { loginMode = mode; const pin = mode === "pin"; loginPanelPasswordEl?.toggleAttribute("hidden", pin); loginPanelPinEl?.toggleAttribute("hidden", !pin); loginTabPasswordEl?.classList.toggle("active", !pin); loginTabPinEl?.classList.toggle("active", pin); loginTabPasswordEl?.setAttribute("aria-selected", pin ? "false" : "true"); loginTabPinEl?.setAttribute("aria-selected", pin ? "true" : "false"); showError("", "password"); showError("", "pin"); if (pin) { resetPin(); renderPinCells(); } } function permissionLevel(resource) { const perms = currentUser?.permissions || {}; return perms[resource] || "none"; } function canAccessPage(page) { const map = { dashboard: "dashboard", config: "config", missions: "missions", integrations: "integrations", }; const resource = map[page]; if (!resource) return true; return permissionLevel(resource) !== "none"; } function canWrite(resource) { return permissionLevel(resource) === "write"; } function applyNavPermissions() { if (window.NavApp?.applyPermissions) { window.NavApp.applyPermissions(); } document.body.classList.toggle("auth-readonly-config", !canWrite("config")); document.body.classList.toggle("auth-readonly-missions", !canWrite("missions")); document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations")); } function updateUserMenu() { if (!currentUser) return; if (window.TopbarApp?.updateUserMenu) { window.TopbarApp.updateUserMenu(currentUser); return; } if (userMenuBtnEl) { const label = (currentUser.group_name || "USER").toUpperCase(); userMenuBtnEl.title = `${currentUser.display_name || currentUser.username} (${currentUser.group_name || ""})`; const labelEl = el("mirUserLabel"); if (labelEl) labelEl.textContent = label; } } function unlockApp() { setLoginLoading(false); document.body.classList.remove("auth-logged-out"); if (loginScreenEl) { loginScreenEl.setAttribute("hidden", ""); loginScreenEl.style.display = "none"; } if (shellEl) { shellEl.classList.remove("auth-locked"); shellEl.style.display = ""; } applyNavPermissions(); ready = true; window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } })); updateUserMenu(); } function lockApp() { ready = false; currentUser = null; document.body.classList.add("auth-logged-out"); window.TopbarApp?.hideJoystickOverlay?.(); if (shellEl) shellEl.classList.add("auth-locked"); if (loginScreenEl) { loginScreenEl.removeAttribute("hidden"); loginScreenEl.style.display = ""; } showError("", "password"); showError("", "pin"); resetPin(); } async function tryRestoreSession() { try { const data = await apiJson("/api/auth/me"); currentUser = data.user; unlockApp(); return true; } catch { lockApp(); return false; } } async function loginPassword(username, password) { showError("", "password"); const data = await apiJson("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }), }); currentUser = data.user; unlockApp(); } async function loginPin(pin) { showError("", "pin"); const data = await apiJson("/api/auth/login", { method: "POST", body: JSON.stringify({ pin }), }); currentUser = data.user; unlockApp(); } async function logout() { try { await window.TopbarApp?.disengageJoystick?.(); } catch { /* ignore */ } window.TopbarApp?.hideJoystickOverlay?.(); try { await apiJson("/api/auth/logout", { method: "POST", body: "{}" }); } catch { /* ignore */ } currentUser = null; ready = false; lockApp(); userMenuPanelEl?.setAttribute("hidden", ""); window.dispatchEvent(new Event("lm:auth-logout")); } async function saveProfile() { const display_name = el("mirProfileDisplayName")?.value?.trim() || ""; if (!display_name) throw new Error(t("auth.profile.displayNameRequired")); const data = await apiJson("/api/auth/profile", { method: "PUT", body: JSON.stringify({ display_name }), }); currentUser = data.user; updateUserMenu(); } function bindEvents() { loginTabPasswordEl?.addEventListener("click", (evt) => { evt.preventDefault(); setLoginMode("password"); }); loginTabPinEl?.addEventListener("click", (evt) => { evt.preventDefault(); setLoginMode("pin"); }); loginFormEl?.addEventListener("submit", async (evt) => { evt.preventDefault(); const username = el("loginUsername")?.value?.trim() || ""; const password = el("loginPasswordInput")?.value || ""; if (!username || !password) { showError(t("login.error.missingCredentials"), "password"); return; } setLoginLoading(true); showError("", "password"); try { await loginPassword(username, password); } catch (e) { const msg = String(e.message || ""); if (msg.includes("credentials") || msg.includes("401")) { showError(t("login.error.badCredentials"), "password"); } else if (msg.includes("fetch") || msg.includes("Failed")) { showError(t("login.error.serverUnreachable"), "password"); } else { showError(msg || t("login.error.failed"), "password"); } setLoginLoading(false); } }); loginKeypadEl?.addEventListener("click", (evt) => { const btn = evt.target.closest("[data-key]"); if (!btn || pinSubmitting) return; const key = btn.getAttribute("data-key"); if (key === "back") { backspacePin(); return; } if (/^[0-9]$/.test(key)) appendPinDigit(key); }); document.addEventListener("keydown", (evt) => { if (loginMode !== "pin" || pinSubmitting) return; if (/^[0-9]$/.test(evt.key)) { evt.preventDefault(); appendPinDigit(evt.key); } else if (evt.key === "Backspace") { evt.preventDefault(); backspacePin(); } }); el("mirUserSignOutBtn")?.addEventListener("click", (evt) => { evt.preventDefault(); logout(); }); el("mirUserChangePasswordBtn")?.addEventListener("click", (evt) => { evt.preventDefault(); userMenuPanelEl?.setAttribute("hidden", ""); changePasswordErrorEl && (changePasswordErrorEl.textContent = ""); changePasswordDialogEl?.showModal(); }); el("mirProfileSaveBtn")?.addEventListener("click", async (evt) => { evt.preventDefault(); try { await saveProfile(); userMenuPanelEl?.setAttribute("hidden", ""); } catch (e) { alert(e.message || t("auth.profile.saveFailed")); } }); changePasswordFormEl?.addEventListener("submit", async (evt) => { evt.preventDefault(); const current = el("changePasswordCurrent")?.value || ""; const next = el("changePasswordNew")?.value || ""; const confirm = el("changePasswordConfirm")?.value || ""; if (next !== confirm) { if (changePasswordErrorEl) changePasswordErrorEl.textContent = t("auth.changePassword.mismatch"); return; } try { await apiJson("/api/auth/password", { method: "PUT", body: JSON.stringify({ current_password: current, new_password: next }), }); changePasswordDialogEl?.close(); changePasswordFormEl.reset(); } catch (e) { if (changePasswordErrorEl) changePasswordErrorEl.textContent = e.message || t("auth.changePassword.failed"); } }); } window.AuthApp = { isReady: () => ready, getUser: () => currentUser, canAccessPage, canWrite, logout, whenReady(fn) { if (ready) fn(currentUser); else window.addEventListener("lm:auth-ready", () => fn(currentUser), { once: true }); }, }; bindEvents(); window.addEventListener("lm:locale-change", () => { const loading = loginScreenEl?.classList.contains("is-loading"); setLoginLoading(loading); }); setLoginMode("password"); shellEl?.classList.add("auth-locked"); if (window.location.search) { try { history.replaceState({}, "", window.location.pathname); } catch { /* ignore */ } } tryRestoreSession(); })();