monacousa-portal/pages/auth/verify.vue

540 lines
16 KiB
Vue

<template>
<div :class="containerClasses">
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card class="elevation-12 rounded-lg">
<v-card-text class="text-center pa-8">
<!-- Circuit Breaker - Too Many Attempts -->
<div v-if="isBlocked" class="mb-6">
<v-icon
color="warning"
size="80"
class="mb-4"
>
mdi-timer-sand
</v-icon>
<h1 class="text-h4 font-weight-bold text-warning mb-3">
Verification Temporarily Blocked
</h1>
<p class="text-body-1 text-medium-emphasis mb-4">
{{ statusMessage }}
</p>
<v-alert
type="warning"
variant="tonal"
class="mb-4 text-start"
icon="mdi-information"
>
<div class="text-body-2">
<strong>Why was this blocked?</strong>
<ul class="mt-2">
<li>Multiple failed verification attempts detected</li>
<li>This prevents server overload and potential issues</li>
<li>The block will be lifted automatically</li>
</ul>
</div>
</v-alert>
</div>
<!-- Loading State -->
<div v-else-if="verifying" class="mb-6">
<v-progress-circular
color="primary"
size="80"
width="6"
indeterminate
class="mb-4"
/>
<h1 class="text-h4 font-weight-bold text-primary mb-3">
Verifying Your Email
</h1>
<p class="text-body-1 text-medium-emphasis" v-if="verificationState">
{{ statusMessage || 'Please wait while we verify your email address...' }}
</p>
<p class="text-body-1 text-medium-emphasis" v-else>
Please wait while we verify your email address...
</p>
<!-- Attempt Counter -->
<div v-if="verificationState && verificationState.attempts > 1" class="mt-2">
<v-chip size="small" color="primary" variant="outlined">
Attempt {{ verificationState.attempts }}/{{ verificationState.maxAttempts }}
</v-chip>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="mb-6">
<v-icon
color="error"
size="80"
class="mb-4"
>
mdi-alert-circle
</v-icon>
<h1 class="text-h4 font-weight-bold text-error mb-3">
Verification Failed
</h1>
<p class="text-body-1 text-medium-emphasis mb-4">
{{ error }}
</p>
<!-- Circuit Breaker Status -->
<div v-if="verificationState && statusMessage" class="mb-4">
<v-alert
type="info"
variant="tonal"
class="text-start"
icon="mdi-information"
>
{{ statusMessage }}
</v-alert>
</div>
<v-alert
type="error"
variant="tonal"
class="mb-4 text-start"
icon="mdi-information"
>
<div class="text-body-2">
<strong>Common Issues:</strong>
<ul class="mt-2">
<li>The verification link may have expired</li>
<li>The link may have already been used</li>
<li>The link may be malformed</li>
<li v-if="partialSuccess">Server configuration issues (contact support)</li>
</ul>
</div>
</v-alert>
</div>
<div v-if="!verifying && !isBlocked" class="d-flex flex-column gap-3">
<v-btn
v-if="error && canRetry"
color="primary"
size="large"
variant="elevated"
block
@click="retryVerification"
:loading="verifying"
class="text-none"
>
<v-icon start>mdi-refresh</v-icon>
Retry Verification
</v-btn>
<v-btn
color="secondary"
size="large"
variant="outlined"
block
to="/signup"
class="text-none"
>
<v-icon start>mdi-account-plus</v-icon>
Register Again
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
<div v-else-if="isBlocked" class="d-flex flex-column gap-3">
<v-btn
color="secondary"
size="large"
variant="outlined"
block
to="/signup"
class="text-none"
>
<v-icon start>mdi-account-plus</v-icon>
Register Again
</v-btn>
<v-btn
color="outline"
size="small"
variant="text"
block
to="/"
class="text-none"
>
<v-icon start>mdi-home</v-icon>
Return to Home
</v-btn>
</div>
<!-- Additional help -->
<div class="mt-6 pt-4 border-t">
<p class="text-caption text-medium-emphasis mb-2">
Need help? Contact support at:
</p>
<v-chip
size="small"
variant="outlined"
prepend-icon="mdi-email"
>
support@monacousa.org
</v-chip>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: false,
middleware: 'guest'
});
import { getStaticDeviceInfo, getDeviceCssClasses, applyMobileSafariOptimizations, getMobileSafariViewportMeta } from '~/utils/static-device-detection';
import {
getVerificationState,
initVerificationState,
recordAttempt,
shouldBlockVerification,
getStatusMessage,
navigateWithFallback,
getMobileNavigationDelay,
type VerificationAttempt
} from '~/utils/verification-state';
// Get route and token immediately
const route = useRoute();
const token = route.query.token as string || '';
// Reactive state - keep minimal reactivity
const verifying = ref(false);
const error = ref('');
const partialSuccess = ref(false);
// Verification state management
const verificationState = ref<VerificationAttempt | null>(null);
const isBlocked = ref(false);
const canRetry = ref(true);
const statusMessage = ref('');
// Static device detection - no reactive dependencies
const deviceInfo = getStaticDeviceInfo();
// Static container classes - must be reactive for template
const containerClasses = ref(getDeviceCssClasses('verification-page'));
// Set page title with mobile viewport optimization
useHead({
title: 'Verifying Email - MonacoUSA Portal',
meta: [
{
name: 'description',
content: 'Verifying your email address for the MonacoUSA Portal.'
},
{ name: 'viewport', content: getMobileSafariViewportMeta() }
]
});
// Update UI state based on verification state
const updateUIState = () => {
if (!verificationState.value) return;
statusMessage.value = getStatusMessage(verificationState.value);
isBlocked.value = shouldBlockVerification(token);
canRetry.value = verificationState.value.attempts < verificationState.value.maxAttempts && !isBlocked.value;
console.log('[auth/verify] UI State updated:', {
status: verificationState.value.status,
attempts: verificationState.value.attempts,
isBlocked: isBlocked.value,
canRetry: canRetry.value
});
};
// Verify email function with circuit breaker
const verifyEmail = async () => {
if (!token) {
error.value = 'No verification token provided. Please check your email for the correct verification link.';
return;
}
// Initialize or get existing verification state
verificationState.value = initVerificationState(token);
updateUIState();
// Check if verification should be blocked
if (shouldBlockVerification(token)) {
console.log('[auth/verify] Verification blocked by circuit breaker');
return;
}
console.log(`[auth/verify] Starting verification attempt ${verificationState.value.attempts + 1}/${verificationState.value.maxAttempts}`);
try {
verifying.value = true;
error.value = '';
partialSuccess.value = false;
// Call the API endpoint to verify the email
const response = await $fetch(`/api/auth/verify-email?token=${token}`, {
method: 'GET'
}) as any;
console.log('[auth/verify] Email verification successful:', response);
// Record successful attempt
verificationState.value = recordAttempt(token, true);
updateUIState();
// Extract response data
const email = response?.data?.email || '';
const isPartialSuccess = response?.data?.partialSuccess || false;
const keycloakError = response?.data?.keycloakError;
if (isPartialSuccess) {
partialSuccess.value = true;
console.log('[auth/verify] Partial success - Keycloak error:', keycloakError);
}
// Construct redirect URL
let redirectUrl = `/auth/verify-success`;
const queryParams = [];
if (email) {
queryParams.push(`email=${encodeURIComponent(email)}`);
}
if (isPartialSuccess) {
queryParams.push('warning=partial');
if (keycloakError) {
queryParams.push(`error=${encodeURIComponent(keycloakError)}`);
}
}
if (queryParams.length > 0) {
redirectUrl += '?' + queryParams.join('&');
}
// Use progressive navigation with mobile delay
const navigationDelay = getMobileNavigationDelay();
console.log(`[auth/verify] Navigating to success page with ${navigationDelay}ms delay`);
setTimeout(async () => {
try {
await navigateWithFallback(redirectUrl, { replace: true });
} catch (navError) {
console.error('[auth/verify] Navigation failed:', navError);
// Final fallback - direct window location
window.location.replace(redirectUrl);
}
}, navigationDelay);
} catch (err: any) {
console.error('[auth/verify] Email verification failed:', err);
// Record failed attempt
const errorMessage = err.data?.message || err.message || 'Email verification failed';
verificationState.value = recordAttempt(token, false, errorMessage);
updateUIState();
// Set error message based on status code
if (err.statusCode === 410) {
error.value = 'Verification link has expired. Please request a new verification email.';
} else if (err.statusCode === 409) {
error.value = 'This verification link has already been used or is invalid.';
} else if (err.statusCode === 400) {
error.value = 'Invalid verification token. Please request a new verification email.';
} else if (err.statusCode === 404) {
error.value = 'User not found. The verification token may be invalid.';
} else {
error.value = errorMessage;
}
verifying.value = false;
}
};
// Retry verification
const retryVerification = async () => {
if (!canRetry.value || isBlocked.value) {
console.log('[auth/verify] Retry blocked - canRetry:', canRetry.value, 'isBlocked:', isBlocked.value);
return;
}
console.log('[auth/verify] Retrying verification...');
await verifyEmail();
};
// Component initialization - Safari iOS reload loop prevention
onMounted(() => {
console.log('[auth/verify] Component mounted with token:', token?.substring(0, 20) + '...');
// Apply mobile Safari optimizations early
if (deviceInfo.isMobileSafari) {
applyMobileSafariOptimizations();
console.log('[auth/verify] Mobile Safari optimizations applied');
}
// Check if token exists
if (!token) {
error.value = 'No verification token provided. Please check your email for the correct verification link.';
return;
}
// Initialize verification state
verificationState.value = initVerificationState(token, 3);
updateUIState();
// Check if verification is blocked before starting
if (shouldBlockVerification(token)) {
console.log('[auth/verify] Verification blocked by circuit breaker on mount');
return;
}
// Start verification process with a small delay to ensure stability
setTimeout(() => {
verifyEmail();
}, 200);
});
</script>
<style scoped>
.verification-page {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
overflow-x: hidden; /* Prevent horizontal scroll on mobile */
}
/* Mobile Safari optimizations */
.verification-page.is-mobile-safari {
min-height: 100vh;
min-height: -webkit-fill-available;
}
.verification-page.performance-mode {
will-change: auto;
transform: translateZ(0); /* Lighter hardware acceleration */
}
.fill-height {
min-height: 100vh;
min-height: calc(var(--vh, 1vh) * 100); /* Mobile Safari fallback */
}
/* Mobile Safari fill-height optimization */
.is-mobile-safari .fill-height {
min-height: -webkit-fill-available;
}
.border-t {
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.gap-3 {
gap: 12px;
}
/* Loading animation */
.v-progress-circular {
animation: pulse 2s ease-in-out infinite;
}
.performance-mode .v-progress-circular {
animation: none; /* Disable animations on performance mode */
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
/* Custom scrollbar for mobile */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background: rgba(163, 21, 21, 0.5);
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.verification-page {
padding: 16px;
}
.v-card {
margin: 0;
}
/* Optimize button spacing on mobile */
.gap-3 {
gap: 8px;
}
}
/* Improve touch targets on mobile */
@media (hover: none) and (pointer: coarse) {
.v-btn {
min-height: 48px; /* Ensure touch-friendly button size */
}
}
/* Performance mode optimizations */
.performance-mode .v-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; /* Lighter shadow */
}
.performance-mode .v-btn {
transition: none; /* Remove button transitions for better performance */
}
/* Error state styling */
.text-error {
color: rgb(var(--v-theme-error));
}
/* Better list styling */
ul {
margin: 0;
padding-left: 20px;
}
li {
margin: 4px 0;
}
</style>