mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 23:23:32 +08:00
Fix login hang, chat boot wiring, and username wipe on remount.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5bcaf7fd2d
commit
42092423fb
10 changed files with 261 additions and 113 deletions
|
|
@ -779,7 +779,7 @@
|
||||||
<script defer src="/admin/assets/vendor/marked.min.js"></script>
|
<script defer src="/admin/assets/vendor/marked.min.js"></script>
|
||||||
<script defer src="/admin/assets/vendor/purify.min.js"></script>
|
<script defer src="/admin/assets/vendor/purify.min.js"></script>
|
||||||
<script defer src="/admin/assets/admin-theme.js"></script>
|
<script defer src="/admin/assets/admin-theme.js"></script>
|
||||||
<script type="module" src="/admin/assets/chat.js?v=20260812-chat-3" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load chat.js',h:'Confirm the gateway is up and /admin/assets/chat.js is reachable.'}:{t:'无法加载 chat.js',h:'请确认网关已启动且 /admin/assets/chat.js 可访问。'};var s=document.getElementById('chat-boot-splash');if(s){var a=s.querySelector('.chat-boot-splash__title span:last-child');var b=s.querySelector('.chat-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
<script type="module" src="/admin/assets/chat.js?v=20260812-chat-6" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load chat.js',h:'Confirm the gateway is up and /admin/assets/chat.js is reachable.'}:{t:'无法加载 chat.js',h:'请确认网关已启动且 /admin/assets/chat.js 可访问。'};var s=document.getElementById('chat-boot-splash');if(s){var a=s.querySelector('.chat-boot-splash__title span:last-child');var b=s.querySelector('.chat-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
||||||
<!-- Mermaid is larger; load after chat.js so login is not delayed by parse. -->
|
<!-- Mermaid is larger; load after chat.js so login is not delayed by parse. -->
|
||||||
<script
|
<script
|
||||||
defer
|
defer
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>oliver</title>
|
<title>oliver</title>
|
||||||
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
||||||
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-19" />
|
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-21" />
|
||||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-19" />
|
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-21" />
|
||||||
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-19" />
|
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-21" />
|
||||||
<style>
|
<style>
|
||||||
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
||||||
.admin-boot-splash {
|
.admin-boot-splash {
|
||||||
|
|
@ -129,33 +129,33 @@
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-19",
|
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-21",
|
||||||
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-19",
|
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-21",
|
||||||
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-19",
|
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-21",
|
||||||
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-19",
|
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-21",
|
||||||
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-19",
|
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-21",
|
||||||
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-19",
|
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-21",
|
||||||
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-19",
|
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-19",
|
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-19",
|
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-19",
|
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-19",
|
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-19",
|
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-19",
|
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-19",
|
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-19",
|
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-19",
|
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-19",
|
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-19",
|
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-19",
|
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-19",
|
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-19",
|
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-21",
|
||||||
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-19",
|
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-21",
|
||||||
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-19",
|
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-21",
|
||||||
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-19"
|
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/admin/assets/app.js?v=20260812-19" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
<script type="module" src="/admin/assets/app.js?v=20260812-21" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@
|
||||||
* - interfaces/admin/static/index.html importmap + CSS/JS ?v=
|
* - interfaces/admin/static/index.html importmap + CSS/JS ?v=
|
||||||
* - interfaces/admin/static/app.js entry query (optional if importmap covers it)
|
* - 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";
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
async function apiGet(path) {
|
||||||
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
const token = localStorage.getItem(AUTH_TOKEN_KEY) || "";
|
||||||
const headers = { accept: "application/json" };
|
const headers = { accept: "application/json" };
|
||||||
|
|
@ -1057,8 +1072,11 @@ async function apiGet(path) {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
localStorage.removeItem(AUTH_SESSION_KEY);
|
localStorage.removeItem(AUTH_SESSION_KEY);
|
||||||
state.authSession = null;
|
state.authSession = null;
|
||||||
setTimeout(() => boot().catch(() => {}), 0);
|
setTimeout(() => scheduleChatReauth(), 0);
|
||||||
return await new Promise(() => {});
|
const err = new Error("auth_required");
|
||||||
|
err.name = "AuthRequiredError";
|
||||||
|
err.authRequired = true;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`GET ${path} ${res.status}`);
|
if (!res.ok) throw new Error(`GET ${path} ${res.status}`);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|
@ -1088,8 +1106,11 @@ async function apiPost(path, body) {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
localStorage.removeItem(AUTH_SESSION_KEY);
|
localStorage.removeItem(AUTH_SESSION_KEY);
|
||||||
state.authSession = null;
|
state.authSession = null;
|
||||||
setTimeout(() => boot().catch(() => {}), 0);
|
setTimeout(() => scheduleChatReauth(), 0);
|
||||||
return await new Promise(() => {});
|
const err = new Error("auth_required");
|
||||||
|
err.name = "AuthRequiredError";
|
||||||
|
err.authRequired = true;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`POST ${path} ${res.status}`);
|
if (!res.ok) throw new Error(`POST ${path} ${res.status}`);
|
||||||
return data ?? {};
|
return data ?? {};
|
||||||
|
|
@ -1108,8 +1129,11 @@ async function apiPatch(path, body) {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
localStorage.removeItem(AUTH_SESSION_KEY);
|
localStorage.removeItem(AUTH_SESSION_KEY);
|
||||||
state.authSession = null;
|
state.authSession = null;
|
||||||
setTimeout(() => boot().catch(() => {}), 0);
|
setTimeout(() => scheduleChatReauth(), 0);
|
||||||
return await new Promise(() => {});
|
const err = new Error("auth_required");
|
||||||
|
err.name = "AuthRequiredError";
|
||||||
|
err.authRequired = true;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`PATCH ${path} ${res.status}`);
|
if (!res.ok) throw new Error(`PATCH ${path} ${res.status}`);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|
@ -1124,8 +1148,11 @@ async function apiDelete(path) {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
localStorage.removeItem(AUTH_SESSION_KEY);
|
localStorage.removeItem(AUTH_SESSION_KEY);
|
||||||
state.authSession = null;
|
state.authSession = null;
|
||||||
setTimeout(() => boot().catch(() => {}), 0);
|
setTimeout(() => scheduleChatReauth(), 0);
|
||||||
return await new Promise(() => {});
|
const err = new Error("auth_required");
|
||||||
|
err.name = "AuthRequiredError";
|
||||||
|
err.authRequired = true;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`DELETE ${path} ${res.status}`);
|
if (!res.ok) throw new Error(`DELETE ${path} ${res.status}`);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|
@ -2898,8 +2925,22 @@ async function fileToPayloadEntry(file) {
|
||||||
|
|
||||||
async function renderLogin() {
|
async function renderLogin() {
|
||||||
applyI18nStatic();
|
applyI18nStatic();
|
||||||
const username = el("input", { class: "input", placeholder: t("auth.username") });
|
if (!state.loginDraft) state.loginDraft = { username: "", password: "" };
|
||||||
const password = el("input", { class: "input", type: "password", placeholder: t("auth.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 status = el("div", { class: "muted", text: "" });
|
||||||
const btn = el("button", {
|
const btn = el("button", {
|
||||||
class: "btn btn--primary",
|
class: "btn btn--primary",
|
||||||
|
|
@ -2907,6 +2948,12 @@ async function renderLogin() {
|
||||||
text: t("auth.login"),
|
text: t("auth.login"),
|
||||||
});
|
});
|
||||||
let busy = false;
|
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) => {
|
const setBusy = (on) => {
|
||||||
busy = !!on;
|
busy = !!on;
|
||||||
btn.disabled = busy;
|
btn.disabled = busy;
|
||||||
|
|
@ -2914,6 +2961,7 @@ async function renderLogin() {
|
||||||
};
|
};
|
||||||
const doLogin = async () => {
|
const doLogin = async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
syncDraft();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
status.textContent = t("auth.loggingIn");
|
status.textContent = t("auth.loggingIn");
|
||||||
try {
|
try {
|
||||||
|
|
@ -2934,6 +2982,8 @@ async function renderLogin() {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
draft.username = "";
|
||||||
|
draft.password = "";
|
||||||
localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token));
|
localStorage.setItem(AUTH_TOKEN_KEY, String(resp.token));
|
||||||
localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
|
localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
|
||||||
state.authSession = resp.session || null;
|
state.authSession = resp.session || null;
|
||||||
|
|
@ -2946,10 +2996,25 @@ async function renderLogin() {
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
await boot();
|
await invokeChatBoot();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = String((err && err.message) || err || t("auth.invalid"));
|
if (err && err.authRequired) return;
|
||||||
setBusy(false);
|
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) => {
|
const onEnterLogin = (ev) => {
|
||||||
|
|
@ -2963,7 +3028,7 @@ async function renderLogin() {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
doLogin();
|
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: "card__title", text: t("auth.login") }),
|
||||||
el("div", { class: "chat-login-fields" }, [username, password]),
|
el("div", { class: "chat-login-fields" }, [username, password]),
|
||||||
el("div", { class: "row chat-login-actions" }, [btn]),
|
el("div", { class: "row chat-login-actions" }, [btn]),
|
||||||
|
|
@ -2971,7 +3036,9 @@ async function renderLogin() {
|
||||||
]);
|
]);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
username.focus();
|
if (document.activeElement === username || document.activeElement === password) return;
|
||||||
|
if (draft.username && !draft.password) password.focus();
|
||||||
|
else username.focus();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 0);
|
}, 0);
|
||||||
return el("div", { class: "chat-app--login" }, [card]);
|
return el("div", { class: "chat-app--login" }, [card]);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ async function boot() {
|
||||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
localStorage.removeItem(AUTH_SESSION_KEY);
|
localStorage.removeItem(AUTH_SESSION_KEY);
|
||||||
state.authSession = null;
|
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 {
|
try {
|
||||||
await withTimeout(apiPost("/admin/api/auth/bootstrap", {}), 2500, "auth_bootstrap_timeout");
|
await withTimeout(apiPost("/admin/api/auth/bootstrap", {}), 2500, "auth_bootstrap_timeout");
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -57,6 +64,8 @@ async function boot() {
|
||||||
syncAuthUserLabel();
|
syncAuthUserLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.boot = boot;
|
||||||
|
|
||||||
document.body.addEventListener("click", async (e) => {
|
document.body.addEventListener("click", async (e) => {
|
||||||
const menuBtn = e.target.closest && e.target.closest(".chat-sess-menu-item[data-menu-action]");
|
const menuBtn = e.target.closest && e.target.closest(".chat-sess-menu-item[data-menu-action]");
|
||||||
if (menuBtn) {
|
if (menuBtn) {
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,6 @@ export const state = {
|
||||||
statusReasonPairs: [],
|
statusReasonPairs: [],
|
||||||
jobsBadgeEl: null,
|
jobsBadgeEl: null,
|
||||||
jobsBtnLabelEl: null,
|
jobsBtnLabelEl: null,
|
||||||
|
/** @type {null | (() => Promise<any>)} set by main.js to avoid circular imports */
|
||||||
|
boot: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -144,22 +144,37 @@ function getStoredAuthToken() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */
|
/** 会话在服务端失效或本地缺 token 时清理,并下一帧回到登录(避免在 router 内部 await router 盖住登录页) */
|
||||||
|
let _reauthTimer = null;
|
||||||
function scheduleReauthAfter401(requestUrl) {
|
function scheduleReauthAfter401(requestUrl) {
|
||||||
const u = String(requestUrl || "");
|
const u = String(requestUrl || "");
|
||||||
if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) return;
|
if (u.includes("/admin/api/auth/login") || u.includes("/admin/api/auth/bootstrap")) return;
|
||||||
authStoreRemove(AUTH_TOKEN_KEY);
|
authStoreRemove(AUTH_TOKEN_KEY);
|
||||||
authStoreRemove(AUTH_SESSION_KEY);
|
authStoreRemove(AUTH_SESSION_KEY);
|
||||||
state.authSession = null;
|
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;
|
const fn = state.reauthHandler;
|
||||||
if (typeof fn === "function") fn();
|
if (typeof fn === "function") fn();
|
||||||
}, 0);
|
}, 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() {
|
function _haltAfter401() {
|
||||||
// 401 means "login required". We re-route to login; callers should not show a "request failed" popup.
|
// 401 means "login required". Re-auth is already scheduled; reject so page boots
|
||||||
// Returning a never-resolving promise avoids bubbling errors into UI code that would flash an alert.
|
// (Promise.all / await) do not hang forever on a never-resolving promise.
|
||||||
return new Promise(() => {});
|
return Promise.reject(_authRequiredError());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiGet(path) {
|
async function apiGet(path) {
|
||||||
|
|
@ -711,6 +726,7 @@ export {
|
||||||
getStoredAuthToken,
|
getStoredAuthToken,
|
||||||
scheduleReauthAfter401,
|
scheduleReauthAfter401,
|
||||||
_haltAfter401,
|
_haltAfter401,
|
||||||
|
isAuthRequiredError,
|
||||||
apiGet,
|
apiGet,
|
||||||
apiGetOptional,
|
apiGetOptional,
|
||||||
apiGetNoHang,
|
apiGetNoHang,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,36 @@
|
||||||
import { state, t, el, mount, 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";
|
||||||
|
|
||||||
|
/** Survive login remounts (reauth races) so typing is not wiped. */
|
||||||
|
const loginDraft = { username: "", password: "" };
|
||||||
|
|
||||||
async function renderLogin() {
|
async function renderLogin() {
|
||||||
applyI18nStatic();
|
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 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 status = el("div", { class: "muted", text: "" });
|
||||||
const btn = el("button", { class: "btn btn--primary", type: "button", text: t("auth.login") });
|
const btn = el("button", { class: "btn btn--primary", type: "button", text: t("auth.login") });
|
||||||
let busy = false;
|
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) => {
|
const setBusy = (on) => {
|
||||||
busy = !!on;
|
busy = !!on;
|
||||||
btn.disabled = busy;
|
btn.disabled = busy;
|
||||||
|
|
@ -17,6 +39,7 @@ async function renderLogin() {
|
||||||
|
|
||||||
const doLogin = async () => {
|
const doLogin = async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
syncDraft();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
status.textContent = t("auth.loggingIn");
|
status.textContent = t("auth.loggingIn");
|
||||||
try {
|
try {
|
||||||
|
|
@ -32,6 +55,8 @@ async function renderLogin() {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
loginDraft.username = "";
|
||||||
|
loginDraft.password = "";
|
||||||
const newTok = String(resp.token || "").trim();
|
const newTok = String(resp.token || "").trim();
|
||||||
authStoreSet(AUTH_TOKEN_KEY, newTok);
|
authStoreSet(AUTH_TOKEN_KEY, newTok);
|
||||||
authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
|
authStoreSet(AUTH_SESSION_KEY, JSON.stringify(resp.session || {}));
|
||||||
|
|
@ -46,8 +71,20 @@ async function renderLogin() {
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
await navigateAdmin();
|
await navigateAdmin();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status.textContent = String((err && err.message) || err || t("auth.invalid"));
|
mount(
|
||||||
setBusy(false);
|
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();
|
ev.preventDefault();
|
||||||
doLogin();
|
doLogin();
|
||||||
});
|
});
|
||||||
// Focus after mount so users can type immediately.
|
// Focus after mount so users can type immediately (keep caret if already typing).
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
username.focus();
|
if (document.activeElement === username || document.activeElement === password) return;
|
||||||
|
if (loginDraft.username && !loginDraft.password) password.focus();
|
||||||
|
else username.focus();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, 0);
|
}, 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: "card__title", text: t("auth.login") }),
|
||||||
el("div", { class: "row" }, [username]),
|
el("div", { class: "row" }, [username]),
|
||||||
userHint,
|
userHint,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
const PLUGINS_PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
|
@ -100,14 +100,17 @@ async function renderPlugins() {
|
||||||
wecom_longconn_inbound_queue_maxsize: 200,
|
wecom_longconn_inbound_queue_maxsize: 200,
|
||||||
};
|
};
|
||||||
const [p, mcp, mcpBinding, toolPolicyRaw] = await Promise.all([
|
const [p, mcp, mcpBinding, toolPolicyRaw] = await Promise.all([
|
||||||
apiGet("/admin/api/plugins"),
|
apiGetNoHang("/admin/api/plugins").then((r) => r || { plugins: [] }),
|
||||||
apiGet("/admin/api/mcp/servers").catch(() => ({ servers: [] })),
|
apiGetNoHang("/admin/api/mcp/servers").then((r) => r || { servers: [] }),
|
||||||
apiGet("/admin/api/mcp/binding").catch(() => ({
|
apiGetNoHang("/admin/api/mcp/binding").then(
|
||||||
available_specialists: ["generalist"],
|
(r) =>
|
||||||
servers: [],
|
r || {
|
||||||
mapping: {},
|
available_specialists: ["generalist"],
|
||||||
})),
|
servers: [],
|
||||||
apiGet("/admin/api/tool-policy").catch(() => null),
|
mapping: {},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
apiGetNoHang("/admin/api/tool-policy"),
|
||||||
]);
|
]);
|
||||||
let toolPolicy = toolPolicyRaw && typeof toolPolicyRaw === "object" ? toolPolicyRaw : defaultToolPolicy;
|
let toolPolicy = toolPolicyRaw && typeof toolPolicyRaw === "object" ? toolPolicyRaw : defaultToolPolicy;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
state, t, el, mount, setActive, applyI18nStatic, getRoute,
|
state, t, el, mount, setActive, applyI18nStatic, getRoute,
|
||||||
authStoreRemove, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, getStoredAuthToken, apiPost,
|
authStoreRemove, AUTH_TOKEN_KEY, AUTH_SESSION_KEY, getStoredAuthToken, apiPost,
|
||||||
|
isAuthRequiredError,
|
||||||
} from "./core.js";
|
} from "./core.js";
|
||||||
import { hasPermission, canManageApiGrants, isAdministratorUsername } from "./pages/authz.js";
|
import { hasPermission, canManageApiGrants, isAdministratorUsername } from "./pages/authz.js";
|
||||||
import { renderStack } from "./pages/stack.js";
|
import { renderStack } from "./pages/stack.js";
|
||||||
|
|
@ -26,6 +27,12 @@ async function router() {
|
||||||
authStoreRemove(AUTH_SESSION_KEY);
|
authStoreRemove(AUTH_SESSION_KEY);
|
||||||
state.authSession = null;
|
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 {
|
try {
|
||||||
await apiPost("/admin/api/auth/bootstrap", {});
|
await apiPost("/admin/api/auth/bootstrap", {});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -57,6 +64,11 @@ async function router() {
|
||||||
}
|
}
|
||||||
const forbiddenCard = () =>
|
const forbiddenCard = () =>
|
||||||
el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.forbidden") })]);
|
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) => {
|
const mountPageLoading = (titleKey) => {
|
||||||
mount(
|
mount(
|
||||||
el("div", { class: "card" }, [
|
el("div", { class: "card" }, [
|
||||||
|
|
@ -71,55 +83,55 @@ async function router() {
|
||||||
requestAnimationFrame(() => resolve());
|
requestAnimationFrame(() => resolve());
|
||||||
});
|
});
|
||||||
let view;
|
let view;
|
||||||
if (page === "stack") {
|
try {
|
||||||
mountPageLoading("title.stack");
|
if (page === "stack") {
|
||||||
await yieldForPaint();
|
mountPageLoading("title.stack");
|
||||||
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");
|
|
||||||
await yieldForPaint();
|
await yieldForPaint();
|
||||||
view = await renderPlugins();
|
view = await renderStack();
|
||||||
}
|
} else if (page === "scheduled-jobs") {
|
||||||
} else if (page === "skills") {
|
view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard();
|
||||||
if (!hasPermission("admin:read")) {
|
} else if (page === "users") {
|
||||||
view = forbiddenCard();
|
view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard();
|
||||||
} else {
|
} else if (page === "memory") view = await renderMemory();
|
||||||
mountPageLoading("title.skills");
|
else if (page === "models") view = await renderModels();
|
||||||
await yieldForPaint();
|
else if (page === "api-grants" || page === "session-monitor" || page === "admin-audit") {
|
||||||
view = await renderSkills();
|
view = el("div", { class: "card" }, [
|
||||||
}
|
el("div", { class: "card__title", text: t("common.notFound") }),
|
||||||
} else if (page === "attachments") {
|
el("div", { class: "muted", text: t("common.pageRemoved") }),
|
||||||
view = isAdministratorUsername() ? await renderAttachments() : forbiddenCard();
|
]);
|
||||||
} else if (page === "workspace-paths") {
|
} else if (page === "audit") view = await renderAudit(route.params.get("session_id") || "");
|
||||||
view =
|
else if (page === "plugins") {
|
||||||
hasPermission("admin:user:read") || hasPermission("admin:workspace_paths:read")
|
if (!hasPermission("admin:user:write")) {
|
||||||
? await renderWorkspacePaths()
|
view = forbiddenCard();
|
||||||
: forbiddenCard();
|
} else {
|
||||||
} else if (page === "profile") {
|
mountPageLoading("title.plugins");
|
||||||
view = await renderProfile();
|
await yieldForPaint();
|
||||||
} else view = el("div", { class: "card" }, [el("div", { class: "card__title", text: t("common.notFound") })]);
|
view = await renderPlugins();
|
||||||
mount(view);
|
}
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue