Files
sub2api/frontend/src/views/auth/EmailVerifyView.vue
2025-12-18 14:26:55 +08:00

424 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
Verify Your Email
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
We'll send a verification code to <span class="font-medium text-gray-700 dark:text-gray-300">{{ email }}</span>
</p>
</div>
<!-- No Data Warning -->
<div v-if="!hasRegisterData" class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800/50">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<div class="text-sm text-amber-700 dark:text-amber-400">
<p class="font-medium">Session expired</p>
<p class="mt-1">Please go back to the registration page and start again.</p>
</div>
</div>
</div>
<!-- Verification Form -->
<form v-else @submit.prevent="handleVerify" class="space-y-5">
<!-- Verification Code Input -->
<div>
<label for="code" class="input-label text-center">
Verification Code
</label>
<input
id="code"
v-model="verifyCode"
type="text"
required
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
:disabled="isLoading"
class="input text-center text-xl tracking-[0.5em] font-mono py-3"
:class="{ 'input-error': errors.code }"
placeholder="000000"
/>
<p v-if="errors.code" class="input-error-text text-center">
{{ errors.code }}
</p>
<p v-else class="input-hint text-center">
Enter the 6-digit code sent to your email
</p>
</div>
<!-- Code Status -->
<div v-if="codeSent" class="p-4 rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-sm text-green-700 dark:text-green-400">
Verification code sent! Please check your inbox.
</p>
</div>
</div>
<!-- Turnstile Widget for Resend -->
<div v-if="turnstileEnabled && turnstileSiteKey && showResendTurnstile">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="p-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || !verifyCode"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4 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>
<svg v-else class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ isLoading ? 'Verifying...' : 'Verify & Create Account' }}
</button>
<!-- Resend Code -->
<div class="text-center">
<button
v-if="countdown > 0"
type="button"
disabled
class="text-sm text-gray-400 dark:text-dark-500 cursor-not-allowed"
>
Resend code in {{ countdown }}s
</button>
<button
v-else
type="button"
@click="handleResendCode"
:disabled="isSendingCode || (turnstileEnabled && showResendTurnstile && !resendTurnstileToken)"
class="text-sm text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="isSendingCode">Sending...</span>
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span>
<span v-else>Resend verification code</span>
</button>
</div>
</form>
</div>
<!-- Footer -->
<template #footer>
<button
@click="handleBack"
class="text-gray-500 dark:text-dark-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</svg>
Back to registration
</button>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { AuthLayout } from '@/components/layout';
import TurnstileWidget from '@/components/TurnstileWidget.vue';
import { useAuthStore, useAppStore } from '@/stores';
import { getPublicSettings, sendVerifyCode } from '@/api/auth';
// ==================== Router & Stores ====================
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
// ==================== State ====================
const isLoading = ref<boolean>(false);
const isSendingCode = ref<boolean>(false);
const errorMessage = ref<string>('');
const codeSent = ref<boolean>(false);
const verifyCode = ref<string>('');
const countdown = ref<number>(0);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
// Registration data from sessionStorage
const email = ref<string>('');
const password = ref<string>('');
const initialTurnstileToken = ref<string>('');
const hasRegisterData = ref<boolean>(false);
// Public settings
const turnstileEnabled = ref<boolean>(false);
const turnstileSiteKey = ref<string>('');
const siteName = ref<string>('Sub2API');
// Turnstile for resend
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
const resendTurnstileToken = ref<string>('');
const showResendTurnstile = ref<boolean>(false);
const errors = ref({
code: '',
turnstile: '',
});
// ==================== Lifecycle ====================
onMounted(async () => {
// Load registration data from sessionStorage
const registerDataStr = sessionStorage.getItem('register_data');
if (registerDataStr) {
try {
const registerData = JSON.parse(registerDataStr);
email.value = registerData.email || '';
password.value = registerData.password || '';
initialTurnstileToken.value = registerData.turnstile_token || '';
hasRegisterData.value = !!(email.value && password.value);
} catch {
hasRegisterData.value = false;
}
}
// Load public settings
try {
const settings = await getPublicSettings();
turnstileEnabled.value = settings.turnstile_enabled;
turnstileSiteKey.value = settings.turnstile_site_key || '';
siteName.value = settings.site_name || 'Sub2API';
} catch (error) {
console.error('Failed to load public settings:', error);
}
// Auto-send verification code if we have valid data
if (hasRegisterData.value) {
await sendCode();
}
});
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
});
// ==================== Countdown ====================
function startCountdown(seconds: number): void {
countdown.value = seconds;
if (countdownTimer) {
clearInterval(countdownTimer);
}
countdownTimer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
} else {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
}
}, 1000);
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
resendTurnstileToken.value = token;
errors.value.turnstile = '';
}
function onTurnstileExpire(): void {
resendTurnstileToken.value = '';
errors.value.turnstile = 'Verification expired, please try again';
}
function onTurnstileError(): void {
resendTurnstileToken.value = '';
errors.value.turnstile = 'Verification failed, please try again';
}
// ==================== Send Code ====================
async function sendCode(): Promise<void> {
isSendingCode.value = true;
errorMessage.value = '';
try {
const response = await sendVerifyCode({
email: email.value,
// 优先使用重发时新获取的 token因为初始 token 可能已被使用)
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined,
});
codeSent.value = true;
startCountdown(response.countdown);
// Reset turnstile statetoken 已使用,清除以避免重复使用)
initialTurnstileToken.value = '';
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.';
}
appStore.showError(errorMessage.value);
} finally {
isSendingCode.value = false;
}
}
// ==================== Handlers ====================
async function handleResendCode(): Promise<void> {
// If turnstile is enabled and we haven't shown it yet, show it
if (turnstileEnabled.value && !showResendTurnstile.value) {
showResendTurnstile.value = true;
return;
}
// If turnstile is enabled but no token yet, wait
if (turnstileEnabled.value && !resendTurnstileToken.value) {
errors.value.turnstile = 'Please complete the verification';
return;
}
await sendCode();
}
function validateForm(): boolean {
errors.value.code = '';
if (!verifyCode.value.trim()) {
errors.value.code = 'Verification code is required';
return false;
}
if (!/^\d{6}$/.test(verifyCode.value.trim())) {
errors.value.code = 'Please enter a valid 6-digit code';
return false;
}
return true;
}
async function handleVerify(): Promise<void> {
errorMessage.value = '';
if (!validateForm()) {
return;
}
isLoading.value = true;
try {
// Register with verification code
await authStore.register({
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined,
});
// Clear session data
sessionStorage.removeItem('register_data');
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
// 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.';
}
appStore.showError(errorMessage.value);
} finally {
isLoading.value = false;
}
}
function handleBack(): void {
// Clear session data
sessionStorage.removeItem('register_data');
// Go back to registration
router.push('/register');
}
</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);
}
</style>