证书直接路径与续期保留改造前保存用户权限界面进度
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-09 16:16:31 +08:00
parent cc3ab896f5
commit dd9d659cb7
9 changed files with 220 additions and 61 deletions

View File

@ -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, "无权执行该用户管理操作")
}

View 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)
}

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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