+
+
+ {{ presetLabel(preset) }} ({{ displayUrl(preset.url) }})
+
+
+
+
+
diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
index 31f6e3bd2..489b00632 100644
--- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
@@ -130,6 +130,94 @@ describe('BulkEditAccountModal', () => {
})
})
+ it('全部目标为 Grok OAuth 时,官方主机 base_url 作为手动端点切换正常提交', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['grok'],
+ selectedTypes: ['oauth']
+ })
+
+ await wrapper.get('#bulk-edit-base-url-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ credentials: {
+ base_url: 'https://api.x.ai/v1'
+ }
+ })
+ })
+
+ it('所选全为 grok 时展示快捷端点,点击后填入并自动勾选 base_url', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['grok'],
+ selectedTypes: ['oauth']
+ })
+
+ const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]')
+ expect(presets.length).toBe(5)
+
+ // 第三个预设为区域 API (us-east-1.api.x.ai/v1)
+ await presets[2].trigger('click')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ credentials: {
+ base_url: 'https://us-east-1.api.x.ai/v1'
+ }
+ })
+ })
+
+ it('所选含非 grok 平台时不展示快捷端点', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['grok', 'anthropic'],
+ selectedTypes: ['apikey']
+ })
+
+ expect(wrapper.findAll('[data-testid="grok-base-url-preset"]').length).toBe(0)
+ })
+
+ it('全部目标为 Grok OAuth 时,第三方 base_url 正常提交', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['grok'],
+ selectedTypes: ['oauth']
+ })
+
+ await wrapper.get('#bulk-edit-base-url-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-base-url').setValue('https://relay.example.com/v1')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ credentials: {
+ base_url: 'https://relay.example.com/v1'
+ }
+ })
+ })
+
+ it('混合类型选择(含 apikey)时官方主机 base_url 不拦截', async () => {
+ const wrapper = mountModal({
+ selectedPlatforms: ['grok'],
+ selectedTypes: ['apikey', 'oauth']
+ })
+
+ await wrapper.get('#bulk-edit-base-url-enabled').setValue(true)
+ await wrapper.get('#bulk-edit-base-url').setValue('https://api.x.ai/v1')
+ await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
+ expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
+ credentials: {
+ base_url: 'https://api.x.ai/v1'
+ }
+ })
+ })
+
it('OpenAI 账号批量编辑可开启自动透传', async () => {
const wrapper = mountModal({
selectedPlatforms: ['openai'],
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
new file mode 100644
index 000000000..310a37088
--- /dev/null
+++ b/frontend/src/components/account/__tests__/EditAccountModal.grokUpstream.spec.ts
@@ -0,0 +1,228 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent } from 'vue'
+import { mount } from '@vue/test-utils'
+
+const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({
+ updateAccountMock: vi.fn(),
+ checkMixedChannelRiskMock: vi.fn(),
+ authIsSimpleMode: { value: true }
+}))
+
+vi.mock('@/stores/app', () => ({
+ useAppStore: () => ({
+ showError: vi.fn(),
+ showSuccess: vi.fn(),
+ showInfo: vi.fn()
+ })
+}))
+
+vi.mock('@/stores/auth', () => ({
+ useAuthStore: () => ({
+ get isSimpleMode() {
+ return authIsSimpleMode.value
+ }
+ })
+}))
+
+vi.mock('@/api/admin', () => ({
+ adminAPI: {
+ accounts: {
+ update: updateAccountMock,
+ checkMixedChannelRisk: checkMixedChannelRiskMock
+ },
+ settings: {
+ getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
+ getSettings: vi.fn().mockResolvedValue({})
+ },
+ tlsFingerprintProfiles: {
+ list: vi.fn().mockResolvedValue([])
+ }
+ }
+}))
+
+vi.mock('@/api/admin/accounts', () => ({
+ getAntigravityDefaultModelMapping: vi.fn()
+}))
+
+vi.mock('vue-i18n', async () => {
+ const actual = await vi.importActual
('vue-i18n')
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: (key: string) => key
+ })
+ }
+})
+
+import EditAccountModal from '../EditAccountModal.vue'
+
+const BaseDialogStub = defineComponent({
+ name: 'BaseDialog',
+ props: {
+ show: {
+ type: Boolean,
+ default: false
+ }
+ },
+ template: '
'
+})
+
+function buildGrokOAuthAccount(credentials: Record = {}) {
+ return {
+ id: 5,
+ name: 'Grok OAuth',
+ notes: '',
+ platform: 'grok',
+ type: 'oauth',
+ credentials: {
+ expires_at: '2027-01-01T00:00:00Z',
+ token_type: 'Bearer',
+ ...credentials
+ },
+ credentials_status: { has_access_token: true, has_refresh_token: true },
+ extra: {},
+ proxy_id: null,
+ concurrency: 1,
+ priority: 1,
+ rate_multiplier: 1,
+ status: 'active',
+ group_ids: [],
+ expires_at: null,
+ auto_pause_on_expired: false
+ } as any
+}
+
+function mountModal(account: any) {
+ return mount(EditAccountModal, {
+ props: {
+ show: true,
+ account,
+ proxies: [],
+ groups: []
+ },
+ global: {
+ stubs: {
+ BaseDialog: BaseDialogStub,
+ Select: true,
+ Icon: true,
+ ProxySelector: true,
+ GroupSelector: true,
+ ModelWhitelistSelector: true
+ }
+ }
+ })
+}
+
+describe('EditAccountModal Grok OAuth upstream config', () => {
+ beforeEach(() => {
+ authIsSimpleMode.value = true
+ updateAccountMock.mockReset()
+ checkMixedChannelRiskMock.mockReset()
+ checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
+ })
+
+ it('enabling the custom base URL toggle and saving persists base_url', async () => {
+ const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ // 官方地址 → 开关初始为关(视同未定制)
+ const toggle = wrapper.get('[data-testid="grok-custom-base-url-toggle"]')
+ await toggle.trigger('click')
+
+ const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
+ await input.setValue('https://my-relay.example.com')
+
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+ await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
+
+ const payload = updateAccountMock.mock.calls[0]?.[1]
+ expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com')
+ })
+
+ it('accepts the official API host as a manual endpoint switch and persists it', async () => {
+ const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click')
+ await wrapper.get('[data-testid="grok-custom-base-url-input"]').setValue('https://api.x.ai/v1')
+
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+ await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
+
+ const payload = updateAccountMock.mock.calls[0]?.[1]
+ expect(payload?.credentials?.base_url).toBe('https://api.x.ai/v1')
+ })
+
+ it('echoes a stored official API endpoint with the toggle on', async () => {
+ const account = buildGrokOAuthAccount({ base_url: 'https://us-west-2.api.x.ai/v1' })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
+ expect((input.element as HTMLInputElement).value).toBe('https://us-west-2.api.x.ai/v1')
+ })
+
+ it('fills the input from an endpoint preset chip', async () => {
+ const account = buildGrokOAuthAccount({ base_url: 'https://cli-chat-proxy.grok.com/v1' })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ await wrapper.get('[data-testid="grok-custom-base-url-toggle"]').trigger('click')
+ const presets = wrapper.findAll('[data-testid="grok-base-url-preset"]')
+ expect(presets.length).toBe(5)
+
+ // 第二个预设为官方 API (api.x.ai/v1)
+ await presets[1].trigger('click')
+ const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
+ expect((input.element as HTMLInputElement).value).toBe('https://api.x.ai/v1')
+ })
+
+ it('loads an existing custom base_url with the toggle on and keeps it on save', async () => {
+ const account = buildGrokOAuthAccount({ base_url: 'https://my-relay.example.com' })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ const input = wrapper.get('[data-testid="grok-custom-base-url-input"]')
+ expect((input.element as HTMLInputElement).value).toBe('https://my-relay.example.com')
+
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+ await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
+
+ const payload = updateAccountMock.mock.calls[0]?.[1]
+ expect(payload?.credentials?.base_url).toBe('https://my-relay.example.com')
+ })
+
+ it('keeps stored header overrides intact on an untouched save', async () => {
+ const account = buildGrokOAuthAccount({
+ header_override_enabled: true,
+ header_overrides: {
+ 'user-agent': 'grok-pager/0.2.93',
+ 'x-grok-client-identifier': 'grok-pager',
+ 'x-grok-client-version': '0.2.93',
+ 'x-xai-token-auth': 'xai-grok-cli'
+ }
+ })
+ updateAccountMock.mockResolvedValue(account)
+
+ const wrapper = mountModal(account)
+
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+ await vi.waitFor(() => expect(updateAccountMock).toHaveBeenCalledTimes(1))
+
+ const payload = updateAccountMock.mock.calls[0]?.[1]
+ expect(payload?.credentials?.header_override_enabled).toBe(true)
+ expect(payload?.credentials?.header_overrides).toEqual({
+ 'user-agent': 'grok-pager/0.2.93',
+ 'x-grok-client-identifier': 'grok-pager',
+ 'x-grok-client-version': '0.2.93',
+ 'x-xai-token-auth': 'xai-grok-cli'
+ })
+ })
+})
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
index 98de8f0c0..8cd20e89e 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
@@ -771,6 +771,10 @@ describe('EditAccountModal', () => {
const wrapper = mountModal(account)
+ expect(wrapper.text()).toContain('admin.accounts.openai.codexImageTool')
+ expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolDesc')
+ expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolEnabledDesc')
+
await wrapper.get('button[data-testid="codex-image-tool-enabled"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
@@ -809,6 +813,9 @@ describe('EditAccountModal', () => {
const wrapper = mountModal(account)
+ expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlock')
+ expect(wrapper.text()).toContain('admin.accounts.openai.codexImageToolBlockDesc')
+
await wrapper.get('button[data-testid="codex-image-tool-block"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
index 207cacc3d..6d4b59e2a 100644
--- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
+++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts
@@ -11,6 +11,7 @@ import {
buildPlanTypeOptions,
isCustomGrokBaseUrl,
isHeaderOverrideCapable,
+ GROK_BASE_URL_PRESETS,
parseHeaderOverridesJson,
planTypeDisplayLabel,
readPlanType,
@@ -169,11 +170,15 @@ describe('serializeHeaderOverrideRows', () => {
})
describe('isCustomGrokBaseUrl', () => {
- it('treats official hosts and their variants as not customized', () => {
- expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(false)
+ it('treats only the default CLI gateway host as not customized', () => {
expect(isCustomGrokBaseUrl('https://cli-chat-proxy.grok.com/v1')).toBe(false)
- expect(isCustomGrokBaseUrl('HTTPS://API.X.AI:443/')).toBe(false)
- expect(isCustomGrokBaseUrl('https://api.x.ai:8443/v1')).toBe(false)
+ expect(isCustomGrokBaseUrl('HTTPS://CLI-CHAT-PROXY.GROK.COM:443/')).toBe(false)
+ })
+
+ it('treats manually switched official/regional endpoints as customized (must echo back)', () => {
+ expect(isCustomGrokBaseUrl('https://api.x.ai/v1')).toBe(true)
+ expect(isCustomGrokBaseUrl('https://us-west-2.api.x.ai/v1')).toBe(true)
+ expect(isCustomGrokBaseUrl('https://eu-west-1.api.x.ai/v1')).toBe(true)
})
it('treats empty, non-string and unparseable values as not customized', () => {
@@ -191,6 +196,30 @@ describe('isCustomGrokBaseUrl', () => {
})
})
+describe('GROK_BASE_URL_PRESETS', () => {
+ it('covers the CLI gateway, official API and regional endpoints', () => {
+ const urls = GROK_BASE_URL_PRESETS.map((p) => p.url)
+ expect(urls).toEqual([
+ 'https://cli-chat-proxy.grok.com/v1',
+ 'https://api.x.ai/v1',
+ 'https://us-east-1.api.x.ai/v1',
+ 'https://us-west-2.api.x.ai/v1',
+ 'https://eu-west-1.api.x.ai/v1'
+ ])
+ for (const preset of GROK_BASE_URL_PRESETS) {
+ // 每个预设要么有 i18n 标签键,要么有区域标识等字面标签
+ expect(Boolean(preset.labelKey) || Boolean(preset.label)).toBe(true)
+ if (preset.labelKey) {
+ expect(['cli', 'official']).toContain(preset.labelKey)
+ }
+ }
+ // 区域端点用区域标识作字面标签(us-east-1 这样的专有名词不做 i18n)
+ expect(GROK_BASE_URL_PRESETS[2].label).toBe('us-east-1')
+ expect(GROK_BASE_URL_PRESETS[3].label).toBe('us-west-2')
+ expect(GROK_BASE_URL_PRESETS[4].label).toBe('eu-west-1')
+ })
+})
+
describe('validateHeaderOverrideRows', () => {
it('accepts valid rows and empty placeholder rows', () => {
expect(
diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts
index 32726a43d..322141525 100644
--- a/frontend/src/components/account/credentialsBuilder.ts
+++ b/frontend/src/components/account/credentialsBuilder.ts
@@ -199,12 +199,15 @@ export function serializeHeaderOverrideRows(rows: HeaderOverrideRow[]): string {
// ========== Grok 自定义转发地址(base_url 仅改写转发端点,凭证生命周期不受影响) ==========
-const GROK_OFFICIAL_BASE_URL_HOSTS = new Set(['api.x.ai', 'cli-chat-proxy.grok.com'])
+/** OAuth 账号建号/刷新默认写入的 CLI 网关 host——只有它视同"未定制"。 */
+const GROK_DEFAULT_GATEWAY_HOST = 'cli-chat-proxy.grok.com'
/**
- * 判断 Grok 账号存储的 base_url 是否为自定义转发地址。
- * 官方主机的任意变体与无法解析的值均视为"未定制"(与后端 IsOfficialBaseURL 对齐),
- * 用于 OAuth 账号编辑时决定"自定义上游地址"开关的初始状态。
+ * 判断 Grok 账号存储的 base_url 是否为主动指定的上游端点。
+ * 运营方可在官方 API / 区域 API / 第三方转发地址之间手动切换(应对单端点
+ * 不可用),这些值都必须回显(开关开启 + 显示地址)。仅默认 CLI 网关
+ * (建号/刷新自动写入)、空值与无法解析的值视为"未定制"(与后端
+ * GetGrokBaseURL 的回落语义对齐),用于 OAuth 账号编辑时决定开关初始状态。
*/
export function isCustomGrokBaseUrl(value: unknown): boolean {
if (typeof value !== 'string') return false
@@ -216,9 +219,29 @@ export function isCustomGrokBaseUrl(value: unknown): boolean {
} catch {
return false
}
- return !GROK_OFFICIAL_BASE_URL_HOSTS.has(parsed.hostname.toLowerCase())
+ return parsed.hostname.toLowerCase() !== GROK_DEFAULT_GATEWAY_HOST
}
+export interface GrokBaseUrlPreset {
+ /** i18n 子键:admin.accounts.grokCustomBaseUrl.presets. */
+ labelKey?: 'cli' | 'official'
+ /** 字面标签(如区域标识 us-east-1),专有名词不参与 i18n */
+ label?: string
+ url: string
+}
+
+/**
+ * Grok 快捷端点(仅供快速填充,输入框仍可自由填写任意转发地址)。
+ * 官方端点偶发不可用时,运营方靠这组预设在端点间手动切换。
+ */
+export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
+ { labelKey: 'cli', url: 'https://cli-chat-proxy.grok.com/v1' },
+ { labelKey: 'official', url: 'https://api.x.ai/v1' },
+ { label: 'us-east-1', url: 'https://us-east-1.api.x.ai/v1' },
+ { label: 'us-west-2', url: 'https://us-west-2.api.x.ai/v1' },
+ { label: 'eu-west-1', url: 'https://eu-west-1.api.x.ai/v1' }
+]
+
/**
* 将请求头覆写写入 credentials。
* create 模式:关闭时不写入任何字段;edit 模式:关闭时删除字段(全量替换语义)。
diff --git a/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts b/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts
new file mode 100644
index 000000000..31f9eca73
--- /dev/null
+++ b/frontend/src/components/admin/monitor/MonitorActionsCell.spec.ts
@@ -0,0 +1,77 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import type { ChannelMonitor } from '@/api/admin/channelMonitor'
+import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}))
+
+function makeMonitor(overrides: Partial = {}): ChannelMonitor {
+ return {
+ id: 42,
+ name: 'primary',
+ provider: 'openai',
+ api_mode: 'chat_completions',
+ endpoint: 'https://api.example.com',
+ api_key_masked: 'sk-t***',
+ primary_model: 'gpt-4o-mini',
+ extra_models: [],
+ group_name: '',
+ enabled: true,
+ interval_seconds: 60,
+ jitter_seconds: 0,
+ last_checked_at: null,
+ created_by: 1,
+ created_at: '2026-07-16T00:00:00Z',
+ updated_at: '2026-07-16T00:00:00Z',
+ primary_status: '',
+ primary_latency_ms: null,
+ availability_7d: 0,
+ extra_models_status: [],
+ template_id: null,
+ extra_headers: {},
+ body_override_mode: 'off',
+ body_override: null,
+ ...overrides,
+ }
+}
+
+describe('MonitorActionsCell duplicate action', () => {
+ it('emits the selected monitor when duplicate is clicked', async () => {
+ const row = makeMonitor()
+ const wrapper = mount(MonitorActionsCell, {
+ props: { row, running: false, duplicating: false },
+ })
+
+ await wrapper.get('[data-testid="monitor-duplicate"]').trigger('click')
+
+ expect(wrapper.emitted('duplicate')).toEqual([[row]])
+ })
+
+ it('disables the action while the same monitor is being duplicated', () => {
+ const wrapper = mount(MonitorActionsCell, {
+ props: { row: makeMonitor(), running: false, duplicating: true },
+ })
+ const button = wrapper.get('[data-testid="monitor-duplicate"]')
+
+ expect(button.attributes('disabled')).toBeDefined()
+ expect(button.attributes('title')).toBe('admin.channelMonitor.duplicating')
+ expect(button.text()).toContain('admin.channelMonitor.duplicating')
+ })
+
+ it('disables the action when the stored API key cannot be decrypted', () => {
+ const wrapper = mount(MonitorActionsCell, {
+ props: {
+ row: makeMonitor({ api_key_decrypt_failed: true }),
+ running: false,
+ duplicating: false,
+ },
+ })
+ const button = wrapper.get('[data-testid="monitor-duplicate"]')
+
+ expect(button.attributes('disabled')).toBeDefined()
+ expect(button.attributes('title')).toBe('admin.channelMonitor.duplicateKeyUnavailable')
+ })
+})
diff --git a/frontend/src/components/admin/monitor/MonitorActionsCell.vue b/frontend/src/components/admin/monitor/MonitorActionsCell.vue
index 74aa40172..324af8ed3 100644
--- a/frontend/src/components/admin/monitor/MonitorActionsCell.vue
+++ b/frontend/src/components/admin/monitor/MonitorActionsCell.vue
@@ -8,6 +8,18 @@
{{ t('admin.channelMonitor.runNow') }}
+
+
+
+ {{ duplicating ? t('admin.channelMonitor.duplicating') : t('admin.channelMonitor.duplicate') }}
+
+
diff --git a/frontend/src/components/admin/user/BulkEditUserModal.vue b/frontend/src/components/admin/user/BulkEditUserModal.vue
new file mode 100644
index 000000000..d511dff2f
--- /dev/null
+++ b/frontend/src/components/admin/user/BulkEditUserModal.vue
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+
+
+ {{ submitting ? t('admin.users.bulkLimits.applying') : t('admin.users.bulkLimits.apply') }}
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/user/UserCreateModal.vue b/frontend/src/components/admin/user/UserCreateModal.vue
index 609f7cd52..1397a4c85 100644
--- a/frontend/src/components/admin/user/UserCreateModal.vue
+++ b/frontend/src/components/admin/user/UserCreateModal.vue
@@ -64,34 +64,57 @@
+
+
+