Files
iAOP/web/shared/session.js
T

498 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* iAOP 全站统一会话层(FBA 接入版,Epic #159 · Phase 1,2026-08)。
*
* 认证轨道优先级:
* 1. FBA(fastapi-best-architecture,统一认证/RBAC 基座)
* · 部署形态:同源 nginx 以 /fba/ 前缀反代到 fba_server(见 deploy/fba/)
* · 登录 POST /fba/api/v1/auth/login {username,password,uuid,captcha}
* · 用户 GET /fba/api/v1/sys/users/me(Bearer JWT)
* · 刷新 POST /fba/api/v1/auth/refresh(refresh cookie)
* · 登出 POST /fba/api/v1/auth/logout
* · JWT 落 localStorage(iaop.fba.token.v1),401 时尝试 refresh 一次
* 2. core/auth(iAOP 旧后端 /auth/*,HMAC 会话 HttpOnly cookie)—— 保留过渡
* 3. UserStore 本地账号(纯静态部署演示)—— 最终降级
*
* FBA 是否可用通过探测 /fba/api/v1/auth/captcha 判定(结果缓存于会话内)。
*
* FBA 角色名 → iAOP 角色映射(FBA 侧角色按此命名即可对上,见 FBA_ROLE_MAP):
* is_superuser → admin
* 角色名含 admin → admin
* 角色名含 engineer/editor → engineer
* 其他/无角色 → readonly
*
* 对外 API 保持与旧版一致(currentUser/login/logout/requireLoginElseRedirect/
* modulesFor/canAccess),新增:
* fbaStatus() → Promise<{available, captchaEnabled}> 登录页据此显示验证码
* getCaptcha() → Promise<{uuid, imageSrc}> 刷新图形验证码
* getAuthHeaders() → {Authorization: 'Bearer ...'} | {} 业务请求带 JWT(Phase 2/4 用)
* login(username, password, captchaOpt) 第三参 {uuid, captcha} 仅在 FBA 轨道需要。
*/
"use strict";
var IAOP_SESSION = (function () {
var SESSION_KEY = "iaop.session.v1"; // 旧本地会话(降级轨道)
var FBA_TOKEN_KEY = "iaop.fba.token.v1"; // FBA JWT
var FBA_BASE = "/fba/api/v1"; // nginx 反代前缀(deploy/fba/nginx-fba.conf)
var BACKEND_TIMEOUT_MS = 1500;
var FBA_TIMEOUT_MS = 2500;
/* ---- 模块注册表(门户卡片 + 访问控制单一数据源) ---------------------- */
var MODULES = [
{ key: "cockpit", title: "配置化驾驶舱", icon: "🚀", url: "cockpit/",
desc: "四状态工艺流程视图、实时趋势、KPI 卡片、告警面板、NL 查询(PRD 5.5)",
roles: ["readonly", "engineer", "admin"], tag: "只读", tagColor: "default" },
{ key: "chat", title: "对话助手", icon: "💬", url: "chat/assistant.html",
desc: "NL 工艺查询 / 报警智能解释 / 交接班报告生成(PRD 5.4)",
roles: ["readonly", "engineer", "admin"], tag: "AI 助手", tagColor: "blue" },
{ key: "studio", title: "模板配置台", icon: "🧩", url: "studio/",
desc: "点位字典导入 / 模型超参 / RAG 知识库 / 布局编排 / 版本发布(PRD 5.7)",
roles: ["engineer", "admin"], tag: "可配置", tagColor: "green",
children: [
{ key: "import", title: "点位导入", hash: "#import" },
{ key: "hyper", title: "模型超参", hash: "#hyper" },
{ key: "layout", title: "驾驶舱编排", hash: "#layout" },
{ key: "version", title: "版本发布", hash: "#version" }
] },
{ key: "admin", title: "管理控制台", icon: "⚙️", url: "admin/",
desc: "模型管理(版本/阶段/回滚)、知识库管理、告警确认、审计查询",
roles: ["admin"], tag: "管理员", tagColor: "gold",
children: [
{ key: "models", title: "模型管理", hash: "#models" },
{ key: "kb", title: "知识库管理", hash: "#kb" },
{ key: "alerts", title: "告警确认", hash: "#alerts" },
{ key: "audit", title: "审计查询", hash: "#audit" }
] },
{ key: "users", title: "用户与角色", icon: "👤", url: "auth/users.html",
desc: "用户管理、角色分配(readonly / engineer / admin)、审计(PRD 8.2)",
roles: ["admin"], tag: "管理员", tagColor: "gold" },
{ key: "mobile", title: "移动端驾驶舱", icon: "📱", url: "mobile/",
desc: "移动端只读驾驶舱与交接班摘要(iOS14+ / Android 10+)",
roles: ["readonly", "engineer", "admin"], tag: "移动端", tagColor: "default" }
];
var ROLE_LABELS = { readonly: "Viewer · 只读", engineer: "Editor · 配置",
admin: "Publisher · 管理" };
var ROLE_TAG_COLORS = { readonly: "default", engineer: "green", admin: "gold" };
/* FBA 角色名(小写)→ iAOP 角色。FBA 后台建角色时按此命名即可自动映射。 */
var FBA_ROLE_MAP = {
admin: "admin", administrator: "admin", "超级管理员": "admin", "管理员": "admin",
engineer: "engineer", editor: "engineer", "工程师": "engineer",
viewer: "readonly", readonly: "readonly", "只读": "readonly"
};
/* ---- 通用工具 ---------------------------------------------------------- */
function fetchWithTimeout(url, opts, timeoutMs) {
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () { reject(new Error("timeout")); },
timeoutMs || BACKEND_TIMEOUT_MS);
fetch(url, opts).then(function (r) {
clearTimeout(timer); resolve(r);
}, function (e) {
clearTimeout(timer); reject(e);
});
});
}
function readJson(key) {
try {
var v = localStorage.getItem(key);
return v ? JSON.parse(v) : null;
} catch (e) { return null; }
}
function writeJson(key, obj) {
try { localStorage.setItem(key, JSON.stringify(obj)); } catch (e) {}
}
function removeKey(key) {
try { localStorage.removeItem(key); } catch (e) {}
}
/* =======================================================================
* 轨道 1:FBA
* ===================================================================== */
var _fbaProbe = null; // 缓存探测 Promise,避免每个页面重复打探测请求
/* 探测 FBA 可用性 + 验证码开关。结果:{available, captchaEnabled} */
function fbaStatus() {
if (_fbaProbe) return _fbaProbe;
_fbaProbe = fetchWithTimeout(FBA_BASE + "/auth/captcha", null, FBA_TIMEOUT_MS)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.code === 200 && d.data) {
return { available: true, captchaEnabled: !!d.data.is_enabled };
}
return { available: false, captchaEnabled: false };
})
.catch(function () { return { available: false, captchaEnabled: false }; });
return _fbaProbe;
}
/* 拉取图形验证码:{uuid, imageSrc};FBA 不可用或已关闭验证码时 resolve(null) */
function getCaptcha() {
return fetchWithTimeout(FBA_BASE + "/auth/captcha", null, FBA_TIMEOUT_MS)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.code === 200 && d.data && d.data.is_enabled) {
return {
uuid: d.data.uuid,
imageSrc: "data:image/jpeg;base64," + d.data.image
};
}
return null;
})
.catch(function () { return null; });
}
function saveFbaToken(data) {
writeJson(FBA_TOKEN_KEY, {
access_token: data.access_token,
session_uuid: data.session_uuid,
expire: data.access_token_expire_time || null,
loginAt: new Date().toISOString()
});
}
function readFbaToken() {
var t = readJson(FBA_TOKEN_KEY);
return t && t.access_token ? t : null;
}
function clearFbaToken() { removeKey(FBA_TOKEN_KEY); }
function getAuthHeaders() {
var t = readFbaToken();
return t ? { Authorization: "Bearer " + t.access_token } : {};
}
/* FBA 用户详情 → iAOP 统一用户结构 {username, role, active} */
function mapFbaUser(u) {
if (!u) return null;
var role = "readonly";
if (u.is_superuser) {
role = "admin";
} else if (Array.isArray(u.roles)) {
for (var i = 0; i < u.roles.length; i++) {
var name = String(u.roles[i] || "").toLowerCase();
if (FBA_ROLE_MAP[name]) { role = FBA_ROLE_MAP[name]; break; }
// 部分匹配兜底:角色名包含关键字
if (name.indexOf("admin") >= 0) { role = "admin"; break; }
if (name.indexOf("engineer") >= 0 || name.indexOf("editor") >= 0) {
role = "engineer";
}
}
}
return { username: u.username, nickname: u.nickname || u.username,
role: role, active: u.status === 1 || u.status === "1" || u.active !== false,
is_superuser: !!u.is_superuser, source: "fba" };
}
/* 带 JWT 调 FBA 接口;401 时先 refresh 一次再重试 */
function fbaFetch(path, opts, _retried) {
opts = opts || {};
opts.headers = Object.assign({}, getAuthHeaders(), opts.headers || {});
return fetch(FBA_BASE + path, opts).then(function (r) {
if (r.status === 401 && !_retried) {
return fbaRefresh().then(function (ok) {
if (!ok) throw Object.assign(new Error("fba-unauthorized"), { fba401: true });
return fbaFetch(path, opts, true);
});
}
return r;
});
}
/* 刷新 access token(refresh token 走 HttpOnly cookie,由登录响应种下) */
function fbaRefresh() {
return fetch(FBA_BASE + "/auth/refresh", {
method: "POST", credentials: "include"
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.code === 200 && d.data && d.data.access_token) {
saveFbaToken(d.data);
return true;
}
clearFbaToken();
return false;
})
.catch(function () { return false; });
}
/* FBA 当前用户:无 token 直接 null;401/网络错 → null(交给降级轨道) */
function fbaCurrentUser() {
if (!readFbaToken()) return Promise.resolve(null);
return fbaFetch("/sys/users/me")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.code === 200 && d.data) return mapFbaUser(d.data);
return null;
})
.catch(function () { return null; });
}
/* FBA 登录。captchaOpt = {uuid, captcha}(FBA 默认强制图形验证码) */
function fbaLogin(username, password, captchaOpt) {
var body = { username: username, password: password };
if (captchaOpt && captchaOpt.uuid) {
body.uuid = captchaOpt.uuid;
body.captcha = captchaOpt.captcha || "";
}
return fetch(FBA_BASE + "/auth/login", {
method: "POST",
credentials: "include", // 种下 refresh cookie
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(function (r) {
return r.json().then(function (d) {
if (r.ok && d && d.code === 200 && d.data && d.data.access_token) {
saveFbaToken(d.data);
var user = mapFbaUser(d.data.user) || { username: username, role: "readonly", active: true, source: "fba" };
return user;
}
// FBA 在线且明确拒绝(密码错/验证码错/账号锁定等):不降级,直接抛给登录页
var msg = (d && d.msg) || "用户名或密码错误";
throw Object.assign(new Error(msg), { authReject: true, fba: true });
}, function () {
throw new Error("fba-bad-response");
});
});
}
function fbaLogout() {
var t = readFbaToken();
clearFbaToken();
if (!t) return;
try {
fetch(FBA_BASE + "/auth/logout", {
method: "POST", credentials: "include",
headers: { Authorization: "Bearer " + t.access_token }
}).catch(function () {});
} catch (e) {}
}
/* =======================================================================
* 轨道 2/3:旧 core/auth + UserStore 本地账号(原逻辑保留,作为降级)
* ===================================================================== */
function saveSession(user) {
writeJson(SESSION_KEY, {
username: user.username, role: user.role,
loginAt: new Date().toISOString()
});
}
function readSession() { return readJson(SESSION_KEY); }
function clearSession() { removeKey(SESSION_KEY); }
function legacyCurrentUser() {
return fetchWithTimeout("/auth/me", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && d.user) return d.user;
throw new Error("no backend session");
})
.catch(function () {
var s = readSession();
if (!s || !s.username) return null;
if (typeof UserStore === "undefined") return s;
var u = UserStore.find(s.username);
if (!u || !u.active) { clearSession(); return null; }
return { username: u.username, role: u.role, active: u.active };
});
}
function legacyLogin(username, password) {
return fetchWithTimeout("/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password })
}).then(function (r) {
if (r.ok) return r.json().then(function (d) {
saveSession(d.user);
return d.user;
});
if (r.status >= 400 && r.status < 500) {
return r.text().then(function (t) {
var msg = "用户名或密码错误";
try { msg = (JSON.parse(t) || {}).error || msg; } catch (e) {}
throw Object.assign(new Error(msg), { authReject: true });
});
}
throw new Error("backend-http-" + r.status);
}).catch(function (e) {
if (e && e.authReject) throw e;
if (typeof UserStore === "undefined") {
throw new Error("认证服务不可用,且本地账号存储未加载");
}
return UserStore.seedDefaults().then(function () {
return UserStore.verify(username, password);
}).then(function (u) {
if (!u) throw new Error("用户名或密码错误");
saveSession(u);
return u;
});
});
}
/* =======================================================================
* 对外统一 API
* ===================================================================== */
/* 当前用户:FBA(有 token 时)→ 旧后端 → 本地会话 */
function currentUser() {
return fbaCurrentUser().then(function (u) {
if (u) return u;
return legacyCurrentUser();
});
}
/* 登录:FBA 可用走 FBA(拒绝不降级);FBA 不可达 → 旧双轨 */
function login(username, password, captchaOpt) {
return fbaStatus().then(function (st) {
if (st.available) {
return fbaLogin(username, password, captchaOpt).catch(function (e) {
if (e && (e.authReject || e.fba401)) throw e;
// FBA 探测可用但登录请求网络层失败 → 允许降级旧轨道
if (e && e.message && e.message.indexOf("fba-") === 0) throw e;
return legacyLogin(username, password);
});
}
return legacyLogin(username, password);
});
}
/* 被 iframe 嵌入时(SPA 外壳)重定向要作用于顶层窗口 */
function topWindow() {
try { return window.self !== window.top ? window.top : window; }
catch (e) { return window; }
}
/* 登出:三条轨道都清 */
function logout(loginUrl) {
clearSession();
fbaLogout();
try {
fetch("/auth/logout", { method: "POST", credentials: "include" })
.catch(function () {});
} catch (e) {}
var next = encodeURIComponent(location.pathname + location.search);
topWindow().location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
}
function requireLoginElseRedirect(loginUrl) {
return currentUser().then(function (u) {
if (!u) {
var top = topWindow();
var next = encodeURIComponent(
top.location.pathname + top.location.search + top.location.hash);
top.location.href = (loginUrl || "../auth/login.html") + "?next=" + next;
return false;
}
return u;
});
}
/* ---- 角色→模块 ---------------------------------------------------------- */
function modulesFor(role) {
return MODULES.filter(function (m) { return m.roles.indexOf(role) >= 0; });
}
function canAccess(moduleKey, role) {
var m = MODULES.find(function (x) { return x.key === moduleKey; });
return !!m && m.roles.indexOf(role) >= 0;
}
/* ---- Epic #159 Phase 2:服务端驱动菜单 ----------------------------------
* 有 FBA token 时:GET /fba/api/v1/sys/menus/sidebar 取当前用户在服务端
* 被授权的菜单树(vben5 结构,按角色在服务端过滤),与本地 MODULES 注册表
* 按 name == module.key 匹配,得到「服务端授权 ∩ 本地已知模块」的最终菜单;
* 子菜单同理按 child.key 匹配过滤。
* FBA 不可用 / 无匹配(菜单未播种 deploy/fba/seed_iaop_menus.py)时,
* 降级为本地角色过滤,保证菜单不空白。
* 返回 Promise<MODULES 子集>。 */
function menuTree(user) {
if (!user) return Promise.resolve([]);
if (!readFbaToken()) return Promise.resolve(modulesFor(user.role));
return fbaFetch("/sys/menus/sidebar")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || d.code !== 200 || !Array.isArray(d.data)) {
return modulesFor(user.role);
}
var allowed = {}; // moduleKey -> true(无子菜单)| {childKey: true}
d.data.forEach(function (node) {
if (!node || !node.name) return;
var mod = null;
for (var i = 0; i < MODULES.length; i++) {
if (MODULES[i].key === node.name) { mod = MODULES[i]; break; }
}
if (!mod) return; // FBA 自有菜单(系统管理等)不匹配,跳过
if (node.children && node.children.length) {
var kids = {};
node.children.forEach(function (c) {
if (c && c.name) kids[c.name] = true;
});
allowed[mod.key] = kids;
} else {
allowed[mod.key] = true;
}
});
var mods = MODULES.filter(function (m) { return !!allowed[m.key]; })
.map(function (m) {
if (!m.children || !m.children.length || allowed[m.key] === true) return m;
var kids = allowed[m.key];
var copy = Object.assign({}, m, {
children: m.children.filter(function (c) { return !!kids[c.key]; })
});
return copy;
});
// 服务端树里没有任何 iAOP 模块(未播种)→ 本地角色过滤兜底
return mods.length ? mods : modulesFor(user.role);
})
.catch(function () { return modulesFor(user.role); });
}
/* ---- Epic #159 Phase 4:按钮级权限码 ------------------------------------
* FBA GET /auth/codes 返回当前用户权限码集合(服务端按角色聚合;
* 超管返回全部菜单 perms,含播种脚本写入的 iaop:<module>)。
* 结果按会话缓存(登录后只拉一次),非 FBA 轨道返回空数组。 */
var _codesCache = null; // Promise<string[]> | null
function perms() {
if (_codesCache) return _codesCache;
if (!readFbaToken()) return Promise.resolve([]);
_codesCache = fbaFetch("/auth/codes")
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
return d && d.code === 200 && Array.isArray(d.data) ? d.data : [];
})
.catch(function () { return []; });
return _codesCache;
}
function hasPerm(code) {
return perms().then(function (codes) {
return codes.indexOf(code) >= 0;
});
}
return {
MODULES: MODULES,
ROLE_LABELS: ROLE_LABELS,
ROLE_TAG_COLORS: ROLE_TAG_COLORS,
currentUser: currentUser,
login: login,
logout: logout,
requireLoginElseRedirect: requireLoginElseRedirect,
modulesFor: modulesFor,
canAccess: canAccess,
/* FBA 新增 */
fbaStatus: fbaStatus,
getCaptcha: getCaptcha,
getAuthHeaders: getAuthHeaders,
menuTree: menuTree,
perms: perms,
hasPerm: hasPerm
};
})();
/* 兼容层:与 #150 auth.js 导出同签名(studio/admin/users 直接复用)。
* 若页面同时加载了 auth.js,本兼容层在后加载时覆盖之,获得本地降级能力。 */
window.IAOP_AUTH = {
currentUser: IAOP_SESSION.currentUser,
requireLoginElseRedirect: IAOP_SESSION.requireLoginElseRedirect,
AUTH_BASE: ""
};