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"]); +
+ + +

{{ error }}