diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 066b811..9742b41 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,18 @@ # Scert 整改任务执行清单 +## 2026年10月6日 普通用户账号显示补齐 + +### 已确认需求 + +依据右侧显示当前登录账号及权限的既有要求,补齐普通用户独立用户中心;管理员与普通用户不得出现不可用的系统设置入口。不修改后端权限、不清库、不重置密码、不操作第二台恢复锁。 + +- [✓] 正式页面完成管理员、普通用户表单登录;管理员账号权限可见,普通用户中心缺少独立账号权限区域。两者系统设置接口均403。 +- [x] 普通用户右上角显示实时会话账号与中文权限,切换业务页时保持可见;会话失效、重新验证期间清除旧身份,晚到请求不能覆盖新会话。 +- [x] 非超管隐藏系统设置按钮,保留后端及前端函数权限校验。 +- [✓] 新增身份显示、转义、会话清理及角色入口回归;专项31项、前端全量347项通过,生产前端构建通过。这是本机构建机验证,不代替线上可视验收。 +- [~] 构建 Linux 发布候选;本次仅变更前端,第一台计划保留旧资源与回退副本后更新 Web,避免不必要的后端重启触发历史事务恢复。后端二进制和生产数据库保持不变,第二台不接收部署包。 +- [ ] 第一台新资源摘要、三角色账号权限、普通用户跨板块显示及访问边界浏览器复核。 + ## 2026年10月4日 Linux 上线整改接手状态 ### 本轮任务清单 diff --git a/frontend/src/App.js b/frontend/src/App.js index 521d59e..a779eb4 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -4,6 +4,7 @@ export function useAppShell() { const setupState = ref(null); const authenticated = ref(null); const isAdmin = ref(null); + const currentUser = ref(null); const bootstrapping = ref(true); const error = ref(""); const routePath = window.location.pathname.replace(/\/+$/, "") || "/"; @@ -15,6 +16,7 @@ export function useAppShell() { bootstrapping.value = true; authenticated.value = null; isAdmin.value = null; + currentUser.value = null; error.value = ""; try { let response = await fetch("/api/v1/bootstrap/status", { cache: "no-store" }); @@ -49,11 +51,13 @@ export function useAppShell() { setupState.value = result; authenticated.value = session.authenticated; isAdmin.value = session.isAdmin; + currentUser.value = session.currentUser || null; } catch (err) { if (version !== bootstrapVersion) return; setupState.value = null; authenticated.value = null; isAdmin.value = null; + currentUser.value = null; error.value = err.message || "无法连接核心服务,请先启动 scert serve"; } finally { if (version === bootstrapVersion) bootstrapping.value = false; @@ -73,6 +77,9 @@ export function useAppShell() { return { authenticated: result.authenticated === true, isAdmin: result.is_admin === true || ["super_admin", "admin"].includes(result.role), + currentUser: result.authenticated === true + ? { account: result.account, role: result.role } + : null, }; } @@ -96,6 +103,7 @@ export function useAppShell() { setupState.value = { ...setupState.value, initialized: true }; authenticated.value = false; isAdmin.value = false; + currentUser.value = null; } function handlePageHide() { @@ -103,6 +111,7 @@ export function useAppShell() { bootstrapVersion += 1; authenticated.value = null; isAdmin.value = null; + currentUser.value = null; bootstrapping.value = true; } @@ -130,6 +139,7 @@ export function useAppShell() { setupState, authenticated, isAdmin, + currentUser, bootstrapping, error, routePath, diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js index 199aed5..c0ad256 100644 --- a/frontend/src/App.test.js +++ b/frontend/src/App.test.js @@ -9,6 +9,58 @@ function dispatchPageEvent(type, persisted = true) { window.dispatchEvent(event); } +test("用户中心身份只取已认证会话白名单,重新验证期间立即清除", async (t) => { + let session = { authenticated: true, account: "普通用户", role: "user", extra: "not-for-view" }; + const { state } = await mountComposable(t, useAppShell, async (path) => { + if (path.endsWith("/status")) return Response.json({ initialized: true }); + return Response.json(session); + }); + assert.deepEqual(state.currentUser.value, { account: "普通用户", role: "user" }); + session = { authenticated: true, account: "新用户", role: "user" }; + const reload = state.bootstrap(); + assert.equal(state.currentUser.value, null); + await reload; + assert.deepEqual(state.currentUser.value, { account: "新用户", role: "user" }); + dispatchPageEvent("pagehide"); + assert.equal(state.currentUser.value, null); + session = { authenticated: false, account: "不得显示", role: "super_admin" }; + dispatchPageEvent("pageshow"); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(state.currentUser.value, null); +}); + +for (const failure of ["unauthorized", "network", "uninitialized"]) { + test(`身份核验失败或回到初始化不能保留旧账号:${failure}`, async (t) => { + let fail = false; + const { state } = await mountComposable(t, useAppShell, async (path) => { + if (fail && failure === "network") throw new Error("连接失败"); + if (path.endsWith("/status")) { + return Response.json({ initialized: !(fail && failure === "uninitialized") }); + } + if (fail) return new Response(null, { status: 401 }); + return Response.json({ authenticated: true, account: "普通用户", role: "user" }); + }); + assert.equal(state.currentUser.value.account, "普通用户"); + fail = true; + await state.bootstrap(); + assert.equal(state.currentUser.value, null); + }); +} + +test("旧会话请求晚到不能覆盖新版用户身份", async (t) => { + const pending = deferred(); + let count = 0; + const { state } = await mountComposable(t, useAppShell, async (path) => { + if (path.endsWith("/status")) return Response.json({ initialized: true }); + count += 1; + return count === 1 ? pending.promise : Response.json({ authenticated: true, account: "新用户", role: "user" }); + }); + await state.bootstrap(); + pending.resolve(Response.json({ authenticated: true, account: "旧用户", role: "super_admin" })); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(state.currentUser.value, { account: "新用户", role: "user" }); +}); + test("历史缓存恢复先隐藏旧控制台,再按真实会话显示登录页", async (t) => { let active = true; const sessionOptions = []; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 389720a..23bbd16 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,6 +11,7 @@ const { setupState, authenticated, isAdmin, + currentUser, bootstrapping, error, routePath, @@ -48,6 +49,7 @@ const { diff --git a/frontend/src/views/Dashboard/DashboardLogout.test.js b/frontend/src/views/Dashboard/DashboardLogout.test.js index 4761ca1..6f744e7 100644 --- a/frontend/src/views/Dashboard/DashboardLogout.test.js +++ b/frontend/src/views/Dashboard/DashboardLogout.test.js @@ -28,7 +28,7 @@ test("管理侧栏始终提供退出入口,并展示忙碌和失败状态", as { loggingOut: false, error: "退出登录失败" }, ]) { const html = await renderToString(Vue.createSSRApp({ - setup: () => ({ activeSection: "overview", sections: [], logout() {}, ...state }), + setup: () => ({ activeSection: "overview", sections: [], currentUser: null, logout() {}, ...state }), render, })); assert.match(html, /class="navigation-session"/); diff --git a/frontend/src/views/Dashboard/DashboardNavigation.vue b/frontend/src/views/Dashboard/DashboardNavigation.vue index 5f46be5..3b957c0 100644 --- a/frontend/src/views/Dashboard/DashboardNavigation.vue +++ b/frontend/src/views/Dashboard/DashboardNavigation.vue @@ -68,6 +68,7 @@ defineEmits(["select", "settings"]); state, render }); + app.component("UserManagement", { render: () => null }); + app.component("ProjectManagement", { render: () => null }); + return renderToString(app); +} + +test("普通用户切换板块时右上角仍显示会话账号与中文权限", async () => { + for (const section of ["account", "projects"]) { + const html = await renderComponent("./UserWorkspace.vue", { + currentUser: { account: "用户<测试>", role: "user" }, + workspaceRoleLabels, + loggingOut: false, + error: "", + logout() {}, + section, + }); + assert.match(html, /[\s\S]*aria-label="当前登录账号"[\s\S]*<\/header>/); + assert.match(html, /用户<测试>/); + assert.match(html, /权限:普通用户/); + assert.doesNotMatch(html, /用户<测试>/); + } +}); + +test("未取得会话不显示旧身份,未知权限不伪装成管理员", async () => { + for (const currentUser of [null, { account: "", role: "unknown" }]) { + const html = await renderComponent("./UserWorkspace.vue", { + currentUser, workspaceRoleLabels, loggingOut: false, error: "", logout() {}, section: "account", + }); + if (!currentUser) assert.doesNotMatch(html, /aria-label="当前登录账号"/); + else { + assert.match(html, /未命名账号/); + assert.match(html, /权限:未知权限/); + } + } +}); + +test("系统设置导航仅对当前已确认的超级管理员显示", async () => { + for (const role of [null, "user", "admin", "super_admin", "unknown"]) { + const html = await renderComponent("../Dashboard/DashboardNavigation.vue", { + activeSection: "overview", sections: [], + currentUser: role ? { account: "测试", role } : null, + roleLabels: workspaceRoleLabels, + loggingOut: false, error: "", logout() {}, + }); + assert.equal(html.includes("系统设置"), role === "super_admin"); + assert.match(html, /退出登录/); + } +}); + +test("应用把已验证会话传给独立用户中心", async () => { + const source = await readFile(new URL("../../App.vue", import.meta.url), "utf8"); + assert.match(source, /]*:current-user="currentUser"/); +}); diff --git a/frontend/src/views/UserManagement/UserWorkspace.vue b/frontend/src/views/UserManagement/UserWorkspace.vue index e1dea85..02d0eb6 100644 --- a/frontend/src/views/UserManagement/UserWorkspace.vue +++ b/frontend/src/views/UserManagement/UserWorkspace.vue @@ -1,7 +1,11 @@ @@ -10,9 +14,20 @@ const { loggingOut, error, logout, section } = useUserWorkspace(); Scert 用户中心 - - {{ loggingOut ? "正在退出……" : "退出登录" }} - + + + 当前登录账号 + {{ currentUser.account || "未命名账号" }} + 权限:{{ workspaceRoleLabels[currentUser.role] || "未知权限" }} + + + {{ loggingOut ? "正在退出……" : "退出登录" }} + + {{ error }}
{{ error }}