update function login
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-16 09:57:55 +07:00
parent 6fa15b69e7
commit 9aee5f4100
19 changed files with 2272 additions and 64 deletions

View File

@@ -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
View 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();
})();

View File

@@ -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);
})();

View File

@@ -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>

View File

@@ -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 });
})();

View File

@@ -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);
})();

View File

@@ -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; }