This commit is contained in:
372
www/auth.js
Normal file
372
www/auth.js
Normal file
@@ -0,0 +1,372 @@
|
||||
(() => {
|
||||
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("userMenuBtn");
|
||||
const userMenuPanelEl = el("userMenuPanel");
|
||||
const userMenuNameEl = el("userMenuName");
|
||||
const userMenuGroupEl = el("userMenuGroup");
|
||||
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;
|
||||
|
||||
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("Mã PIN không hợp lệ. Liên hệ quản trị viên.", "pin");
|
||||
} else {
|
||||
showError(msg || "Mã PIN không hợp lệ", "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 ? "Đang đăng nhập…" : "Đăng nhập";
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
document.querySelectorAll(".navItem[data-page]").forEach((a) => {
|
||||
const page = a.dataset.page || "";
|
||||
const allowed = canAccessPage(page);
|
||||
a.hidden = !allowed;
|
||||
a.style.display = allowed ? "" : "none";
|
||||
});
|
||||
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 (userMenuNameEl) userMenuNameEl.textContent = currentUser.display_name || currentUser.username || "—";
|
||||
if (userMenuGroupEl) userMenuGroupEl.textContent = currentUser.group_name || "—";
|
||||
if (userMenuBtnEl) {
|
||||
const label = currentUser.display_name || currentUser.username || "User";
|
||||
userMenuBtnEl.textContent = label;
|
||||
userMenuBtnEl.title = `${label} (${currentUser.group_name || ""})`;
|
||||
}
|
||||
}
|
||||
|
||||
function unlockApp() {
|
||||
setLoginLoading(false);
|
||||
if (loginScreenEl) {
|
||||
loginScreenEl.setAttribute("hidden", "");
|
||||
loginScreenEl.style.display = "none";
|
||||
}
|
||||
if (shellEl) {
|
||||
shellEl.classList.remove("auth-locked");
|
||||
shellEl.style.display = "";
|
||||
}
|
||||
applyNavPermissions();
|
||||
updateUserMenu();
|
||||
ready = true;
|
||||
window.dispatchEvent(new CustomEvent("lm:auth-ready", { detail: { user: currentUser } }));
|
||||
}
|
||||
|
||||
function lockApp() {
|
||||
ready = false;
|
||||
currentUser = null;
|
||||
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 apiJson("/api/auth/logout", { method: "POST", body: "{}" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
currentUser = null;
|
||||
ready = false;
|
||||
lockApp();
|
||||
userMenuPanelEl?.setAttribute("hidden", "");
|
||||
window.dispatchEvent(new Event("lm:auth-logout"));
|
||||
}
|
||||
|
||||
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("Nhập tên đăng nhập và mật khẩu", "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("Sai tên đăng nhập hoặc mật khẩu. Thử Admin / admin", "password");
|
||||
} else if (msg.includes("fetch") || msg.includes("Failed")) {
|
||||
showError("Không kết nối được server. Kiểm tra http://localhost:8080", "password");
|
||||
} else {
|
||||
showError(msg || "Đăng nhập thất bại", "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();
|
||||
}
|
||||
});
|
||||
|
||||
userMenuBtnEl?.addEventListener("click", (evt) => {
|
||||
evt.stopPropagation();
|
||||
const open = userMenuPanelEl?.hasAttribute("hidden");
|
||||
if (open) userMenuPanelEl?.removeAttribute("hidden");
|
||||
else userMenuPanelEl?.setAttribute("hidden", "");
|
||||
});
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
userMenuPanelEl?.setAttribute("hidden", "");
|
||||
});
|
||||
|
||||
el("userMenuSignOutBtn")?.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
logout();
|
||||
});
|
||||
|
||||
el("userMenuChangePasswordBtn")?.addEventListener("click", (evt) => {
|
||||
evt.preventDefault();
|
||||
userMenuPanelEl?.setAttribute("hidden", "");
|
||||
changePasswordErrorEl && (changePasswordErrorEl.textContent = "");
|
||||
changePasswordDialogEl?.showModal();
|
||||
});
|
||||
|
||||
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 = "Mật khẩu mới không khớp";
|
||||
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 || "Đổi mật khẩu thất bại";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
setLoginMode("password");
|
||||
shellEl?.classList.add("auth-locked");
|
||||
tryRestoreSession();
|
||||
})();
|
||||
Reference in New Issue
Block a user