fix: hide batch image entry without allowed key

This commit is contained in:
Turtle_Li
2026-07-06 15:03:17 +08:00
parent d73fa8eab2
commit 616cf17d9e
5 changed files with 122 additions and 3 deletions
@@ -195,6 +195,7 @@ import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore }
import VersionBadge from '@/components/common/VersionBadge.vue'
import { sanitizeSvg } from '@/utils/sanitize'
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
import { useBatchImageAccess } from '@/composables/useBatchImageAccess'
interface NavItem {
path: string
@@ -240,6 +241,7 @@ const appStore = useAppStore()
const authStore = useAuthStore()
const onboardingStore = useOnboardingStore()
const adminSettingsStore = useAdminSettingsStore()
const { canUseBatchImage, refreshBatchImageAccess } = useBatchImageAccess()
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
const mobileOpen = computed(() => appStore.mobileOpen)
@@ -683,6 +685,7 @@ const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
const flagBatchImageAccess = () => canUseBatchImage.value
// buildSelfNavItems 构造用户自己的导航项(用户端主菜单和管理员的"我的账户"子菜单共享这组声明)。
// withDashboard=true 时包含仪表盘(用户端),false 时不含(管理员的个人区已经有独立仪表盘入口)。
@@ -696,7 +699,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
}
items.push(
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/batch-image', label: t('nav.batchImage'), icon: BatchImageIcon, hideInSimpleMode: true },
{ path: '/batch-image', label: t('nav.batchImage'), icon: BatchImageIcon, hideInSimpleMode: true, featureFlag: flagBatchImageAccess },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels },
{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor },
@@ -917,6 +920,7 @@ watch(
)
onMounted(() => {
void refreshBatchImageAccess()
if (isAdmin.value) {
adminSettingsStore.fetch()
}
@@ -34,7 +34,7 @@
/>
</button>
<button @click="router.push('/batch-image')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<button v-if="canUseBatchImage" @click="router.push('/batch-image')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-sky-100 transition-transform group-hover:scale-105 dark:bg-sky-900/30">
<Icon name="sparkles" size="lg" class="text-sky-600 dark:text-sky-400" />
</div>
@@ -68,9 +68,16 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import { useBatchImageAccess } from '@/composables/useBatchImageAccess'
const router = useRouter()
const { t } = useI18n()
const { canUseBatchImage, refreshBatchImageAccess } = useBatchImageAccess()
onMounted(() => {
void refreshBatchImageAccess()
})
</script>
@@ -0,0 +1,83 @@
import { computed, ref } from 'vue'
import { keysAPI } from '@/api/keys'
import { useAuthStore } from '@/stores/auth'
import type { ApiKey } from '@/types'
const loaded = ref(false)
const loading = ref(false)
const hasAllowedBatchImageKey = ref(false)
let pendingLoad: Promise<boolean> | null = null
const pageSize = 100
function keyAllowsBatchImage(key: ApiKey): boolean {
return (
key.status === 'active' &&
key.group?.platform === 'gemini' &&
key.group?.allow_batch_image_generation === true
)
}
async function loadBatchImageAccess(force = false): Promise<boolean> {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
loaded.value = true
hasAllowedBatchImageKey.value = false
return false
}
if (loaded.value && !force) {
return hasAllowedBatchImageKey.value
}
if (pendingLoad && !force) {
return pendingLoad
}
loading.value = true
pendingLoad = (async () => {
let page = 1
while (true) {
const response = await keysAPI.list(page, pageSize, {
status: 'active',
sort_by: 'created_at',
sort_order: 'desc'
})
if ((response.items || []).some(keyAllowsBatchImage)) {
hasAllowedBatchImageKey.value = true
loaded.value = true
return true
}
if (page >= response.pages || (response.items || []).length === 0) {
hasAllowedBatchImageKey.value = false
loaded.value = true
return false
}
page += 1
}
})()
.catch(() => {
hasAllowedBatchImageKey.value = false
loaded.value = true
return false
})
.finally(() => {
loading.value = false
pendingLoad = null
})
return pendingLoad
}
export function useBatchImageAccess() {
const canUseBatchImage = computed(() => hasAllowedBatchImageKey.value)
return {
canUseBatchImage,
batchImageAccessLoaded: computed(() => loaded.value),
batchImageAccessLoading: computed(() => loading.value),
refreshBatchImageAccess: loadBatchImageAccess,
}
}
@@ -225,6 +225,7 @@
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<button
v-if="canUseBatchImage"
type="button"
class="group flex items-center gap-3 rounded-lg bg-gray-50 p-3 text-left transition-colors hover:bg-sky-50 dark:bg-dark-800/50 dark:hover:bg-sky-900/20"
@click="router.push('/batch-image')"
@@ -361,6 +362,7 @@ import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Select from '@/components/common/Select.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
import { useBatchImageAccess } from '@/composables/useBatchImageAccess'
import {
Chart as ChartJS,
@@ -387,6 +389,7 @@ ChartJS.register(
const appStore = useAppStore()
const router = useRouter()
const { canUseBatchImage, refreshBatchImageAccess } = useBatchImageAccess()
const stats = ref<DashboardStats | null>(null)
const loading = ref(false)
const chartsLoading = ref(false)
@@ -746,6 +749,7 @@ const loadChartData = async () => {
}
onMounted(() => {
void refreshBatchImageAccess()
loadDashboardStats()
})
</script>
@@ -55,6 +55,28 @@ Covered by automated tests and smoke:
- Stale pre-provider jobs can be failed and released.
- Completed job download only returns successful outputs.
## Access Control And Visibility
The batch image feature has two independent gates:
- Global runtime gate: `BATCH_IMAGE_ENABLED` controls whether `/v1/images/batches*` is available at all. If disabled, the backend returns `404 BATCH_IMAGE_DISABLED` regardless of group settings. This value is loaded at application startup, so changing the server environment requires restarting/redeploying the app container.
- Group/API-key gate: `groups.allow_batch_image_generation` controls whether a user's API key may use the feature. If the global gate is enabled but the API key's group is not allowed, the backend returns `403 BATCH_IMAGE_GROUP_DISABLED`.
Frontend visibility follows the same group/API-key gate for user-facing entry points:
- Sidebar `/batch-image` entry is shown only when the current user has at least one active Gemini API key whose group has `allow_batch_image_generation=true`.
- User dashboard quick action is hidden under the same condition.
- Admin dashboard's shortcut to the user-facing batch image page is also hidden under the same current-user API-key condition; admin group configuration remains available under group management.
- The frontend check pages through active keys in batches of 100 and stops as soon as it finds an allowed key. The result is cached in a shared composable for sidebar/dashboard reuse, and API errors fail closed by hiding the entry.
This frontend hiding is only a UX affordance. Backend authorization remains the source of truth, so direct API calls without an allowed group still fail.
Quick action origin:
- `UserDashboardQuickActions.vue` is an upstream dashboard component. The batch image button was added by the custom batch image work to fit into the existing quick action surface.
- The admin dashboard quick action block and the batch image shortcut inside it were added by the custom batch image work.
- The sidebar batch image module entry was added by the custom batch image work.
## Residual Risks
- Real provider failure combinations should still be tested with controlled fake/fixture provider outputs: malformed output JSONL, missing image bytes, provider cancelled after partial success, and delayed output indexing.
@@ -64,4 +86,3 @@ Covered by automated tests and smoke:
## Recommendation
Proceed to broader review with Claude and/or manual exploratory testing. Before production enablement, add one integration test for cancel/settle concurrency and one for persistent settlement billing failure recovery.