diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html index 502aaa5f..0ae2b4e8 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 2e4d4c22..3c5fcecb 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-18"; +export const ADMIN_ASSET_V = "20260812-19"; diff --git a/interfaces/admin/static/js/chat/core.js b/interfaces/admin/static/js/chat/core.js index 63444a74..7ec55648 100644 --- a/interfaces/admin/static/js/chat/core.js +++ b/interfaces/admin/static/js/chat/core.js @@ -1073,7 +1073,18 @@ async function apiPost(path, body) { headers, body: JSON.stringify(body ?? {}), }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch (_) { + data = null; + } if (res.status === 401) { + const u = String(path || ""); + if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) { + return data && typeof data === "object" ? data : { ok: false, error: "unauthorized" }; + } localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_SESSION_KEY); state.authSession = null; @@ -1081,7 +1092,7 @@ async function apiPost(path, body) { return await new Promise(() => {}); } if (!res.ok) throw new Error(`POST ${path} ${res.status}`); - return await res.json(); + return data ?? {}; } async function apiPatch(path, body) { @@ -2890,27 +2901,56 @@ async function renderLogin() { const username = el("input", { class: "input", placeholder: t("auth.username") }); const password = el("input", { class: "input", type: "password", placeholder: t("auth.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 setBusy = (on) => { + busy = !!on; + btn.disabled = busy; + btn.textContent = busy ? t("auth.loggingIn") : t("auth.login"); + }; const doLogin = async () => { - const resp = await apiPost("/admin/api/auth/login", { - tenant_id: "", - username: username.value.trim().toLowerCase(), - password: password.value.trim(), - purpose: "chat", - }); - if (!resp.ok || !resp.token) { - const err = String(resp.error || ""); - status.textContent = - err === "user_disabled" - ? t("auth.disabled") - : err === "admin_role_required" - ? t("auth.chatLoginDenied") - : t("auth.invalid"); - return; + if (busy) return; + setBusy(true); + status.textContent = t("auth.loggingIn"); + try { + const resp = await apiPost("/admin/api/auth/login", { + tenant_id: "", + username: username.value.trim().toLowerCase(), + password: password.value.trim(), + purpose: "chat", + }); + if (!resp || !resp.ok || !resp.token) { + const err = String((resp && resp.error) || ""); + status.textContent = + err === "user_disabled" + ? t("auth.disabled") + : err === "admin_role_required" + ? t("auth.chatLoginDenied") + : t("auth.invalid"); + setBusy(false); + return; + } + localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token)); + localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); + state.authSession = resp.session || null; + 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: t("chat.loading") }), + ]), + ]), + ); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await boot(); + } catch (err) { + status.textContent = String((err && err.message) || err || t("auth.invalid")); + setBusy(false); } - localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token)); - localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); - state.authSession = resp.session || null; - await boot(); }; const onEnterLogin = (ev) => { if (ev.key !== "Enter") return; @@ -2919,10 +2959,9 @@ async function renderLogin() { }; username.addEventListener("keydown", onEnterLogin); password.addEventListener("keydown", onEnterLogin); - const btn = el("button", { - class: "btn btn--primary", - text: t("auth.login"), - onclick: doLogin, + btn.addEventListener("click", (ev) => { + ev.preventDefault(); + doLogin(); }); const card = el("div", { class: "card u-modal-card-sm" }, [ el("div", { class: "card__title", text: t("auth.login") }), diff --git a/interfaces/admin/static/js/chat/i18n/messages-en.js b/interfaces/admin/static/js/chat/i18n/messages-en.js index 93e12623..6da21cdc 100644 --- a/interfaces/admin/static/js/chat/i18n/messages-en.js +++ b/interfaces/admin/static/js/chat/i18n/messages-en.js @@ -4,6 +4,7 @@ export default { "auth.disabled": "Account is disabled", "auth.invalid": "Login failed", "auth.login": "Login", + "auth.loggingIn": "Signing in…", "auth.logout": "Logout", "auth.password": "Password", "auth.username": "Username", diff --git a/interfaces/admin/static/js/chat/i18n/messages-zh.js b/interfaces/admin/static/js/chat/i18n/messages-zh.js index 79ac5869..544f874a 100644 --- a/interfaces/admin/static/js/chat/i18n/messages-zh.js +++ b/interfaces/admin/static/js/chat/i18n/messages-zh.js @@ -4,6 +4,7 @@ export default { "auth.disabled": "账号已被禁用,请联系管理员", "auth.invalid": "登录失败,请检查凭据", "auth.login": "登录", + "auth.loggingIn": "登录中…", "auth.logout": "退出登录", "auth.password": "密码", "auth.username": "用户名", diff --git a/interfaces/admin/static/js/core.js b/interfaces/admin/static/js/core.js index 5e834dc5..c966a4c7 100644 --- a/interfaces/admin/static/js/core.js +++ b/interfaces/admin/static/js/core.js @@ -218,6 +218,12 @@ async function apiPost(path, body) { data = null; } if (res.status === 401) { + const isAuthEndpoint = + url.includes("/admin/api/auth/login") || url.includes("/admin/api/auth/bootstrap"); + // Login/bootstrap must never hang on the never-resolving 401 sentinel. + if (isAuthEndpoint) { + return data && typeof data === "object" ? data : { ok: false, error: "unauthorized" }; + } scheduleReauthAfter401(url); return await _haltAfter401(); } diff --git a/interfaces/admin/static/js/i18n/messages-en.js b/interfaces/admin/static/js/i18n/messages-en.js index e9a98e90..a82984a3 100644 --- a/interfaces/admin/static/js/i18n/messages-en.js +++ b/interfaces/admin/static/js/i18n/messages-en.js @@ -72,6 +72,7 @@ export default { "auth.disabled": "Account is disabled", "auth.invalid": "Login failed", "auth.login": "Login", + "auth.loggingIn": "Signing in…", "auth.logout": "Logout", "auth.password": "Password", "auth.username": "Username", diff --git a/interfaces/admin/static/js/i18n/messages-zh.js b/interfaces/admin/static/js/i18n/messages-zh.js index 0782064b..fdf0ac62 100644 --- a/interfaces/admin/static/js/i18n/messages-zh.js +++ b/interfaces/admin/static/js/i18n/messages-zh.js @@ -72,6 +72,7 @@ export default { "auth.disabled": "账号已被禁用,请联系管理员", "auth.invalid": "登录失败,请检查凭据", "auth.login": "登录", + "auth.loggingIn": "登录中…", "auth.logout": "退出登录", "auth.password": "密码", "auth.username": "用户名", diff --git a/interfaces/admin/static/js/pages/login.js b/interfaces/admin/static/js/pages/login.js index 7b7a02a4..50d3629c 100644 --- a/interfaces/admin/static/js/pages/login.js +++ b/interfaces/admin/static/js/pages/login.js @@ -1,4 +1,4 @@ -import { state, t, el, applyI18nStatic, apiPost, authStoreSet, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, navigateAdmin } from "../core.js"; +import { state, t, el, mount, applyI18nStatic, apiPost, authStoreSet, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, navigateAdmin } from "../core.js"; async function renderLogin() { applyI18nStatic(); @@ -6,24 +6,51 @@ async function renderLogin() { const userHint = el("div", { class: "muted", text: t("auth.consoleUsernameHint") }); const password = el("input", { class: "input", type: "password", placeholder: t("auth.password") }); const status = el("div", { class: "muted", text: "" }); - const doLogin = async () => { - const resp = await apiPost("/admin/api/auth/login", { - tenant_id: "", - username: username.value.trim(), - password: password.value.trim(), - purpose: "console", - }); - if (!resp.ok || !resp.token) { - const err = String(resp.error || ""); - status.textContent = err === "user_disabled" ? t("auth.disabled") : t("auth.invalid"); - return; - } - const newTok = String(resp.token || "").trim(); - authStoreSet(AUTH_TOKEN_KEY, newTok); - authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); - state.authSession = resp.session || null; - await navigateAdmin(); + const btn = el("button", { class: "btn btn--primary", type: "button", text: t("auth.login") }); + let busy = false; + + const setBusy = (on) => { + busy = !!on; + btn.disabled = busy; + btn.textContent = busy ? t("auth.loggingIn") : t("auth.login"); }; + + const doLogin = async () => { + if (busy) return; + setBusy(true); + status.textContent = t("auth.loggingIn"); + try { + const resp = await apiPost("/admin/api/auth/login", { + tenant_id: "", + username: username.value.trim(), + password: password.value.trim(), + purpose: "console", + }); + if (!resp || !resp.ok || !resp.token) { + const err = String((resp && resp.error) || ""); + status.textContent = err === "user_disabled" ? t("auth.disabled") : t("auth.invalid"); + setBusy(false); + return; + } + const newTok = String(resp.token || "").trim(); + authStoreSet(AUTH_TOKEN_KEY, newTok); + authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {})); + state.authSession = resp.session || null; + // Leave the login form immediately so the click feels responsive. + mount( + el("div", { class: "card" }, [ + el("div", { class: "card__title", text: t("title.stack") }), + el("div", { class: "muted", text: t("chat.loading") }), + ]), + ); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + await navigateAdmin(); + } catch (err) { + status.textContent = String((err && err.message) || err || t("auth.invalid")); + setBusy(false); + } + }; + const onEnterLogin = (ev) => { if (ev.key !== "Enter") return; ev.preventDefault(); @@ -31,7 +58,10 @@ async function renderLogin() { }; username.addEventListener("keydown", onEnterLogin); password.addEventListener("keydown", onEnterLogin); - const btn = el("button", { class: "btn btn--primary", text: t("auth.login"), onclick: doLogin }); + btn.addEventListener("click", (ev) => { + ev.preventDefault(); + doLogin(); + }); // Focus after mount so users can type immediately. setTimeout(() => { try { @@ -48,5 +78,4 @@ async function renderLogin() { ]); } - export { renderLogin };