feat(notify): convert email lists to NotifyEmailEntry struct with toggle support
- Change balance_notify_extra_emails and account_quota_notify_emails
from []string to []NotifyEmailEntry{email, disabled, verified}
- Add per-email enable/disable toggle for both user and admin notifications
- Add PUT /user/notify-email/toggle API endpoint
- Fix critical bug: API key auth cache snapshot missing balance notify
fields (Email, Username, BalanceNotifyEnabled, etc.), causing
notifications to never fire on cached request paths
- Bump cache snapshot version 3→4 to invalidate stale entries
- Add SQL migration 104 to convert old format data
- Backward compatible: parseNotifyEmails auto-detects old/new format
- User balance notify: max 3 emails (primary + 2 extra)
- Admin quota notify: unlimited emails, each with toggle
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { CustomMenuItem, CustomEndpoint } from '@/types'
|
||||
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types'
|
||||
|
||||
export interface DefaultSubscriptionSetting {
|
||||
group_id: number
|
||||
@@ -139,7 +139,7 @@ export interface SystemSettings {
|
||||
balance_low_notify_enabled: boolean
|
||||
balance_low_notify_threshold: number
|
||||
account_quota_notify_enabled: boolean
|
||||
account_quota_notify_emails: string[]
|
||||
account_quota_notify_emails: NotifyEmailEntry[]
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@@ -243,7 +243,7 @@ export interface UpdateSettingsRequest {
|
||||
balance_low_notify_enabled?: boolean
|
||||
balance_low_notify_threshold?: number
|
||||
account_quota_notify_enabled?: boolean
|
||||
account_quota_notify_emails?: string[]
|
||||
account_quota_notify_emails?: NotifyEmailEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { User, ChangePasswordRequest } from '@/types'
|
||||
import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
@@ -24,7 +24,7 @@ export async function updateProfile(profile: {
|
||||
username?: string
|
||||
balance_notify_enabled?: boolean
|
||||
balance_notify_threshold?: number | null
|
||||
balance_notify_extra_emails?: string[]
|
||||
balance_notify_extra_emails?: NotifyEmailEntry[]
|
||||
}): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user', profile)
|
||||
return data
|
||||
@@ -73,13 +73,24 @@ export async function removeNotifyEmail(email: string): Promise<void> {
|
||||
await apiClient.delete('/user/notify-email', { data: { email } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a notify email's disabled state
|
||||
* @param email - Email address (empty string for primary email placeholder)
|
||||
* @param disabled - Whether to disable the email
|
||||
*/
|
||||
export async function toggleNotifyEmail(email: string, disabled: boolean): Promise<User> {
|
||||
const { data } = await apiClient.put<User>('/user/notify-email/toggle', { email, disabled })
|
||||
return data
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
sendNotifyEmailCode,
|
||||
verifyNotifyEmail,
|
||||
removeNotifyEmail
|
||||
removeNotifyEmail,
|
||||
toggleNotifyEmail
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
|
||||
@@ -45,23 +45,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primary email (always shown, with toggle) -->
|
||||
<!-- Email list with toggles -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
|
||||
<div class="space-y-2 mb-3">
|
||||
<div class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ userEmail }}</span>
|
||||
<span class="text-xs text-gray-400">{{ t('profile.balanceNotify.primaryEmail') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verified extra emails with toggle -->
|
||||
<div v-if="extraEmails.length > 0" class="space-y-2 mb-3">
|
||||
<div v-for="email in extraEmails" :key="email"
|
||||
<!-- All email entries (primary placeholder + extra) -->
|
||||
<div v-for="(entry, idx) in emailEntries" :key="idx"
|
||||
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ email }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="handleRemoveEmail(email)" class="text-red-500 hover:text-red-700 text-xs">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input type="checkbox" :checked="!entry.disabled" @change="handleEmailToggle(entry)" class="sr-only peer" />
|
||||
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{{ entry.email === '' ? userEmail : entry.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span v-if="entry.email === ''" class="text-xs text-gray-400">{{ t('profile.balanceNotify.primaryEmail') }}</span>
|
||||
<span v-else-if="!entry.verified" class="text-xs text-yellow-500">{{ t('profile.balanceNotify.unverified') }}</span>
|
||||
<button v-if="entry.email !== ''" @click="handleRemoveEmail(entry.email)" class="text-red-500 hover:text-red-700 text-xs">
|
||||
{{ t('profile.balanceNotify.removeEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -100,8 +103,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add new email input -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Add new email input (hidden when at limit) -->
|
||||
<div v-if="canAddMore" class="flex gap-2">
|
||||
<input
|
||||
v-model="newEmail"
|
||||
type="email"
|
||||
@@ -117,6 +120,9 @@
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-400">
|
||||
{{ t('profile.balanceNotify.maxEmailsReached') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -124,12 +130,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { userAPI } from '@/api'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { NotifyEmailEntry } from '@/types'
|
||||
|
||||
const maxTotalEmails = 3 // primary + 2 extra
|
||||
|
||||
interface PendingEmail {
|
||||
email: string
|
||||
@@ -144,7 +153,7 @@ interface PendingEmail {
|
||||
const props = defineProps<{
|
||||
enabled: boolean
|
||||
threshold: number | null
|
||||
extraEmails: string[]
|
||||
extraEmails: NotifyEmailEntry[]
|
||||
systemDefaultThreshold: number
|
||||
userEmail: string
|
||||
}>()
|
||||
@@ -155,14 +164,18 @@ const appStore = useAppStore()
|
||||
|
||||
const notifyEnabled = ref(props.enabled)
|
||||
const customThreshold = ref<number | null>(props.threshold)
|
||||
const extraEmails = ref<string[]>([...props.extraEmails])
|
||||
const emailEntries = ref<NotifyEmailEntry[]>([...props.extraEmails])
|
||||
const pendingEmails = ref<PendingEmail[]>([])
|
||||
const newEmail = ref('')
|
||||
const savingThreshold = ref(false)
|
||||
|
||||
const canAddMore = computed(() => {
|
||||
return emailEntries.value.length + pendingEmails.value.length < maxTotalEmails
|
||||
})
|
||||
|
||||
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
|
||||
watch(() => props.threshold, (val) => { customThreshold.value = val })
|
||||
watch(() => props.extraEmails, (val) => { extraEmails.value = [...val] })
|
||||
watch(() => props.extraEmails, (val) => { emailEntries.value = [...val] })
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const pe of pendingEmails.value) {
|
||||
@@ -194,10 +207,25 @@ const handleThresholdUpdate = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailToggle(entry: NotifyEmailEntry) {
|
||||
const newDisabled = !entry.disabled
|
||||
try {
|
||||
const updated = await userAPI.toggleNotifyEmail(entry.email, newDisabled)
|
||||
authStore.user = updated
|
||||
emailEntries.value = [...updated.balance_notify_extra_emails]
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
function addPendingEmail() {
|
||||
const email = newEmail.value.trim()
|
||||
if (!email) return
|
||||
if (email === props.userEmail || extraEmails.value.includes(email) || pendingEmails.value.some(p => p.email === email)) {
|
||||
// Check duplicates against existing entries and pending
|
||||
const isDuplicate = emailEntries.value.some(e =>
|
||||
(e.email === '' ? props.userEmail : e.email).toLowerCase() === email.toLowerCase()
|
||||
) || pendingEmails.value.some(p => p.email.toLowerCase() === email.toLowerCase())
|
||||
if (isDuplicate) {
|
||||
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
|
||||
return
|
||||
}
|
||||
@@ -234,12 +262,12 @@ async function verifyPending(idx: number) {
|
||||
pe.verifying = true
|
||||
try {
|
||||
await userAPI.verifyNotifyEmail(pe.email, pe.code)
|
||||
extraEmails.value.push(pe.email)
|
||||
if (pe.timer) clearInterval(pe.timer)
|
||||
pendingEmails.value.splice(idx, 1)
|
||||
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
|
||||
const updated = await userAPI.getProfile()
|
||||
authStore.user = updated
|
||||
emailEntries.value = [...updated.balance_notify_extra_emails]
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
@@ -250,10 +278,10 @@ async function verifyPending(idx: number) {
|
||||
const handleRemoveEmail = async (email: string) => {
|
||||
try {
|
||||
await userAPI.removeNotifyEmail(email)
|
||||
extraEmails.value = extraEmails.value.filter(e => e !== email)
|
||||
appStore.showSuccess(t('profile.balanceNotify.removeSuccess'))
|
||||
const updated = await userAPI.getProfile()
|
||||
authStore.user = updated
|
||||
emailEntries.value = [...updated.balance_notify_extra_emails]
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
|
||||
@@ -930,6 +930,8 @@ export default {
|
||||
removeEmail: 'Remove',
|
||||
removeSuccess: 'Email removed',
|
||||
emailDuplicate: 'This email already exists',
|
||||
maxEmailsReached: 'Maximum number of notification emails reached',
|
||||
unverified: 'Unverified',
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -934,6 +934,8 @@ export default {
|
||||
removeEmail: '移除',
|
||||
removeSuccess: '邮箱已移除',
|
||||
emailDuplicate: '该邮箱已存在',
|
||||
maxEmailsReached: '已达到通知邮箱数量上限',
|
||||
unverified: '未验证',
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ export interface FetchOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// ==================== Notification Types ====================
|
||||
|
||||
/** Notification email entry with enable/disable and verification state.
|
||||
* email="" is a placeholder for the primary email (user's registration email or admin email). */
|
||||
export interface NotifyEmailEntry {
|
||||
email: string
|
||||
disabled: boolean
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
// ==================== User & Auth Types ====================
|
||||
|
||||
export interface User {
|
||||
@@ -35,7 +45,7 @@ export interface User {
|
||||
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
|
||||
balance_notify_enabled: boolean
|
||||
balance_notify_threshold: number | null
|
||||
balance_notify_extra_emails: string[]
|
||||
balance_notify_extra_emails: NotifyEmailEntry[]
|
||||
subscriptions?: UserSubscription[] // User's active subscriptions
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -2726,8 +2726,12 @@
|
||||
<div v-if="form.account_quota_notify_enabled">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.emails') }}</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(_, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
|
||||
<input v-model="form.account_quota_notify_emails[index]" type="email" class="input flex-1" :placeholder="t('admin.settings.quotaNotify.emailPlaceholder')" />
|
||||
<div v-for="(entry, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
|
||||
<label class="relative inline-flex items-center cursor-pointer shrink-0">
|
||||
<input type="checkbox" :checked="!entry.disabled" @change="entry.disabled = !entry.disabled" class="sr-only peer" />
|
||||
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
<input v-model="entry.email" type="email" class="input flex-1" :placeholder="t('admin.settings.quotaNotify.emailPlaceholder')" />
|
||||
<button @click="form.account_quota_notify_emails.splice(index, 1)" class="btn btn-secondary px-2" type="button">
|
||||
<Icon name="x" size="xs" class="h-4 w-4" />
|
||||
</button>
|
||||
@@ -3024,7 +3028,7 @@ const form = reactive<SettingsForm>({
|
||||
balance_low_notify_enabled: false,
|
||||
balance_low_notify_threshold: 0,
|
||||
account_quota_notify_enabled: false,
|
||||
account_quota_notify_emails: [] as string[]
|
||||
account_quota_notify_emails: [] as { email: string; disabled: boolean; verified: boolean }[]
|
||||
})
|
||||
|
||||
// Proxies for web search emulation ProxySelector
|
||||
@@ -3249,7 +3253,7 @@ const addQuotaNotifyEmail = () => {
|
||||
if (!form.account_quota_notify_emails) {
|
||||
form.account_quota_notify_emails = []
|
||||
}
|
||||
form.account_quota_notify_emails.push('')
|
||||
form.account_quota_notify_emails.push({ email: '', disabled: false, verified: true })
|
||||
}
|
||||
|
||||
// LinuxDo OAuth redirect URL suggestion
|
||||
@@ -3595,7 +3599,7 @@ async function saveSettings() {
|
||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
|
||||
account_quota_notify_enabled: form.account_quota_notify_enabled,
|
||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e: string) => e.trim() !== ''),
|
||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e) => e.email.trim() !== ''),
|
||||
}
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
|
||||
Reference in New Issue
Block a user