diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 7f7c3b59..64daa5e4 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 00000000..83e73e60 --- /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 5d2681d5..c18b39f1 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 8e999ddc..3aba9ed8 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 6bbc5c46..5e28d607 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 7b1060d3..0847f3a9 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 bae50398..9b7662da 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 19acaac7..97d6b9f3 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.vue +++ b/frontend/src/views/components/UserManagement/UserManagement.vue @@ -91,26 +91,58 @@ /> -