sub2api/frontend/src/components/account/QuotaLimitCard.vue
erio a9880ee7b9 fix: round-2 audit fixes — security, code quality, and UI improvements
Security (HIGH):
- Normalize all Redis cache keys to lowercase (verifyCode, passwordReset)
- Fix verify code TTL renewal on failed attempts: use remaining TTL via
  ExpiresAt field instead of resetting to full 15-minute window
- Add 3 missing fields to diffSettings audit log (promo_code, invitation_code,
  custom_endpoints)

Code quality (MEDIUM):
- Extract filterVerifiedEmails shared helper (balance_notify_service.go)
- Add Pricing array non-empty validation for channel pricing rules
- Add platform token semantics comment in gateway_service.go
- Complete validatePlanPatch test coverage (+10 test cases)
- Replace string types with QuotaThresholdType/QuotaResetMode across frontend
- Remove duplicate getPlatformTextColor/getRateBadgeClass in ChannelsView
- Return EMAIL_NOT_FOUND error on RemoveNotifyEmail miss

UI improvements:
- Reorder cost tooltip: user billing above separator, account billing below
- Add NaN guard to accountBilled function
- Move timezone selector inline into reset-mode row (no longer standalone)
2026-04-14 09:35:05 +08:00

247 lines
10 KiB
Vue

<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import QuotaDimensionRow from './QuotaDimensionRow.vue'
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
const { t } = useI18n()
const props = withDefaults(defineProps<{
totalLimit: number | null
dailyLimit: number | null
weeklyLimit: number | null
dailyResetMode: QuotaResetMode | null
dailyResetHour: number | null
weeklyResetMode: QuotaResetMode | null
weeklyResetDay: number | null
weeklyResetHour: number | null
resetTimezone: string | null
quotaNotifyGlobalEnabled?: boolean
quotaNotifyDailyEnabled?: boolean | null
quotaNotifyDailyThreshold?: number | null
quotaNotifyDailyThresholdType?: QuotaThresholdType | null
quotaNotifyWeeklyEnabled?: boolean | null
quotaNotifyWeeklyThreshold?: number | null
quotaNotifyWeeklyThresholdType?: QuotaThresholdType | null
quotaNotifyTotalEnabled?: boolean | null
quotaNotifyTotalThreshold?: number | null
quotaNotifyTotalThresholdType?: QuotaThresholdType | null
}>(), {
quotaNotifyGlobalEnabled: false,
quotaNotifyDailyEnabled: null,
quotaNotifyDailyThreshold: null,
quotaNotifyDailyThresholdType: null,
quotaNotifyWeeklyEnabled: null,
quotaNotifyWeeklyThreshold: null,
quotaNotifyWeeklyThresholdType: null,
quotaNotifyTotalEnabled: null,
quotaNotifyTotalThreshold: null,
quotaNotifyTotalThresholdType: null,
})
const emit = defineEmits<{
'update:totalLimit': [value: number | null]
'update:dailyLimit': [value: number | null]
'update:weeklyLimit': [value: number | null]
'update:dailyResetMode': [value: QuotaResetMode | null]
'update:dailyResetHour': [value: number | null]
'update:weeklyResetMode': [value: QuotaResetMode | null]
'update:weeklyResetDay': [value: number | null]
'update:weeklyResetHour': [value: number | null]
'update:resetTimezone': [value: string | null]
'update:quotaNotifyDailyEnabled': [value: boolean | null]
'update:quotaNotifyDailyThreshold': [value: number | null]
'update:quotaNotifyDailyThresholdType': [value: QuotaThresholdType | null]
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
'update:quotaNotifyWeeklyThreshold': [value: number | null]
'update:quotaNotifyWeeklyThresholdType': [value: QuotaThresholdType | null]
'update:quotaNotifyTotalEnabled': [value: boolean | null]
'update:quotaNotifyTotalThreshold': [value: number | null]
'update:quotaNotifyTotalThresholdType': [value: QuotaThresholdType | null]
}>()
const enabled = computed(() =>
(props.totalLimit != null && props.totalLimit > 0) ||
(props.dailyLimit != null && props.dailyLimit > 0) ||
(props.weeklyLimit != null && props.weeklyLimit > 0)
)
const localEnabled = ref(enabled.value)
const collapsed = ref(false)
// Sync when props change externally
watch(enabled, (val) => {
localEnabled.value = val
})
// When toggle is turned off, clear all values and expand
watch(localEnabled, (val) => {
if (!val) {
collapsed.value = false
emit('update:totalLimit', null)
emit('update:dailyLimit', null)
emit('update:weeklyLimit', null)
emit('update:dailyResetMode', null)
emit('update:dailyResetHour', null)
emit('update:weeklyResetMode', null)
emit('update:weeklyResetDay', null)
emit('update:weeklyResetHour', null)
emit('update:resetTimezone', null)
}
})
// Common timezone options
const timezoneOptions = [
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
'Asia/Dubai', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow',
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
]
// Hours for dropdown (0-23)
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
// Day of week options
const dayOptions = [
{ value: 1, key: 'monday' },
{ value: 2, key: 'tuesday' },
{ value: 3, key: 'wednesday' },
{ value: 4, key: 'thursday' },
{ value: 5, key: 'friday' },
{ value: 6, key: 'saturday' },
{ value: 0, key: 'sunday' },
]
// Precomputed hint strings for the weekly fixed mode
const weeklyFixedHint = computed(() => {
const dayKey = dayOptions.find(d => d.value === (props.weeklyResetDay ?? 1))?.key || 'monday'
return t('admin.accounts.quotaWeeklyLimitHintFixed', {
day: t('admin.accounts.dayOfWeek.' + dayKey),
hour: String(props.weeklyResetHour ?? 0).padStart(2, '0'),
timezone: props.resetTimezone || 'UTC',
})
})
const dailyFixedHint = computed(() =>
t('admin.accounts.quotaDailyLimitHintFixed', {
hour: String(props.dailyResetHour ?? 0).padStart(2, '0'),
timezone: props.resetTimezone || 'UTC',
})
)
</script>
<template>
<div class="rounded-lg border border-gray-200 dark:border-dark-600">
<!-- Header: toggle + collapse -->
<div class="flex items-center justify-between p-4" :class="{ 'pb-0': localEnabled && !collapsed }">
<div class="flex items-center gap-2 flex-1 cursor-pointer" @click="localEnabled && (collapsed = !collapsed)">
<svg v-if="localEnabled" class="h-4 w-4 text-gray-400 transition-transform" :class="{ '-rotate-90': collapsed }" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
<div>
<label class="input-label mb-0 cursor-pointer">{{ t('admin.accounts.quotaLimitToggle') }}</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.quotaLimitToggleHint') }}
</p>
</div>
</div>
<button
type="button"
@click="localEnabled = !localEnabled"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
localEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
localEnabled ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
<!-- Collapsible content -->
<div v-if="localEnabled && !collapsed" class="space-y-2 p-4 pt-3">
<!-- Daily quota -->
<QuotaDimensionRow
dim="daily"
:label="t('admin.accounts.quotaDailyLimit')"
:limit="dailyLimit"
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
:notify-enabled="props.quotaNotifyDailyEnabled"
:notify-threshold="props.quotaNotifyDailyThreshold"
:notify-threshold-type="props.quotaNotifyDailyThresholdType"
:reset-mode="dailyResetMode"
:reset-hour="dailyResetHour"
:reset-day="null"
:reset-timezone="resetTimezone"
:hint-rolling="t('admin.accounts.quotaDailyLimitHint')"
:hint-fixed="dailyFixedHint"
:hour-options="hourOptions"
:day-options="dayOptions"
:timezone-options="timezoneOptions"
@update:limit="emit('update:dailyLimit', $event)"
@update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)"
@update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)"
@update:notify-threshold-type="emit('update:quotaNotifyDailyThresholdType', $event)"
@update:reset-mode="emit('update:dailyResetMode', $event)"
@update:reset-hour="emit('update:dailyResetHour', $event)"
@update:reset-timezone="emit('update:resetTimezone', $event)"
/>
<!-- Weekly quota -->
<QuotaDimensionRow
dim="weekly"
:label="t('admin.accounts.quotaWeeklyLimit')"
:limit="weeklyLimit"
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
:notify-enabled="props.quotaNotifyWeeklyEnabled"
:notify-threshold="props.quotaNotifyWeeklyThreshold"
:notify-threshold-type="props.quotaNotifyWeeklyThresholdType"
:reset-mode="weeklyResetMode"
:reset-hour="weeklyResetHour"
:reset-day="weeklyResetDay"
:reset-timezone="resetTimezone"
:hint-rolling="t('admin.accounts.quotaWeeklyLimitHint')"
:hint-fixed="weeklyFixedHint"
:hour-options="hourOptions"
:day-options="dayOptions"
:timezone-options="timezoneOptions"
@update:limit="emit('update:weeklyLimit', $event)"
@update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)"
@update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)"
@update:notify-threshold-type="emit('update:quotaNotifyWeeklyThresholdType', $event)"
@update:reset-mode="emit('update:weeklyResetMode', $event)"
@update:reset-hour="emit('update:weeklyResetHour', $event)"
@update:reset-day="emit('update:weeklyResetDay', $event)"
@update:reset-timezone="emit('update:resetTimezone', $event)"
/>
<!-- Total quota -->
<QuotaDimensionRow
dim="total"
:label="t('admin.accounts.quotaTotalLimit')"
:limit="totalLimit"
:quota-notify-global-enabled="quotaNotifyGlobalEnabled"
:notify-enabled="props.quotaNotifyTotalEnabled"
:notify-threshold="props.quotaNotifyTotalThreshold"
:notify-threshold-type="props.quotaNotifyTotalThresholdType"
:reset-mode="null"
:reset-hour="null"
:reset-day="null"
:reset-timezone="null"
:hint-rolling="t('admin.accounts.quotaTotalLimitHint')"
hint-fixed=""
:hour-options="hourOptions"
:day-options="dayOptions"
@update:limit="emit('update:totalLimit', $event)"
@update:notify-enabled="emit('update:quotaNotifyTotalEnabled', $event)"
@update:notify-threshold="emit('update:quotaNotifyTotalThreshold', $event)"
@update:notify-threshold-type="emit('update:quotaNotifyTotalThresholdType', $event)"
/>
</div>
</div>
</template>