Files
App/www/auth.js
HiepLM 365a15c32a
Some checks are pending
Test / test (push) Waiting to run
update full objects type
2026-06-20 11:43:48 +02:00

415 lines
12 KiB
JavaScript

(() => {
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 isDistributor() {
return currentUser?.group_id === "group_distributors";
}
function canAccessPage(page) {
if (page === "config") return isDistributor();
const map = {
dashboard: "dashboard",
maps: "maps",
missions: "missions",
sounds: "integrations",
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-maps", !canWrite("maps"));
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");
if (!data?.user) {
lockApp();
return false;
}
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();
})();