补齐普通用户账号权限显示并收紧系统设置入口
This commit is contained in:
parent
1f29c2e11b
commit
39cbd83312
@ -1,5 +1,18 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月6日 普通用户账号显示补齐
|
||||
|
||||
### 已确认需求
|
||||
|
||||
依据右侧显示当前登录账号及权限的既有要求,补齐普通用户独立用户中心;管理员与普通用户不得出现不可用的系统设置入口。不修改后端权限、不清库、不重置密码、不操作第二台恢复锁。
|
||||
|
||||
- [✓] 正式页面完成管理员、普通用户表单登录;管理员账号权限可见,普通用户中心缺少独立账号权限区域。两者系统设置接口均403。
|
||||
- [x] 普通用户右上角显示实时会话账号与中文权限,切换业务页时保持可见;会话失效、重新验证期间清除旧身份,晚到请求不能覆盖新会话。
|
||||
- [x] 非超管隐藏系统设置按钮,保留后端及前端函数权限校验。
|
||||
- [✓] 新增身份显示、转义、会话清理及角色入口回归;专项31项、前端全量347项通过,生产前端构建通过。这是本机构建机验证,不代替线上可视验收。
|
||||
- [~] 构建 Linux 发布候选;本次仅变更前端,第一台计划保留旧资源与回退副本后更新 Web,避免不必要的后端重启触发历史事务恢复。后端二进制和生产数据库保持不变,第二台不接收部署包。
|
||||
- [ ] 第一台新资源摘要、三角色账号权限、普通用户跨板块显示及访问边界浏览器复核。
|
||||
|
||||
## 2026年10月4日 Linux 上线整改接手状态
|
||||
|
||||
### 本轮任务清单
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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"/);
|
||||
|
||||
@ -68,6 +68,7 @@ defineEmits(["select", "settings"]);
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-if="currentUser?.role === 'super_admin'"
|
||||
class="navigation-item"
|
||||
type="button"
|
||||
title="配置服务、证书签发和邮件通知"
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
74
frontend/src/views/UserManagement/UserWorkspace.test.js
Normal file
74
frontend/src/views/UserManagement/UserWorkspace.test.js
Normal 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, /用户<测试>/);
|
||||
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"/);
|
||||
});
|
||||
@ -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="用户中心导航">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user