From dd9d659cb7dee31d22e031f683ad5b0213962af8 Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 9 Sep 2026 16:16:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=81=E4=B9=A6=E7=9B=B4=E6=8E=A5=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=E7=BB=AD=E6=9C=9F=E4=BF=9D=E7=95=99=E6=94=B9?= =?UTF-8?q?=E9=80=A0=E5=89=8D=E4=BF=9D=E5=AD=98=E7=94=A8=E6=88=B7=E6=9D=83?= =?UTF-8?q?=E9=99=90=E7=95=8C=E9=9D=A2=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/user.go | 17 +++- frontend/src/utils/userRoles.mjs | 20 ++++ .../views/components/HomeAside/HomeAside.css | 39 ++++++++ .../views/components/HomeAside/HomeAside.js | 7 ++ .../views/components/HomeAside/HomeAside.vue | 13 ++- .../UserManagement/UserManagement.css | 11 +++ .../UserManagement/UserManagement.js | 97 ++++++++++--------- .../UserManagement/UserManagement.vue | 55 +++++++++-- test/frontend/user-management.test.mjs | 22 ++++- 9 files changed, 220 insertions(+), 61 deletions(-) create mode 100644 frontend/src/utils/userRoles.mjs diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 7f7c3b5..64daa5e 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -283,7 +283,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } if reqData.Password != "" { if err := authorizeCapability(ctx, access.CapabilityPasswordReset, targetRole, false); err != nil { - response.NewErrorResponse(response.NoAccessPrivileges, "密码重置需要近期认证和有效离线恢复码", "").FPrint(w) + writeUserAuthorizationError(w, err, "密码重置需要为当前管理员配置有效安全码") return } } @@ -308,7 +308,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { ctx.TargetRecoveryConfigured = configured } if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, targetRole, newRole, false); err != nil { - response.NewErrorResponse(response.NoAccessPrivileges, "角色变更需要近期认证和恢复凭据", "").FPrint(w) + writeUserAuthorizationError(w, err, "更改权限级别前,请先为该用户配置有效安全码") return } user.ApplyRole(newRole) @@ -404,3 +404,16 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } response.NewSuccessResponse(user).FPrint(w) } + +// writeUserAuthorizationError 保留授权限制,并让界面按错误类型继续身份确认。 +func writeUserAuthorizationError(w http.ResponseWriter, err error, recoveryMessage string) { + if access.IsDenied(err, access.CodeRecentAuthRequired) { + writeSecurityErrorWithCode(w, response.NoAccessPrivileges, "请确认当前密码后继续操作", string(access.CodeRecentAuthRequired)) + return + } + if access.IsDenied(err, access.CodeRecoveryCodeRequired) { + writeSecurityErrorWithCode(w, response.NoAccessPrivileges, recoveryMessage, string(access.CodeRecoveryCodeRequired)) + return + } + writeSecurityError(w, response.NoAccessPrivileges, "无权执行该用户管理操作") +} diff --git a/frontend/src/utils/userRoles.mjs b/frontend/src/utils/userRoles.mjs new file mode 100644 index 0000000..83e73e6 --- /dev/null +++ b/frontend/src/utils/userRoles.mjs @@ -0,0 +1,20 @@ +const roleOptions = { + super_admin: { label: '超级管理员', type: 'danger' }, + admin: { label: '管理员', type: 'primary' }, + user: { label: '普通用户', type: 'info' } +} + +export function roleMeta(role) { + return roleOptions[role] || { label: '未知权限', type: 'warning' } +} + +export function assignableRoles(actorRole) { + const roles = actorRole === 'super_admin' + ? ['user', 'admin'] + : actorRole === 'admin' ? ['user'] : [] + return roles.map(value => ({ value, ...roleMeta(value) })) +} + +export function canManageUser(actorRole, targetRole) { + return assignableRoles(actorRole).some(option => option.value === targetRole) +} diff --git a/frontend/src/views/components/HomeAside/HomeAside.css b/frontend/src/views/components/HomeAside/HomeAside.css index 5d2681d..c18b39f 100644 --- a/frontend/src/views/components/HomeAside/HomeAside.css +++ b/frontend/src/views/components/HomeAside/HomeAside.css @@ -63,6 +63,7 @@ .menu-container { flex-grow: 1; + min-height: 0; overflow-y: auto; padding: 0 10px; } @@ -99,13 +100,46 @@ } .sidebar-footer { + flex-shrink: 0; padding: 14px 16px 18px; border-top: 1px solid var(--pm-border-color); margin-top: auto; } +.account-identity { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 4px 8px; + padding: 0 4px 12px; +} + +.account-caption { + grid-column: 1 / -1; + color: var(--pm-text-secondary); + font-size: 11px; + line-height: 1.4; +} + +.account-name { + overflow: hidden; + color: var(--pm-text-primary); + font-size: 14px; + font-weight: 600; + line-height: 1.5; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-role { + border-radius: 6px; + font-size: 11px; +} + .settings-btn { display: flex; + width: 100%; + font-family: inherit; align-items: center; gap: 12px; padding: 12px 14px; @@ -128,3 +162,8 @@ color: var(--pm-text-primary); transform: translateY(-1px); } + +.settings-btn:focus-visible { + outline: 2px solid var(--pm-primary-color); + outline-offset: 2px; +} diff --git a/frontend/src/views/components/HomeAside/HomeAside.js b/frontend/src/views/components/HomeAside/HomeAside.js index 8e999dd..3aba9ed 100644 --- a/frontend/src/views/components/HomeAside/HomeAside.js +++ b/frontend/src/views/components/HomeAside/HomeAside.js @@ -13,6 +13,7 @@ import { Setting } from "@element-plus/icons-vue"; import { useGlobalStatusStore } from "../../../stores/useGlobalStatusStore"; import BrandLogo from '../BrandLogo/BrandLogo.vue'; +import { roleMeta } from '@/utils/userRoles.mjs'; export function usePage() { const groupStore = useGroupStore(); @@ -20,6 +21,10 @@ export function usePage() { const globalStatus = useGlobalStatusStore(); const isLogin = computed(() => globalStatus.isLogin); + + const currentAccount = computed(() => globalStatus.userInfos.account || '账号未加载'); + + const currentRole = computed(() => roleMeta(globalStatus.role)); const router = useRouter(); @@ -82,6 +87,8 @@ export function usePage() { groupStore, globalStatus, isLogin, + currentAccount, + currentRole, router, data, searchQuery, diff --git a/frontend/src/views/components/HomeAside/HomeAside.vue b/frontend/src/views/components/HomeAside/HomeAside.vue index 6bbc5c4..5e28d60 100644 --- a/frontend/src/views/components/HomeAside/HomeAside.vue +++ b/frontend/src/views/components/HomeAside/HomeAside.vue @@ -30,10 +30,17 @@ @@ -48,6 +55,8 @@ const { handleMenuSelect, data, isLogin, + currentAccount, + currentRole, openSettings } = usePage() diff --git a/frontend/src/views/components/UserManagement/UserManagement.css b/frontend/src/views/components/UserManagement/UserManagement.css index 7b1060d..0847f3a 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.css +++ b/frontend/src/views/components/UserManagement/UserManagement.css @@ -100,6 +100,17 @@ padding: 0 8px; } +.role-select { + width: 100%; +} + +.role-requirement { + margin-top: 6px; + color: var(--el-color-warning); + font-size: 12px; + line-height: 1.5; +} + .security-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js index bae5039..9b7662d 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.js +++ b/frontend/src/views/components/UserManagement/UserManagement.js @@ -9,17 +9,8 @@ import {ElNotification} from "element-plus"; import {Plus} from "@element-plus/icons-vue"; import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore"; -import {confirmRecentAuthentication, isRecentAuthenticationCancelled} from '@/utils/recentAuthentication' - -const roleOptions = { - super_admin: { label: '超级管理员', type: 'danger' }, - admin: { label: '管理员', type: 'primary' }, - user: { label: '普通用户', type: 'info' } -} - -export function roleMeta(role) { - return roleOptions[role] || { label: '未知权限', type: 'warning' } -} +import {confirmRecentAuthentication, isRecentAuthenticationCancelled, requestWithRecentAuthentication} from '@/utils/recentAuthentication' +import {roleMeta, assignableRoles, canManageUser} from '@/utils/userRoles.mjs' export function recoveryCodeMeta(configured) { return configured @@ -77,6 +68,17 @@ export function usePage() { const securityDialogOpening = ref('') + const submitting = ref(false) + + const availableRoles = computed(() => assignableRoles(globalStatus.role)) + + const canEditSelectedUser = computed(() => editModel.value === 'create' + ? availableRoles.value.length > 0 + : canManageUser(globalStatus.role, selectedUser.value?.Role)) + + const canSelectRole = computed(() => canEditSelectedUser.value + && (editModel.value === 'create' || globalStatus.hasCapability('role.manage'))) + const editingCurrentUser = computed(() => isSameUser(selectedUser.value, globalStatus.userInfos)) const recoveryCodeWebManaged = computed(() => canManageRecoveryCode(selectedUser.value)) @@ -85,8 +87,12 @@ export function usePage() { "account": "", "name": "", "password": "", - "disabled": false + "disabled": false, + "role": "user" }) + + const roleChanged = computed(() => editModel.value === 'edit' + && editUserInfo.role !== selectedUser.value?.Role) const title = ref(lang.editUser) @@ -109,6 +115,7 @@ export function usePage() { editUserInfo.name = row.Name editUserInfo.disabled = row.Disabled === 1 editUserInfo.password = "" + editUserInfo.role = row.Role editModel.value = "edit" title.value = lang.editUser userInfoDialog.value = true @@ -120,6 +127,7 @@ export function usePage() { editUserInfo.name = "" editUserInfo.disabled = false editUserInfo.password = "" + editUserInfo.role = "user" editModel.value = "create" title.value = lang.newUser userInfoDialog.value = true @@ -161,47 +169,38 @@ export function usePage() { reflushList() } - const submit = function () { - if (editModel.value === 'edit') { - let newData = { + const submit = async function () { + if (submitting.value || !canEditSelectedUser.value) return + submitting.value = true + try { + const creating = editModel.value === 'create' + const newData = { "account": editUserInfo.account, "username": editUserInfo.name, "disabled": editUserInfo.disabled ? 1 : 0 } - if (editUserInfo.password !== "") { - newData["password"] = editUserInfo.password + if (creating || roleChanged.value) newData.role = editUserInfo.role + if (creating || editUserInfo.password !== '') newData.password = editUserInfo.password + const res = await requestWithRecentAuthentication(() => creating + ? http.post('/api/v1/user/create', newData) + : http.post('/api/v1/user/edit', newData)) + if (!res || res.errorNo !== 0) { + throw new Error(res?.errorMsg || '保存用户失败') } - - http.post('/api/v1/user/edit', newData).then(res => { + ElNotification({ title: lang.succ, message: '用户信息已保存', type: 'success' }) + editUserInfo.password = '' + reflushList() + userInfoDialog.value = false + } catch (error) { + if (!isRecentAuthenticationCancelled(error)) { ElNotification({ - title: res.errorNo === 0 ? lang.succ : lang.fail, - message: res.errorNo === 0 ? "" : res.data, - type: res.errorNo === 0 ? 'success' : 'error', + title: lang.fail, + message: error?.message || '保存用户失败,请稍后重试', + type: 'error' }) - if (res.errorNo === 0) { - reflushList() - userInfoDialog.value = false - } - }) - } else { - let newData = { - "account": editUserInfo.account, - "username": editUserInfo.name, - "disabled": editUserInfo.disabled ? 1 : 0, - "password": editUserInfo.password } - - http.post('/api/v1/user/create', newData).then(res => { - ElNotification({ - title: res.errorNo === 0 ? lang.succ : lang.fail, - message: res.errorNo === 0 ? "" : res.data, - type: res.errorNo === 0 ? 'success' : 'error', - }) - if (res.errorNo === 0) { - reflushList() - userInfoDialog.value = false - } - }) + } finally { + submitting.value = false } } @@ -220,6 +219,11 @@ export function usePage() { userInfoDialog, editModel, editUserInfo, + availableRoles, + canEditSelectedUser, + canSelectRole, + roleChanged, + submitting, selectedUser, editingCurrentUser, recoveryCodeWebManaged, @@ -242,5 +246,6 @@ export function usePage() { } export { - Plus + Plus, + roleMeta } diff --git a/frontend/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue index 19acaac..97d6b9f 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.vue +++ b/frontend/src/views/components/UserManagement/UserManagement.vue @@ -91,26 +91,58 @@ /> - +
- + - + + + + + + + {{ roleMeta(editUserInfo.role).label }} + + + 更改级别前,请先在下方配置该用户的安全码。 + + +
@@ -121,7 +153,7 @@ 部署端管理
@@ -139,6 +171,7 @@
@@ -152,6 +185,7 @@ {{ lang.status }} @@ -202,6 +238,11 @@ const { submit, editModel, editUserInfo, + availableRoles, + canEditSelectedUser, + canSelectRole, + roleChanged, + submitting, selectedUser, editingCurrentUser, recoveryCodeWebManaged, diff --git a/test/frontend/user-management.test.mjs b/test/frontend/user-management.test.mjs index 808a6c1..4012b1d 100644 --- a/test/frontend/user-management.test.mjs +++ b/test/frontend/user-management.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { compileFunction } from 'node:vm' +import { roleMeta, assignableRoles, canManageUser } from '../../frontend/src/utils/userRoles.mjs' const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc') @@ -14,7 +15,7 @@ function loadStatusHelpers() { .replace(/^import .*$/gm, '') .replace(/export \{[\s\S]*?\}\s*$/, '') .replaceAll('export ', '') - return compileFunction(`${source}\nreturn { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta, isSameUser, canManageRecoveryCode }`)() + return compileFunction(`${source}\nreturn { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta, isSameUser, canManageRecoveryCode }`, ['roleMeta'])(roleMeta) } function loadExternalEmailHelpers() { @@ -106,7 +107,7 @@ test('超级管理员安全码只能在部署端管理,普通用户仍使用 const source = read('UserManagement.vue') assert.match(source, /部署端管理/) assert.match(source, /仅支持本机管理/) - assert.match(source, /:disabled="!recoveryCodeWebManaged"/) + assert.match(source, /:disabled="!recoveryCodeWebManaged \|\| submitting"/) assert.match(source, /:self-managed="editingCurrentUser"/) }) @@ -132,13 +133,26 @@ test('编辑外部邮箱按本人和代管场景使用不同的验证码接口', test('普通用户编辑请求不直接携带安全码或外部邮箱', () => { const source = read('UserManagement.js') - const submitStart = source.indexOf('const submit = function') - const editRequestEnd = source.indexOf(".then(res =>", source.indexOf("http.post('/api/v1/user/edit', newData)", submitStart)) + const submitStart = source.indexOf('const submit = async function') + const editRequestEnd = source.indexOf('if (!res || res.errorNo', submitStart) const submitSource = source.slice(submitStart, editRequestEnd) assert.match(submitSource, /\/api\/v1\/user\/edit/) assert.doesNotMatch(submitSource, /recovery_code|recoveryCode|external_email|externalEmail/) }) +test('用户级别选项遵守账户管理边界,不提供超级管理员授权入口', () => { + assert.deepEqual(assignableRoles('super_admin').map(item => item.value), ['user', 'admin']) + assert.deepEqual(assignableRoles('admin').map(item => item.value), ['user']) + assert.deepEqual(assignableRoles('user'), []) + assert.deepEqual(assignableRoles('unknown'), []) + assert.equal(canManageUser('super_admin', 'super_admin'), false) + assert.equal(canManageUser('super_admin', 'admin'), true) + assert.equal(canManageUser('admin', 'admin'), false) + assert.equal(canManageUser('admin', 'user'), true) + assert.match(read('UserManagement.vue'), /v-model="editUserInfo\.role"/) + assert.match(read('UserManagement.js'), /requestWithRecentAuthentication/) +}) + test('用户管理样式可编译并保持紧凑表格布局', () => { const { code, errors } = compileStyle({ source: read('UserManagement.css'),