This commit is contained in:
42
www/app.js
42
www/app.js
@@ -123,6 +123,10 @@ const state = {
|
||||
function setActivePage(page) {
|
||||
const valid = ["dashboard", "config", "missions", "integrations"];
|
||||
let p = valid.includes(page) ? page : "config";
|
||||
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
|
||||
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
|
||||
p = fallback || "dashboard";
|
||||
}
|
||||
if (page === "overview") p = "dashboard";
|
||||
navItemEls.forEach((a) => {
|
||||
const on = (a.dataset.page || "") === p;
|
||||
@@ -562,6 +566,7 @@ function setStatus(msg) {
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
@@ -3359,7 +3364,8 @@ function initRobotModelPanelCollapse() {
|
||||
}
|
||||
|
||||
initLayoutManagerEvents();
|
||||
initNavigation();
|
||||
if (window.AuthApp?.isReady()) initNavigation();
|
||||
else window.addEventListener("lm:auth-ready", () => initNavigation(), { once: true });
|
||||
initSplitPane();
|
||||
initLidarForm();
|
||||
initMotorWheelsEvents();
|
||||
@@ -3402,21 +3408,25 @@ saveLayoutBtn.addEventListener("click", async () => {
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await api("/api/health");
|
||||
await loadMotorCatalog();
|
||||
await loadAll();
|
||||
selectedText.textContent = "none";
|
||||
selectedRelText.textContent = "—";
|
||||
setStatus("Sẵn sàng");
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e);
|
||||
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
|
||||
if (msg.includes("stack") || msg.includes("Maximum call")) {
|
||||
setStatus(`Lỗi JavaScript: ${msg}`);
|
||||
} else {
|
||||
setStatus(`Không kết nối được backend: ${msg}`);
|
||||
const boot = async () => {
|
||||
try {
|
||||
await api("/api/health");
|
||||
await loadMotorCatalog();
|
||||
await loadAll();
|
||||
selectedText.textContent = "none";
|
||||
selectedRelText.textContent = "—";
|
||||
setStatus("Sẵn sàng");
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e);
|
||||
if (overviewBackendEl) overviewBackendEl.textContent = `Lỗi: ${msg}`;
|
||||
if (msg.includes("stack") || msg.includes("Maximum call")) {
|
||||
setStatus(`Lỗi JavaScript: ${msg}`);
|
||||
} else {
|
||||
setStatus(`Không kết nối được backend: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (window.AuthApp?.isReady()) await boot();
|
||||
else window.AuthApp?.whenReady(() => { boot(); });
|
||||
})();
|
||||
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -349,6 +349,7 @@
|
||||
}
|
||||
|
||||
function startDashboardPoll() {
|
||||
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
||||
stopDashboardPoll();
|
||||
missions()?.refreshQueue?.();
|
||||
store.queueUnsub = missions()?.onQueueUpdate?.(() => refreshDynamicWidgets());
|
||||
@@ -386,5 +387,10 @@
|
||||
},
|
||||
};
|
||||
|
||||
init();
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
window.addEventListener("lm:auth-logout", stopDashboardPoll);
|
||||
})();
|
||||
|
||||
123
www/index.html
123
www/index.html
@@ -7,7 +7,88 @@
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div id="loginScreen" class="loginScreen">
|
||||
<div class="loginFrame">
|
||||
<header class="loginHeader">
|
||||
<div class="loginHeaderBrand" id="loginRobotLabel">RobotApp</div>
|
||||
<div class="loginHeaderRight">
|
||||
<span class="loginHeaderPrompt">Chọn cách đăng nhập:</span>
|
||||
<div class="loginTabs" role="tablist">
|
||||
<button id="loginTabPassword" type="button" class="loginTab active" role="tab" aria-selected="true">
|
||||
Tên đăng nhập và mật khẩu
|
||||
</button>
|
||||
<button id="loginTabPin" type="button" class="loginTab" role="tab" aria-selected="false">
|
||||
Mã PIN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="loginCard">
|
||||
<div id="loginPanelPassword" class="loginPanel">
|
||||
<div id="loginHelpPassword" class="loginHelp">
|
||||
<h2 class="loginHelpTitle">Đăng nhập bằng tên và mật khẩu</h2>
|
||||
<p>Nhập tên đăng nhập và mật khẩu để truy cập robot.</p>
|
||||
<p>Tài khoản do quản trị viên cấp hoặc xem trong tài liệu hướng dẫn robot.</p>
|
||||
<p>Nếu chưa có tài khoản, vui lòng liên hệ quản trị viên robot.</p>
|
||||
</div>
|
||||
<div class="loginForms">
|
||||
<form id="loginForm" class="loginForm" action="#" method="post">
|
||||
<label class="loginField">
|
||||
<span class="loginFieldLabel">Tên đăng nhập:</span>
|
||||
<input id="loginUsername" name="username" type="text" autocomplete="username" placeholder="Admin" required />
|
||||
</label>
|
||||
<label class="loginField">
|
||||
<span class="loginFieldLabel">Mật khẩu:</span>
|
||||
<input id="loginPasswordInput" name="password" type="password" autocomplete="current-password" placeholder="" required />
|
||||
</label>
|
||||
<button type="submit" class="loginSubmit" data-mode="password">
|
||||
<svg class="loginSubmitIcon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12.65 10A5.99 5.99 0 0 0 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6a5.99 5.99 0 0 0 5.65-4H17v2h3v-2h1v-3h-3V9h-1.35zM7 14a4 4 0 1 1 0-8 4 4 0 0 1 0 8z"/>
|
||||
</svg>
|
||||
<span class="loginSubmitLabel">Đăng nhập</span>
|
||||
</button>
|
||||
</form>
|
||||
<p id="loginError" class="loginError" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loginPanelPin" class="loginPanel loginPanel--pin" hidden>
|
||||
<div class="loginPinLeft">
|
||||
<div class="loginHelp">
|
||||
<h2 class="loginHelpTitle">Đăng nhập bằng mã PIN</h2>
|
||||
<p>Người dùng được kích hoạt PIN có thể đăng nhập tại đây.</p>
|
||||
<p>Nếu chưa có mã PIN 4 chữ số, vui lòng liên hệ quản trị viên robot.</p>
|
||||
<p class="loginHelpNote">Không có mã PIN cấu hình sẵn — quản trị viên phải gán PIN trước.</p>
|
||||
</div>
|
||||
<div class="loginPinBoxes" id="loginPinBoxes" role="group" aria-label="Mã PIN 4 chữ số">
|
||||
<div class="loginPinCell" data-idx="0"></div>
|
||||
<div class="loginPinCell" data-idx="1"></div>
|
||||
<div class="loginPinCell" data-idx="2"></div>
|
||||
<div class="loginPinCell" data-idx="3"></div>
|
||||
</div>
|
||||
<input id="loginPin" type="hidden" value="" autocomplete="off" />
|
||||
<p id="loginPinError" class="loginError loginPinError" hidden></p>
|
||||
</div>
|
||||
<div class="loginKeypad" id="loginKeypad" aria-label="Bàn phím số">
|
||||
<button type="button" class="loginKey" data-key="1">1</button>
|
||||
<button type="button" class="loginKey" data-key="2">2</button>
|
||||
<button type="button" class="loginKey" data-key="3">3</button>
|
||||
<button type="button" class="loginKey" data-key="4">4</button>
|
||||
<button type="button" class="loginKey" data-key="5">5</button>
|
||||
<button type="button" class="loginKey" data-key="6">6</button>
|
||||
<button type="button" class="loginKey" data-key="7">7</button>
|
||||
<button type="button" class="loginKey" data-key="8">8</button>
|
||||
<button type="button" class="loginKey" data-key="9">9</button>
|
||||
<button type="button" class="loginKey loginKey--wide" data-key="0">0</button>
|
||||
<button type="button" class="loginKey loginKey--back" data-key="back" aria-label="Xóa">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell auth-locked">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brandIcon">R</div>
|
||||
@@ -56,6 +137,17 @@
|
||||
<div class="pageTitle">Cấu Hình</div>
|
||||
</div>
|
||||
<div class="topbarActions">
|
||||
<div class="userMenuWrap">
|
||||
<button id="userMenuBtn" type="button" class="btn subtle userMenuBtn" aria-haspopup="true">…</button>
|
||||
<div id="userMenuPanel" class="userMenuPanel" hidden>
|
||||
<div class="userMenuHeader">
|
||||
<div id="userMenuName" class="userMenuName">—</div>
|
||||
<div id="userMenuGroup" class="userMenuGroup mutedNote">—</div>
|
||||
</div>
|
||||
<button id="userMenuChangePasswordBtn" type="button" class="userMenuItem">Đổi mật khẩu</button>
|
||||
<button id="userMenuSignOutBtn" type="button" class="userMenuItem userMenuItemDanger">Đăng xuất</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="refreshBtn" type="button" class="btn subtle">Tải lại</button>
|
||||
<button id="saveLayoutBtn" class="btn primary" type="button">Lưu layout</button>
|
||||
</div>
|
||||
@@ -922,6 +1014,35 @@ GET /api/v2.0.0/status</pre>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="changePasswordDialog" class="missionDialog">
|
||||
<form id="changePasswordForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Đổi mật khẩu</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" onclick="document.getElementById('changePasswordDialog').close()" aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="changePasswordCurrent">Mật khẩu hiện tại</label>
|
||||
<input id="changePasswordCurrent" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="changePasswordNew">Mật khẩu mới</label>
|
||||
<input id="changePasswordNew" type="password" autocomplete="new-password" required minlength="4" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="changePasswordConfirm">Xác nhận mật khẩu mới</label>
|
||||
<input id="changePasswordConfirm" type="password" autocomplete="new-password" required minlength="4" />
|
||||
</div>
|
||||
<p id="changePasswordError" class="loginError"></p>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" onclick="document.getElementById('changePasswordDialog').close()">Hủy</button>
|
||||
<button type="submit" class="btn primary">Lưu</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/auth.js"></script>
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
<script src="/integrations.js"></script>
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
}
|
||||
|
||||
async function apiJson(url, opts = {}) {
|
||||
const res = await fetch(url, opts);
|
||||
if (window.AuthApp && !window.AuthApp.isReady()) {
|
||||
throw new Error("not authenticated");
|
||||
}
|
||||
const res = await fetch(url, { credentials: "include", ...opts });
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
@@ -440,5 +443,9 @@
|
||||
refreshAll,
|
||||
};
|
||||
|
||||
init();
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
})();
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
|
||||
async function loadStoreFromBackend() {
|
||||
try {
|
||||
const res = await fetch("/api/missions");
|
||||
const res = await fetch("/api/missions", { credentials: "include" });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.missions)) store.missions = data.missions;
|
||||
@@ -209,6 +209,7 @@
|
||||
async function syncStoreToBackend() {
|
||||
try {
|
||||
await fetch("/api/missions", {
|
||||
credentials: "include",
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ missions: store.missions, groups: store.groups }),
|
||||
@@ -396,7 +397,11 @@
|
||||
}
|
||||
|
||||
async function missionApi(path, opts = {}) {
|
||||
if (window.AuthApp && !window.AuthApp.isReady()) {
|
||||
throw new Error("not authenticated");
|
||||
}
|
||||
const res = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||
...opts,
|
||||
});
|
||||
@@ -479,6 +484,7 @@
|
||||
}
|
||||
|
||||
async function refreshQueue() {
|
||||
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
||||
try {
|
||||
const data = await missionApi("/api/mission_queue");
|
||||
store.queue = Array.isArray(data.queue) ? data.queue : [];
|
||||
@@ -486,6 +492,7 @@
|
||||
renderQueuePanel();
|
||||
notifyQueueUpdate();
|
||||
} catch (e) {
|
||||
if (String(e.message || "").includes("not authenticated")) return;
|
||||
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
|
||||
}
|
||||
}
|
||||
@@ -706,6 +713,7 @@
|
||||
}
|
||||
|
||||
function startQueuePoll() {
|
||||
if (window.AuthApp && !window.AuthApp.isReady()) return;
|
||||
stopQueuePoll();
|
||||
refreshQueue();
|
||||
store.queuePollTimer = setInterval(refreshQueue, 1500);
|
||||
@@ -1368,5 +1376,10 @@
|
||||
},
|
||||
};
|
||||
|
||||
init();
|
||||
function boot() {
|
||||
init();
|
||||
}
|
||||
if (window.AuthApp?.isReady()) boot();
|
||||
else window.addEventListener("lm:auth-ready", boot, { once: true });
|
||||
window.addEventListener("lm:auth-logout", stopQueuePoll);
|
||||
})();
|
||||
|
||||
405
www/style.css
405
www/style.css
@@ -1034,3 +1034,408 @@ canvas {
|
||||
.integrationCode { font-size: 13px; word-break: break-all; }
|
||||
.integrationTestRow .integrationTestActions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.integrationTestRow select { min-width: 220px; }
|
||||
|
||||
/* --- Auth / Sign in (MiR §2.1) --- */
|
||||
.shell.auth-locked { display: none !important; }
|
||||
.loginScreen[hidden] { display: none !important; }
|
||||
.loginForm[hidden] { display: none !important; }
|
||||
.loginHelp[hidden] { display: none !important; }
|
||||
.loginError[hidden] { display: none !important; }
|
||||
|
||||
.loginPanel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
padding: 36px 40px 40px;
|
||||
}
|
||||
|
||||
.loginPanel--pin {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.loginPanel[hidden] { display: none !important; }
|
||||
|
||||
.loginScreen {
|
||||
--mir-blue: #3d6cb3;
|
||||
--mir-blue-dark: #2f5a9a;
|
||||
--mir-green: #5cb85c;
|
||||
--mir-green-hover: #4cae4c;
|
||||
--mir-tab-inactive: #c8c8c8;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
background: var(--mir-blue);
|
||||
font-family: "Segoe UI", ui-sans-serif, system-ui, -apple-system, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.loginFrame {
|
||||
width: min(920px, 100%);
|
||||
}
|
||||
|
||||
.loginHeader {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 8px 0 4px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.loginHeaderBrand {
|
||||
color: #fff;
|
||||
font-size: clamp(2rem, 5vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.loginHeaderRight {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.loginHeaderPrompt {
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.loginTabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.loginTab {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 12px 18px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: var(--mir-tab-inactive);
|
||||
color: #333;
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loginTab:hover:not(.active) {
|
||||
background: #d8d8d8;
|
||||
}
|
||||
|
||||
.loginTab.active {
|
||||
background: #fff;
|
||||
color: #111;
|
||||
padding-bottom: 13px;
|
||||
}
|
||||
|
||||
.loginCard {
|
||||
background: #fff;
|
||||
border-radius: 0 8px 8px 8px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.loginHelpNote {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.loginPinLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.loginPinBoxes {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.loginPinCell {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border: 2px solid #c8c8c8;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
color: #111;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.loginPinCell.filled::after {
|
||||
content: "•";
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loginPinCell.active {
|
||||
border-color: var(--mir-blue);
|
||||
box-shadow: 0 0 0 2px rgba(61, 108, 179, 0.2);
|
||||
}
|
||||
|
||||
.loginPinError {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.loginKeypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
width: min(100%, 300px);
|
||||
margin: 0 auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.loginKey {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--mir-green);
|
||||
color: #fff;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 20px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.12s ease, transform 0.08s ease;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.loginKey:hover {
|
||||
background: var(--mir-green-hover);
|
||||
}
|
||||
|
||||
.loginKey:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.loginKey--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.loginKey--back {
|
||||
background: #3a3a3a;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.loginKey--back:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.loginScreen.is-loading .loginKey {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loginHelp {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.loginHelpTitle {
|
||||
margin: 0 0 16px;
|
||||
color: #222;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.loginHelp p {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.loginHelp p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.loginForms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.loginField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loginFieldLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.loginField input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
border: 1px solid #c5c5c5;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loginField input:focus {
|
||||
outline: 2px solid rgba(61, 108, 179, 0.35);
|
||||
border-color: var(--mir-blue);
|
||||
}
|
||||
|
||||
.loginField input:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px #fff8d6 inset;
|
||||
box-shadow: 0 0 0 1000px #fff8d6 inset;
|
||||
}
|
||||
|
||||
.loginSubmit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
padding: 10px 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--mir-green);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.loginSubmit:hover {
|
||||
background: var(--mir-green-hover);
|
||||
}
|
||||
|
||||
.loginSubmit:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.loginSubmitIcon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.loginScreen.is-loading .loginSubmit {
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loginError {
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.loginHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.loginHeaderRight {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.loginHeaderPrompt {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.loginTabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loginTab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
border-radius: 6px 6px 0 0;
|
||||
white-space: normal;
|
||||
font-size: 13px;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.loginPanel {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
padding: 24px 20px 28px;
|
||||
}
|
||||
|
||||
.loginKeypad {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.loginCard {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.userMenuWrap { position: relative; }
|
||||
.userMenuBtn { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.userMenuPanel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow2);
|
||||
padding: 8px;
|
||||
z-index: 50;
|
||||
}
|
||||
.userMenuHeader { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
||||
.userMenuName { font-weight: 600; }
|
||||
.userMenuGroup { font-size: 12px; }
|
||||
.userMenuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.userMenuItem:hover { background: var(--panel2); }
|
||||
.userMenuItemDanger { color: var(--danger); }
|
||||
|
||||
body.auth-readonly-config #saveLayoutBtn,
|
||||
body.auth-readonly-config .btn.primary[data-write-config] { display: none !important; }
|
||||
body.auth-readonly-missions .missionToolbar .btn.primary,
|
||||
body.auth-readonly-missions #missionCreateBtn { pointer-events: none; opacity: 0.45; }
|
||||
body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-events: none; opacity: 0.45; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user