const DEFAULT_AUTH_USERNAME = "admin"; const DEFAULT_AUTH_PASSWORD = "hwlab2026"; const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000; const AUTH_ROUTE = Object.freeze({ session: "/auth/session", login: "/auth/login", logout: "/auth/logout" }); let activeAuth = null; let expiryTimer = null; export async function ensureWorkbenchAuth({ loginShell, appShell }) { if (!loginShell || !appShell) { throw new Error("missing auth shell"); } const config = resolveAuthConfig(); hideWorkbench(appShell); showLogin(loginShell); const session = await readActiveSession(config); if (session.authenticated) { return activateAuthenticatedWorkbench({ loginShell, appShell, session, config }); } return new Promise((resolve) => { initLoginForm({ loginShell, appShell, config, onAuthenticated: (authSession) => { resolve(activateAuthenticatedWorkbench({ loginShell, appShell, session: authSession, config })); } }); }); } export function initWorkbenchLogout(button) { if (!button) return; button.addEventListener("click", async () => { await logoutActiveSession(); window.location.reload(); }); } function resolveAuthConfig() { const configured = globalThis.HWLAB_CLOUD_WEB_CONFIG?.auth ?? {}; const sessionTtlMs = clampSessionTtl(configured.sessionTtlMs ?? configured.ttlMs ?? DEFAULT_SESSION_TTL_MS); const mode = ["server", "local", "auto"].includes(configured.mode) ? configured.mode : "auto"; return { mode, username: nonEmptyString(configured.username, DEFAULT_AUTH_USERNAME), password: nonEmptyString(configured.password, DEFAULT_AUTH_PASSWORD), sessionTtlMs }; } function nonEmptyString(value, fallback) { return typeof value === "string" && value.trim() ? value.trim() : fallback; } function clampSessionTtl(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) return DEFAULT_SESSION_TTL_MS; return Math.min(Math.max(Math.trunc(parsed), 60_000), 7 * 24 * 60 * 60 * 1000); } async function readActiveSession(config) { const serverSession = await fetchServerSession(); if (serverSession.available) { return { authenticated: serverSession.authenticated, mode: "server", user: serverSession.user, expiresAt: serverSession.expiresAt }; } if (config.mode !== "auto" || !config.username || !config.password) { return { authenticated: false, mode: "server" }; } const serverResult = await attemptServerLogin(config.username, config.password); if (!serverResult.available || !serverResult.authenticated) { return { authenticated: false, mode: "server" }; } return { authenticated: true, mode: "server", user: serverResult.user, expiresAt: serverResult.expiresAt }; } async function fetchServerSession() { try { const response = await fetch(AUTH_ROUTE.session, { method: "GET", credentials: "same-origin", headers: { accept: "application/json" } }); if (response.status === 404 || response.status === 405) { return { available: false }; } const payload = await response.json().catch(() => ({})); return { available: true, authenticated: response.ok && payload.authenticated === true, user: safeUser(payload.user), expiresAt: stringOrNull(payload.expiresAt) }; } catch { return { available: false }; } } function clearLocalSession() { // Legacy local-only workbench auth was removed; keep cleanup idempotent for old browsers. try { window.localStorage?.removeItem("hwlab.cloudWorkbench.auth.v1"); } catch {} } function initLoginForm({ loginShell, appShell, config, onAuthenticated }) { const form = required(loginShell, "#login-form"); const usernameInput = required(loginShell, "#login-username"); const passwordInput = required(loginShell, "#login-password"); const submit = required(loginShell, "#login-submit"); const error = required(loginShell, "#login-error"); form.addEventListener("submit", async (event) => { event.preventDefault(); setLoginError(error, ""); submit.disabled = true; form.setAttribute("aria-busy", "true"); const username = usernameInput.value.trim(); const password = passwordInput.value; const result = await attemptLogin({ username, password, config }); submit.disabled = false; form.removeAttribute("aria-busy"); if (!result.authenticated) { hideWorkbench(appShell); showLogin(loginShell); setLoginError(error, "账号或密码不正确,请重新输入。"); passwordInput.select(); passwordInput.focus(); return; } onAuthenticated(result); }); window.requestAnimationFrame(() => usernameInput.focus()); } async function attemptLogin({ username, password, config }) { const serverResult = await attemptServerLogin(username, password); if (serverResult.available && serverResult.authenticated) { return { authenticated: true, mode: "server", user: serverResult.user, expiresAt: serverResult.expiresAt }; } return { authenticated: false, mode: "server" }; } async function attemptServerLogin(username, password) { try { const response = await fetch(AUTH_ROUTE.login, { method: "POST", credentials: "same-origin", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ username, password }) }); if (response.status === 404 || response.status === 405) { return { available: false }; } const payload = await response.json().catch(() => ({})); return { available: true, authenticated: response.ok && payload.authenticated === true, user: safeUser(payload.user), expiresAt: stringOrNull(payload.expiresAt) }; } catch { return { available: false }; } } function activateAuthenticatedWorkbench({ loginShell, appShell, session, config }) { activeAuth = { mode: session.mode, config }; hideLogin(loginShell); showWorkbench(appShell); scheduleExpiry(session.expiresAt); return session; } function showLogin(loginShell) { loginShell.hidden = false; loginShell.removeAttribute("aria-hidden"); document.body.dataset.authState = "login"; } function hideLogin(loginShell) { loginShell.hidden = true; loginShell.setAttribute("aria-hidden", "true"); } function showWorkbench(appShell) { appShell.hidden = false; appShell.removeAttribute("aria-hidden"); document.body.dataset.authState = "authenticated"; } function hideWorkbench(appShell) { appShell.hidden = true; appShell.setAttribute("aria-hidden", "true"); } function setLoginError(error, message) { error.textContent = message; error.hidden = !message; } async function logoutActiveSession() { window.clearTimeout(expiryTimer); expiryTimer = null; clearLocalSession(); if (activeAuth?.mode === "server") { try { await fetch(AUTH_ROUTE.logout, { method: "POST", credentials: "same-origin", headers: { accept: "application/json" } }); } catch { // Reloading after a failed logout still returns to the login check path. } } } function scheduleExpiry(expiresAt) { window.clearTimeout(expiryTimer); expiryTimer = null; const deadline = Date.parse(expiresAt ?? ""); if (!Number.isFinite(deadline)) return; const remaining = deadline - Date.now(); if (remaining <= 0) { void logoutActiveSession().then(() => window.location.reload()); return; } expiryTimer = window.setTimeout(() => { void logoutActiveSession().then(() => window.location.reload()); }, Math.min(remaining, 2_147_483_647)); } function required(root, selector) { const element = root.querySelector(selector); if (!element) throw new Error(`missing auth element ${selector}`); return element; } function safeUser(user) { const username = typeof user?.username === "string" && user.username.trim() ? user.username.trim() : DEFAULT_AUTH_USERNAME; return { username }; } function stringOrNull(value) { return typeof value === "string" && value.trim() ? value.trim() : null; }