Merge pull request #724 from PMExtra/feat/registration-email-domain-whitelist

feat(registration): add email domain whitelist policy
This commit is contained in:
Wesley Liddick
2026-03-04 15:51:51 +08:00
committed by GitHub
25 changed files with 1112 additions and 267 deletions

View File

@@ -18,6 +18,7 @@ export interface SystemSettings {
// Registration settings
registration_enabled: boolean
email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
@@ -86,6 +87,7 @@ export interface SystemSettings {
export interface UpdateSettingsRequest {
registration_enabled?: boolean
email_verify_enabled?: boolean
registration_email_suffix_whitelist?: string[]
promo_code_enabled?: boolean
password_reset_enabled?: boolean
invitation_code_enabled?: boolean

View File

@@ -312,6 +312,9 @@ export default {
passwordMinLength: 'Password must be at least 6 characters',
loginFailed: 'Login failed. Please check your credentials and try again.',
registrationFailed: 'Registration failed. Please try again.',
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
emailSuffixNotAllowedWithAllowed:
'This email domain is not allowed. Allowed domains: {suffixes}',
loginSuccess: 'Login successful! Welcome back.',
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
reloginRequired: 'Session expired. Please log in again.',
@@ -3528,6 +3531,11 @@ export default {
enableRegistrationHint: 'Allow new users to register',
emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations',
emailSuffixWhitelist: 'Email Domain Whitelist',
emailSuffixWhitelistHint:
'Only email addresses from the specified domains can register (for example, @qq.com, @gmail.com)',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: 'Leave empty for no restriction',
promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration',

View File

@@ -312,6 +312,8 @@ export default {
passwordMinLength: '密码至少需要 6 个字符',
loginFailed: '登录失败,请检查您的凭据后重试。',
registrationFailed: '注册失败,请重试。',
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
loginSuccess: '登录成功!欢迎回来。',
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
reloginRequired: '会话已过期,请重新登录。',
@@ -3698,6 +3700,11 @@ export default {
enableRegistrationHint: '允许新用户注册',
emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱',
emailSuffixWhitelist: '邮箱域名白名单',
emailSuffixWhitelistHint:
'仅允许使用指定域名的邮箱注册账号(例如 @qq.com, @gmail.com',
emailSuffixWhitelistPlaceholder: 'example.com',
emailSuffixWhitelistInputHint: '留空则不限制',
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册',

View File

@@ -312,6 +312,7 @@ export const useAppStore = defineStore('app', () => {
return {
registration_enabled: false,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
password_reset_enabled: false,
invitation_code_enabled: false,

View File

@@ -87,6 +87,7 @@ export interface CustomMenuItem {
export interface PublicSettings {
registration_enabled: boolean
email_verify_enabled: boolean
registration_email_suffix_whitelist: string[]
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { buildAuthErrorMessage } from '@/utils/authError'
describe('buildAuthErrorMessage', () => {
it('prefers response detail message when available', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
detail: 'detailed message',
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('detailed message')
})
it('falls back to response message when detail is unavailable', () => {
const message = buildAuthErrorMessage(
{
response: {
data: {
message: 'plain message'
}
},
},
{ fallback: 'fallback' }
)
expect(message).toBe('plain message')
})
it('falls back to error.message when response payload is unavailable', () => {
const message = buildAuthErrorMessage(
{
message: 'error message'
},
{ fallback: 'fallback' }
)
expect(message).toBe('error message')
})
it('uses fallback when no message can be extracted', () => {
expect(buildAuthErrorMessage({}, { fallback: 'fallback' })).toBe('fallback')
})
})

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
isRegistrationEmailSuffixAllowed,
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
normalizeRegistrationEmailSuffixWhitelist,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
describe('registrationEmailPolicy utils', () => {
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
})
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
expect(
normalizeRegistrationEmailSuffixDomains([
'@example.com',
'Example.com',
'',
'-invalid.com',
'foo..bar.com',
' @foo.bar ',
'@foo.bar'
])
).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
const input = '\n @example.com,example.com@foo.bar\t@FOO.bar '
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
const input = '@exa!mple.com, @foo.bar, @bad#token.com, @ok-domain.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'ok-domain.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
})
it('parseRegistrationEmailSuffixWhitelistInput returns empty list for blank input', () => {
expect(parseRegistrationEmailSuffixWhitelistInput(' \n \n')).toEqual([])
})
it('normalizeRegistrationEmailSuffixWhitelist returns canonical @domain list', () => {
expect(
normalizeRegistrationEmailSuffixWhitelist([
'@Example.com',
'foo.bar',
'',
'-invalid.com',
' @foo.bar '
])
).toEqual(['@example.com', '@foo.bar'])
})
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
})
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', [])).toBe(true)
})
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
})
})

View File

@@ -0,0 +1,25 @@
interface APIErrorLike {
message?: string
response?: {
data?: {
detail?: string
message?: string
}
}
}
function extractErrorMessage(error: unknown): string {
const err = (error || {}) as APIErrorLike
return err.response?.data?.detail || err.response?.data?.message || err.message || ''
}
export function buildAuthErrorMessage(
error: unknown,
options: {
fallback: string
}
): string {
const { fallback } = options
const message = extractErrorMessage(error)
return message || fallback
}

View File

@@ -0,0 +1,115 @@
const EMAIL_SUFFIX_TOKEN_SPLIT_RE = /[\s,]+/
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
const EMAIL_SUFFIX_DOMAIN_PATTERN =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/
// normalizeRegistrationEmailSuffixDomain converts raw input into a canonical domain token.
// It removes leading "@", lowercases input, and strips all invalid characters.
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
value = value.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
return value
}
export function normalizeRegistrationEmailSuffixDomains(
items: string[] | null | undefined
): string[] {
if (!items || items.length === 0) {
return []
}
const seen = new Set<string>()
const normalized: string[] = []
for (const item of items) {
const domain = normalizeRegistrationEmailSuffixDomain(item)
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
continue
}
seen.add(domain)
normalized.push(domain)
}
return normalized
}
export function parseRegistrationEmailSuffixWhitelistInput(input: string): string[] {
if (!input || !input.trim()) {
return []
}
const seen = new Set<string>()
const normalized: string[] = []
for (const token of input.split(EMAIL_SUFFIX_TOKEN_SPLIT_RE)) {
const domain = normalizeRegistrationEmailSuffixDomainStrict(token)
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
continue
}
seen.add(domain)
normalized.push(domain)
}
return normalized
}
export function normalizeRegistrationEmailSuffixWhitelist(
items: string[] | null | undefined
): string[] {
return normalizeRegistrationEmailSuffixDomains(items).map((domain) => `@${domain}`)
}
function extractRegistrationEmailDomain(email: string): string {
const raw = String(email || '').trim().toLowerCase()
if (!raw) {
return ''
}
const atIndex = raw.indexOf('@')
if (atIndex <= 0 || atIndex >= raw.length - 1) {
return ''
}
if (raw.indexOf('@', atIndex + 1) !== -1) {
return ''
}
return raw.slice(atIndex + 1)
}
export function isRegistrationEmailSuffixAllowed(
email: string,
whitelist: string[] | null | undefined
): boolean {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(whitelist)
if (normalizedWhitelist.length === 0) {
return true
}
const emailDomain = extractRegistrationEmailDomain(email)
if (!emailDomain) {
return false
}
const emailSuffix = `@${emailDomain}`
return normalizedWhitelist.includes(emailSuffix)
}
// Pasted domains should be strict: any invalid character drops the whole token.
function normalizeRegistrationEmailSuffixDomainStrict(raw: string): string {
let value = String(raw || '').trim().toLowerCase()
if (!value) {
return ''
}
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
if (!value || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
return ''
}
return value
}
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
if (!domain) {
return false
}
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
}

View File

@@ -324,6 +324,56 @@
<Toggle v-model="form.email_verify_enabled" />
</div>
<!-- Email Suffix Whitelist -->
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.emailSuffixWhitelist')
}}</label>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.emailSuffixWhitelistHint') }}
</p>
<div
class="mt-3 rounded-lg border border-gray-300 bg-white p-2 dark:border-dark-500 dark:bg-dark-700"
>
<div class="flex flex-wrap items-center gap-2">
<span
v-for="suffix in registrationEmailSuffixWhitelistTags"
:key="suffix"
class="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-700 dark:bg-dark-600 dark:text-gray-200"
>
<span class="text-gray-400 dark:text-gray-500">@</span>
<span>{{ suffix }}</span>
<button
type="button"
class="rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-300 dark:hover:bg-dark-500 dark:hover:text-white"
@click="removeRegistrationEmailSuffixWhitelistTag(suffix)"
>
<Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
</button>
</span>
<div
class="flex min-w-[220px] flex-1 items-center gap-1 rounded border border-transparent px-2 py-1 focus-within:border-primary-300 dark:focus-within:border-primary-700"
>
<span class="font-mono text-sm text-gray-400 dark:text-gray-500">@</span>
<input
v-model="registrationEmailSuffixWhitelistDraft"
type="text"
class="w-full bg-transparent text-sm font-mono text-gray-900 outline-none placeholder:text-gray-400 dark:text-white dark:placeholder:text-gray-500"
:placeholder="t('admin.settings.registration.emailSuffixWhitelistPlaceholder')"
@input="handleRegistrationEmailSuffixWhitelistDraftInput"
@keydown="handleRegistrationEmailSuffixWhitelistDraftKeydown"
@blur="commitRegistrationEmailSuffixWhitelistDraft"
@paste="handleRegistrationEmailSuffixWhitelistPaste"
/>
</div>
</div>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.emailSuffixWhitelistInputHint') }}
</p>
</div>
<!-- Promo Code -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
@@ -1364,6 +1414,12 @@ import ImageUpload from '@/components/common/ImageUpload.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores'
import { useAdminSettingsStore } from '@/stores/adminSettings'
import {
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
normalizeRegistrationEmailSuffixDomains,
parseRegistrationEmailSuffixWhitelistInput
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n()
const appStore = useAppStore()
@@ -1375,6 +1431,8 @@ const saving = ref(false)
const testingSmtp = ref(false)
const sendingTestEmail = ref(false)
const testEmailAddress = ref('')
const registrationEmailSuffixWhitelistTags = ref<string[]>([])
const registrationEmailSuffixWhitelistDraft = ref('')
// Admin API Key 状态
const adminApiKeyLoading = ref(true)
@@ -1414,6 +1472,7 @@ type SettingsForm = SystemSettings & {
const form = reactive<SettingsForm>({
registration_enabled: true,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
invitation_code_enabled: false,
password_reset_enabled: false,
@@ -1484,6 +1543,74 @@ const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[
}))
)
const registrationEmailSuffixWhitelistSeparatorKeys = new Set([' ', ',', '', 'Enter', 'Tab'])
function removeRegistrationEmailSuffixWhitelistTag(suffix: string) {
registrationEmailSuffixWhitelistTags.value = registrationEmailSuffixWhitelistTags.value.filter(
(item) => item !== suffix
)
}
function addRegistrationEmailSuffixWhitelistTag(raw: string) {
const suffix = normalizeRegistrationEmailSuffixDomain(raw)
if (
!isRegistrationEmailSuffixDomainValid(suffix) ||
registrationEmailSuffixWhitelistTags.value.includes(suffix)
) {
return
}
registrationEmailSuffixWhitelistTags.value = [
...registrationEmailSuffixWhitelistTags.value,
suffix
]
}
function commitRegistrationEmailSuffixWhitelistDraft() {
if (!registrationEmailSuffixWhitelistDraft.value) {
return
}
addRegistrationEmailSuffixWhitelistTag(registrationEmailSuffixWhitelistDraft.value)
registrationEmailSuffixWhitelistDraft.value = ''
}
function handleRegistrationEmailSuffixWhitelistDraftInput() {
registrationEmailSuffixWhitelistDraft.value = normalizeRegistrationEmailSuffixDomain(
registrationEmailSuffixWhitelistDraft.value
)
}
function handleRegistrationEmailSuffixWhitelistDraftKeydown(event: KeyboardEvent) {
if (event.isComposing) {
return
}
if (registrationEmailSuffixWhitelistSeparatorKeys.has(event.key)) {
event.preventDefault()
commitRegistrationEmailSuffixWhitelistDraft()
return
}
if (
event.key === 'Backspace' &&
!registrationEmailSuffixWhitelistDraft.value &&
registrationEmailSuffixWhitelistTags.value.length > 0
) {
registrationEmailSuffixWhitelistTags.value.pop()
}
}
function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
const text = event.clipboardData?.getData('text') || ''
if (!text.trim()) {
return
}
event.preventDefault()
const tokens = parseRegistrationEmailSuffixWhitelistInput(text)
for (const token of tokens) {
addRegistrationEmailSuffixWhitelistTag(token)
}
}
// LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return ''
@@ -1546,6 +1673,10 @@ async function loadSettings() {
validity_days: item.validity_days
}))
: []
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist
)
registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = ''
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
@@ -1615,6 +1746,9 @@ async function saveSettings() {
const payload: UpdateSettingsRequest = {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
registration_email_suffix_whitelist: registrationEmailSuffixWhitelistTags.value.map(
(suffix) => `@${suffix}`
),
promo_code_enabled: form.promo_code_enabled,
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
@@ -1660,6 +1794,10 @@ async function saveSettings() {
}
const updated = await adminAPI.settings.updateSettings(payload)
Object.assign(form, updated)
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist
)
registrationEmailSuffixWhitelistDraft.value = ''
form.smtp_password = ''
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''

View File

@@ -177,8 +177,13 @@ import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n()
const { t, locale } = useI18n()
// ==================== Router & Stores ====================
@@ -208,6 +213,7 @@ const hasRegisterData = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile for resend
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -244,6 +250,9 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
} catch (error) {
console.error('Failed to load public settings:', error)
}
@@ -306,6 +315,12 @@ async function sendCode(): Promise<void> {
errorMessage.value = ''
try {
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
errorMessage.value = buildEmailSuffixNotAllowedMessage()
appStore.showError(errorMessage.value)
return
}
const response = await sendVerifyCode({
email: email.value,
// 优先使用重发时新获取的 token因为初始 token 可能已被使用)
@@ -320,15 +335,9 @@ async function sendCode(): Promise<void> {
showResendTurnstile.value = false
resendTurnstileToken.value = ''
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Failed to send verification code. Please try again.'
}
errorMessage.value = buildAuthErrorMessage(error, {
fallback: 'Failed to send verification code. Please try again.'
})
appStore.showError(errorMessage.value)
} finally {
@@ -380,6 +389,12 @@ async function handleVerify(): Promise<void> {
isLoading.value = true
try {
if (!isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
errorMessage.value = buildEmailSuffixNotAllowedMessage()
appStore.showError(errorMessage.value)
return
}
// Register with verification code
await authStore.register({
email: email.value,
@@ -399,15 +414,9 @@ async function handleVerify(): Promise<void> {
// Redirect to dashboard
await router.push('/dashboard')
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = 'Verification failed. Please try again.'
}
errorMessage.value = buildAuthErrorMessage(error, {
fallback: 'Verification failed. Please try again.'
})
appStore.showError(errorMessage.value)
} finally {
@@ -422,6 +431,19 @@ function handleBack(): void {
// Go back to registration
router.push('/register')
}
function buildEmailSuffixNotAllowedMessage(): string {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(
registrationEmailSuffixWhitelist.value
)
if (normalizedWhitelist.length === 0) {
return t('auth.emailSuffixNotAllowed')
}
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
return t('auth.emailSuffixNotAllowedWithAllowed', {
suffixes: normalizedWhitelist.join(separator)
})
}
</script>
<style scoped>

View File

@@ -293,8 +293,13 @@ import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
const { t } = useI18n()
const { t, locale } = useI18n()
// ==================== Router & Stores ====================
@@ -319,6 +324,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API')
const linuxdoOAuthEnabled = ref<boolean>(false)
const registrationEmailSuffixWhitelist = ref<string[]>([])
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
@@ -370,6 +376,9 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API'
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
settings.registration_email_suffix_whitelist || []
)
// Read promo code from URL parameter only if promo code is enabled
if (promoCodeEnabled.value) {
@@ -557,6 +566,19 @@ function validateEmail(email: string): boolean {
return emailRegex.test(email)
}
function buildEmailSuffixNotAllowedMessage(): string {
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(
registrationEmailSuffixWhitelist.value
)
if (normalizedWhitelist.length === 0) {
return t('auth.emailSuffixNotAllowed')
}
const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
return t('auth.emailSuffixNotAllowedWithAllowed', {
suffixes: normalizedWhitelist.join(separator)
})
}
function validateForm(): boolean {
// Reset errors
errors.email = ''
@@ -573,6 +595,11 @@ function validateForm(): boolean {
} else if (!validateEmail(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
} else if (
!isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value)
) {
errors.email = buildEmailSuffixNotAllowedMessage()
isValid = false
}
// Password validation
@@ -694,15 +721,9 @@ async function handleRegister(): Promise<void> {
}
// Handle registration error
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.registrationFailed')
}
errorMessage.value = buildAuthErrorMessage(error, {
fallback: t('auth.registrationFailed')
})
// Also show error toast
appStore.showError(errorMessage.value)