feat(keys): add bulk editing to API key management

This commit is contained in:
yan9651688
2026-09-06 11:25:33 +08:00
parent ab99d56e96
commit 58f461b087
9 changed files with 698 additions and 2 deletions
+3
View File
@@ -3,6 +3,9 @@
FRONTEND_CRITICAL_VITEST := \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/keys.bulkUpdate.spec.ts \
src/components/keys/__tests__/BulkEditKeysModal.spec.ts \
src/views/user/__tests__/KeysView.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { bulkUpdate } from '../keys'
const { put } = vi.hoisted(() => ({ put: vi.fn() }))
vi.mock('../client', () => ({ apiClient: { put } }))
describe('API key bulk updates', () => {
beforeEach(() => {
put.mockReset()
})
it('updates each selected key once and preserves partial failures', async () => {
const failure = { response: { status: 403, data: { detail: 'Forbidden' } } }
put.mockImplementation((url: string) => url === '/keys/2'
? Promise.reject(failure)
: Promise.resolve({ data: { id: Number(url.split('/').pop()) } }))
const updates = { group_id: 12, quota: 0, expires_at: '', ip_whitelist: [] }
const result = await bulkUpdate([1, 2, 1, 3], updates)
expect(put.mock.calls).toEqual([
['/keys/1', updates], ['/keys/2', updates], ['/keys/3', updates]
])
expect(result).toEqual({ succeededIds: [1, 3], failures: [{ id: 2, error: failure }] })
})
it('limits in-flight requests and continues after a failed batch member', async () => {
const settle: Array<() => void> = []
let inFlight = 0
let peak = 0
put.mockImplementation(() => new Promise((resolve, reject) => {
inFlight++
peak = Math.max(peak, inFlight)
const index = settle.length
settle.push(() => {
inFlight--
if (index === 0) reject(new Error('network error'))
else resolve({ data: {} })
})
}))
const pending = bulkUpdate([1, 2, 3, 4, 5, 6, 7], { status: 'inactive' })
expect(put).toHaveBeenCalledTimes(5)
settle.slice(0, 4).forEach((finish) => finish())
await flushPromises()
expect(put).toHaveBeenCalledTimes(5)
settle[4]()
await flushPromises()
expect(put).toHaveBeenCalledTimes(7)
settle.slice(5).forEach((finish) => finish())
const result = await pending
expect(peak).toBe(5)
expect(result.succeededIds).toEqual([2, 3, 4, 5, 6, 7])
expect(result.failures.map(({ id }) => id)).toEqual([1])
})
it('does not send requests when no keys are selected', async () => {
expect(await bulkUpdate([], { quota: 0 })).toEqual({ succeededIds: [], failures: [] })
expect(put).not.toHaveBeenCalled()
})
})
+27
View File
@@ -111,6 +111,32 @@ export async function update(id: number, updates: UpdateApiKeyRequest): Promise<
return data
}
export interface BulkUpdateApiKeysResult {
succeededIds: number[]
failures: Array<{ id: number; error: unknown }>
}
/** Reuse per-key validation and permissions, with at most five requests in flight. */
export async function bulkUpdate(
ids: number[],
updates: UpdateApiKeyRequest
): Promise<BulkUpdateApiKeysResult> {
const uniqueIds = [...new Set(ids)]
const result: BulkUpdateApiKeysResult = { succeededIds: [], failures: [] }
for (let offset = 0; offset < uniqueIds.length; offset += 5) {
const batch = uniqueIds.slice(offset, offset + 5)
const responses = await Promise.allSettled(batch.map((id) => update(id, updates)))
responses.forEach((response, index) => {
if (response.status === 'fulfilled') {
result.succeededIds.push(batch[index])
} else {
result.failures.push({ id: batch[index], error: response.reason })
}
})
}
return result
}
/**
* Delete API key
* @param id - API key ID
@@ -136,6 +162,7 @@ export const keysAPI = {
getById,
create,
update,
bulkUpdate,
delete: deleteKey,
toggleStatus
}
@@ -0,0 +1,298 @@
<template>
<BaseDialog
:show="show"
:title="t('keys.bulkEdit.title')"
width="normal"
:close-on-escape="!submitting"
:show-close-button="!submitting"
@close="close"
>
<form id="bulk-edit-keys-form" class="space-y-5" @submit.prevent="submit">
<div class="space-y-1 text-sm">
<p class="font-medium text-gray-900 dark:text-white">
{{ t('keys.bulkEdit.selectedCount', { count: pendingKeys.length }) }}
</p>
<p class="text-gray-500 dark:text-gray-400">{{ t('keys.bulkEdit.hint') }}</p>
</div>
<fieldset :disabled="submitting" class="space-y-5">
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium">
<input v-model="enabled.group_id" type="checkbox" class="checkbox" data-test="enable-group" />
{{ t('keys.groupLabel') }}
</label>
<Select
v-if="enabled.group_id"
v-model="groupId"
:options="groupOptions"
:placeholder="t('keys.selectGroup')"
:disabled="submitting"
:aria-label="t('keys.groupLabel')"
searchable
data-test="group-input"
/>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium">
<input v-model="enabled.status" type="checkbox" class="checkbox" data-test="enable-status" />
{{ t('keys.statusLabel') }}
</label>
<Select
v-if="enabled.status"
v-model="status"
:options="statusOptions"
:disabled="submitting"
:aria-label="t('keys.statusLabel')"
data-test="status-input"
/>
</div>
<div v-for="field in limitFields" :key="field.key" class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium">
<input
v-model="enabled[field.key]"
type="checkbox"
class="checkbox"
:data-test="`enable-${field.key}`"
/>
{{ t(field.label) }}
</label>
<div v-if="enabled[field.key]">
<input
v-model="limits[field.key]"
type="number"
min="0"
step="any"
required
class="input"
:aria-label="t(field.label)"
:data-test="`${field.key}-input`"
/>
<p class="input-hint">{{ t('keys.bulkEdit.limitHint') }}</p>
</div>
</div>
<div class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium">
<input v-model="enabled.expires_at" type="checkbox" class="checkbox" data-test="enable-expiration" />
{{ t('keys.expiration') }}
</label>
<div v-if="enabled.expires_at" class="space-y-2">
<label class="flex items-center gap-2 text-sm">
<input v-model="neverExpires" type="checkbox" class="checkbox" data-test="never-expires" />
{{ t('keys.noExpiration') }}
</label>
<input
v-if="!neverExpires"
v-model="expirationDate"
type="datetime-local"
required
class="input"
:aria-label="t('keys.expirationDate')"
data-test="expiration-input"
/>
</div>
</div>
<div v-for="field in ipFields" :key="field.key" class="space-y-2">
<label class="flex items-center gap-2 text-sm font-medium">
<input
v-model="enabled[field.key]"
type="checkbox"
class="checkbox"
:data-test="`enable-${field.key}`"
/>
{{ t(field.label) }}
</label>
<div v-if="enabled[field.key]">
<textarea
v-model="ipLists[field.key]"
rows="3"
class="input font-mono text-sm"
:aria-label="t(field.label)"
:data-test="`${field.key}-input`"
/>
<p class="input-hint">{{ t('keys.bulkEdit.ipHint') }}</p>
</div>
</div>
</fieldset>
<p v-if="validationError" role="alert" class="text-sm text-red-600 dark:text-red-400">
{{ validationError }}
</p>
<div v-if="failures.length" role="alert" class="space-y-2 text-sm text-red-600 dark:text-red-400">
<p>{{ t('keys.bulkEdit.failureHint') }}</p>
<ul class="max-h-40 space-y-1 overflow-y-auto">
<li v-for="failure in failures" :key="failure.id" class="break-words">
#{{ failure.id }} {{ failure.name }}: {{ failure.message }}
</li>
</ul>
</div>
</form>
<template #footer>
<button type="button" class="btn btn-secondary" :disabled="submitting" @click="close">
{{ t('common.cancel') }}
</button>
<button
type="submit"
form="bulk-edit-keys-form"
class="btn btn-primary"
:disabled="!canSubmit"
data-test="submit"
>
{{ submitting ? t('keys.saving') : t('keys.bulkEdit.apply', { count: pendingKeys.length }) }}
</button>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { keysAPI } from '@/api'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import type { ApiKey, Group, UpdateApiKeyRequest } from '@/types'
type SelectedKey = Pick<ApiKey, 'id' | 'name'>
type LimitField = 'quota' | 'rate_limit_5h' | 'rate_limit_1d' | 'rate_limit_7d'
type IPField = 'ip_whitelist' | 'ip_blacklist'
type EditableField = LimitField | IPField | 'group_id' | 'status' | 'expires_at'
const props = defineProps<{
show: boolean
selectedKeys: SelectedKey[]
groups: Group[]
}>()
const emit = defineEmits<{
close: []
updated: [succeededIds: number[]]
}>()
const { t } = useI18n()
const appStore = useAppStore()
const submitting = ref(false)
const pendingKeys = ref<SelectedKey[]>([])
const failures = ref<Array<{ id: number; name: string; message: string }>>([])
const enabled = reactive<Record<EditableField, boolean>>({
group_id: false,
status: false,
quota: false,
rate_limit_5h: false,
rate_limit_1d: false,
rate_limit_7d: false,
expires_at: false,
ip_whitelist: false,
ip_blacklist: false
})
const groupId = ref<number | null>(null)
const status = ref<'active' | 'inactive'>('active')
const limits = reactive<Record<LimitField, string | number>>({
quota: '', rate_limit_5h: '', rate_limit_1d: '', rate_limit_7d: ''
})
const ipLists = reactive<Record<IPField, string>>({ ip_whitelist: '', ip_blacklist: '' })
const neverExpires = ref(false)
const expirationDate = ref('')
const limitFields: Array<{ key: LimitField; label: string }> = [
{ key: 'quota', label: 'keys.quotaAmount' },
{ key: 'rate_limit_5h', label: 'keys.rateLimit5h' },
{ key: 'rate_limit_1d', label: 'keys.rateLimit1d' },
{ key: 'rate_limit_7d', label: 'keys.rateLimit7d' }
]
const ipFields: Array<{ key: IPField; label: string }> = [
{ key: 'ip_whitelist', label: 'keys.ipWhitelist' },
{ key: 'ip_blacklist', label: 'keys.ipBlacklist' }
]
const groupOptions = computed(() => props.groups.map((group) => ({ value: group.id, label: group.name })))
const statusOptions = computed(() => [
{ value: 'active', label: t('keys.enable') },
{ value: 'inactive', label: t('keys.disable') }
])
const validationError = computed(() => {
if (enabled.group_id && !props.groups.some((group) => group.id === groupId.value)) {
return t('keys.groupRequired')
}
for (const { key } of limitFields) {
if (!enabled[key]) continue
const value = String(limits[key]).trim()
if (!value || !Number.isFinite(Number(value)) || Number(value) < 0) {
return t('keys.bulkEdit.invalidLimit')
}
}
if (enabled.expires_at && !neverExpires.value && !Number.isFinite(Date.parse(expirationDate.value))) {
return t('keys.bulkEdit.invalidExpiration')
}
return ''
})
const canSubmit = computed(() =>
pendingKeys.value.length > 0 && Object.values(enabled).some(Boolean)
&& !validationError.value && !submitting.value
)
watch(() => props.show, (show) => {
if (!show) return
pendingKeys.value = props.selectedKeys.map(({ id, name }) => ({ id, name }))
failures.value = []
for (const field of Object.keys(enabled) as EditableField[]) enabled[field] = false
for (const { key } of limitFields) limits[key] = ''
for (const { key } of ipFields) ipLists[key] = ''
groupId.value = null
status.value = 'active'
neverExpires.value = false
expirationDate.value = ''
}, { immediate: true })
const close = () => {
if (!submitting.value) emit('close')
}
const errorMessage = (error: unknown): string => {
const message = (error as { message?: unknown } | null)?.message
return typeof message === 'string' && message ? message : t('keys.failedToSave')
}
const submit = async () => {
if (!canSubmit.value) return
const updates: UpdateApiKeyRequest = {}
if (enabled.group_id) updates.group_id = groupId.value
if (enabled.status) updates.status = status.value
for (const { key } of limitFields) {
if (enabled[key]) updates[key] = Number(limits[key])
}
for (const { key } of ipFields) {
if (enabled[key]) updates[key] = ipLists[key].split('\n').map((ip) => ip.trim()).filter(Boolean)
}
if (enabled.expires_at) {
updates.expires_at = neverExpires.value ? '' : new Date(expirationDate.value).toISOString()
}
submitting.value = true
try {
const result = await keysAPI.bulkUpdate(pendingKeys.value.map((key) => key.id), updates)
failures.value = result.failures.map(({ id, error }) => ({
id,
name: pendingKeys.value.find((key) => key.id === id)?.name ?? '',
message: errorMessage(error)
}))
const failedIds = new Set(result.failures.map(({ id }) => id))
pendingKeys.value = pendingKeys.value.filter((key) => failedIds.has(key.id))
if (result.succeededIds.length) emit('updated', result.succeededIds)
if (result.failures.length) {
appStore.showError(t('keys.bulkEdit.partialFailure', {
success: result.succeededIds.length, failed: result.failures.length
}))
} else {
appStore.showSuccess(t('keys.bulkEdit.success', { count: result.succeededIds.length }))
emit('close')
}
} catch (error) {
appStore.showError(errorMessage(error))
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import BulkEditKeysModal from '../BulkEditKeysModal.vue'
import type { Group } from '@/types'
const { bulkUpdate, showSuccess, showError } = vi.hoisted(() => ({
bulkUpdate: vi.fn(), showSuccess: vi.fn(), showError: vi.fn()
}))
vi.mock('@/api', () => ({ keysAPI: { bulkUpdate } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess, showError }) }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string, params?: Record<string, unknown>) => `${key} ${JSON.stringify(params ?? {})}` })
}))
const mountModal = () => mount(BulkEditKeysModal, {
props: {
show: true,
selectedKeys: [{ id: 1, name: 'First' }, { id: 2, name: 'Second' }],
groups: [{ id: 7, name: 'Available group' }] as Group[]
},
global: {
stubs: {
BaseDialog: {
name: 'BaseDialog',
props: ['show', 'closeOnEscape', 'showCloseButton'],
emits: ['close'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
},
Select: {
props: ['modelValue', 'options', 'disabled'],
emits: ['update:modelValue'],
template: `<select :disabled="disabled" :value="modelValue" @change="$emit('update:modelValue', $event.target.value === '7' ? 7 : $event.target.value)">
<option value=""></option>
<option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>`
}
}
}
})
describe('BulkEditKeysModal', () => {
beforeEach(() => {
vi.clearAllMocks()
bulkUpdate.mockResolvedValue({ succeededIds: [1, 2], failures: [] })
})
it('requires an explicit field choice and sends only that field', async () => {
const wrapper = mountModal()
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).not.toHaveBeenCalled()
await wrapper.get('[data-test="enable-status"]').setValue(true)
await wrapper.get('[data-test="status-input"]').setValue('inactive')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { status: 'inactive' })
expect(wrapper.emitted('updated')).toEqual([[[1, 2]]])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('preserves other fields when changing one window limit, including zero', async () => {
const wrapper = mountModal()
await wrapper.get('[data-test="enable-rate_limit_1d"]').setValue(true)
await wrapper.get('[data-test="rate_limit_1d-input"]').setValue('0')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { rate_limit_1d: 0 })
})
it.each(['', '-1', 'not-a-number'])('rejects an invalid selected quota: %s', async (value) => {
const wrapper = mountModal()
await wrapper.get('[data-test="enable-quota"]').setValue(true)
await wrapper.get('[data-test="quota-input"]').setValue(value)
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).not.toHaveBeenCalled()
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
})
it('requires an available group when changing group', async () => {
const wrapper = mountModal()
await wrapper.get('[data-test="enable-group"]').setValue(true)
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).not.toHaveBeenCalled()
await wrapper.get('[data-test="group-input"]').setValue('7')
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { group_id: 7 })
})
it('clears only an explicitly selected IP list and expiration', async () => {
const wrapper = mountModal()
await wrapper.get('[data-test="enable-ip_whitelist"]').setValue(true)
await wrapper.get('[data-test="enable-expiration"]').setValue(true)
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).not.toHaveBeenCalled()
await wrapper.get('[data-test="never-expires"]').setValue(true)
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], { ip_whitelist: [], expires_at: '' })
})
it('normalizes IP lines and converts the chosen local date to ISO', async () => {
const wrapper = mountModal()
await wrapper.get('[data-test="enable-ip_blacklist"]').setValue(true)
await wrapper.get('[data-test="ip_blacklist-input"]').setValue(' 192.0.2.1 \n\n 198.51.100.0/24\n')
await wrapper.get('[data-test="enable-expiration"]').setValue(true)
await wrapper.get('[data-test="expiration-input"]').setValue('2030-01-02T03:04')
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).toHaveBeenCalledWith([1, 2], {
ip_blacklist: ['192.0.2.1', '198.51.100.0/24'],
expires_at: new Date('2030-01-02T03:04').toISOString()
})
})
it('reports individual failures and retries only failed keys', async () => {
bulkUpdate.mockResolvedValueOnce({
succeededIds: [1],
failures: [{ id: 2, error: { status: 403, message: 'Group access denied' } }]
}).mockResolvedValueOnce({ succeededIds: [2], failures: [] })
const wrapper = mountModal()
await wrapper.get('[data-test="enable-quota"]').setValue(true)
await wrapper.get('[data-test="quota-input"]').setValue('25.50')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('#2 Second: Group access denied')
expect(wrapper.emitted('updated')).toEqual([[[1]]])
expect(wrapper.emitted('close')).toBeUndefined()
expect(showSuccess).not.toHaveBeenCalled()
await wrapper.setProps({ selectedKeys: [{ id: 2, name: 'Second' }, { id: 3, name: 'New selection' }] })
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkUpdate).toHaveBeenLastCalledWith([2], { quota: 25.5 })
expect(wrapper.emitted('updated')).toEqual([[[1]], [[2]]])
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('prevents duplicate submissions and closing during an update', async () => {
let finish!: (result: { succeededIds: number[]; failures: [] }) => void
bulkUpdate.mockReturnValue(new Promise((resolve) => { finish = resolve }))
const wrapper = mountModal()
await wrapper.get('[data-test="enable-status"]').setValue(true)
await wrapper.get('form').trigger('submit')
await wrapper.get('form').trigger('submit')
wrapper.findComponent({ name: 'BaseDialog' }).vm.$emit('close')
expect(bulkUpdate).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('close')).toBeUndefined()
expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
finish({ succeededIds: [1, 2], failures: [] })
await flushPromises()
})
it('resets all field choices when reopened for a new selection', async () => {
const wrapper = mountModal()
await wrapper.get('[data-test="enable-quota"]').setValue(true)
await wrapper.get('[data-test="quota-input"]').setValue('12')
await wrapper.setProps({ show: false })
await wrapper.setProps({ show: true, selectedKeys: [{ id: 3, name: 'Third' }] })
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
expect(wrapper.find('[data-test="quota-input"]').exists()).toBe(false)
await wrapper.get('[data-test="enable-status"]').setValue(true)
await wrapper.get('form').trigger('submit')
expect(bulkUpdate).toHaveBeenCalledWith([3], { status: 'active' })
})
})
+15
View File
@@ -83,6 +83,21 @@ export default {
columnAlwaysVisible: 'This column is always visible',
createKey: 'Create API Key',
editKey: 'Edit API Key',
bulkEdit: {
title: 'Bulk Edit',
selectedCount: '{count} keys selected',
selectKey: 'Select key {name}',
clearSelection: 'Clear selection',
hint: 'Check the fields to update. Unchecked fields keep their current values.',
limitHint: 'Enter 0 for no limit. Existing usage is preserved.',
ipHint: 'One IP or CIDR per line. Leave empty to clear this list on the selected keys.',
invalidLimit: 'Enter a valid amount greater than or equal to 0.',
invalidExpiration: 'Choose a valid expiration date or select Never expires.',
apply: 'Apply to {count} keys',
success: 'Updated {count} keys',
partialFailure: 'Updated {success} keys; {failed} failed',
failureHint: 'These keys could not be updated. Adjust the settings and retry. Only failed keys will be retried.'
},
deleteKey: 'Delete API Key',
deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.",
id: 'ID',
+15
View File
@@ -83,6 +83,21 @@ export default {
columnAlwaysVisible: '该列固定显示,不可隐藏',
createKey: '创建密钥',
editKey: '编辑密钥',
bulkEdit: {
title: '批量编辑',
selectedCount: '已选择 {count} 个密钥',
selectKey: '选择密钥 {name}',
clearSelection: '取消选择',
hint: '勾选需要修改的字段,未勾选的字段保持原值。',
limitHint: '输入 0 表示不限制;已用额度保持不变。',
ipHint: '每行一个 IP 或 CIDR;留空将清空所选密钥的此项名单。',
invalidLimit: '请输入大于或等于 0 的有效金额。',
invalidExpiration: '请选择有效的过期时间,或勾选永久有效。',
apply: '应用到 {count} 个密钥',
success: '已更新 {count} 个密钥',
partialFailure: '已更新 {success} 个密钥,{failed} 个失败',
failureHint: '以下密钥更新失败,可修改设置后重试。再次提交只会更新失败的密钥。'
},
deleteKey: '删除密钥',
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
id: 'ID',
+50
View File
@@ -28,6 +28,22 @@
:api-base-url="publicSettings?.api_base_url || ''"
:custom-endpoints="publicSettings?.custom_endpoints || []"
/>
<div v-if="selectedIds.length" class="flex flex-wrap items-center gap-3 text-sm">
<span class="text-gray-600 dark:text-gray-300">
{{ t('keys.bulkEdit.selectedCount', { count: selectedIds.length }) }}
</span>
<button
class="btn btn-primary btn-sm"
:disabled="loading"
data-test="bulk-edit-keys"
@click="showBulkEditModal = true"
>
{{ t('keys.bulkEdit.title') }}
</button>
<button class="btn btn-secondary btn-sm" @click="selectedIds = []">
{{ t('keys.bulkEdit.clearSelection') }}
</button>
</div>
</div>
</template>
@@ -85,6 +101,11 @@
:columns="columns"
:data="apiKeys"
:loading="loading"
selectable
row-key="id"
:selected-keys="selectedIds"
:selection-label="(key: ApiKey) => t('keys.bulkEdit.selectKey', { name: key.name })"
@update:selected-keys="handleSelectionChange"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@@ -952,6 +973,14 @@
</template>
</BaseDialog>
<BulkEditKeysModal
:show="showBulkEditModal"
:selected-keys="selectedApiKeys"
:groups="groups"
@close="showBulkEditModal = false"
@updated="handleBulkUpdated"
/>
<!-- Delete Confirmation Dialog -->
<ConfirmDialog
:show="showDeleteDialog"
@@ -1128,6 +1157,7 @@ const { t } = useI18n()
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import BulkEditKeysModal from '@/components/keys/BulkEditKeysModal.vue'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
@@ -1270,6 +1300,21 @@ const columns = computed<Column[]>(() =>
)
const apiKeys = ref<ApiKey[]>([])
const selectedIds = ref<number[]>([])
const showBulkEditModal = ref(false)
const selectedApiKeys = computed(() => apiKeys.value.filter((key) => selectedIds.value.includes(key.id)))
const handleSelectionChange = (ids: Array<string | number>) => {
const visibleIds = new Set(apiKeys.value.map((key) => key.id))
selectedIds.value = [...new Set(ids.map(Number))].filter((id) => visibleIds.has(id))
}
const handleBulkUpdated = (succeededIds: number[]) => {
const succeeded = new Set(succeededIds)
selectedIds.value = selectedIds.value.filter((id) => !succeeded.has(id))
loadApiKeys()
}
const groups = ref<Group[]>([])
const loading = ref(false)
const submitting = ref(false)
@@ -1393,6 +1438,7 @@ const statusFilterOptions = computed(() => [
])
const onFilterChange = () => {
selectedIds.value = []
pagination.value.page = 1
loadApiKeys()
}
@@ -1477,6 +1523,7 @@ const loadApiKeys = async () => {
})
if (signal.aborted) return
apiKeys.value = response.items
handleSelectionChange(selectedIds.value)
pagination.value.total = response.total
pagination.value.pages = response.pages
@@ -1540,17 +1587,20 @@ const closeUseKeyModal = () => {
}
const handlePageChange = (page: number) => {
selectedIds.value = []
pagination.value.page = page
loadApiKeys()
}
const handlePageSizeChange = (pageSize: number) => {
selectedIds.value = []
pagination.value.page_size = pageSize
pagination.value.page = 1
loadApiKeys()
}
const handleSort = (key: string, order: 'asc' | 'desc') => {
selectedIds.value = []
sortState.value.sort_by = key
sortState.value.sort_order = order
pagination.value.page = 1
@@ -153,8 +153,8 @@ const TablePageLayoutStub = {
const DataTableStub = {
name: 'DataTable',
props: ['columns', 'data'],
emits: ['sort'],
props: { columns: Array, data: Array, selectedKeys: Array, selectable: Boolean },
emits: ['sort', 'update:selectedKeys'],
template: `
<div>
<div data-test="columns">{{ columns.map((col) => col.key).join(',') }}</div>
@@ -230,6 +230,7 @@ const mountView = async () => {
SearchInput: SearchInputStub,
Icon: IconStub,
UseKeyModal: true,
BulkEditKeysModal: true,
EndpointPopover: true,
GroupBadge: true,
GroupOptionItem: true,
@@ -305,6 +306,66 @@ describe('user KeysView column settings', () => {
expect(visibleColumnKeys(wrapper)).not.toContain('id')
})
it('opens bulk editing with only selected visible keys', async () => {
const wrapper = await mountView()
const table = wrapper.findComponent({ name: 'DataTable' })
expect(table.props('selectable')).toBe(true)
table.vm.$emit('update:selectedKeys', [1, 99])
await nextTick()
await wrapper.get('[data-test="bulk-edit-keys"]').trigger('click')
const modal = wrapper.findComponent({ name: 'BulkEditKeysModal' })
expect(modal.props('show')).toBe(true)
expect(modal.props('selectedKeys').map((key: ApiKey) => key.id)).toEqual([1])
wrapper.unmount()
})
it.each(['filter', 'page size', 'sort'])('clears selection on %s changes', async (change) => {
const wrapper = await mountView()
const table = wrapper.findComponent({ name: 'DataTable' })
table.vm.$emit('update:selectedKeys', [1])
await nextTick()
if (change === 'filter') {
wrapper.findComponent({ name: 'SearchInput' }).vm.$emit('search')
} else if (change === 'page size') {
await wrapper.get('[data-test="page-size-50"]').trigger('click')
} else {
table.vm.$emit('sort', 'created_at', 'asc')
}
await flushPromises()
expect(table.props('selectedKeys')).toEqual([])
expect(wrapper.find('[data-test="bulk-edit-keys"]').exists()).toBe(false)
wrapper.unmount()
})
it('removes successful keys from the selection and refreshes the table', async () => {
listKeys.mockResolvedValue({
items: [createApiKey(), { ...createApiKey(), id: 2, name: 'Second' }],
total: 2, pages: 1
})
const wrapper = await mountView()
const table = wrapper.findComponent({ name: 'DataTable' })
table.vm.$emit('update:selectedKeys', [1, 2])
await nextTick()
await wrapper.get('[data-test="bulk-edit-keys"]').trigger('click')
wrapper.findComponent({ name: 'BulkEditKeysModal' }).vm.$emit('updated', [1])
await flushPromises()
expect(listKeys).toHaveBeenCalledTimes(2)
expect(table.props('selectedKeys')).toEqual([2])
wrapper.unmount()
})
it('drops keys that are no longer visible after a refresh', async () => {
const wrapper = await mountView()
const table = wrapper.findComponent({ name: 'DataTable' })
table.vm.$emit('update:selectedKeys', [1])
await nextTick()
listKeys.mockResolvedValue({ items: [], total: 0, pages: 0 })
await wrapper.get('button[title="Refresh"]').trigger('click')
await flushPromises()
expect(table.props('selectedKeys')).toEqual([])
wrapper.unmount()
})
it('shows a hidden column when toggled and persists the preference', async () => {
const wrapper = await mountView()