424 lines
14 KiB
Vue
424 lines
14 KiB
Vue
<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 state(token 已使用,清除以避免重复使用)
|
||
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>
|