修复管理员分组多选并将角色与分组原子保存
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-26 00:03:53 +08:00
parent 5703fded24
commit 12cd774816
5 changed files with 150 additions and 14 deletions

View File

@ -52,6 +52,12 @@ type userCreateRequest struct {
Role string `json:"role"`
}
type userEditRequest struct {
userCreateRequest
GroupIDs *[]int `json:"group_ids"`
MembershipVersion int `json:"membership_version"`
}
func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
targetRole := access.RoleUser
var reqData userCreateRequest
@ -283,7 +289,7 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var reqData userCreateRequest
var reqData userEditRequest
if err := decodeUserRequest(w, req, &reqData); err != nil {
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
return
@ -321,6 +327,15 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
targetRole := user.EffectiveRole()
writeGuard := accountWriteGuard(ctx, user)
if reqData.GroupIDs != nil {
if !requireAccountGroupManagement(ctx, w) {
return
}
if reqData.MembershipVersion < 1 || reqData.MembershipVersion != user.SecurityVersion {
writeAccountGroupError(w, accountgroup.ErrConflict)
return
}
}
if !requireAccountScope(ctx, w, user.ID) {
return
}
@ -394,7 +409,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
now := time.Now().UTC()
user.PasswordChangedAt = &now
}
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole {
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole || reqData.GroupIDs != nil {
user.SessionVersion = originalSessionVersion + 1
user.SecurityVersion = originalSecurityVersion + 1
}
@ -430,7 +445,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if reqData.Password != "" {
updateColumns = append(updateColumns, "password", "must_change_password", "password_changed_at")
}
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole {
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole || reqData.GroupIDs != nil {
updateColumns = append(updateColumns, "session_version", "security_version")
}
update := tx.ID(user.ID).Where("session_version = ? AND security_version = ?", originalSessionVersion, originalSecurityVersion).Cols(updateColumns...)
@ -453,6 +468,18 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return
}
}
// 角色和分组在同一事务中校验与更新,任何失败均不能留下部分修改。
if reqData.GroupIDs != nil {
ids, err := accountgroup.ValidateMembership(tx, user.EffectiveRole(), *reqData.GroupIDs)
if err == nil {
err = accountgroup.ReplaceMembership(tx, user.ID, ids)
}
if err != nil {
_ = tx.Rollback()
writeAccountGroupError(w, err)
return
}
}
if user.EffectiveRole() != access.RoleAdmin {
count, err := tx.Where("user_id = ?", user.ID).Count(&models.AccountGroupMember{})
if err != nil {

View File

@ -12,7 +12,13 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) {
const membershipVisible = ref(false)
const membershipUser = ref(null)
const membershipSelection = ref([])
const membershipDraft = ref(false)
let applyMembershipDraft = null
const membershipLimit = computed(() => membershipUser.value?.Role === 'admin' ? 100 : 1)
const membershipSingleSelection = computed({
get: () => membershipSelection.value.length === 1 ? membershipSelection.value[0] : null,
set: value => { membershipSelection.value = [value] }
})
let generation = 0
let alive = true
onBeforeUnmount(() => { alive = false; generation++ })
@ -107,16 +113,27 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) {
}
}
const editMembership = user => {
const editMembership = (user, options = {}) => {
if (!canManageGroups.value || groupSaving.value) return
membershipUser.value = user
membershipSelection.value = [...(user.GroupIDs || [0])]
membershipUser.value = { ...user, Role: options.role || user.Role }
membershipSelection.value = [...(options.groupIDs || user.GroupIDs || [0])]
applyMembershipDraft = options.onApply || null
membershipDraft.value = Boolean(applyMembershipDraft)
membershipVisible.value = true
}
const saveMembership = async () => {
if (!canManageGroups.value || groupSaving.value || !membershipUser.value) return
if (membershipSelection.value.length === 0) membershipSelection.value = [0]
if (membershipSelection.value.length > membershipLimit.value) {
ElMessage.error('普通用户只能选择一个分组,请重新选择')
return
}
if (applyMembershipDraft) {
applyMembershipDraft([...membershipSelection.value])
membershipVisible.value = false
return
}
groupSaving.value = true
try {
const response = await requestWithRecentAuthentication(() => http.post('/api/v1/user/groups/membership', {
@ -145,6 +162,7 @@ export function useAccountGroups(globalStatus, onMembershipSaved = () => {}) {
return {
accountGroups, groupsLoading, groupsError, canManageGroups, groupSaving,
membershipVisible, membershipUser, membershipSelection, membershipLimit,
membershipDraft, membershipSingleSelection,
refreshGroups, loadGroup, toggleGroup, saveGroup, editMembership, saveMembership
}
}

View File

@ -214,8 +214,49 @@
outline-offset: 4px;
}
.membership-select {
.membership-options {
width: 100%;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding: 8px 12px;
box-sizing: border-box;
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-secondary);
}
.membership-options-title {
padding: 0 6px;
color: var(--pm-text-secondary);
font-size: 12px;
}
.membership-option {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
overflow-wrap: anywhere;
}
.membership-option:hover {
background: var(--el-fill-color);
}
.membership-option input {
flex: 0 0 auto;
width: 16px;
height: 16px;
accent-color: var(--el-color-primary);
}
.membership-option:focus-within {
outline: 2px solid var(--el-color-primary);
outline-offset: -2px;
}
.membership-summary {

View File

@ -57,20 +57,31 @@ export function usePage() {
const editModel = ref("edit")
const selectedUser = ref(null)
const editingGroupIDs = ref([0])
const editingMembershipVersion = ref(0)
const membershipChanged = computed(() => {
const saved = selectedUser.value?.GroupIDs || [0]
return saved.length !== editingGroupIDs.value.length
|| saved.some(id => !editingGroupIDs.value.includes(id))
})
const selectedMembershipGroups = computed(() => {
const user = selectedUser.value
if (!user) return []
return groups.accountGroups.value.filter(group =>
groups.canManageGroups.value
? (user.GroupIDs || [0]).includes(group.id)
? editingGroupIDs.value.includes(group.id)
: group.rows.some(member => member.ID === user.ID)
)
})
const openMembershipDialog = () => {
if (submitting.value || !selectedUser.value) return
groups.editMembership(selectedUser.value)
groups.editMembership(selectedUser.value, {
role: editUserInfo.role,
groupIDs: editingGroupIDs.value,
onApply: ids => { editingGroupIDs.value = ids }
})
}
const recoveryCodeDialogVisible = ref(false)
@ -122,6 +133,8 @@ export function usePage() {
const handleEdit = function (idx, row) {
if (!canEditUser(row)) return
selectedUser.value = row
editingGroupIDs.value = [...(row.GroupIDs || [0])]
editingMembershipVersion.value = row.MembershipVersion
editUserInfo.account = row.Account
editUserInfo.name = row.Name
editUserInfo.disabled = row.Disabled === 1
@ -193,6 +206,13 @@ export function usePage() {
}
if (creating || roleChanged.value) newData.role = editUserInfo.role
if (creating || editUserInfo.password !== '') newData.password = editUserInfo.password
if (!creating && groups.canManageGroups.value && (membershipChanged.value || roleChanged.value)) {
if (editUserInfo.role !== 'admin' && editingGroupIDs.value.length !== 1) {
throw new Error('普通用户只能属于一个分组,请在分组管理中选择一个分组')
}
newData.group_ids = [...editingGroupIDs.value]
newData.membership_version = editingMembershipVersion.value
}
const res = await requestWithRecentAuthentication(() => creating
? http.post('/api/v1/user/create', newData)
: http.post('/api/v1/user/edit', newData))

View File

@ -123,16 +123,44 @@
<p>账号:{{ membershipUser?.Account }}</p>
<el-form label-position="top">
<el-form-item label="所属分组">
<el-select v-model="membershipSelection" multiple :multiple-limit="membershipLimit" :disabled="groupSaving" placeholder="未选择时归入默认分组" class="membership-select">
<el-option v-for="group in accountGroups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
<fieldset class="membership-options" :disabled="groupSaving">
<legend class="membership-options-title">
{{ membershipLimit > 1 ? '管理员可勾选多个分组' : '请选择一个分组' }}
</legend>
<label
v-for="group in accountGroups"
:key="group.id"
class="membership-option"
>
<input
v-if="membershipLimit > 1"
v-model="membershipSelection"
type="checkbox"
:value="group.id"
:disabled="membershipSelection.length >= membershipLimit && !membershipSelection.includes(group.id)"
/>
<input
v-else
v-model="membershipSingleSelection"
type="radio"
name="account-membership"
:value="group.id"
/>
<span>{{ group.name }}</span>
</label>
</fieldset>
</el-form-item>
</el-form>
<p class="settings-desc">管理员可以多选;普通用户只能选择一个分组。</p>
<p v-if="membershipDraft" class="settings-desc">
此处仅应用到编辑草稿,点击编辑用户的“确认”后,权限级别和分组才会一起保存。
</p>
<el-alert title="修改归属会使该账号的旧登录会话和通知发信凭据失效,需要重新登录并配置通知凭据。" type="warning" :closable="false" />
<template #footer>
<el-button :disabled="groupSaving" @click="membershipVisible = false">取消</el-button>
<el-button type="primary" :loading="groupSaving" @click="saveMembership">保存分组</el-button>
<el-button type="primary" :loading="groupSaving" @click="saveMembership">
{{ membershipDraft ? '应用到编辑' : '保存分组' }}
</el-button>
</template>
</el-dialog>
@ -213,7 +241,7 @@
</el-button>
</div>
<p v-if="canManageGroups" class="membership-hint">
分组单独保存,不会提交本窗口其他修改;选择数量按账号已保存的权限级别校验。
分组选择跟随当前权限级别,点击“确认”后与其他修改一起保存;取消则不保存。
</p>
<p v-else class="membership-hint">
仅显示你有权查看的分组,成员归属由超级管理员维护。
@ -312,6 +340,8 @@ const {
membershipUser,
membershipSelection,
membershipLimit,
membershipDraft,
membershipSingleSelection,
loadGroup,
toggleGroup,
saveGroup,