fix(frontend): accept unlimited (0) user concurrency in the edit dialog

The admin user edit modal rejected concurrency < 1, so a user whose
concurrency is already 0 could not be saved at all — the guard runs
before the request, blocking notes, password, role and RPM edits on that
user too.

Everywhere else already treats 0 as unlimited: the gateway skips slot
limiting when maxConcurrency <= 0 (ConcurrencyService.AcquireUserSlot),
the batch limits endpoint binds concurrency with min=0, and the bulk edit
modal only rejects negative values.

Reject negative and non-integer values instead, mirror the RPM field with
min/step and a "0 = unlimited" placeholder and hint, and rename the error
key to match its new meaning. Account concurrency is unchanged.
This commit is contained in:
Yoga Sakti
2026-08-22 13:22:21 +07:00
parent d45135d87d
commit 5dfad32b87
4 changed files with 109 additions and 5 deletions
@@ -43,7 +43,16 @@
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
<input
v-model.number="form.concurrency"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.users.form.concurrencyPlaceholder')"
data-test="concurrency-input"
/>
<p class="input-hint">{{ t('admin.users.form.concurrencyHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.users.form.rpmLimit') }}</label>
@@ -132,8 +141,9 @@ const handleUpdateUser = async () => {
appStore.showError(t('admin.users.emailRequired'))
return
}
if (form.concurrency < 1) {
appStore.showError(t('admin.users.concurrencyMin'))
// 0 = 不限制,与网关 (AcquireUserSlot: maxConcurrency <= 0) 和批量改限额一致
if (!Number.isInteger(form.concurrency) || form.concurrency < 0) {
appStore.showError(t('admin.users.concurrencyNonNegative'))
return
}
const userId = props.user.id
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import UserEditModal from '../UserEditModal.vue'
const { update, updateUserAttributeValues, showSuccess, showError } = vi.hoisted(() => ({
update: vi.fn(),
updateUserAttributeValues: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
users: { update },
userAttributes: { updateUserAttributeValues }
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess, showError })
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({ copyToClipboard: vi.fn() })
}))
// useStepUp pulls in the API client, which needs the real i18n instance.
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
})
}))
const mountModal = (concurrency: number) => mount(UserEditModal, {
props: {
show: true,
user: { id: 7, email: 'user@example.test', username: 'user', notes: '', role: 'user', concurrency, rpm_limit: 0 } as never
},
global: {
stubs: {
BaseDialog: {
props: ['show', 'title'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
},
Select: true,
Icon: true,
UserAttributeForm: true,
TotpStepUpDialog: true
}
}
})
describe('UserEditModal concurrency', () => {
beforeEach(() => {
update.mockReset()
updateUserAttributeValues.mockReset()
showSuccess.mockReset()
showError.mockReset()
update.mockResolvedValue({})
})
// Regression coverage for issue #5977: the gateway treats concurrency <= 0 as
// unlimited (AcquireUserSlot) and both the batch limits endpoint and the bulk
// edit modal accept 0, so this dialog must not be the only place that rejects
// it — doing so blocked every other edit on such a user.
it('saves an unlimited (0) concurrency instead of blocking the whole form', async () => {
const wrapper = mountModal(0)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(showError).not.toHaveBeenCalled()
expect(update).toHaveBeenCalledWith(7, expect.objectContaining({ concurrency: 0 }))
expect(wrapper.emitted('success')).toBeTruthy()
})
it('still rejects a negative concurrency', async () => {
const wrapper = mountModal(3)
await wrapper.get('[data-test="concurrency-input"]').setValue('-1')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(showError).toHaveBeenCalledWith('admin.users.concurrencyNonNegative')
expect(update).not.toHaveBeenCalled()
})
})