diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html index 0ae2b4e8..b772cb7f 100644 --- a/interfaces/admin/static/chat.html +++ b/interfaces/admin/static/chat.html @@ -779,7 +779,7 @@ - + - + diff --git a/interfaces/admin/static/js/asset-v.js b/interfaces/admin/static/js/asset-v.js index 3c5fcecb..8a163be2 100644 --- a/interfaces/admin/static/js/asset-v.js +++ b/interfaces/admin/static/js/asset-v.js @@ -3,4 +3,4 @@ * - interfaces/admin/static/index.html importmap + CSS/JS ?v= * - interfaces/admin/static/app.js entry query (optional if importmap covers it) */ -export const ADMIN_ASSET_V = "20260812-19"; +export const ADMIN_ASSET_V = "20260812-21"; diff --git a/interfaces/admin/static/js/chat/core.js b/interfaces/admin/static/js/chat/core.js index 7ec55648..1cf85ea8 100644 --- a/interfaces/admin/static/js/chat/core.js +++ b/interfaces/admin/static/js/chat/core.js @@ -1048,6 +1048,21 @@ function withTimeout(promise, ms, label) { }); } +function invokeChatBoot() { + const fn = state.boot; + if (typeof fn === "function") return fn(); + return Promise.reject(new Error("chat_boot_uninitialized")); +} + +let _chatReauthTimer = null; +function scheduleChatReauth() { + if (_chatReauthTimer != null) return; + _chatReauthTimer = setTimeout(() => { + _chatReauthTimer = null; + invokeChatBoot().catch(() => {}); + }, 0); +} + async function apiGet(path) { const token = localStorage.getItem(AUTH_TOKEN_KEY) || ""; const headers = { accept: "application/json" }; @@ -1057,8 +1072,11 @@ async function apiGet(path) { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_SESSION_KEY); state.authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); + setTimeout(() => scheduleChatReauth(), 0); + const err = new Error("auth_required"); + err.name = "AuthRequiredError"; + err.authRequired = true; + throw err; } if (!res.ok) throw new Error(`GET ${path} ${res.status}`); return await res.json(); @@ -1088,8 +1106,11 @@ async function apiPost(path, body) { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_SESSION_KEY); state.authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); + setTimeout(() => scheduleChatReauth(), 0); + const err = new Error("auth_required"); + err.name = "AuthRequiredError"; + err.authRequired = true; + throw err; } if (!res.ok) throw new Error(`POST ${path} ${res.status}`); return data ?? {}; @@ -1108,8 +1129,11 @@ async function apiPatch(path, body) { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_SESSION_KEY); state.authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); + setTimeout(() => scheduleChatReauth(), 0); + const err = new Error("auth_required"); + err.name = "AuthRequiredError"; + err.authRequired = true; + throw err; } if (!res.ok) throw new Error(`PATCH ${path} ${res.status}`); return await res.json(); @@ -1124,8 +1148,11 @@ async function apiDelete(path) { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_SESSION_KEY); state.authSession = null; - setTimeout(() => boot().catch(() => {}), 0); - return await new Promise(() => {}); + setTimeout(() => scheduleChatReauth(), 0); + const err = new Error("auth_required"); + err.name = "AuthRequiredError"; + err.authRequired = true; + throw err; } if (!res.ok) throw new Error(`DELETE ${path} ${res.status}`); return await res.json(); @@ -2898,8 +2925,22 @@ async function fileToPayloadEntry(file) { async function renderLogin() { applyI18nStatic(); - const username = el("input", { class: "input", placeholder: t("auth.username") }); - const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") }); + if (!state.loginDraft) state.loginDraft = { username: "", password: "" }; + const draft = state.loginDraft; + const username = el("input", { + class: "input", + placeholder: t("auth.username"), + autocomplete: "username", + spellcheck: "false", + }); + username.value = draft.username || ""; + const password = el("input", { + class: "input", + type: "password", + placeholder: t("auth.password"), + autocomplete: "current-password", + }); + password.value = draft.password || ""; const status = el("div", { class: "muted", text: "" }); const btn = el("button", { class: "btn btn--primary", @@ -2907,6 +2948,12 @@ async function renderLogin() { text: t("auth.login"), }); let busy = false; + const syncDraft = () => { + draft.username = String(username.value || ""); + draft.password = String(password.value || ""); + }; + username.addEventListener("input", syncDraft); + password.addEventListener("input", syncDraft); const setBusy = (on) => { busy = !!on; btn.disabled = busy; @@ -2914,6 +2961,7 @@ async function renderLogin() { }; const doLogin = async () => { if (busy) return; + syncDraft(); setBusy(true); status.textContent = t("auth.loggingIn"); try { @@ -2934,6 +2982,8 @@ async function renderLogin() { setBusy(false); return; } + draft.username = ""; + draft.password = ""; localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token)); localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); state.authSession = resp.session || null; @@ -2946,10 +2996,25 @@ async function renderLogin() { ]), ); await new Promise((resolve) => requestAnimationFrame(() => resolve())); - await boot(); + await invokeChatBoot(); } catch (err) { - status.textContent = String((err && err.message) || err || t("auth.invalid")); - setBusy(false); + if (err && err.authRequired) return; + mount( + el("div", { class: "chat-app--login" }, [ + el("div", { class: "card u-modal-card-sm" }, [ + el("div", { class: "card__title", text: t("auth.login") }), + el("div", { class: "muted", text: String((err && err.message) || err || t("auth.invalid")) }), + el("div", { class: "row" }, [ + el("button", { + class: "btn btn--primary", + type: "button", + text: t("auth.login"), + onclick: () => invokeChatBoot().catch(() => {}), + }), + ]), + ]), + ]), + ); } }; const onEnterLogin = (ev) => { @@ -2963,7 +3028,7 @@ async function renderLogin() { ev.preventDefault(); doLogin(); }); - const card = el("div", { class: "card u-modal-card-sm" }, [ + const card = el("div", { class: "card u-modal-card-sm", "data-chat-login": "1" }, [ el("div", { class: "card__title", text: t("auth.login") }), el("div", { class: "chat-login-fields" }, [username, password]), el("div", { class: "row chat-login-actions" }, [btn]), @@ -2971,7 +3036,9 @@ async function renderLogin() { ]); setTimeout(() => { try { - username.focus(); + if (document.activeElement === username || document.activeElement === password) return; + if (draft.username && !draft.password) password.focus(); + else username.focus(); } catch (_) {} }, 0); return el("div", { class: "chat-app--login" }, [card]); diff --git a/interfaces/admin/static/js/chat/main.js b/interfaces/admin/static/js/chat/main.js index 31e9f12e..5c92dce3 100644 --- a/interfaces/admin/static/js/chat/main.js +++ b/interfaces/admin/static/js/chat/main.js @@ -27,6 +27,13 @@ async function boot() { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_SESSION_KEY); state.authSession = null; + const existingLogin = document.querySelector("#app [data-chat-login='1']"); + const ae = document.activeElement; + if (existingLogin && ae && existingLogin.contains(ae)) { + applyI18nStatic(); + syncAuthUserLabel(); + return; + } try { await withTimeout(apiPost("/admin/api/auth/bootstrap", {}), 2500, "auth_bootstrap_timeout"); } catch (_) {} @@ -57,6 +64,8 @@ async function boot() { syncAuthUserLabel(); } +state.boot = boot; + document.body.addEventListener("click", async (e) => { const menuBtn = e.target.closest && e.target.closest(".chat-sess-menu-item[data-menu-action]"); if (menuBtn) { diff --git a/interfaces/admin/static/js/chat/state.js b/interfaces/admin/static/js/chat/state.js index 66f230c9..d0d9fc63 100644 --- a/interfaces/admin/static/js/chat/state.js +++ b/interfaces/admin/static/js/chat/state.js @@ -22,4 +22,6 @@ export const state = { statusReasonPairs: [], jobsBadgeEl: null, jobsBtnLabelEl: null, + /** @type {null | (() => Promise)} set by main.js to avoid circular imports */ + boot: null, }; diff --git a/interfaces/admin/static/js/core.js b/interfaces/admin/static/js/core.js index c966a4c7..53d1acca 100644 --- a/interfaces/admin/static/js/core.js +++ b/interfaces/admin/static/js/core.js @@ -144,22 +144,37 @@ function getStoredAuthToken() { } /** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */ +let _reauthTimer = null; function scheduleReauthAfter401(requestUrl) { const u = String(requestUrl || ""); if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) return; authStoreRemove(AUTH_TOKEN_KEY); authStoreRemove(AUTH_SESSION_KEY); state.authSession = null; - setTimeout(() => { + // Coalesce: many parallel 401s must not remount login repeatedly (wipes username while typing). + if (_reauthTimer != null) return; + _reauthTimer = setTimeout(() => { + _reauthTimer = null; const fn = state.reauthHandler; if (typeof fn === "function") fn(); }, 0); } +function isAuthRequiredError(err) { + return !!(err && (err.authRequired === true || err.name === "AuthRequiredError")); +} + +function _authRequiredError() { + const err = new Error("auth_required"); + err.name = "AuthRequiredError"; + err.authRequired = true; + return err; +} + function _haltAfter401() { - // 401 means "login required". We re-route to login; callers should not show a "request failed" popup. - // Returning a never-resolving promise avoids bubbling errors into UI code that would flash an alert. - return new Promise(() => {}); + // 401 means "login required". Re-auth is already scheduled; reject so page boots + // (Promise.all / await) do not hang forever on a never-resolving promise. + return Promise.reject(_authRequiredError()); } async function apiGet(path) { @@ -711,6 +726,7 @@ export { getStoredAuthToken, scheduleReauthAfter401, _haltAfter401, + isAuthRequiredError, apiGet, apiGetOptional, apiGetNoHang, diff --git a/interfaces/admin/static/js/pages/login.js b/interfaces/admin/static/js/pages/login.js index 50d3629c..d48378cb 100644 --- a/interfaces/admin/static/js/pages/login.js +++ b/interfaces/admin/static/js/pages/login.js @@ -1,14 +1,36 @@ import { state, t, el, mount, applyI18nStatic, apiPost, authStoreSet, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, navigateAdmin } from "../core.js"; +/** Survive login remounts (reauth races) so typing is not wiped. */ +const loginDraft = { username: "", password: "" }; + async function renderLogin() { applyI18nStatic(); - const username = el("input", { class: "input", placeholder: t("auth.username"), value: "" }); + const username = el("input", { + class: "input", + placeholder: t("auth.username"), + autocomplete: "username", + spellcheck: "false", + }); + username.value = loginDraft.username; const userHint = el("div", { class: "muted", text: t("auth.consoleUsernameHint") }); - const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") }); + const password = el("input", { + class: "input", + type: "password", + placeholder: t("auth.password"), + autocomplete: "current-password", + }); + password.value = loginDraft.password; const status = el("div", { class: "muted", text: "" }); const btn = el("button", { class: "btn btn--primary", type: "button", text: t("auth.login") }); let busy = false; + const syncDraft = () => { + loginDraft.username = String(username.value || ""); + loginDraft.password = String(password.value || ""); + }; + username.addEventListener("input", syncDraft); + password.addEventListener("input", syncDraft); + const setBusy = (on) => { busy = !!on; btn.disabled = busy; @@ -17,6 +39,7 @@ async function renderLogin() { const doLogin = async () => { if (busy) return; + syncDraft(); setBusy(true); status.textContent = t("auth.loggingIn"); try { @@ -32,6 +55,8 @@ async function renderLogin() { setBusy(false); return; } + loginDraft.username = ""; + loginDraft.password = ""; const newTok = String(resp.token || "").trim(); authStoreSet(AUTH_TOKEN_KEY, newTok); authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); @@ -46,8 +71,20 @@ async function renderLogin() { await new Promise((resolve) => requestAnimationFrame(() => resolve())); await navigateAdmin(); } catch (err) { - status.textContent = String((err && err.message) || err || t("auth.invalid")); - setBusy(false); + mount( + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("auth.login") }), + el("div", { class: "muted", text: String((err && err.message) || err || t("auth.invalid")) }), + el("div", { class: "row" }, [ + el("button", { + class: "btn btn--primary", + type: "button", + text: t("auth.login"), + onclick: () => navigateAdmin(), + }), + ]), + ]), + ); } }; @@ -62,13 +99,15 @@ async function renderLogin() { ev.preventDefault(); doLogin(); }); - // Focus after mount so users can type immediately. + // Focus after mount so users can type immediately (keep caret if already typing). setTimeout(() => { try { - username.focus(); + if (document.activeElement === username || document.activeElement === password) return; + if (loginDraft.username && !loginDraft.password) password.focus(); + else username.focus(); } catch (_) {} }, 0); - return el("div", { class: "card" }, [ + return el("div", { class: "card", "data-admin-login": "1" }, [ el("div", { class: "card__title", text: t("auth.login") }), el("div", { class: "row" }, [username]), userHint, diff --git a/interfaces/admin/static/js/pages/plugins.js b/interfaces/admin/static/js/pages/plugins.js index 39caa02f..a2850c05 100644 --- a/interfaces/admin/static/js/pages/plugins.js +++ b/interfaces/admin/static/js/pages/plugins.js @@ -1,4 +1,4 @@ -import { t, el, tdCell, apiGet, apiPost, renderPageShell, markPrewarmReminder, tf, rowActions } from "../core.js"; +import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, renderPageShell, markPrewarmReminder, tf, rowActions } from "../core.js"; const PLUGINS_PAGE_SIZE = 15; @@ -100,14 +100,17 @@ async function renderPlugins() { wecom_longconn_inbound_queue_maxsize: 200, }; const [p, mcp, mcpBinding, toolPolicyRaw] = await Promise.all([ - apiGet("/admin/api/plugins"), - apiGet("/admin/api/mcp/servers").catch(() => ({ servers: [] })), - apiGet("/admin/api/mcp/binding").catch(() => ({ - available_specialists: ["generalist"], - servers: [], - mapping: {}, - })), - apiGet("/admin/api/tool-policy").catch(() => null), + apiGetNoHang("/admin/api/plugins").then((r) => r || { plugins: [] }), + apiGetNoHang("/admin/api/mcp/servers").then((r) => r || { servers: [] }), + apiGetNoHang("/admin/api/mcp/binding").then( + (r) => + r || { + available_specialists: ["generalist"], + servers: [], + mapping: {}, + }, + ), + apiGetNoHang("/admin/api/tool-policy"), ]); let toolPolicy = toolPolicyRaw && typeof toolPolicyRaw === "object" ? toolPolicyRaw : defaultToolPolicy; diff --git a/interfaces/admin/static/js/router.js b/interfaces/admin/static/js/router.js index 5d8ef04f..a610a71f 100644 --- a/interfaces/admin/static/js/router.js +++ b/interfaces/admin/static/js/router.js @@ -1,6 +1,7 @@ import { state, t, el, mount, setActive, applyI18nStatic, getRoute, authStoreRemove, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, getStoredAuthToken, apiPost, + isAuthRequiredError, } from "./core.js"; import { hasPermission, canManageApiGrants, isAdministratorUsername } from "./pages/authz.js"; import { renderStack } from "./pages/stack.js"; @@ -26,6 +27,12 @@ async function router() { authStoreRemove(AUTH_SESSION_KEY); state.authSession = null; } + const existingLogin = document.querySelector("#content [data-admin-login='1']"); + const ae = document.activeElement; + // Avoid wiping username/password while the user is typing on an already-shown login form. + if (existingLogin && ae && existingLogin.contains(ae)) { + return; + } try { await apiPost("/admin/api/auth/bootstrap", {}); } catch (_) {} @@ -57,6 +64,11 @@ async function router() { } const forbiddenCard = () => el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.forbidden") })]); + const errorCard = (err) => + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("common.error") }), + el("div", { class: "pre", text: String((err && err.message) || err || "") }), + ]); const mountPageLoading = (titleKey) => { mount( el("div", { class: "card" }, [ @@ -71,55 +83,55 @@ async function router() { requestAnimationFrame(() => resolve()); }); let view; - if (page === "stack") { - mountPageLoading("title.stack"); - await yieldForPaint(); - view = await renderStack(); - } - else if (page === "scheduled-jobs") { - view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard(); - } - else if (page === "users") { - view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard(); - } else if (page === "memory") view = await renderMemory(); - else if (page === "models") view = await renderModels(); - else if (page === "api-grants" || page === "session-monitor" || page === "admin-audit") { - view = el("div", { class: "card" }, [ - el("div", { class: "card__title", text: t("common.notFound") }), - el("div", { class: "muted", text: t("common.pageRemoved") }), - ]); - } else if (page === "audit") view = await renderAudit(route.params.get("session_id") || ""); - else if (false && page === "session-monitor") { - view = isAdministratorUsername() ? await renderSessionMonitor() : el("div", { class: "card" }, [el("div", { class: "card__title", text: t("sessionMonitor.onlyAdministrator") })]); - } else if (false && page === "admin-audit") { - view = hasPermission("admin:user:write") ? await renderAdminAudit() : forbiddenCard(); - } else if (page === "plugins") { - if (!hasPermission("admin:user:write")) { - view = forbiddenCard(); - } else { - mountPageLoading("title.plugins"); + try { + if (page === "stack") { + mountPageLoading("title.stack"); await yieldForPaint(); - view = await renderPlugins(); - } - } else if (page === "skills") { - if (!hasPermission("admin:read")) { - view = forbiddenCard(); - } else { - mountPageLoading("title.skills"); - await yieldForPaint(); - view = await renderSkills(); - } - } else if (page === "attachments") { - view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard(); - } else if (page === "workspace-paths") { - view = - hasPermission("admin:user:read") || hasPermission("admin:workspace_paths:read") - ? await renderWorkspacePaths() - : forbiddenCard(); - } else if (page === "profile") { - view = await renderProfile(); - } else view = el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.notFound") })]); - mount(view); + view = await renderStack(); + } else if (page === "scheduled-jobs") { + view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard(); + } else if (page === "users") { + view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard(); + } else if (page === "memory") view = await renderMemory(); + else if (page === "models") view = await renderModels(); + else if (page === "api-grants" || page === "session-monitor" || page === "admin-audit") { + view = el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("common.notFound") }), + el("div", { class: "muted", text: t("common.pageRemoved") }), + ]); + } else if (page === "audit") view = await renderAudit(route.params.get("session_id") || ""); + else if (page === "plugins") { + if (!hasPermission("admin:user:write")) { + view = forbiddenCard(); + } else { + mountPageLoading("title.plugins"); + await yieldForPaint(); + view = await renderPlugins(); + } + } else if (page === "skills") { + if (!hasPermission("admin:read")) { + view = forbiddenCard(); + } else { + mountPageLoading("title.skills"); + await yieldForPaint(); + view = await renderSkills(); + } + } else if (page === "attachments") { + view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard(); + } else if (page === "workspace-paths") { + view = + hasPermission("admin:user:read") || hasPermission("admin:workspace_paths:read") + ? await renderWorkspacePaths() + : forbiddenCard(); + } else if (page === "profile") { + view = await renderProfile(); + } else view = el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.notFound") })]); + } catch (err) { + // Re-auth already scheduled; do not paint an error over the login redirect. + if (isAuthRequiredError(err)) return; + view = errorCard(err); + } + if (view) mount(view); }