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)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
|
||||
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -11,9 +12,9 @@ const props = defineProps<{
|
||||
quotaNotifyGlobalEnabled: boolean
|
||||
notifyEnabled: boolean | null
|
||||
notifyThreshold: number | null
|
||||
notifyThresholdType: string | null
|
||||
notifyThresholdType: QuotaThresholdType | null
|
||||
// Reset mode (only for daily/weekly, null for total)
|
||||
resetMode: 'rolling' | 'fixed' | null
|
||||
resetMode: QuotaResetMode | null
|
||||
resetHour: number | null
|
||||
resetDay: number | null // weekly only
|
||||
resetTimezone: string | null
|
||||
@@ -22,14 +23,15 @@ const props = defineProps<{
|
||||
// Shared options passed from parent
|
||||
hourOptions: number[]
|
||||
dayOptions: { value: number; key: string }[]
|
||||
timezoneOptions?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:limit': [value: number | null]
|
||||
'update:notifyEnabled': [value: boolean | null]
|
||||
'update:notifyThreshold': [value: number | null]
|
||||
'update:notifyThresholdType': [value: string | null]
|
||||
'update:resetMode': [value: 'rolling' | 'fixed' | null]
|
||||
'update:notifyThresholdType': [value: QuotaThresholdType | null]
|
||||
'update:resetMode': [value: QuotaResetMode | null]
|
||||
'update:resetHour': [value: number | null]
|
||||
'update:resetDay': [value: number | null]
|
||||
'update:resetTimezone': [value: string | null]
|
||||
@@ -43,7 +45,7 @@ const onLimitInput = (e: Event) => {
|
||||
}
|
||||
|
||||
const onModeChange = (e: Event) => {
|
||||
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
|
||||
const val = (e.target as HTMLSelectElement).value as QuotaResetMode
|
||||
emit('update:resetMode', val)
|
||||
if (val === 'fixed') {
|
||||
if (props.resetHour == null) emit('update:resetHour', 0)
|
||||
@@ -51,6 +53,17 @@ const onModeChange = (e: Event) => {
|
||||
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezoneOffsetLabel(tz: string): string {
|
||||
try {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
|
||||
const parts = dtf.formatToParts(new Date())
|
||||
const tzPart = parts.find(p => p.type === 'timeZoneName')
|
||||
return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -95,6 +108,11 @@ const onModeChange = (e: Event) => {
|
||||
<select :value="resetHour ?? 0" @change="emit('update:resetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
|
||||
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
|
||||
</select>
|
||||
<template v-if="timezoneOptions && timezoneOptions.length > 0">
|
||||
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input py-1 text-xs w-auto">
|
||||
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
|
||||
</select>
|
||||
</template>
|
||||
</template>
|
||||
<span class="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<template v-if="resetMode === 'fixed'">{{ hintFixed }}</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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()
|
||||
|
||||
@@ -9,22 +10,22 @@ const props = withDefaults(defineProps<{
|
||||
totalLimit: number | null
|
||||
dailyLimit: number | null
|
||||
weeklyLimit: number | null
|
||||
dailyResetMode: 'rolling' | 'fixed' | null
|
||||
dailyResetMode: QuotaResetMode | null
|
||||
dailyResetHour: number | null
|
||||
weeklyResetMode: 'rolling' | 'fixed' | null
|
||||
weeklyResetMode: QuotaResetMode | null
|
||||
weeklyResetDay: number | null
|
||||
weeklyResetHour: number | null
|
||||
resetTimezone: string | null
|
||||
quotaNotifyGlobalEnabled?: boolean
|
||||
quotaNotifyDailyEnabled?: boolean | null
|
||||
quotaNotifyDailyThreshold?: number | null
|
||||
quotaNotifyDailyThresholdType?: string | null
|
||||
quotaNotifyDailyThresholdType?: QuotaThresholdType | null
|
||||
quotaNotifyWeeklyEnabled?: boolean | null
|
||||
quotaNotifyWeeklyThreshold?: number | null
|
||||
quotaNotifyWeeklyThresholdType?: string | null
|
||||
quotaNotifyWeeklyThresholdType?: QuotaThresholdType | null
|
||||
quotaNotifyTotalEnabled?: boolean | null
|
||||
quotaNotifyTotalThreshold?: number | null
|
||||
quotaNotifyTotalThresholdType?: string | null
|
||||
quotaNotifyTotalThresholdType?: QuotaThresholdType | null
|
||||
}>(), {
|
||||
quotaNotifyGlobalEnabled: false,
|
||||
quotaNotifyDailyEnabled: null,
|
||||
@@ -42,21 +43,21 @@ const emit = defineEmits<{
|
||||
'update:totalLimit': [value: number | null]
|
||||
'update:dailyLimit': [value: number | null]
|
||||
'update:weeklyLimit': [value: number | null]
|
||||
'update:dailyResetMode': [value: 'rolling' | 'fixed' | null]
|
||||
'update:dailyResetMode': [value: QuotaResetMode | null]
|
||||
'update:dailyResetHour': [value: number | null]
|
||||
'update:weeklyResetMode': [value: 'rolling' | 'fixed' | 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: string | null]
|
||||
'update:quotaNotifyDailyThresholdType': [value: QuotaThresholdType | null]
|
||||
'update:quotaNotifyWeeklyEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyWeeklyThreshold': [value: number | null]
|
||||
'update:quotaNotifyWeeklyThresholdType': [value: string | null]
|
||||
'update:quotaNotifyWeeklyThresholdType': [value: QuotaThresholdType | null]
|
||||
'update:quotaNotifyTotalEnabled': [value: boolean | null]
|
||||
'update:quotaNotifyTotalThreshold': [value: number | null]
|
||||
'update:quotaNotifyTotalThresholdType': [value: string | null]
|
||||
'update:quotaNotifyTotalThresholdType': [value: QuotaThresholdType | null]
|
||||
}>()
|
||||
|
||||
const enabled = computed(() =>
|
||||
@@ -89,11 +90,6 @@ watch(localEnabled, (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Whether any fixed mode is active (to show timezone selector)
|
||||
const hasFixedMode = computed(() =>
|
||||
props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed'
|
||||
)
|
||||
|
||||
// Common timezone options
|
||||
const timezoneOptions = [
|
||||
'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata',
|
||||
@@ -102,18 +98,6 @@ const timezoneOptions = [
|
||||
'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland',
|
||||
]
|
||||
|
||||
// Compute GMT offset label (e.g. "GMT+8", "GMT-5") for a given IANA timezone.
|
||||
function getTimezoneOffsetLabel(tz: string): string {
|
||||
try {
|
||||
const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' })
|
||||
const parts = dtf.formatToParts(new Date())
|
||||
const tzPart = parts.find(p => p.type === 'timeZoneName')
|
||||
return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Hours for dropdown (0-23)
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
@@ -197,6 +181,7 @@ const dailyFixedHint = computed(() =>
|
||||
: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)"
|
||||
@@ -223,6 +208,7 @@ const dailyFixedHint = computed(() =>
|
||||
: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)"
|
||||
@@ -233,14 +219,6 @@ const dailyFixedHint = computed(() =>
|
||||
@update:reset-timezone="emit('update:resetTimezone', $event)"
|
||||
/>
|
||||
|
||||
<!-- Timezone selector (shared by daily/weekly when fixed mode is active) -->
|
||||
<div v-if="hasFixedMode">
|
||||
<label class="input-label">{{ t('admin.accounts.quotaResetTimezone') }}</label>
|
||||
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input text-sm">
|
||||
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Total quota -->
|
||||
<QuotaDimensionRow
|
||||
dim="total"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE } from '@/constants/account'
|
||||
import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE, type QuotaThresholdType } from '@/constants/account'
|
||||
|
||||
defineProps<{
|
||||
enabled: boolean | null
|
||||
threshold: number | null
|
||||
thresholdType: string | null // "fixed" (default) or "percentage"
|
||||
thresholdType: QuotaThresholdType | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean | null]
|
||||
'update:threshold': [value: number | null]
|
||||
'update:thresholdType': [value: string | null]
|
||||
'update:thresholdType': [value: QuotaThresholdType | null]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -43,7 +43,7 @@ const emit = defineEmits<{
|
||||
/>
|
||||
<select
|
||||
:value="thresholdType || QUOTA_THRESHOLD_TYPE_FIXED"
|
||||
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value)"
|
||||
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value as QuotaThresholdType)"
|
||||
class="input py-1 text-xs w-[4.5rem] flex-shrink-0 text-center"
|
||||
>
|
||||
<option :value="QUOTA_THRESHOLD_TYPE_FIXED">$</option>
|
||||
|
||||
@@ -313,10 +313,6 @@
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
@@ -325,7 +321,12 @@
|
||||
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
|
||||
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</div>
|
||||
<!-- Account billing (separated from user billing) -->
|
||||
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
|
||||
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
|
||||
<span class="font-semibold text-green-400">
|
||||
${{ accountBilled({
|
||||
@@ -355,7 +356,8 @@ import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILL
|
||||
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
|
||||
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
|
||||
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
|
||||
return base * (row.account_rate_multiplier ?? 1)
|
||||
const result = base * (row.account_rate_multiplier ?? 1)
|
||||
return Number.isNaN(result) ? 0 : result
|
||||
}
|
||||
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
|
||||
Reference in New Issue
Block a user