const DEFAULT_AUTH_USERNAME = "admin"; const DEFAULT_AUTH_PASSWORD = "hwlab2026"; const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000; const AUTH_STORAGE_KEY = "hwlab.cloudWorkbench.auth.v1"; 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) { if (config.mode !== "local") { const serverSession = await fetchServerSession(); if (serverSession.available) { if (!serverSession.authenticated && config.mode !== "server") { const localSession = readLocalSession(config); if (localSession.authenticated) { const restoredServerSession = await restoreServerSessionFromLocal(config); if (restoredServerSession.authenticated) return restoredServerSession; return localSession; } } return { authenticated: serverSession.authenticated, mode: "server", user: serverSession.user, expiresAt: serverSession.expiresAt }; } if (config.mode === "server") { return { authenticated: false, mode: "server" }; } } return readLocalSession(config); } async function restoreServerSessionFromLocal(config) { const serverResult = await attemptServerLogin(config.username, config.password); if (!serverResult.available || !serverResult.authenticated) return { authenticated: false }; writeLocalSession(config); 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 readLocalSession(config) { const now = Date.now(); const payload = readStoredLocalSession(); if (!payload || payload.expiresAt <= now || payload.username !== config.username) { clearLocalSession(); return { authenticated: false, mode: "local" }; } return { authenticated: true, mode: "local", user: { username: payload.username }, expiresAt: new Date(payload.expiresAt).toISOString() }; } function readStoredLocalSession() { try { const raw = window.localStorage?.getItem(AUTH_STORAGE_KEY); if (!raw) return null; const payload = JSON.parse(raw); if (payload?.version !== 1) return null; const expiresAt = Number(payload.expiresAt); if (!Number.isFinite(expiresAt)) return null; return { username: typeof payload.username === "string" ? payload.username : "", expiresAt }; } catch { return null; } } function writeLocalSession(config) { const expiresAt = Date.now() + config.sessionTtlMs; try { window.localStorage?.setItem( AUTH_STORAGE_KEY, JSON.stringify({ version: 1, username: config.username, issuedAt: Date.now(), expiresAt }) ); } catch { // A session can still continue for the current page load when storage is unavailable. } return { authenticated: true, mode: "local", user: { username: config.username }, expiresAt: new Date(expiresAt).toISOString() }; } function clearLocalSession() { try { window.localStorage?.removeItem(AUTH_STORAGE_KEY); } catch { // Ignore storage availability errors. } } 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 }) { if (config.mode !== "local") { const serverResult = await attemptServerLogin(username, password); if (serverResult.available) { if (serverResult.authenticated) { writeLocalSession(config); return { authenticated: true, mode: "server", user: serverResult.user, expiresAt: serverResult.expiresAt }; } return { authenticated: false, mode: "server" }; } if (config.mode === "server") { return { authenticated: false, mode: "server" }; } } if (username === config.username && password === config.password) { return writeLocalSession(config); } return { authenticated: false, mode: "local" }; } 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; }