498 lines
20 KiB
JavaScript
498 lines
20 KiB
JavaScript
/* 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: ""
|
||
};
|