Login and Register heading strings moved from hardcoded Chinese to auth.puroLoginTitle / puroLoginSub / puroRegisterTitle / puroRegisterSub. Landing (LandingView) and Docs (DocsView) intentionally keep hardcoded Chinese this cycle (see spec §6 note 5 — English version deferred). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
832 lines
27 KiB
Vue
832 lines
27 KiB
Vue
<template>
|
||
<AuthLayout>
|
||
<template #narrative>
|
||
<div class="auth-narrative-inner">
|
||
<div class="brand"><span class="hex">⬢</span><span>PURO AI</span></div>
|
||
<div class="auth-narrative-hero">
|
||
<div class="auth-narrative-headline">
|
||
<span class="num-5">5</span> 个订阅<br>
|
||
→ <span class="num-1">1</span> 个 key
|
||
</div>
|
||
<p class="auth-narrative-sub">
|
||
省去切换账号的繁琐,<br>
|
||
省去为多个高昂订阅重复买单。<br>
|
||
<span class="auth-narrative-tagline">PURO(纯粹)—— 让 AI 调用回归本质。</span>
|
||
</p>
|
||
</div>
|
||
<div class="auth-narrative-foot">Claude · ChatGPT · Codex · Gemini</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="space-y-6">
|
||
<!-- Title -->
|
||
<div class="text-center">
|
||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('auth.puroRegisterTitle') }}</h2>
|
||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ t('auth.puroRegisterSub') }}</p>
|
||
</div>
|
||
|
||
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||
<LinuxDoOAuthSection
|
||
v-if="linuxdoOAuthEnabled"
|
||
:disabled="isLoading"
|
||
:show-divider="false"
|
||
/>
|
||
<OidcOAuthSection
|
||
v-if="oidcOAuthEnabled"
|
||
:disabled="isLoading"
|
||
:provider-name="oidcOAuthProviderName"
|
||
:show-divider="false"
|
||
/>
|
||
<div class="flex items-center gap-3">
|
||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||
{{ t('auth.oauthOrContinue') }}
|
||
</span>
|
||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Registration Disabled Message -->
|
||
<div
|
||
v-if="!registrationEnabled && settingsLoaded"
|
||
class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/50 dark:bg-amber-900/20"
|
||
>
|
||
<div class="flex items-start gap-3">
|
||
<div class="flex-shrink-0">
|
||
<Icon name="exclamationCircle" size="md" class="text-amber-500" />
|
||
</div>
|
||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||
{{ t('auth.registrationDisabled') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Registration Form -->
|
||
<form v-else @submit.prevent="handleRegister" class="space-y-5">
|
||
<!-- Email Input -->
|
||
<div>
|
||
<label for="email" class="input-label">
|
||
{{ t('auth.emailLabel') }}
|
||
</label>
|
||
<div class="relative">
|
||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
|
||
</div>
|
||
<input
|
||
id="email"
|
||
v-model="formData.email"
|
||
type="email"
|
||
required
|
||
autofocus
|
||
autocomplete="email"
|
||
:disabled="isLoading"
|
||
class="input pl-11"
|
||
:class="{ 'input-error': errors.email }"
|
||
:placeholder="t('auth.emailPlaceholder')"
|
||
/>
|
||
</div>
|
||
<p v-if="errors.email" class="input-error-text">
|
||
{{ errors.email }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Password Input -->
|
||
<div>
|
||
<label for="password" class="input-label">
|
||
{{ t('auth.passwordLabel') }}
|
||
</label>
|
||
<div class="relative">
|
||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
|
||
</div>
|
||
<input
|
||
id="password"
|
||
v-model="formData.password"
|
||
:type="showPassword ? 'text' : 'password'"
|
||
required
|
||
autocomplete="new-password"
|
||
:disabled="isLoading"
|
||
class="input pl-11 pr-11"
|
||
:class="{ 'input-error': errors.password }"
|
||
:placeholder="t('auth.createPasswordPlaceholder')"
|
||
/>
|
||
<button
|
||
type="button"
|
||
@click="showPassword = !showPassword"
|
||
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
|
||
>
|
||
<Icon v-if="showPassword" name="eyeOff" size="md" />
|
||
<Icon v-else name="eye" size="md" />
|
||
</button>
|
||
</div>
|
||
<p v-if="errors.password" class="input-error-text">
|
||
{{ errors.password }}
|
||
</p>
|
||
<p v-else class="input-hint">
|
||
{{ t('auth.passwordHint') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Invitation Code Input (Required when enabled) -->
|
||
<div v-if="invitationCodeEnabled">
|
||
<label for="invitation_code" class="input-label">
|
||
{{ t('auth.invitationCodeLabel') }}
|
||
</label>
|
||
<div class="relative">
|
||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||
<Icon name="key" size="md" :class="invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
|
||
</div>
|
||
<input
|
||
id="invitation_code"
|
||
v-model="formData.invitation_code"
|
||
type="text"
|
||
:disabled="isLoading"
|
||
class="input pl-11 pr-10"
|
||
:class="{
|
||
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
|
||
'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code
|
||
}"
|
||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||
@input="handleInvitationCodeInput"
|
||
/>
|
||
<!-- Validation indicator -->
|
||
<div v-if="invitationValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
</div>
|
||
<div v-else-if="invitationValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||
<Icon name="checkCircle" size="md" class="text-green-500" />
|
||
</div>
|
||
<div v-else-if="invitationValidation.invalid || errors.invitation_code" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||
</div>
|
||
</div>
|
||
<!-- Invitation code validation result -->
|
||
<transition name="fade">
|
||
<div v-if="invitationValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
|
||
<Icon name="checkCircle" size="sm" class="text-green-600 dark:text-green-400" />
|
||
<span class="text-sm text-green-700 dark:text-green-400">
|
||
{{ t('auth.invitationCodeValid') }}
|
||
</span>
|
||
</div>
|
||
<p v-else-if="invitationValidation.invalid" class="input-error-text">
|
||
{{ invitationValidation.message }}
|
||
</p>
|
||
<p v-else-if="errors.invitation_code" class="input-error-text">
|
||
{{ errors.invitation_code }}
|
||
</p>
|
||
</transition>
|
||
</div>
|
||
|
||
<!-- Promo Code Input (Optional) -->
|
||
<div v-if="promoCodeEnabled">
|
||
<label for="promo_code" class="input-label">
|
||
{{ t('auth.promoCodeLabel') }}
|
||
<span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span>
|
||
</label>
|
||
<div class="relative">
|
||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||
<Icon name="gift" size="md" :class="promoValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" />
|
||
</div>
|
||
<input
|
||
id="promo_code"
|
||
v-model="formData.promo_code"
|
||
type="text"
|
||
:disabled="isLoading"
|
||
class="input pl-11 pr-10"
|
||
:class="{
|
||
'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid,
|
||
'border-red-500 focus:border-red-500 focus:ring-red-500': promoValidation.invalid
|
||
}"
|
||
:placeholder="t('auth.promoCodePlaceholder')"
|
||
@input="handlePromoCodeInput"
|
||
/>
|
||
<!-- Validation indicator -->
|
||
<div v-if="promoValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
</div>
|
||
<div v-else-if="promoValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||
<Icon name="checkCircle" size="md" class="text-green-500" />
|
||
</div>
|
||
<div v-else-if="promoValidation.invalid" class="absolute inset-y-0 right-0 flex items-center pr-3.5">
|
||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||
</div>
|
||
</div>
|
||
<!-- Promo code validation result -->
|
||
<transition name="fade">
|
||
<div v-if="promoValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20">
|
||
<Icon name="gift" size="sm" class="text-green-600 dark:text-green-400" />
|
||
<span class="text-sm text-green-700 dark:text-green-400">
|
||
{{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }}
|
||
</span>
|
||
</div>
|
||
<p v-else-if="promoValidation.invalid" class="input-error-text">
|
||
{{ promoValidation.message }}
|
||
</p>
|
||
</transition>
|
||
</div>
|
||
|
||
<!-- Turnstile Widget -->
|
||
<div v-if="turnstileEnabled && turnstileSiteKey">
|
||
<TurnstileWidget
|
||
ref="turnstileRef"
|
||
:site-key="turnstileSiteKey"
|
||
@verify="onTurnstileVerify"
|
||
@expire="onTurnstileExpire"
|
||
@error="onTurnstileError"
|
||
/>
|
||
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
|
||
{{ errors.turnstile }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Error Message -->
|
||
<transition name="fade">
|
||
<div
|
||
v-if="errorMessage"
|
||
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
|
||
>
|
||
<div class="flex items-start gap-3">
|
||
<div class="flex-shrink-0">
|
||
<Icon name="exclamationCircle" size="md" class="text-red-500" />
|
||
</div>
|
||
<p class="text-sm text-red-700 dark:text-red-400">
|
||
{{ errorMessage }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
|
||
<!-- Submit Button -->
|
||
<button
|
||
type="submit"
|
||
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
|
||
class="btn btn-primary w-full"
|
||
>
|
||
<svg
|
||
v-if="isLoading"
|
||
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<circle
|
||
class="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
stroke-width="4"
|
||
></circle>
|
||
<path
|
||
class="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
></path>
|
||
</svg>
|
||
<Icon v-else name="userPlus" size="md" class="mr-2" />
|
||
{{
|
||
isLoading
|
||
? t('auth.processing')
|
||
: emailVerifyEnabled
|
||
? t('auth.continue')
|
||
: t('auth.createAccount')
|
||
}}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<template #footer>
|
||
<p class="text-gray-500 dark:text-dark-400">
|
||
{{ t('auth.alreadyHaveAccount') }}
|
||
<router-link
|
||
to="/login"
|
||
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||
>
|
||
{{ t('auth.signIn') }}
|
||
</router-link>
|
||
</p>
|
||
</template>
|
||
</AuthLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { AuthLayout } from '@/components/layout'
|
||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||
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, locale } = useI18n()
|
||
|
||
// ==================== Router & Stores ====================
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const authStore = useAuthStore()
|
||
const appStore = useAppStore()
|
||
|
||
// ==================== State ====================
|
||
|
||
const isLoading = ref<boolean>(false)
|
||
const settingsLoaded = ref<boolean>(false)
|
||
const errorMessage = ref<string>('')
|
||
const showPassword = ref<boolean>(false)
|
||
|
||
// Public settings
|
||
const registrationEnabled = ref<boolean>(true)
|
||
const emailVerifyEnabled = ref<boolean>(false)
|
||
const promoCodeEnabled = ref<boolean>(true)
|
||
const invitationCodeEnabled = ref<boolean>(false)
|
||
const turnstileEnabled = ref<boolean>(false)
|
||
const turnstileSiteKey = ref<string>('')
|
||
const siteName = ref<string>('Sub2API')
|
||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||
const oidcOAuthEnabled = ref<boolean>(false)
|
||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||
|
||
// Turnstile
|
||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||
const turnstileToken = ref<string>('')
|
||
|
||
// Promo code validation
|
||
const promoValidating = ref<boolean>(false)
|
||
const promoValidation = reactive({
|
||
valid: false,
|
||
invalid: false,
|
||
bonusAmount: null as number | null,
|
||
message: ''
|
||
})
|
||
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
||
|
||
// Invitation code validation
|
||
const invitationValidating = ref<boolean>(false)
|
||
const invitationValidation = reactive({
|
||
valid: false,
|
||
invalid: false,
|
||
message: ''
|
||
})
|
||
let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const formData = reactive({
|
||
email: '',
|
||
password: '',
|
||
promo_code: '',
|
||
invitation_code: ''
|
||
})
|
||
|
||
const errors = reactive({
|
||
email: '',
|
||
password: '',
|
||
turnstile: '',
|
||
invitation_code: ''
|
||
})
|
||
|
||
// ==================== Lifecycle ====================
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const settings = await getPublicSettings()
|
||
registrationEnabled.value = settings.registration_enabled
|
||
emailVerifyEnabled.value = settings.email_verify_enabled
|
||
promoCodeEnabled.value = settings.promo_code_enabled
|
||
invitationCodeEnabled.value = settings.invitation_code_enabled
|
||
turnstileEnabled.value = settings.turnstile_enabled
|
||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||
siteName.value = settings.site_name || 'Sub2API'
|
||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||
settings.registration_email_suffix_whitelist || []
|
||
)
|
||
|
||
// Read promo code from URL parameter only if promo code is enabled
|
||
if (promoCodeEnabled.value) {
|
||
const promoParam = route.query.promo as string
|
||
if (promoParam) {
|
||
formData.promo_code = promoParam
|
||
// Validate the promo code from URL
|
||
await validatePromoCodeDebounced(promoParam)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load public settings:', error)
|
||
} finally {
|
||
settingsLoaded.value = true
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (promoValidateTimeout) {
|
||
clearTimeout(promoValidateTimeout)
|
||
}
|
||
if (invitationValidateTimeout) {
|
||
clearTimeout(invitationValidateTimeout)
|
||
}
|
||
})
|
||
|
||
// ==================== Promo Code Validation ====================
|
||
|
||
function handlePromoCodeInput(): void {
|
||
const code = formData.promo_code.trim()
|
||
|
||
// Clear previous validation
|
||
promoValidation.valid = false
|
||
promoValidation.invalid = false
|
||
promoValidation.bonusAmount = null
|
||
promoValidation.message = ''
|
||
|
||
if (!code) {
|
||
promoValidating.value = false
|
||
return
|
||
}
|
||
|
||
// Debounce validation
|
||
if (promoValidateTimeout) {
|
||
clearTimeout(promoValidateTimeout)
|
||
}
|
||
|
||
promoValidateTimeout = setTimeout(() => {
|
||
validatePromoCodeDebounced(code)
|
||
}, 500)
|
||
}
|
||
|
||
async function validatePromoCodeDebounced(code: string): Promise<void> {
|
||
if (!code.trim()) return
|
||
|
||
promoValidating.value = true
|
||
|
||
try {
|
||
const result = await validatePromoCode(code)
|
||
|
||
if (result.valid) {
|
||
promoValidation.valid = true
|
||
promoValidation.invalid = false
|
||
promoValidation.bonusAmount = result.bonus_amount || 0
|
||
promoValidation.message = ''
|
||
} else {
|
||
promoValidation.valid = false
|
||
promoValidation.invalid = true
|
||
promoValidation.bonusAmount = null
|
||
// 根据错误码显示对应的翻译
|
||
promoValidation.message = getPromoErrorMessage(result.error_code)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to validate promo code:', error)
|
||
promoValidation.valid = false
|
||
promoValidation.invalid = true
|
||
promoValidation.message = t('auth.promoCodeInvalid')
|
||
} finally {
|
||
promoValidating.value = false
|
||
}
|
||
}
|
||
|
||
function getPromoErrorMessage(errorCode?: string): string {
|
||
switch (errorCode) {
|
||
case 'PROMO_CODE_NOT_FOUND':
|
||
return t('auth.promoCodeNotFound')
|
||
case 'PROMO_CODE_EXPIRED':
|
||
return t('auth.promoCodeExpired')
|
||
case 'PROMO_CODE_DISABLED':
|
||
return t('auth.promoCodeDisabled')
|
||
case 'PROMO_CODE_MAX_USED':
|
||
return t('auth.promoCodeMaxUsed')
|
||
case 'PROMO_CODE_ALREADY_USED':
|
||
return t('auth.promoCodeAlreadyUsed')
|
||
default:
|
||
return t('auth.promoCodeInvalid')
|
||
}
|
||
}
|
||
|
||
// ==================== Invitation Code Validation ====================
|
||
|
||
function handleInvitationCodeInput(): void {
|
||
const code = formData.invitation_code.trim()
|
||
|
||
// Clear previous validation
|
||
invitationValidation.valid = false
|
||
invitationValidation.invalid = false
|
||
invitationValidation.message = ''
|
||
errors.invitation_code = ''
|
||
|
||
if (!code) {
|
||
return
|
||
}
|
||
|
||
// Debounce validation
|
||
if (invitationValidateTimeout) {
|
||
clearTimeout(invitationValidateTimeout)
|
||
}
|
||
|
||
invitationValidateTimeout = setTimeout(() => {
|
||
validateInvitationCodeDebounced(code)
|
||
}, 500)
|
||
}
|
||
|
||
async function validateInvitationCodeDebounced(code: string): Promise<void> {
|
||
invitationValidating.value = true
|
||
|
||
try {
|
||
const result = await validateInvitationCode(code)
|
||
|
||
if (result.valid) {
|
||
invitationValidation.valid = true
|
||
invitationValidation.invalid = false
|
||
invitationValidation.message = ''
|
||
} else {
|
||
invitationValidation.valid = false
|
||
invitationValidation.invalid = true
|
||
invitationValidation.message = getInvitationErrorMessage(result.error_code)
|
||
}
|
||
} catch {
|
||
invitationValidation.valid = false
|
||
invitationValidation.invalid = true
|
||
invitationValidation.message = t('auth.invitationCodeInvalid')
|
||
} finally {
|
||
invitationValidating.value = false
|
||
}
|
||
}
|
||
|
||
function getInvitationErrorMessage(errorCode?: string): string {
|
||
switch (errorCode) {
|
||
case 'INVITATION_CODE_NOT_FOUND':
|
||
return t('auth.invitationCodeInvalid')
|
||
case 'INVITATION_CODE_INVALID':
|
||
return t('auth.invitationCodeInvalid')
|
||
case 'INVITATION_CODE_USED':
|
||
return t('auth.invitationCodeInvalid')
|
||
case 'INVITATION_CODE_DISABLED':
|
||
return t('auth.invitationCodeInvalid')
|
||
default:
|
||
return t('auth.invitationCodeInvalid')
|
||
}
|
||
}
|
||
|
||
// ==================== Turnstile Handlers ====================
|
||
|
||
function onTurnstileVerify(token: string): void {
|
||
turnstileToken.value = token
|
||
errors.turnstile = ''
|
||
}
|
||
|
||
function onTurnstileExpire(): void {
|
||
turnstileToken.value = ''
|
||
errors.turnstile = t('auth.turnstileExpired')
|
||
}
|
||
|
||
function onTurnstileError(): void {
|
||
turnstileToken.value = ''
|
||
errors.turnstile = t('auth.turnstileFailed')
|
||
}
|
||
|
||
// ==================== Validation ====================
|
||
|
||
function validateEmail(email: string): boolean {
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||
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 = ''
|
||
errors.password = ''
|
||
errors.turnstile = ''
|
||
errors.invitation_code = ''
|
||
|
||
let isValid = true
|
||
|
||
// Email validation
|
||
if (!formData.email.trim()) {
|
||
errors.email = t('auth.emailRequired')
|
||
isValid = false
|
||
} 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
|
||
if (!formData.password) {
|
||
errors.password = t('auth.passwordRequired')
|
||
isValid = false
|
||
} else if (formData.password.length < 6) {
|
||
errors.password = t('auth.passwordMinLength')
|
||
isValid = false
|
||
}
|
||
|
||
// Invitation code validation (required when enabled)
|
||
if (invitationCodeEnabled.value) {
|
||
if (!formData.invitation_code.trim()) {
|
||
errors.invitation_code = t('auth.invitationCodeRequired')
|
||
isValid = false
|
||
}
|
||
}
|
||
|
||
// Turnstile validation
|
||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||
errors.turnstile = t('auth.completeVerification')
|
||
isValid = false
|
||
}
|
||
|
||
return isValid
|
||
}
|
||
|
||
// ==================== Form Handlers ====================
|
||
|
||
async function handleRegister(): Promise<void> {
|
||
// Clear previous error
|
||
errorMessage.value = ''
|
||
|
||
// Validate form
|
||
if (!validateForm()) {
|
||
return
|
||
}
|
||
|
||
// Check promo code validation status
|
||
if (formData.promo_code.trim()) {
|
||
// If promo code is being validated, wait
|
||
if (promoValidating.value) {
|
||
errorMessage.value = t('auth.promoCodeValidating')
|
||
return
|
||
}
|
||
// If promo code is invalid, block submission
|
||
if (promoValidation.invalid) {
|
||
errorMessage.value = t('auth.promoCodeInvalidCannotRegister')
|
||
return
|
||
}
|
||
}
|
||
|
||
// Check invitation code validation status (if enabled and code provided)
|
||
if (invitationCodeEnabled.value) {
|
||
// If still validating, wait
|
||
if (invitationValidating.value) {
|
||
errorMessage.value = t('auth.invitationCodeValidating')
|
||
return
|
||
}
|
||
// If invitation code is invalid, block submission
|
||
if (invitationValidation.invalid) {
|
||
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
|
||
return
|
||
}
|
||
// If invitation code is required but not validated yet
|
||
if (formData.invitation_code.trim() && !invitationValidation.valid) {
|
||
errorMessage.value = t('auth.invitationCodeValidating')
|
||
// Trigger validation
|
||
await validateInvitationCodeDebounced(formData.invitation_code.trim())
|
||
if (!invitationValidation.valid) {
|
||
errorMessage.value = t('auth.invitationCodeInvalidCannotRegister')
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
isLoading.value = true
|
||
|
||
try {
|
||
// If email verification is enabled, redirect to verification page
|
||
if (emailVerifyEnabled.value) {
|
||
// Store registration data in sessionStorage
|
||
sessionStorage.setItem(
|
||
'register_data',
|
||
JSON.stringify({
|
||
email: formData.email,
|
||
password: formData.password,
|
||
turnstile_token: turnstileToken.value,
|
||
promo_code: formData.promo_code || undefined,
|
||
invitation_code: formData.invitation_code || undefined
|
||
})
|
||
)
|
||
|
||
// Navigate to email verification page
|
||
await router.push('/email-verify')
|
||
return
|
||
}
|
||
|
||
// Otherwise, directly register
|
||
await authStore.register({
|
||
email: formData.email,
|
||
password: formData.password,
|
||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||
promo_code: formData.promo_code || undefined,
|
||
invitation_code: formData.invitation_code || undefined
|
||
})
|
||
|
||
// Show success toast
|
||
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))
|
||
|
||
// Redirect to dashboard
|
||
await router.push('/dashboard')
|
||
} catch (error: unknown) {
|
||
// Reset Turnstile on error
|
||
if (turnstileRef.value) {
|
||
turnstileRef.value.reset()
|
||
turnstileToken.value = ''
|
||
}
|
||
|
||
// Handle registration error
|
||
errorMessage.value = buildAuthErrorMessage(error, {
|
||
fallback: t('auth.registrationFailed')
|
||
})
|
||
|
||
// Also show error toast
|
||
appStore.showError(errorMessage.value)
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-8px);
|
||
}
|
||
|
||
.auth-narrative-inner {
|
||
position: relative;
|
||
z-index: 2;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
color: var(--text-0);
|
||
}
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-weight: 700;
|
||
font-size: 18px;
|
||
}
|
||
.brand .hex {
|
||
color: var(--cyan);
|
||
font-size: 24px;
|
||
}
|
||
|
||
.auth-narrative-headline {
|
||
font-size: clamp(40px, 5vw, 64px);
|
||
font-weight: 800;
|
||
line-height: 1.05;
|
||
letter-spacing: -0.03em;
|
||
margin-bottom: 24px;
|
||
}
|
||
.auth-narrative-headline .num-5 { color: var(--amber); }
|
||
.auth-narrative-headline .num-1 { color: var(--cyan); }
|
||
.auth-narrative-sub {
|
||
font-size: 15px;
|
||
line-height: 1.7;
|
||
color: var(--text-1);
|
||
}
|
||
.auth-narrative-tagline {
|
||
display: block;
|
||
margin-top: 12px;
|
||
font-size: 12px;
|
||
color: var(--text-3);
|
||
}
|
||
.auth-narrative-foot {
|
||
font-size: 12px;
|
||
color: var(--text-3);
|
||
font-family: var(--font-mono);
|
||
}
|
||
</style>
|