This commit is contained in:
parent
cc3ab896f5
commit
dd9d659cb7
@ -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, "无权执行该用户管理操作")
|
||||
}
|
||||
|
||||
20
frontend/src/utils/userRoles.mjs
Normal file
20
frontend/src/utils/userRoles.mjs
Normal file
@ -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)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -30,10 +30,17 @@
|
||||
</el-menu>
|
||||
</div>
|
||||
<div class="sidebar-footer" v-if="isLogin">
|
||||
<div class="settings-btn" @click="openSettings">
|
||||
<div class="account-identity" aria-label="当前登录账号" aria-live="polite">
|
||||
<span class="account-caption">当前账号</span>
|
||||
<strong class="account-name" :title="currentAccount">{{ currentAccount }}</strong>
|
||||
<el-tag :type="currentRole.type" size="small" effect="light" class="account-role">
|
||||
{{ currentRole.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<button type="button" class="settings-btn" @click="openSettings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span class="settings-label">{{ lang.settings }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -48,6 +55,8 @@ const {
|
||||
handleMenuSelect,
|
||||
data,
|
||||
isLogin,
|
||||
currentAccount,
|
||||
currentRole,
|
||||
openSettings
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -91,26 +91,58 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="userInfoDialog" :title="title" width="450px" class="premium-dialog">
|
||||
<el-dialog
|
||||
v-model="userInfoDialog"
|
||||
:title="title"
|
||||
width="450px"
|
||||
class="premium-dialog"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<el-form label-position="top">
|
||||
<el-form-item :label="lang.account">
|
||||
<el-input :disabled="editModel === 'edit'" v-model="editUserInfo.account" />
|
||||
<el-input :disabled="editModel === 'edit' || submitting" v-model="editUserInfo.account" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="lang.user_name">
|
||||
<el-input v-model="editUserInfo.name" />
|
||||
<el-input v-model="editUserInfo.name" :disabled="!canEditSelectedUser || submitting" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="lang.password">
|
||||
<el-input
|
||||
:placeholder="lang.resetPwd"
|
||||
:placeholder="editModel === 'create' ? '请输入 8–12 字节密码' : '留空则不修改密码'"
|
||||
v-model="editUserInfo.password"
|
||||
:disabled="!canEditSelectedUser || submitting"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="权限级别">
|
||||
<el-select
|
||||
v-if="canSelectRole"
|
||||
v-model="editUserInfo.role"
|
||||
aria-label="权限级别"
|
||||
class="role-select"
|
||||
:disabled="submitting || availableRoles.length < 2"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in availableRoles"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-tag v-else :type="roleMeta(editUserInfo.role).type">
|
||||
{{ roleMeta(editUserInfo.role).label }}
|
||||
</el-tag>
|
||||
<span v-if="roleChanged && !selectedUser?.RecoveryCodeConfigured" class="role-requirement">
|
||||
更改级别前,请先在下方配置该用户的安全码。
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="editModel === 'edit'" class="security-edit-grid">
|
||||
<div class="security-edit-item">
|
||||
<div>
|
||||
@ -121,7 +153,7 @@
|
||||
<span v-else>部署端管理</span>
|
||||
</div>
|
||||
<el-button
|
||||
:disabled="!recoveryCodeWebManaged"
|
||||
:disabled="!recoveryCodeWebManaged || submitting"
|
||||
:loading="securityDialogOpening === 'recovery-code'"
|
||||
@click="openRecoveryCodeDialog"
|
||||
>
|
||||
@ -139,6 +171,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<el-button
|
||||
:disabled="submitting"
|
||||
:loading="securityDialogOpening === 'external-email'"
|
||||
@click="openExternalEmailDialog"
|
||||
>
|
||||
@ -152,6 +185,7 @@
|
||||
<span class="switch-label">{{ lang.status }}</span>
|
||||
<el-switch
|
||||
v-model="editUserInfo.disabled"
|
||||
:disabled="!canEditSelectedUser || submitting"
|
||||
class="ml-2"
|
||||
:active-text="lang.disabled"
|
||||
:inactive-text="lang.enabled"
|
||||
@ -164,8 +198,10 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="userInfoDialog = false">{{ lang.cancel }}</el-button>
|
||||
<el-button type="primary" @click="submit">{{ lang.confirm }}</el-button>
|
||||
<el-button :disabled="submitting" @click="userInfoDialog = false">{{ lang.cancel }}</el-button>
|
||||
<el-button type="primary" :disabled="!canEditSelectedUser" :loading="submitting" @click="submit">
|
||||
{{ lang.confirm }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@ -202,6 +238,11 @@ const {
|
||||
submit,
|
||||
editModel,
|
||||
editUserInfo,
|
||||
availableRoles,
|
||||
canEditSelectedUser,
|
||||
canSelectRole,
|
||||
roleChanged,
|
||||
submitting,
|
||||
selectedUser,
|
||||
editingCurrentUser,
|
||||
recoveryCodeWebManaged,
|
||||
|
||||
@ -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'),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user