补齐普通用户账号权限显示并收紧系统设置入口

This commit is contained in:
xuans 2026-10-06 10:40:57 +08:00
parent 1f29c2e11b
commit 39cbd83312
11 changed files with 223 additions and 5 deletions

View File

@ -1,5 +1,18 @@
# Scert 整改任务执行清单
## 2026年10月6日 普通用户账号显示补齐
### 已确认需求
依据右侧显示当前登录账号及权限的既有要求,补齐普通用户独立用户中心;管理员与普通用户不得出现不可用的系统设置入口。不修改后端权限、不清库、不重置密码、不操作第二台恢复锁。
- [✓] 正式页面完成管理员、普通用户表单登录;管理员账号权限可见,普通用户中心缺少独立账号权限区域。两者系统设置接口均403。
- [x] 普通用户右上角显示实时会话账号与中文权限,切换业务页时保持可见;会话失效、重新验证期间清除旧身份,晚到请求不能覆盖新会话。
- [x] 非超管隐藏系统设置按钮,保留后端及前端函数权限校验。
- [✓] 新增身份显示、转义、会话清理及角色入口回归;专项31项、前端全量347项通过,生产前端构建通过。这是本机构建机验证,不代替线上可视验收。
- [~] 构建 Linux 发布候选;本次仅变更前端,第一台计划保留旧资源与回退副本后更新 Web,避免不必要的后端重启触发历史事务恢复。后端二进制和生产数据库保持不变,第二台不接收部署包。
- [ ] 第一台新资源摘要、三角色账号权限、普通用户跨板块显示及访问边界浏览器复核。
## 2026年10月4日 Linux 上线整改接手状态
### 本轮任务清单

View File

@ -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,

View File

@ -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 = [];

View File

@ -11,6 +11,7 @@ const {
setupState,
authenticated,
isAdmin,
currentUser,
bootstrapping,
error,
routePath,
@ -48,6 +49,7 @@ const {
<UserWorkspace
v-else-if="setupState && authenticated === true && isAdmin === false"
:current-user="currentUser"
/>
<Dashboard v-else-if="setupState && authenticated === true" />

View File

@ -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"/);

View File

@ -68,6 +68,7 @@ defineEmits(["select", "settings"]);
</button>
</template>
<button
v-if="currentUser?.role === 'super_admin'"
class="navigation-item"
type="button"
title="配置服务、证书签发和邮件通知"

View File

@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import "./UserWorkspace.test.js";
import test from "node:test";
import { groupUsers, useUserManagement } from "./UserManagement.js";
import { useUserGroupEditor } from "./UserGroupEditor.js";

View File

@ -16,6 +16,50 @@
font-size: 26px;
}
.user-workspace-session {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12px;
min-width: 0;
max-width: 60%;
}
.user-workspace-account {
display: grid;
gap: 4px;
min-width: 0;
padding: 10px 14px;
border: 1px solid #d5ddea;
border-radius: 12px;
background: #f8faff;
overflow-wrap: anywhere;
}
.user-workspace-account span {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.user-workspace-account strong {
color: #25344d;
font-size: 14px;
line-height: 1.5;
}
@media (max-width: 760px) {
.user-workspace header {
flex-wrap: wrap;
}
.user-workspace-session {
width: 100%;
max-width: 100%;
}
}
.user-workspace button {
padding: 10px 16px;
border: 1px solid #d5ddea;

View File

@ -1,6 +1,12 @@
import { ref } from "vue";
import { useAccessDenied } from "../AccessDenied/AccessDenied.js";
export const workspaceRoleLabels = {
super_admin: "超级管理员",
admin: "管理员",
user: "普通用户",
};
export function useUserWorkspace() {
const section = ref("account");
return { ...useAccessDenied(), section };

View File

@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import * as Vue from "vue";
import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
import { renderToString } from "@vue/server-renderer";
import { workspaceRoleLabels } from "./UserWorkspace.js";
async function renderComponent(file, state) {
const source = await readFile(new URL(file, import.meta.url), "utf8");
const { descriptor, errors } = parse(source);
assert.deepEqual(errors, []);
assert.ok(compileScript(descriptor, { id: "account-display" }));
const compiled = compileTemplate({
source: descriptor.template.content,
filename: file,
id: "account-display",
compilerOptions: { mode: "function" },
});
assert.deepEqual(compiled.errors, []);
const render = new Function("Vue", compiled.code)(Vue);
const app = Vue.createSSRApp({ setup: () => 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, /<header>[\s\S]*aria-label="当前登录账号"[\s\S]*<\/header>/);
assert.match(html, /用户&lt;测试&gt;/);
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, /<UserWorkspace\s[^>]*:current-user="currentUser"/);
});

View File

@ -1,7 +1,11 @@
<script setup>
import UserManagement from "./UserManagement.vue";
import ProjectManagement from "../ProjectManagement/ProjectManagement.vue";
import { useUserWorkspace } from "./UserWorkspace.js";
import { useUserWorkspace, workspaceRoleLabels } from "./UserWorkspace.js";
defineProps({
currentUser: { type: Object, default: null },
});
const { loggingOut, error, logout, section } = useUserWorkspace();
</script>
@ -10,9 +14,20 @@ const { loggingOut, error, logout, section } = useUserWorkspace();
<main class="user-workspace">
<header>
<h1>Scert 用户中心</h1>
<div class="user-workspace-session">
<div
v-if="currentUser"
class="user-workspace-account"
aria-label="当前登录账号"
>
<span>当前登录账号</span>
<strong>{{ currentUser.account || "未命名账号" }}</strong>
<span>权限:{{ workspaceRoleLabels[currentUser.role] || "未知权限" }}</span>
</div>
<button type="button" :disabled="loggingOut" @click="logout">
{{ loggingOut ? "正在退出……" : "退出登录" }}
</button>
</div>
</header>
<p v-if="error" role="alert">{{ error }}</p>
<nav class="user-workspace-navigation" aria-label="用户中心导航">