feat: 完成 issue #134 登录认证+用户/角色管理(web/auth,RBAC 三级,OIDC 预留,接入配置台)
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# web/auth — iAOP 登录认证 + 用户/角色管理
|
||||||
|
|
||||||
|
issue #134 / PRD 8.2 认证鉴权。纯静态(无构建链)Demo 级实现:
|
||||||
|
登录页 + 会话管理 + 用户/角色管理 + 三级 RBAC 门控 + OIDC SSO 预留。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
```
|
||||||
|
web/auth/
|
||||||
|
├── auth.js # 共享库:用户 CRUD(加盐 SHA-256)/ 会话 / 权限矩阵 / 审计 / OIDC 配置点
|
||||||
|
├── login.html # 登录页(本地账号;配置 OIDC 后出现 SSO 入口)
|
||||||
|
├── users.html # 用户/角色管理页(仅 admin)+ OIDC 配置 + 审计日志查看
|
||||||
|
├── users.js # 管理页逻辑
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 web/ 根目录起服务(studio 集成依赖 ../auth 相对路径)
|
||||||
|
cd web && python -m http.server 8083
|
||||||
|
# 打开 http://127.0.0.1:8083/auth/login.html
|
||||||
|
```
|
||||||
|
|
||||||
|
首次运行内置管理员 **admin / admin123**(登录后请立即在用户管理页改密)。
|
||||||
|
未登录访问 `web/studio/`(模板配置台)会自动跳转登录页,登录后按会话角色
|
||||||
|
应用三级 RBAC(Viewer 只读 / Editor 配置 / Publisher 发布+回滚)。
|
||||||
|
|
||||||
|
## 语义对齐
|
||||||
|
|
||||||
|
| 能力 | 对齐后端 |
|
||||||
|
|------|----------|
|
||||||
|
| 角色 readonly / engineer / admin,动作 view / edit / publish / manage | `core/template-console/rbac.py`(Resource/Action/RoleKind) |
|
||||||
|
| 用户数据(username/role/enabled/salt/hash) | `core/data-bus/config/postgres.template.yaml` users/permissions 表(Demo 落 localStorage,后端 HTTP 就绪后替换 `UserStore` 读写即可) |
|
||||||
|
| 写操作审计(时间/操作人/动作/资源/原因) | `template_registry._log` 风格 + `prompts.drain_audit` 语义 |
|
||||||
|
| OIDC 配置点(issuer/client_id/redirect_uri) | PRD 8.2 双轨:未配置走本地账号,配置后登录页出现 SSO 入口(标准授权码流程 authorize URL 已可生成;令牌换会话的后端端点待企业 IAM 就绪后对接) |
|
||||||
|
|
||||||
|
## 各前端页面接入方式
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="../auth/auth.js"></script>
|
||||||
|
<script>
|
||||||
|
var session = Auth.requireAuth("../auth/login.html"); // 未登录 → 跳登录页
|
||||||
|
if (session) { /* Auth.can(session.role, "edit" | "publish" | "manage") 门控写操作 */ }
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
`web/studio` 已接入(未登录不可进入,写操作按会话角色门控,发布/回滚落审计);
|
||||||
|
auth.js 缺失时 studio 降级为手动角色切换演示,向后兼容。
|
||||||
|
|
||||||
|
## 安全说明(Demo 边界)
|
||||||
|
|
||||||
|
- 密码加盐 SHA-256(Web Crypto)存储,不落明文;会话 token 8 小时过期,
|
||||||
|
账号禁用/删除即时失效;
|
||||||
|
- localStorage 存储仅用于无后端 Demo;生产部署需由后端接管 users 表
|
||||||
|
(PostgreSQL)与会话签发,本库 API(login/session/can/audit)保持不变。
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/* iAOP 认证鉴权共享库(issue #134 / PRD 8.2)。
|
||||||
|
*
|
||||||
|
* 纯前端 Demo 级实现(无构建链、无后端 HTTP 服务),语义对齐
|
||||||
|
* core/template-console/rbac.py(readonly/engineer/admin 三级角色):
|
||||||
|
* - 本地账号:密码加盐 SHA-256 哈希存储(Web Crypto),落 localStorage
|
||||||
|
* (数据模型对齐 core/data-bus postgres.template.yaml 的 users/permissions 表,
|
||||||
|
* 后端 HTTP 服务就绪后仅需替换 UserStore 三处读写);
|
||||||
|
* - 会话管理:登录签发会话 token(8h 过期),各页面 Auth.requireAuth() 门控;
|
||||||
|
* - 权限矩阵:Auth.can(role, action),action ∈ view/edit/publish/manage;
|
||||||
|
* - 审计日志:写操作落 Auth.audit()(复用 template_registry._log 风格:
|
||||||
|
* 时间/操作人/动作/资源/原因),users.html 可查看;
|
||||||
|
* - 预留企业 IAM/OAuth2(OIDC SSO):Auth.configureOidc({issuer, client_id,
|
||||||
|
* redirect_uri}),配置后登录页出现「SSO 登录」按钮并跳转 authorize URL;
|
||||||
|
* 未配置走本地账号(PRD 8.2 双轨)。
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var Auth = (function () {
|
||||||
|
var USERS_KEY = "iaop.users.v1";
|
||||||
|
var SESSION_KEY = "iaop.session.v1";
|
||||||
|
var AUDIT_KEY = "iaop.audit.v1";
|
||||||
|
var OIDC_KEY = "iaop.oidc.v1";
|
||||||
|
var SESSION_TTL_MS = 8 * 3600 * 1000; // 会话 8 小时
|
||||||
|
|
||||||
|
/* 权限矩阵:对齐 rbac.py(VIEW < EDIT < PUBLISH < MANAGE,角色继承向上) */
|
||||||
|
var ROLE_PERMS = {
|
||||||
|
readonly: ["view"],
|
||||||
|
engineer: ["view", "edit"],
|
||||||
|
admin: ["view", "edit", "publish", "manage"]
|
||||||
|
};
|
||||||
|
var ROLE_LABELS = { readonly: "Viewer(只读)", engineer: "Editor(配置)",
|
||||||
|
admin: "Publisher(发布+回滚)" };
|
||||||
|
|
||||||
|
function load(key, fallback) {
|
||||||
|
try { var v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; }
|
||||||
|
catch (e) { return fallback; }
|
||||||
|
}
|
||||||
|
function save(key, obj) { localStorage.setItem(key, JSON.stringify(obj)); }
|
||||||
|
|
||||||
|
/* 密码加盐 SHA-256(Web Crypto;落库字段:salt + hash,不存明文) */
|
||||||
|
function hashPassword(password, salt) {
|
||||||
|
var data = new TextEncoder().encode(salt + ":" + password);
|
||||||
|
return crypto.subtle.digest("SHA-256", data).then(function (buf) {
|
||||||
|
return Array.from(new Uint8Array(buf))
|
||||||
|
.map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function randomHex(n) {
|
||||||
|
var buf = new Uint8Array(n);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
return Array.from(buf).map(function (b) { return b.toString(16).padStart(2, "0"); }).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 首次运行播种内置管理员(admin / admin123,登录后请立即改密) */
|
||||||
|
function ensureSeed() {
|
||||||
|
var users = load(USERS_KEY, null);
|
||||||
|
if (users) return Promise.resolve();
|
||||||
|
var salt = randomHex(16);
|
||||||
|
return hashPassword("admin123", salt).then(function (hash) {
|
||||||
|
save(USERS_KEY, [{ username: "admin", role: "admin", enabled: true,
|
||||||
|
salt: salt, hash: hash,
|
||||||
|
created_at: new Date().toISOString() }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ROLES: Object.keys(ROLE_PERMS),
|
||||||
|
ROLE_LABELS: ROLE_LABELS,
|
||||||
|
|
||||||
|
/* ---------- 用户 CRUD(users 表语义:username/role/enabled/salt/hash) ---------- */
|
||||||
|
listUsers: function () { return load(USERS_KEY, []); },
|
||||||
|
findUser: function (username) {
|
||||||
|
return this.listUsers().find(function (u) { return u.username === username; }) || null;
|
||||||
|
},
|
||||||
|
createUser: function (username, password, role) {
|
||||||
|
var self = this;
|
||||||
|
if (!/^[a-zA-Z0-9_.-]{2,32}$/.test(username)) {
|
||||||
|
return Promise.reject(new Error("用户名需 2-32 位字母数字._-"));
|
||||||
|
}
|
||||||
|
if (this.findUser(username)) return Promise.reject(new Error("用户名已存在"));
|
||||||
|
if (ROLE_PERMS[role] === undefined) return Promise.reject(new Error("非法角色"));
|
||||||
|
if ((password || "").length < 6) return Promise.reject(new Error("密码至少 6 位"));
|
||||||
|
var salt = randomHex(16);
|
||||||
|
return hashPassword(password, salt).then(function (hash) {
|
||||||
|
var users = self.listUsers();
|
||||||
|
users.push({ username: username, role: role, enabled: true,
|
||||||
|
salt: salt, hash: hash, created_at: new Date().toISOString() });
|
||||||
|
save(USERS_KEY, users);
|
||||||
|
self.audit("create", "user", username + " 角色=" + role);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateUser: function (username, patch) {
|
||||||
|
var users = this.listUsers();
|
||||||
|
var u = users.find(function (x) { return x.username === username; });
|
||||||
|
if (!u) return Promise.reject(new Error("用户不存在"));
|
||||||
|
var self = this;
|
||||||
|
var done = Promise.resolve();
|
||||||
|
if (patch.role) {
|
||||||
|
if (ROLE_PERMS[patch.role] === undefined) return Promise.reject(new Error("非法角色"));
|
||||||
|
u.role = patch.role;
|
||||||
|
}
|
||||||
|
if (patch.enabled !== undefined) u.enabled = !!patch.enabled;
|
||||||
|
if (patch.password) {
|
||||||
|
if (patch.password.length < 6) return Promise.reject(new Error("密码至少 6 位"));
|
||||||
|
u.salt = randomHex(16);
|
||||||
|
done = hashPassword(patch.password, u.salt).then(function (h) { u.hash = h; });
|
||||||
|
}
|
||||||
|
return done.then(function () {
|
||||||
|
save(USERS_KEY, users);
|
||||||
|
self.audit("update", "user", username + " " + JSON.stringify(
|
||||||
|
{ role: patch.role, enabled: patch.enabled, password: !!patch.password }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteUser: function (username) {
|
||||||
|
var users = this.listUsers().filter(function (u) { return u.username !== username; });
|
||||||
|
save(USERS_KEY, users);
|
||||||
|
this.audit("delete", "user", username);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 会话 ---------- */
|
||||||
|
login: function (username, password) {
|
||||||
|
var u = this.findUser(username);
|
||||||
|
if (!u) return Promise.reject(new Error("用户不存在"));
|
||||||
|
if (!u.enabled) return Promise.reject(new Error("账号已禁用,请联系管理员"));
|
||||||
|
var self = this;
|
||||||
|
return hashPassword(password, u.salt).then(function (hash) {
|
||||||
|
if (hash !== u.hash) throw new Error("密码错误");
|
||||||
|
var session = { username: u.username, role: u.role, token: randomHex(24),
|
||||||
|
expires: Date.now() + SESSION_TTL_MS };
|
||||||
|
save(SESSION_KEY, session);
|
||||||
|
self.audit("login", "session", u.username);
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
logout: function () {
|
||||||
|
var s = this.session();
|
||||||
|
if (s) this.audit("logout", "session", s.username);
|
||||||
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
},
|
||||||
|
session: function () {
|
||||||
|
var s = load(SESSION_KEY, null);
|
||||||
|
if (!s || !s.expires || s.expires < Date.now()) return null;
|
||||||
|
var u = this.findUser(s.username);
|
||||||
|
if (!u || !u.enabled) return null; // 禁用/删除即时失效
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
/* 页面门控:未登录 → 跳登录页(带 next 回跳);登录后角色变化以会话为准 */
|
||||||
|
requireAuth: function (loginPage) {
|
||||||
|
var s = this.session();
|
||||||
|
if (!s) {
|
||||||
|
var next = encodeURIComponent(location.pathname.split("/").pop() || "index.html");
|
||||||
|
location.href = (loginPage || "../auth/login.html") + "?next=" + next;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
can: function (role, action) {
|
||||||
|
return (ROLE_PERMS[role] || []).indexOf(action) >= 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------- 审计日志(template_registry._log 风格:时间/操作人/动作/资源/原因) ---------- */
|
||||||
|
audit: function (action, resource, reason) {
|
||||||
|
var log = load(AUDIT_KEY, []);
|
||||||
|
var s = this.session();
|
||||||
|
log.push({ time: new Date().toISOString(),
|
||||||
|
actor: s ? s.username : "anonymous",
|
||||||
|
action: action, resource: resource, reason: reason || "" });
|
||||||
|
if (log.length > 500) log = log.slice(-500); // 演示环境限长
|
||||||
|
save(AUDIT_KEY, log);
|
||||||
|
},
|
||||||
|
auditLog: function () { return load(AUDIT_KEY, []); },
|
||||||
|
|
||||||
|
/* ---------- OIDC SSO 预留(PRD 8.2 双轨:未配置走本地账号) ---------- */
|
||||||
|
configureOidc: function (cfg) { save(OIDC_KEY, cfg); this.audit("update", "oidc_config", cfg.issuer || ""); },
|
||||||
|
oidcConfig: function () { return load(OIDC_KEY, null); },
|
||||||
|
/* 构造 OIDC authorize URL(标准授权码流程;企业 IAM 就绪后直接可用) */
|
||||||
|
oidcAuthorizeUrl: function () {
|
||||||
|
var cfg = this.oidcConfig();
|
||||||
|
if (!cfg || !cfg.issuer || !cfg.client_id) return null;
|
||||||
|
var redirect = cfg.redirect_uri ||
|
||||||
|
(location.origin + location.pathname.replace(/[^/]*$/, "login.html"));
|
||||||
|
var state = randomHex(8);
|
||||||
|
sessionStorage.setItem("iaop.oidc.state", state);
|
||||||
|
return cfg.issuer.replace(/\/$/, "") + "/authorize?response_type=code" +
|
||||||
|
"&client_id=" + encodeURIComponent(cfg.client_id) +
|
||||||
|
"&redirect_uri=" + encodeURIComponent(redirect) +
|
||||||
|
"&scope=openid%20profile&state=" + state;
|
||||||
|
},
|
||||||
|
|
||||||
|
_ensureSeed: ensureSeed
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>iAOP 登录</title>
|
||||||
|
<style>
|
||||||
|
/* iAOP 登录页(issue #134 / PRD 8.2):深色主题对齐驾驶舱 */
|
||||||
|
body { margin:0; font-family:"Microsoft YaHei",sans-serif; background:#0b1220;
|
||||||
|
color:#e6edf6; display:flex; align-items:center; justify-content:center;
|
||||||
|
min-height:100vh; }
|
||||||
|
.card { background:#13203a; border:1px solid rgba(255,255,255,.06); border-radius:10px;
|
||||||
|
padding:28px 32px; width:340px; }
|
||||||
|
h1 { font-size:18px; color:#18d3c8; margin:0 0 4px; }
|
||||||
|
.sub { font-size:12px; color:#8aa0bd; margin-bottom:18px; }
|
||||||
|
label { display:block; font-size:13px; margin:12px 0 4px; }
|
||||||
|
input { width:100%; padding:9px 10px; border-radius:6px; border:1px solid rgba(255,255,255,.12);
|
||||||
|
background:#0b1220; color:#e6edf6; box-sizing:border-box; }
|
||||||
|
button { width:100%; margin-top:18px; padding:10px; border:none; border-radius:6px;
|
||||||
|
background:#18d3c8; color:#04202a; font-size:14px; cursor:pointer; }
|
||||||
|
#sso-btn { background:#1e293b; color:#e6edf6; margin-top:10px; }
|
||||||
|
.err { color:#ff3b30; font-size:12px; margin-top:10px; min-height:16px; }
|
||||||
|
.hint { font-size:11px; color:#64748b; margin-top:14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>iAOP 平台登录</h1>
|
||||||
|
<div class="sub">本地账号 / 企业 IAM(SSO) 双轨 · PRD 8.2</div>
|
||||||
|
<label>用户名</label>
|
||||||
|
<input id="username" autocomplete="username" placeholder="admin">
|
||||||
|
<label>密码</label>
|
||||||
|
<input id="password" type="password" autocomplete="current-password" placeholder="admin123(首次内置)">
|
||||||
|
<button id="login-btn">登录</button>
|
||||||
|
<button id="sso-btn" hidden>企业 SSO 登录(OIDC)</button>
|
||||||
|
<div class="err" id="err"></div>
|
||||||
|
<div class="hint">首次运行内置管理员 admin / admin123,登录后请在用户管理页修改密码。<br>
|
||||||
|
SSO 未配置时仅本地账号可用;配置 OIDC(issuer/client_id)后此处出现 SSO 入口。</div>
|
||||||
|
</div>
|
||||||
|
<script src="auth.js"></script>
|
||||||
|
<script>
|
||||||
|
// 已登录则直接回跳
|
||||||
|
var next = new URLSearchParams(location.search).get("next") || "../studio/index.html";
|
||||||
|
Auth._ensureSeed().then(function () {
|
||||||
|
if (Auth.session()) location.href = next;
|
||||||
|
// OIDC 配置点:已配置则显示 SSO 入口(PRD 8.2 双轨)
|
||||||
|
var url = Auth.oidcAuthorizeUrl();
|
||||||
|
if (url) {
|
||||||
|
var btn = document.getElementById("sso-btn");
|
||||||
|
btn.hidden = false;
|
||||||
|
btn.onclick = function () { location.href = url; };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function doLogin() {
|
||||||
|
var err = document.getElementById("err");
|
||||||
|
err.textContent = "";
|
||||||
|
Auth.login(document.getElementById("username").value.trim(),
|
||||||
|
document.getElementById("password").value)
|
||||||
|
.then(function () { location.href = next; })
|
||||||
|
.catch(function (e) { err.textContent = e.message; });
|
||||||
|
}
|
||||||
|
document.getElementById("login-btn").onclick = doLogin;
|
||||||
|
document.getElementById("password").addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") doLogin();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>iAOP 用户/角色管理</title>
|
||||||
|
<style>
|
||||||
|
/* iAOP 用户/角色管理页(issue #134 / PRD 8.2):admin 专属 */
|
||||||
|
:root { --bg:#0b1220; --surface:#13203a; --fg:#e6edf6; --muted:#8aa0bd;
|
||||||
|
--accent:#18d3c8; --line:rgba(255,255,255,.06); }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family:"Microsoft YaHei",sans-serif; background:var(--bg); color:var(--fg); }
|
||||||
|
#topbar { display:flex; align-items:center; justify-content:space-between;
|
||||||
|
padding:10px 16px; background:var(--surface); border-bottom:1px solid var(--line); }
|
||||||
|
h1 { font-size:18px; color:var(--accent); margin:0; }
|
||||||
|
h1 .sub { font-size:12px; color:var(--muted); font-weight:400; }
|
||||||
|
#topbar-right { display:flex; gap:10px; align-items:center; font-size:13px; color:var(--muted); }
|
||||||
|
main { padding:16px; display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||||
|
.panel { background:var(--surface); border:1px solid var(--line); border-radius:8px;
|
||||||
|
padding:14px 16px; }
|
||||||
|
.panel h2 { font-size:14px; color:var(--accent); margin:0 0 10px; }
|
||||||
|
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||||
|
th, td { padding:7px 8px; border-bottom:1px solid var(--line); text-align:left; }
|
||||||
|
th { color:var(--muted); font-weight:400; }
|
||||||
|
select, input { padding:6px 8px; border-radius:6px; border:1px solid var(--line);
|
||||||
|
background:var(--bg); color:var(--fg); font-size:13px; }
|
||||||
|
button { padding:5px 12px; border:none; border-radius:5px; cursor:pointer;
|
||||||
|
background:#1e293b; color:var(--fg); font-size:12px; }
|
||||||
|
button.primary { background:var(--accent); color:#04202a; }
|
||||||
|
button.danger { background:#7a1f1a; color:#ffb4ad; }
|
||||||
|
.row { display:flex; gap:8px; margin:10px 0; flex-wrap:wrap; align-items:center; }
|
||||||
|
.hint { font-size:11px; color:#64748b; }
|
||||||
|
.role-readonly { color:#8aa0bd; } .role-engineer { color:#38bdf8; }
|
||||||
|
.role-admin { color:var(--accent); }
|
||||||
|
.disabled-user { opacity:.45; }
|
||||||
|
#audit-list { max-height:420px; overflow-y:auto; font-size:12px; font-family:Consolas,monospace; }
|
||||||
|
#audit-list div { padding:3px 0; border-bottom:1px solid var(--line); color:var(--muted); }
|
||||||
|
@media (max-width:1000px) { main { grid-template-columns:1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header id="topbar">
|
||||||
|
<h1>用户/角色管理 <span class="sub">RBAC 三级:readonly / engineer / admin · PRD 8.2</span></h1>
|
||||||
|
<div id="topbar-right">
|
||||||
|
<span id="who"></span>
|
||||||
|
<a href="../studio/index.html" style="color:var(--accent);font-size:13px">返回配置台</a>
|
||||||
|
<button id="logout-btn">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>用户列表</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="new-username" placeholder="用户名">
|
||||||
|
<input id="new-password" type="password" placeholder="初始密码(≥6 位)">
|
||||||
|
<select id="new-role">
|
||||||
|
<option value="readonly">Viewer(只读)</option>
|
||||||
|
<option value="engineer">Editor(配置)</option>
|
||||||
|
<option value="admin">Publisher(发布)</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-add" class="primary">新增用户</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="form-err"></div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>用户名</th><th>角色</th><th>状态</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||||
|
<tbody id="user-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>OIDC SSO 配置点(预留,PRD 8.2 双轨)</h2>
|
||||||
|
<div class="row"><input id="oidc-issuer" placeholder="issuer,如 https://iam.example.com" style="flex:2"></div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="oidc-client" placeholder="client_id" style="flex:1">
|
||||||
|
<input id="oidc-redirect" placeholder="回调 redirect_uri(可空)" style="flex:1">
|
||||||
|
<button id="btn-oidc" class="primary">保存</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">配置后登录页出现「企业 SSO 登录」入口(标准 OIDC 授权码流程);
|
||||||
|
未配置走本地账号。令牌换会话的后端端点待 IAM 就绪后对接(见 README)。</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel" style="margin-top:12px">
|
||||||
|
<h2>审计日志(写操作落日志,最近 500 条)</h2>
|
||||||
|
<div id="audit-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script src="auth.js"></script>
|
||||||
|
<script src="users.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/* iAOP 用户/角色管理页逻辑(issue #134)。
|
||||||
|
* 仅 admin(Publisher + MANAGE 权限)可访问;其余角色跳回登录/配置台。
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var session = Auth.requireAuth("login.html");
|
||||||
|
if (!session) return;
|
||||||
|
if (!Auth.can(session.role, "manage")) {
|
||||||
|
alert("仅 Publisher(admin)可访问用户管理");
|
||||||
|
location.href = "../studio/index.html";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("who").textContent =
|
||||||
|
session.username + " · " + Auth.ROLE_LABELS[session.role];
|
||||||
|
|
||||||
|
function el(tag, text) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (text !== undefined) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
var tbody = document.getElementById("user-tbody");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
Auth.listUsers().forEach(function (u) {
|
||||||
|
var tr = el("tr");
|
||||||
|
if (!u.enabled) tr.className = "disabled-user";
|
||||||
|
tr.appendChild(el("td", u.username));
|
||||||
|
// 角色分配(下拉即改)
|
||||||
|
var tdRole = el("td");
|
||||||
|
var sel = el("select");
|
||||||
|
Auth.ROLES.forEach(function (r) {
|
||||||
|
var opt = el("option", Auth.ROLE_LABELS[r]);
|
||||||
|
opt.value = r;
|
||||||
|
if (r === u.role) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.className = "role-" + u.role;
|
||||||
|
sel.onchange = function () {
|
||||||
|
Auth.updateUser(u.username, { role: sel.value }).then(renderUsers);
|
||||||
|
};
|
||||||
|
tdRole.appendChild(sel);
|
||||||
|
tr.appendChild(tdRole);
|
||||||
|
tr.appendChild(el("td", u.enabled ? "启用" : "禁用"));
|
||||||
|
tr.appendChild(el("td", (u.created_at || "").slice(0, 10)));
|
||||||
|
var tdOps = el("td");
|
||||||
|
var toggle = el("button", u.enabled ? "禁用" : "启用");
|
||||||
|
toggle.onclick = function () {
|
||||||
|
Auth.updateUser(u.username, { enabled: !u.enabled }).then(renderUsers);
|
||||||
|
};
|
||||||
|
var reset = el("button", "重置密码");
|
||||||
|
reset.onclick = function () {
|
||||||
|
var pwd = prompt("为 " + u.username + " 设置新密码(≥6 位)");
|
||||||
|
if (pwd) Auth.updateUser(u.username, { password: pwd }).then(renderUsers);
|
||||||
|
};
|
||||||
|
var del = el("button", "删除");
|
||||||
|
del.className = "danger";
|
||||||
|
del.onclick = function () {
|
||||||
|
if (u.username === session.username) { alert("不能删除当前登录账号"); return; }
|
||||||
|
if (confirm("确认删除用户 " + u.username + "?")) {
|
||||||
|
Auth.deleteUser(u.username).then(renderUsers);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
[toggle, reset, del].forEach(function (b) {
|
||||||
|
b.style.marginRight = "4px"; tdOps.appendChild(b);
|
||||||
|
});
|
||||||
|
tr.appendChild(tdOps);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
renderAudit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAudit() {
|
||||||
|
var box = document.getElementById("audit-list");
|
||||||
|
box.innerHTML = "";
|
||||||
|
Auth.auditLog().slice().reverse().forEach(function (e) {
|
||||||
|
box.appendChild(el("div",
|
||||||
|
e.time.slice(0, 19).replace("T", " ") + " | " + e.actor + " | " +
|
||||||
|
e.action + " | " + e.resource + " | " + e.reason));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("btn-add").onclick = function () {
|
||||||
|
var err = document.getElementById("form-err");
|
||||||
|
err.textContent = "";
|
||||||
|
Auth.createUser(document.getElementById("new-username").value.trim(),
|
||||||
|
document.getElementById("new-password").value,
|
||||||
|
document.getElementById("new-role").value)
|
||||||
|
.then(function () {
|
||||||
|
document.getElementById("new-username").value = "";
|
||||||
|
document.getElementById("new-password").value = "";
|
||||||
|
renderUsers();
|
||||||
|
})
|
||||||
|
.catch(function (e) { err.textContent = e.message; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// OIDC 配置点
|
||||||
|
var cfg = Auth.oidcConfig() || {};
|
||||||
|
document.getElementById("oidc-issuer").value = cfg.issuer || "";
|
||||||
|
document.getElementById("oidc-client").value = cfg.client_id || "";
|
||||||
|
document.getElementById("oidc-redirect").value = cfg.redirect_uri || "";
|
||||||
|
document.getElementById("btn-oidc").onclick = function () {
|
||||||
|
Auth.configureOidc({
|
||||||
|
issuer: document.getElementById("oidc-issuer").value.trim(),
|
||||||
|
client_id: document.getElementById("oidc-client").value.trim(),
|
||||||
|
redirect_uri: document.getElementById("oidc-redirect").value.trim()
|
||||||
|
});
|
||||||
|
alert("OIDC 配置已保存;登录页将出现 SSO 入口");
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("logout-btn").onclick = function () {
|
||||||
|
Auth.logout();
|
||||||
|
location.href = "login.html";
|
||||||
|
};
|
||||||
|
|
||||||
|
renderUsers();
|
||||||
|
})();
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script src="../auth/auth.js"></script>
|
||||||
<script src="studio.js"></script>
|
<script src="studio.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -458,6 +458,7 @@ function publish() {
|
|||||||
changelog: "发布模板资产包", snapshot: currentSnapshot()
|
changelog: "发布模板资产包", snapshot: currentSnapshot()
|
||||||
});
|
});
|
||||||
store("studio.releases.v1", studioState.releases);
|
store("studio.releases.v1", studioState.releases);
|
||||||
|
if (typeof Auth !== "undefined") Auth.audit("publish", "release", version); // #134 写操作审计
|
||||||
renderVersions();
|
renderVersions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +481,7 @@ function rollbackTo(index) {
|
|||||||
changelog: "回滚自 " + target.version, snapshot: currentSnapshot()
|
changelog: "回滚自 " + target.version, snapshot: currentSnapshot()
|
||||||
});
|
});
|
||||||
store("studio.releases.v1", studioState.releases);
|
store("studio.releases.v1", studioState.releases);
|
||||||
|
if (typeof Auth !== "undefined") Auth.audit("rollback", "release", "回滚自 " + target.version);
|
||||||
renderVersions(); renderCanvas(); renderHyperForm();
|
renderVersions(); renderCanvas(); renderHyperForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +545,26 @@ var studioState = {
|
|||||||
function persistConfig() { store("studio.config.v1", studioState.config); }
|
function persistConfig() { store("studio.config.v1", studioState.config); }
|
||||||
|
|
||||||
(function init() {
|
(function init() {
|
||||||
|
// 认证门控(issue #134):从 web/ 根目录起服务时加载 ../auth/auth.js,
|
||||||
|
// 未登录跳登录页;登录后角色以会话为准(角色下拉锁定)。
|
||||||
|
// auth.js 缺失(独立起服务于 web/studio)时降级为手动角色切换演示。
|
||||||
|
if (typeof Auth !== "undefined") {
|
||||||
|
var session = Auth.requireAuth("../auth/login.html");
|
||||||
|
if (!session) return; // 正在跳转登录页
|
||||||
|
currentRole = session.role;
|
||||||
|
var roleSelect = document.getElementById("role-select");
|
||||||
|
roleSelect.value = session.role;
|
||||||
|
roleSelect.disabled = true;
|
||||||
|
document.getElementById("role-hint").textContent =
|
||||||
|
session.username + " · " + RBAC[currentRole].label +
|
||||||
|
(Auth.can(session.role, "manage") ? " · 用户管理 →" : "");
|
||||||
|
if (Auth.can(session.role, "manage")) {
|
||||||
|
document.getElementById("role-hint").style.cursor = "pointer";
|
||||||
|
document.getElementById("role-hint").onclick = function () {
|
||||||
|
location.href = "../auth/users.html";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
// 页签切换
|
// 页签切换
|
||||||
document.querySelectorAll(".tab").forEach(function (tab) {
|
document.querySelectorAll(".tab").forEach(function (tab) {
|
||||||
tab.onclick = function () {
|
tab.onclick = function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user