414 lines
12 KiB
JavaScript
414 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",
|
|
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();
|
|
})();
|