First commit
This commit is contained in:
423
frontend/src/views/auth/EmailVerifyView.vue
Normal file
423
frontend/src/views/auth/EmailVerifyView.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<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, computed, 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>
|
||||
328
frontend/src/views/auth/LoginView.vue
Normal file
328
frontend/src/views/auth/LoginView.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.welcomeBack') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.signInToAccount') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
<!-- Email Input -->
|
||||
<div>
|
||||
<label for="email" class="input-label">
|
||||
{{ t('auth.emailLabel') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
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="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="formData.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:disabled="isLoading"
|
||||
class="input pl-11 pr-11"
|
||||
:class="{ 'input-error': errors.password }"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-dark-300 transition-colors"
|
||||
>
|
||||
<svg v-if="showPassword" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="input-error-text">
|
||||
{{ errors.password }}
|
||||
</p>
|
||||
</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 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 || (turnstileEnabled && !turnstileToken)"
|
||||
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="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
{{ isLoading ? t('auth.signingIn') : t('auth.signIn') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<template #footer>
|
||||
<p class="text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.dontHaveAccount') }}
|
||||
<router-link
|
||||
to="/register"
|
||||
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors"
|
||||
>
|
||||
{{ t('auth.signUp') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
const isLoading = ref<boolean>(false);
|
||||
const errorMessage = ref<string>('');
|
||||
const showPassword = ref<boolean>(false);
|
||||
|
||||
// Public settings
|
||||
const turnstileEnabled = ref<boolean>(false);
|
||||
const turnstileSiteKey = ref<string>('');
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
|
||||
const turnstileToken = ref<string>('');
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
turnstile: '',
|
||||
});
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
turnstileEnabled.value = settings.turnstile_enabled;
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || '';
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
turnstileToken.value = token;
|
||||
errors.turnstile = '';
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification expired, please try again';
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification failed, please try again';
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateForm(): boolean {
|
||||
// Reset errors
|
||||
errors.email = '';
|
||||
errors.password = '';
|
||||
errors.turnstile = '';
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
isValid = false;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
errors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (formData.password.length < 6) {
|
||||
errors.password = 'Password must be at least 6 characters';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Turnstile validation
|
||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||
errors.turnstile = 'Please complete the verification';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleLogin(): Promise<void> {
|
||||
// Clear previous error
|
||||
errorMessage.value = '';
|
||||
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Call auth store login
|
||||
await authStore.login({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
appStore.showSuccess('Login successful! Welcome back.');
|
||||
|
||||
// Redirect to dashboard or intended route
|
||||
const redirectTo = router.currentRoute.value.query.redirect as string || '/dashboard';
|
||||
await router.push(redirectTo);
|
||||
} catch (error: unknown) {
|
||||
// Reset Turnstile on error
|
||||
if (turnstileRef.value) {
|
||||
turnstileRef.value.reset();
|
||||
turnstileToken.value = '';
|
||||
}
|
||||
|
||||
// Handle login 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 = 'Login failed. Please check your credentials and try again.';
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
</style>
|
||||
338
frontend/src/views/auth/README.md
Normal file
338
frontend/src/views/auth/README.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Authentication Views
|
||||
|
||||
This directory contains Vue 3 authentication views for the Sub2API frontend application.
|
||||
|
||||
## Components
|
||||
|
||||
### LoginView.vue
|
||||
|
||||
Login page for existing users to authenticate.
|
||||
|
||||
**Features:**
|
||||
- Username and password inputs with validation
|
||||
- Remember me checkbox for persistent sessions
|
||||
- Form validation with real-time error display
|
||||
- Loading state during authentication
|
||||
- Error message display for failed login attempts
|
||||
- Redirect to dashboard on successful login
|
||||
- Link to registration page for new users
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<template>
|
||||
<LoginView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoginView } from '@/views/auth';
|
||||
</script>
|
||||
```
|
||||
|
||||
**Route:**
|
||||
- Path: `/login`
|
||||
- Name: `Login`
|
||||
- Meta: `{ requiresAuth: false }`
|
||||
|
||||
**Validation Rules:**
|
||||
- Username: Required, minimum 3 characters
|
||||
- Password: Required, minimum 6 characters
|
||||
|
||||
**Behavior:**
|
||||
- Calls `authStore.login()` with credentials
|
||||
- Shows success toast on successful login
|
||||
- Shows error toast and inline error message on failure
|
||||
- Redirects to `/dashboard` or intended route from query parameter
|
||||
- Redirects authenticated users away from login page
|
||||
|
||||
### RegisterView.vue
|
||||
|
||||
Registration page for new users to create accounts.
|
||||
|
||||
**Features:**
|
||||
- Username, email, password, and confirm password inputs
|
||||
- Comprehensive form validation
|
||||
- Password strength requirements (8+ characters, letters + numbers)
|
||||
- Email format validation with regex
|
||||
- Password match validation
|
||||
- Loading state during registration
|
||||
- Error message display for failed registration
|
||||
- Redirect to dashboard on success
|
||||
- Link to login page for existing users
|
||||
|
||||
**Usage:**
|
||||
```vue
|
||||
<template>
|
||||
<RegisterView />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RegisterView } from '@/views/auth';
|
||||
</script>
|
||||
```
|
||||
|
||||
**Route:**
|
||||
- Path: `/register`
|
||||
- Name: `Register`
|
||||
- Meta: `{ requiresAuth: false }`
|
||||
|
||||
**Validation Rules:**
|
||||
- Username:
|
||||
- Required
|
||||
- 3-50 characters
|
||||
- Only letters, numbers, underscores, and hyphens
|
||||
- Email:
|
||||
- Required
|
||||
- Valid email format (RFC 5322 regex)
|
||||
- Password:
|
||||
- Required
|
||||
- Minimum 8 characters
|
||||
- Must contain at least one letter and one number
|
||||
- Confirm Password:
|
||||
- Required
|
||||
- Must match password
|
||||
|
||||
**Behavior:**
|
||||
- Calls `authStore.register()` with user data
|
||||
- Shows success toast on successful registration
|
||||
- Shows error toast and inline error message on failure
|
||||
- Redirects to `/dashboard` after successful registration
|
||||
- Redirects authenticated users away from register page
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Structure
|
||||
|
||||
Both views follow a consistent structure:
|
||||
|
||||
```
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<!-- Form -->
|
||||
<!-- Error Message -->
|
||||
<!-- Submit Button -->
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<!-- Footer Links -->
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Imports
|
||||
// State
|
||||
// Validation
|
||||
// Form Handlers
|
||||
</script>
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
Both views use:
|
||||
- `useAuthStore()` - For authentication actions (login, register)
|
||||
- `useAppStore()` - For toast notifications and UI feedback
|
||||
- `useRouter()` - For navigation and redirects
|
||||
|
||||
### Validation Strategy
|
||||
|
||||
**Client-side Validation:**
|
||||
- Real-time validation on form submission
|
||||
- Field-level error messages
|
||||
- Comprehensive validation rules
|
||||
- TypeScript type safety
|
||||
|
||||
**Server-side Validation:**
|
||||
- Backend API validates all inputs
|
||||
- Error responses handled gracefully
|
||||
- User-friendly error messages displayed
|
||||
|
||||
### Styling
|
||||
|
||||
**Design System:**
|
||||
- TailwindCSS utility classes
|
||||
- Consistent color scheme (indigo primary)
|
||||
- Responsive design
|
||||
- Accessible form controls
|
||||
- Loading states with spinner animations
|
||||
|
||||
**Visual Feedback:**
|
||||
- Red border on invalid fields
|
||||
- Error messages below inputs
|
||||
- Global error banner for API errors
|
||||
- Success toasts on completion
|
||||
- Loading spinner on submit button
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Components
|
||||
- `AuthLayout` - Layout wrapper for auth pages from `@/components/layout`
|
||||
|
||||
### Stores
|
||||
- `authStore` - Authentication state management from `@/stores/auth`
|
||||
- `appStore` - Application state and toasts from `@/stores/app`
|
||||
|
||||
### Libraries
|
||||
- Vue 3 Composition API
|
||||
- Vue Router for navigation
|
||||
- Pinia for state management
|
||||
- TypeScript for type safety
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Login Flow
|
||||
|
||||
```typescript
|
||||
// User enters credentials
|
||||
formData.username = 'john_doe';
|
||||
formData.password = 'SecurePass123';
|
||||
|
||||
// Submit form
|
||||
await handleLogin();
|
||||
|
||||
// On success:
|
||||
// - authStore.login() called
|
||||
// - Token and user stored
|
||||
// - Success toast shown
|
||||
// - Redirected to /dashboard
|
||||
|
||||
// On error:
|
||||
// - Error message displayed
|
||||
// - Error toast shown
|
||||
// - Form remains editable
|
||||
```
|
||||
|
||||
### Basic Registration Flow
|
||||
|
||||
```typescript
|
||||
// User enters registration data
|
||||
formData.username = 'jane_smith';
|
||||
formData.email = 'jane@example.com';
|
||||
formData.password = 'SecurePass123';
|
||||
formData.confirmPassword = 'SecurePass123';
|
||||
|
||||
// Submit form
|
||||
await handleRegister();
|
||||
|
||||
// On success:
|
||||
// - authStore.register() called
|
||||
// - Token and user stored
|
||||
// - Success toast shown
|
||||
// - Redirected to /dashboard
|
||||
|
||||
// On error:
|
||||
// - Error message displayed
|
||||
// - Error toast shown
|
||||
// - Form remains editable
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Client-side Errors
|
||||
|
||||
```typescript
|
||||
// Validation errors
|
||||
errors.username = 'Username must be at least 3 characters';
|
||||
errors.email = 'Please enter a valid email address';
|
||||
errors.password = 'Password must be at least 8 characters with letters and numbers';
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
```
|
||||
|
||||
### Server-side Errors
|
||||
|
||||
```typescript
|
||||
// API error responses
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
detail: 'Username already exists'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Displayed as:
|
||||
errorMessage.value = 'Username already exists';
|
||||
appStore.showError('Username already exists');
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Semantic HTML elements (`<label>`, `<input>`, `<button>`)
|
||||
- Proper `for` attributes on labels
|
||||
- ARIA attributes for loading states
|
||||
- Keyboard navigation support
|
||||
- Focus management
|
||||
- Error announcements
|
||||
- Sufficient color contrast
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
### Unit Tests
|
||||
- Form validation logic
|
||||
- Error handling
|
||||
- State management
|
||||
- Router navigation
|
||||
|
||||
### Integration Tests
|
||||
- Full login flow
|
||||
- Full registration flow
|
||||
- Error scenarios
|
||||
- Redirect behavior
|
||||
|
||||
### E2E Tests
|
||||
- Complete user journeys
|
||||
- Form interactions
|
||||
- API integration
|
||||
- Success/error states
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
- OAuth/SSO integration (Google, GitHub)
|
||||
- Two-factor authentication (2FA)
|
||||
- Password strength meter
|
||||
- Email verification flow
|
||||
- Forgot password functionality
|
||||
- Social login buttons
|
||||
- CAPTCHA integration
|
||||
- Session timeout warnings
|
||||
- Password visibility toggle
|
||||
- Autofill support enhancement
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Passwords are never logged or displayed
|
||||
- HTTPS required in production
|
||||
- JWT tokens stored securely in localStorage
|
||||
- CORS protection on API
|
||||
- XSS protection with Vue's automatic escaping
|
||||
- CSRF protection with token-based auth
|
||||
- Rate limiting on backend API
|
||||
- Input sanitization
|
||||
- Secure password requirements
|
||||
|
||||
## Performance
|
||||
|
||||
- Lazy-loaded routes
|
||||
- Minimal bundle size
|
||||
- Fast initial render
|
||||
- Optimized re-renders with reactive refs
|
||||
- No unnecessary watchers
|
||||
- Efficient form validation
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Modern browsers (Chrome, Firefox, Safari, Edge)
|
||||
- ES2015+ required
|
||||
- Flexbox and CSS Grid
|
||||
- Tailwind CSS utilities
|
||||
- Vue 3 runtime
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Auth Store Documentation](/src/stores/README.md#auth-store)
|
||||
- [AuthLayout Component](/src/components/layout/README.md#authlayout)
|
||||
- [Router Configuration](/src/router/index.ts)
|
||||
- [API Documentation](/src/api/README.md#authentication)
|
||||
- [Type Definitions](/src/types/index.ts)
|
||||
372
frontend/src/views/auth/RegisterView.vue
Normal file
372
frontend/src/views/auth/RegisterView.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.createAccount') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.signUpToStart', { siteName }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Registration Disabled Message -->
|
||||
<div v-if="!registrationEnabled && settingsLoaded" 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>
|
||||
<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="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
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="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</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 pr-3.5 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-dark-300 transition-colors"
|
||||
>
|
||||
<svg v-if="showPassword" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<!-- 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 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 || (turnstileEnabled && !turnstileToken)"
|
||||
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="M19 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM4 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 0110.374 21c-2.331 0-4.512-.645-6.374-1.766z" />
|
||||
</svg>
|
||||
{{ 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 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 transition-colors"
|
||||
>
|
||||
{{ t('auth.signIn') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</template>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { AuthLayout } from '@/components/layout';
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue';
|
||||
import { useAuthStore, useAppStore } from '@/stores';
|
||||
import { getPublicSettings } from '@/api/auth';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// ==================== Router & Stores ====================
|
||||
|
||||
const router = useRouter();
|
||||
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 turnstileEnabled = ref<boolean>(false);
|
||||
const turnstileSiteKey = ref<string>('');
|
||||
const siteName = ref<string>('Sub2API');
|
||||
|
||||
// Turnstile
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
|
||||
const turnstileToken = ref<string>('');
|
||||
|
||||
const formData = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
turnstile: '',
|
||||
});
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings();
|
||||
registrationEnabled.value = settings.registration_enabled;
|
||||
emailVerifyEnabled.value = settings.email_verify_enabled;
|
||||
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);
|
||||
} finally {
|
||||
settingsLoaded.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Turnstile Handlers ====================
|
||||
|
||||
function onTurnstileVerify(token: string): void {
|
||||
turnstileToken.value = token;
|
||||
errors.turnstile = '';
|
||||
}
|
||||
|
||||
function onTurnstileExpire(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification expired, please try again';
|
||||
}
|
||||
|
||||
function onTurnstileError(): void {
|
||||
turnstileToken.value = '';
|
||||
errors.turnstile = 'Verification failed, please try again';
|
||||
}
|
||||
|
||||
// ==================== Validation ====================
|
||||
|
||||
function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
// Reset errors
|
||||
errors.email = '';
|
||||
errors.password = '';
|
||||
errors.turnstile = '';
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
isValid = false;
|
||||
} else if (!validateEmail(formData.email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Password validation
|
||||
if (!formData.password) {
|
||||
errors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (formData.password.length < 6) {
|
||||
errors.password = 'Password must be at least 6 characters';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Turnstile validation
|
||||
if (turnstileEnabled.value && !turnstileToken.value) {
|
||||
errors.turnstile = 'Please complete the verification';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// ==================== Form Handlers ====================
|
||||
|
||||
async function handleRegister(): Promise<void> {
|
||||
// Clear previous error
|
||||
errorMessage.value = '';
|
||||
|
||||
// Validate form
|
||||
if (!validateForm()) {
|
||||
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,
|
||||
}));
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
appStore.showSuccess('Account created successfully! Welcome to ' + 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
|
||||
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 = 'Registration failed. Please try again.';
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
</style>
|
||||
609
frontend/src/views/auth/USAGE_EXAMPLES.md
Normal file
609
frontend/src/views/auth/USAGE_EXAMPLES.md
Normal file
@@ -0,0 +1,609 @@
|
||||
# Authentication Views Usage Examples
|
||||
|
||||
This document provides practical examples of how to use the authentication views in the Sub2API frontend.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Login Flow
|
||||
|
||||
**Scenario:** User wants to log into their existing account
|
||||
|
||||
```typescript
|
||||
// Route: /login
|
||||
// Component: LoginView.vue
|
||||
|
||||
// User interactions:
|
||||
// 1. Navigate to /login
|
||||
// 2. Enter username: "john_doe"
|
||||
// 3. Enter password: "MySecurePass123"
|
||||
// 4. Optionally check "Remember me"
|
||||
// 5. Click "Sign In"
|
||||
|
||||
// What happens:
|
||||
// - Form validation runs (client-side)
|
||||
// - If valid, authStore.login() is called
|
||||
// - API request to POST /api/auth/login
|
||||
// - On success:
|
||||
// - Token stored in localStorage
|
||||
// - User data stored in state
|
||||
// - Success toast: "Login successful! Welcome back."
|
||||
// - Redirect to /dashboard (or intended route)
|
||||
// - On error:
|
||||
// - Error message displayed inline
|
||||
// - Error toast shown
|
||||
// - User can retry
|
||||
```
|
||||
|
||||
### 2. Registration Flow
|
||||
|
||||
**Scenario:** New user wants to create an account
|
||||
|
||||
```typescript
|
||||
// Route: /register
|
||||
// Component: RegisterView.vue
|
||||
|
||||
// User interactions:
|
||||
// 1. Navigate to /register
|
||||
// 2. Enter username: "jane_smith"
|
||||
// 3. Enter email: "jane@example.com"
|
||||
// 4. Enter password: "SecurePass123"
|
||||
// 5. Enter confirm password: "SecurePass123"
|
||||
// 6. Click "Create Account"
|
||||
|
||||
// What happens:
|
||||
// - Form validation runs (client-side)
|
||||
// - Username: 3-50 chars, alphanumeric + _ -
|
||||
// - Email: Valid format
|
||||
// - Password: 8+ chars, letters + numbers
|
||||
// - Passwords match
|
||||
// - If valid, authStore.register() is called
|
||||
// - API request to POST /api/auth/register
|
||||
// - On success:
|
||||
// - Token stored in localStorage
|
||||
// - User data stored in state
|
||||
// - Success toast: "Account created successfully! Welcome to Sub2API."
|
||||
// - Redirect to /dashboard
|
||||
// - On error:
|
||||
// - Error message displayed inline
|
||||
// - Error toast shown
|
||||
// - User can retry
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Importing the Views
|
||||
|
||||
```typescript
|
||||
// Method 1: Direct import
|
||||
import LoginView from '@/views/auth/LoginView.vue';
|
||||
import RegisterView from '@/views/auth/RegisterView.vue';
|
||||
|
||||
// Method 2: Named exports from index
|
||||
import { LoginView, RegisterView } from '@/views/auth';
|
||||
|
||||
// Method 3: Lazy loading (recommended for routes)
|
||||
const LoginView = () => import('@/views/auth/LoginView.vue');
|
||||
const RegisterView = () => import('@/views/auth/RegisterView.vue');
|
||||
```
|
||||
|
||||
### Using in Router
|
||||
|
||||
```typescript
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
```
|
||||
|
||||
### Navigation to Auth Views
|
||||
|
||||
```typescript
|
||||
// From template
|
||||
<router-link to="/login">Login</router-link>
|
||||
<router-link to="/register">Sign Up</router-link>
|
||||
|
||||
// From script
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Navigate to login
|
||||
router.push('/login');
|
||||
|
||||
// Navigate to register
|
||||
router.push('/register');
|
||||
|
||||
// Navigate with redirect query
|
||||
router.push({
|
||||
path: '/login',
|
||||
query: { redirect: '/dashboard' }
|
||||
});
|
||||
```
|
||||
|
||||
### Programmatic Auth Flow
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
import { useAppStore } from '@/stores';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Login
|
||||
async function login() {
|
||||
try {
|
||||
await authStore.login({
|
||||
username: 'john_doe',
|
||||
password: 'MySecurePass123'
|
||||
});
|
||||
|
||||
appStore.showSuccess('Login successful!');
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
appStore.showError('Login failed. Please check your credentials.');
|
||||
}
|
||||
}
|
||||
|
||||
// Register
|
||||
async function register() {
|
||||
try {
|
||||
await authStore.register({
|
||||
username: 'jane_smith',
|
||||
email: 'jane@example.com',
|
||||
password: 'SecurePass123'
|
||||
});
|
||||
|
||||
appStore.showSuccess('Account created successfully!');
|
||||
router.push('/dashboard');
|
||||
} catch (error) {
|
||||
appStore.showError('Registration failed. Please try again.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Examples
|
||||
|
||||
### Login Validation
|
||||
|
||||
```typescript
|
||||
// Valid inputs
|
||||
✅ Username: "john_doe" (3+ chars)
|
||||
✅ Password: "SecurePass123" (6+ chars)
|
||||
|
||||
// Invalid inputs
|
||||
❌ Username: "jo" → Error: "Username must be at least 3 characters"
|
||||
❌ Password: "12345" → Error: "Password must be at least 6 characters"
|
||||
❌ Username: "" → Error: "Username is required"
|
||||
❌ Password: "" → Error: "Password is required"
|
||||
```
|
||||
|
||||
### Registration Validation
|
||||
|
||||
```typescript
|
||||
// Valid inputs
|
||||
✅ Username: "jane_smith" (3-50 chars, alphanumeric + _ -)
|
||||
✅ Email: "jane@example.com" (valid format)
|
||||
✅ Password: "SecurePass123" (8+ chars, letters + numbers)
|
||||
✅ Confirm: "SecurePass123" (matches password)
|
||||
|
||||
// Invalid inputs
|
||||
❌ Username: "ja" → Error: "Username must be at least 3 characters"
|
||||
❌ Username: "jane@smith" → Error: "Username can only contain letters, numbers, underscores, and hyphens"
|
||||
❌ Email: "invalid-email" → Error: "Please enter a valid email address"
|
||||
❌ Password: "short" → Error: "Password must be at least 8 characters with letters and numbers"
|
||||
❌ Password: "12345678" → Error: "Password must be at least 8 characters with letters and numbers" (no letters)
|
||||
❌ Password: "password" → Error: "Password must be at least 8 characters with letters and numbers" (no numbers)
|
||||
❌ Confirm: "DifferentPass" → Error: "Passwords do not match"
|
||||
```
|
||||
|
||||
## Error Handling Examples
|
||||
|
||||
### Backend Errors
|
||||
|
||||
```typescript
|
||||
// Example 1: Username already exists
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
detail: "Username 'john_doe' is already taken"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Displayed: "Username 'john_doe' is already taken"
|
||||
|
||||
// Example 2: Invalid credentials
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
detail: "Invalid username or password"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Displayed: "Invalid username or password"
|
||||
|
||||
// Example 3: Network error
|
||||
{
|
||||
message: "Network Error"
|
||||
}
|
||||
// Displayed: "Network Error" + Error toast
|
||||
|
||||
// Example 4: Unknown error
|
||||
{}
|
||||
// Displayed: "Login failed. Please check your credentials and try again." (default)
|
||||
```
|
||||
|
||||
### Client-side Validation Errors
|
||||
|
||||
```typescript
|
||||
// Multiple validation errors displayed simultaneously
|
||||
errors = {
|
||||
username: "Username must be at least 3 characters",
|
||||
email: "Please enter a valid email address",
|
||||
password: "Password must be at least 8 characters with letters and numbers",
|
||||
confirmPassword: "Passwords do not match"
|
||||
}
|
||||
|
||||
// Each error appears below its respective input field with red styling
|
||||
```
|
||||
|
||||
## Testing Examples
|
||||
|
||||
### Unit Test: Login View
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia } from 'pinia';
|
||||
import LoginView from '@/views/auth/LoginView.vue';
|
||||
|
||||
describe('LoginView', () => {
|
||||
it('validates required fields', async () => {
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
},
|
||||
});
|
||||
|
||||
// Submit empty form
|
||||
await wrapper.find('form').trigger('submit');
|
||||
|
||||
// Check for validation errors
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
});
|
||||
|
||||
it('calls authStore.login on valid submission', async () => {
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [createPinia()],
|
||||
},
|
||||
});
|
||||
|
||||
// Fill in form
|
||||
await wrapper.find('#username').setValue('john_doe');
|
||||
await wrapper.find('#password').setValue('SecurePass123');
|
||||
|
||||
// Submit form
|
||||
await wrapper.find('form').trigger('submit');
|
||||
|
||||
// Verify authStore.login was called
|
||||
// (mock implementation needed)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E Test: Registration Flow
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('user can register successfully', async ({ page }) => {
|
||||
// Navigate to register page
|
||||
await page.goto('/register');
|
||||
|
||||
// Fill in registration form
|
||||
await page.fill('#username', 'new_user');
|
||||
await page.fill('#email', 'new_user@example.com');
|
||||
await page.fill('#password', 'SecurePass123');
|
||||
await page.fill('#confirmPassword', 'SecurePass123');
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Verify success toast appears
|
||||
await expect(page.locator('.toast-success')).toBeVisible();
|
||||
await expect(page.locator('.toast-success')).toContainText('Account created successfully');
|
||||
});
|
||||
|
||||
test('shows validation errors for invalid inputs', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
|
||||
// Enter mismatched passwords
|
||||
await page.fill('#password', 'SecurePass123');
|
||||
await page.fill('#confirmPassword', 'DifferentPass');
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Verify error message
|
||||
await expect(page.locator('text=Passwords do not match')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Integration with Navigation Guards
|
||||
|
||||
### Router Guard Example
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Redirect authenticated users away from auth pages
|
||||
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect unauthenticated users to login
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
## Customization Examples
|
||||
|
||||
### Custom Success Redirect
|
||||
|
||||
```typescript
|
||||
// In LoginView.vue
|
||||
async function handleLogin(): Promise<void> {
|
||||
try {
|
||||
await authStore.login({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
appStore.showSuccess('Login successful!');
|
||||
|
||||
// Custom redirect logic
|
||||
const isAdmin = authStore.isAdmin;
|
||||
const redirectTo = isAdmin ? '/admin/dashboard' : '/dashboard';
|
||||
|
||||
await router.push(redirectTo);
|
||||
} catch (error) {
|
||||
// Error handling...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Validation Rules
|
||||
|
||||
```typescript
|
||||
// Custom password strength validation
|
||||
function validatePasswordStrength(password: string): boolean {
|
||||
const hasMinLength = password.length >= 12;
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
return hasMinLength && hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;
|
||||
}
|
||||
|
||||
// Use in validation
|
||||
if (!validatePasswordStrength(formData.password)) {
|
||||
errors.password = 'Password must be at least 12 characters with uppercase, lowercase, numbers, and special characters';
|
||||
isValid = false;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Error Handling
|
||||
|
||||
```typescript
|
||||
// In RegisterView.vue
|
||||
async function handleRegister(): Promise<void> {
|
||||
try {
|
||||
await authStore.register({
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
appStore.showSuccess('Account created successfully!');
|
||||
await router.push('/dashboard');
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { status?: number; data?: { detail?: string } } };
|
||||
|
||||
// Custom error handling based on status code
|
||||
if (err.response?.status === 409) {
|
||||
errorMessage.value = 'This username or email is already registered. Please use a different one.';
|
||||
} else if (err.response?.status === 422) {
|
||||
errorMessage.value = 'Invalid input. Please check your information and try again.';
|
||||
} else if (err.response?.status === 500) {
|
||||
errorMessage.value = 'Server error. Please try again later.';
|
||||
} else {
|
||||
errorMessage.value = err.response?.data?.detail || 'Registration failed. Please try again.';
|
||||
}
|
||||
|
||||
appStore.showError(errorMessage.value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Examples
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
```typescript
|
||||
// Tab order:
|
||||
// 1. Username input
|
||||
// 2. Password input
|
||||
// 3. Remember me checkbox (login) / Confirm password (register)
|
||||
// 4. Submit button
|
||||
// 5. Footer link (register/login)
|
||||
|
||||
// Enter key submits form
|
||||
// Escape key can be used to clear focus
|
||||
```
|
||||
|
||||
### Screen Reader Support
|
||||
|
||||
```html
|
||||
<!-- Proper labels for screen readers -->
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
aria-label="Username"
|
||||
aria-required="true"
|
||||
aria-invalid="false"
|
||||
aria-describedby="username-error"
|
||||
/>
|
||||
<p id="username-error" role="alert" class="text-sm text-red-600">
|
||||
<!-- Error message here -->
|
||||
</p>
|
||||
|
||||
<!-- Loading state announced -->
|
||||
<button type="submit" aria-busy="true" aria-label="Signing in...">
|
||||
<span class="sr-only">Signing in...</span>
|
||||
<!-- Visual content -->
|
||||
</button>
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
```typescript
|
||||
// Router configuration with lazy loading
|
||||
{
|
||||
path: '/login',
|
||||
component: () => import('@/views/auth/LoginView.vue'), // ✅ Lazy loaded
|
||||
}
|
||||
|
||||
// Direct import (not recommended for routes)
|
||||
import LoginView from '@/views/auth/LoginView.vue'; // ❌ Eager loaded
|
||||
```
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. Use `v-once` for static content
|
||||
2. Debounce expensive validation operations
|
||||
3. Minimize reactive dependencies
|
||||
4. Use `shallowRef` for complex objects when possible
|
||||
5. Avoid unnecessary watchers
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. Never log passwords or tokens
|
||||
2. Use HTTPS in production
|
||||
3. Implement rate limiting on backend
|
||||
4. Validate all inputs server-side
|
||||
5. Use secure password hashing (bcrypt, argon2)
|
||||
6. Implement CSRF protection
|
||||
7. Set secure cookie flags
|
||||
8. Use Content Security Policy headers
|
||||
9. Sanitize all user inputs
|
||||
10. Implement account lockout after failed attempts
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Token not persisting after refresh
|
||||
|
||||
```typescript
|
||||
// Solution: Initialize auth state on app mount
|
||||
// In main.ts or App.vue
|
||||
import { useAuthStore } from '@/stores';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
authStore.checkAuth(); // Restore auth from localStorage
|
||||
```
|
||||
|
||||
### Issue: Redirect loop after login
|
||||
|
||||
```typescript
|
||||
// Solution: Check router guard logic
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// ✅ Correct: Check specific routes
|
||||
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// ❌ Wrong: Blanket redirect
|
||||
// if (authStore.isAuthenticated) {
|
||||
// next('/dashboard'); // This causes loops!
|
||||
// }
|
||||
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
### Issue: Form not clearing after successful submission
|
||||
|
||||
```typescript
|
||||
// Solution: Reset form data
|
||||
async function handleLogin(): Promise<void> {
|
||||
try {
|
||||
await authStore.login({...});
|
||||
|
||||
// Reset form
|
||||
formData.username = '';
|
||||
formData.password = '';
|
||||
formData.remember = false;
|
||||
|
||||
// Clear errors
|
||||
errors.username = '';
|
||||
errors.password = '';
|
||||
|
||||
await router.push('/dashboard');
|
||||
} catch (error) {
|
||||
// Error handling...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Vue 3 Documentation](https://vuejs.org/)
|
||||
- [Vue Router Documentation](https://router.vuejs.org/)
|
||||
- [Pinia Documentation](https://pinia.vuejs.org/)
|
||||
- [TailwindCSS Documentation](https://tailwindcss.com/)
|
||||
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
|
||||
591
frontend/src/views/auth/VISUAL_GUIDE.md
Normal file
591
frontend/src/views/auth/VISUAL_GUIDE.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# Authentication Views Visual Guide
|
||||
|
||||
This document describes the visual design and layout of the authentication views.
|
||||
|
||||
## Layout Structure
|
||||
|
||||
Both LoginView and RegisterView use the AuthLayout component, which provides:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Sub2API Logo │ │
|
||||
│ │ "Subscription to API Conversion" │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [Form Content - White Card] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Footer Links] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
Background: Gradient (Indigo → White → Purple)
|
||||
Card: White with rounded corners and shadow
|
||||
Max Width: 28rem (448px)
|
||||
Centered: Both horizontally and vertically
|
||||
```
|
||||
|
||||
## LoginView Visual Design
|
||||
|
||||
### Default State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔷 Sub2API │
|
||||
│ Subscription to API Conversion Platform │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Welcome Back │ │
|
||||
│ │ Sign in to your account to continue│ │
|
||||
│ │ │ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Enter your username │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ •••••••••••••• │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ☐ Remember me │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Sign In │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Don't have an account? Sign up │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Loading State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ john_doe │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ •••••••••••• │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ☑ Remember me │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ ⟳ Signing in... │ │ ← Spinner
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ (Button disabled) │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Error State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ jo │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Username must be at least 3 │ ← Red text
|
||||
│ │ characters │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Password is required │ ← Red text
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ ⚠ Invalid username or │ │ ← Error banner
|
||||
│ │ │ password. Please try │ │
|
||||
│ │ │ again. │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ Sign In │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## RegisterView Visual Design
|
||||
|
||||
### Default State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔷 Sub2API │
|
||||
│ Subscription to API Conversion Platform │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Create Account │ │
|
||||
│ │ Sign up to start using Sub2API │ │
|
||||
│ │ │ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Choose a username │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Email │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ your.email@example.com │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Create a strong password │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ At least 8 characters with letters │ │
|
||||
│ │ and numbers │ │
|
||||
│ │ │ │
|
||||
│ │ Confirm Password │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Confirm your password │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────────────────┐ │ │
|
||||
│ │ │ Create Account │ │ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ By signing up, you agree to our │ │
|
||||
│ │ Terms of Service and Privacy Policy│ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Already have an account? Sign in │
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Validation Errors
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ jane@smith │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Username can only contain │ ← Red text
|
||||
│ │ letters, numbers, _, and - │ │
|
||||
│ │ │ │
|
||||
│ │ Email │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ invalid-email │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Please enter a valid email │ ← Red text
|
||||
│ │ address │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ short │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Password must be at least 8 │ ← Red text
|
||||
│ │ characters with letters │ │
|
||||
│ │ and numbers │ │
|
||||
│ │ │ │
|
||||
│ │ Confirm Password │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ different │ │ ← Red border
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ ⚠ Passwords do not match │ ← Red text
|
||||
│ │ │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Loading State
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ Username: jane_smith │ │
|
||||
│ │ Email: jane@example.com │ │
|
||||
│ │ Password: •••••••••••• │ │
|
||||
│ │ Confirm: •••••••••••• │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ ⟳ Creating account... │ │ ← Spinner
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ │ (All inputs disabled) │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
- **Indigo-600**: `#4F46E5` - Primary buttons, links, brand color
|
||||
- **Indigo-700**: `#4338CA` - Button hover state
|
||||
- **Indigo-500**: `#6366F1` - Focus ring
|
||||
|
||||
### Neutral Colors
|
||||
- **Gray-900**: `#111827` - Headings
|
||||
- **Gray-700**: `#374151` - Labels
|
||||
- **Gray-600**: `#4B5563` - Body text
|
||||
- **Gray-500**: `#6B7280` - Helper text
|
||||
- **Gray-300**: `#D1D5DB` - Borders
|
||||
- **Gray-100**: `#F3F4F6` - Disabled backgrounds
|
||||
- **White**: `#FFFFFF` - Card backgrounds
|
||||
|
||||
### Error Colors
|
||||
- **Red-600**: `#DC2626` - Error text
|
||||
- **Red-500**: `#EF4444` - Error border, focus ring
|
||||
- **Red-50**: `#FEF2F2` - Error banner background
|
||||
- **Red-200**: `#FECACA` - Error banner border
|
||||
|
||||
### Success Colors
|
||||
- **Green-600**: `#16A34A` - Success text
|
||||
- **Green-50**: `#F0FDF4` - Success banner background
|
||||
|
||||
### Background Gradient
|
||||
- **From**: Indigo-100 (`#E0E7FF`)
|
||||
- **Via**: White (`#FFFFFF`)
|
||||
- **To**: Purple-100 (`#F3E8FF`)
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
- **Default**: System font stack (`ui-sans-serif, system-ui, -apple-system, ...`)
|
||||
|
||||
### Font Sizes
|
||||
- **Headings (h2)**: `1.5rem` (24px), `font-bold`
|
||||
- **Body**: `0.875rem` (14px), `font-normal`
|
||||
- **Labels**: `0.875rem` (14px), `font-medium`
|
||||
- **Helper text**: `0.75rem` (12px), `font-normal`
|
||||
- **Error text**: `0.875rem` (14px), `font-normal`
|
||||
|
||||
### Line Heights
|
||||
- **Headings**: `1.5`
|
||||
- **Body**: `1.5`
|
||||
- **Helper text**: `1.25`
|
||||
|
||||
## Spacing
|
||||
|
||||
### Card Spacing
|
||||
- **Padding**: `2rem` (32px) all sides
|
||||
- **Gap between sections**: `1.5rem` (24px)
|
||||
- **Gap between fields**: `1rem` (16px)
|
||||
|
||||
### Input Spacing
|
||||
- **Padding**: `0.5rem 1rem` (8px 16px)
|
||||
- **Label margin-bottom**: `0.25rem` (4px)
|
||||
- **Error text margin-top**: `0.25rem` (4px)
|
||||
|
||||
### Button Spacing
|
||||
- **Padding**: `0.5rem 1rem` (8px 16px)
|
||||
- **Margin-top**: `1rem` (16px)
|
||||
|
||||
## Interactive States
|
||||
|
||||
### Input States
|
||||
|
||||
**Default:**
|
||||
```css
|
||||
border: 1px solid #D1D5DB (gray-300)
|
||||
focus: 2px ring #6366F1 (indigo-500)
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```css
|
||||
border: 1px solid #EF4444 (red-500)
|
||||
focus: 2px ring #EF4444 (red-500)
|
||||
```
|
||||
|
||||
**Disabled:**
|
||||
```css
|
||||
background: #F3F4F6 (gray-100)
|
||||
cursor: not-allowed
|
||||
opacity: 0.6
|
||||
```
|
||||
|
||||
### Button States
|
||||
|
||||
**Default:**
|
||||
```css
|
||||
background: #4F46E5 (indigo-600)
|
||||
text: #FFFFFF (white)
|
||||
shadow: shadow-sm
|
||||
```
|
||||
|
||||
**Hover:**
|
||||
```css
|
||||
background: #4338CA (indigo-700)
|
||||
transition: colors 150ms
|
||||
```
|
||||
|
||||
**Focus:**
|
||||
```css
|
||||
outline: none
|
||||
ring: 2px offset-2 #6366F1 (indigo-500)
|
||||
```
|
||||
|
||||
**Disabled:**
|
||||
```css
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
```
|
||||
|
||||
**Loading:**
|
||||
```css
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
+ spinning icon
|
||||
```
|
||||
|
||||
### Link States
|
||||
|
||||
**Default:**
|
||||
```css
|
||||
color: #4F46E5 (indigo-600)
|
||||
font-weight: 500 (medium)
|
||||
```
|
||||
|
||||
**Hover:**
|
||||
```css
|
||||
color: #6366F1 (indigo-500)
|
||||
transition: colors 150ms
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoints
|
||||
|
||||
**Mobile (< 640px):**
|
||||
```
|
||||
- Full width container
|
||||
- Padding: 1rem (16px)
|
||||
- Smaller text sizes
|
||||
```
|
||||
|
||||
**Tablet (640px - 768px):**
|
||||
```
|
||||
- Max width: 28rem (448px)
|
||||
- Centered layout
|
||||
- Standard spacing
|
||||
```
|
||||
|
||||
**Desktop (> 768px):**
|
||||
```
|
||||
- Max width: 28rem (448px)
|
||||
- Centered layout
|
||||
- Standard spacing
|
||||
```
|
||||
|
||||
### Mobile Optimizations
|
||||
|
||||
1. Touch-friendly tap targets (44px minimum)
|
||||
2. Proper keyboard handling on mobile
|
||||
3. Prevent zoom on input focus
|
||||
4. Responsive font sizes
|
||||
5. Full-width inputs
|
||||
6. Adequate spacing for thumbs
|
||||
|
||||
## Animations
|
||||
|
||||
### Transitions
|
||||
- Color changes: `150ms ease-in-out`
|
||||
- Opacity changes: `150ms ease-in-out`
|
||||
- Transform: `150ms ease-in-out`
|
||||
|
||||
### Loading Spinner
|
||||
```css
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
animation: spin 1s linear infinite;
|
||||
```
|
||||
|
||||
### Toast Animations
|
||||
- Enter: Slide in from right + fade in
|
||||
- Exit: Slide out to right + fade out
|
||||
- Duration: 300ms
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
### Visual Indicators
|
||||
- Clear focus states (2px ring)
|
||||
- Error states (red border + red text)
|
||||
- Loading states (spinner + text)
|
||||
- Success states (green toast)
|
||||
|
||||
### Color Contrast
|
||||
- Text on white: > 7:1 (AAA)
|
||||
- Labels on white: > 4.5:1 (AA)
|
||||
- Buttons: > 4.5:1 (AA)
|
||||
- Error text: > 4.5:1 (AA)
|
||||
|
||||
### Interactive Elements
|
||||
- Minimum size: 44x44px (mobile)
|
||||
- Clear hover states
|
||||
- Distinct disabled states
|
||||
- Keyboard accessible
|
||||
|
||||
### Screen Reader Support
|
||||
- Proper labels on all inputs
|
||||
- ARIA attributes where needed
|
||||
- Error announcements
|
||||
- Loading state announcements
|
||||
|
||||
## Icons
|
||||
|
||||
### Loading Spinner
|
||||
```svg
|
||||
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
### Error Icon
|
||||
```svg
|
||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- Chrome/Edge: Latest 2 versions
|
||||
- Firefox: Latest 2 versions
|
||||
- Safari: Latest 2 versions
|
||||
- Mobile Safari: iOS 14+
|
||||
- Chrome Mobile: Latest 2 versions
|
||||
|
||||
### CSS Features Used
|
||||
- Flexbox (full support)
|
||||
- CSS Grid (full support)
|
||||
- CSS Transitions (full support)
|
||||
- CSS Custom Properties (full support)
|
||||
- Gradient backgrounds (full support)
|
||||
|
||||
### JavaScript Features Used
|
||||
- ES2015+ syntax
|
||||
- Async/await
|
||||
- Optional chaining
|
||||
- Nullish coalescing
|
||||
- Modules
|
||||
|
||||
## Print Styles
|
||||
|
||||
(Not applicable for authentication pages - users shouldn't print login forms)
|
||||
|
||||
## Dark Mode Considerations
|
||||
|
||||
**Future Enhancement:**
|
||||
- Dark mode toggle in user preferences
|
||||
- System preference detection
|
||||
- Persistent dark mode setting
|
||||
- Adjusted color palette for dark backgrounds
|
||||
|
||||
```css
|
||||
/* Example dark mode colors (not implemented yet) */
|
||||
dark:bg-gray-900
|
||||
dark:text-white
|
||||
dark:border-gray-700
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
### Target Metrics
|
||||
- First Contentful Paint (FCP): < 1s
|
||||
- Largest Contentful Paint (LCP): < 2.5s
|
||||
- Time to Interactive (TTI): < 3s
|
||||
- Cumulative Layout Shift (CLS): < 0.1
|
||||
- First Input Delay (FID): < 100ms
|
||||
|
||||
### Optimization Strategies
|
||||
- Lazy load non-critical resources
|
||||
- Minimize initial bundle size
|
||||
- Use efficient animations (transform, opacity)
|
||||
- Optimize images (logo, icons)
|
||||
- Preconnect to API domain
|
||||
- Cache static assets
|
||||
|
||||
## Component Size
|
||||
|
||||
### Bundle Impact
|
||||
- LoginView.vue: ~4 KB (minified)
|
||||
- RegisterView.vue: ~6 KB (minified)
|
||||
- AuthLayout.vue: ~1 KB (minified)
|
||||
- Total: ~11 KB (excluding dependencies)
|
||||
|
||||
### Dependencies
|
||||
- Vue 3: ~40 KB (runtime)
|
||||
- Vue Router: ~15 KB
|
||||
- Pinia: ~10 KB
|
||||
- Total framework overhead: ~65 KB (gzipped)
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Visual Regression Tests
|
||||
- [ ] Default state (login)
|
||||
- [ ] Default state (register)
|
||||
- [ ] Loading state
|
||||
- [ ] Error state (validation)
|
||||
- [ ] Error state (API)
|
||||
- [ ] Success state
|
||||
- [ ] Mobile view
|
||||
- [ ] Tablet view
|
||||
- [ ] Desktop view
|
||||
- [ ] Focus states
|
||||
- [ ] Hover states
|
||||
|
||||
### Cross-browser Tests
|
||||
- [ ] Chrome (Windows, Mac, Linux)
|
||||
- [ ] Firefox (Windows, Mac, Linux)
|
||||
- [ ] Safari (Mac, iOS)
|
||||
- [ ] Edge (Windows)
|
||||
- [ ] Chrome Mobile (Android)
|
||||
- [ ] Safari Mobile (iOS)
|
||||
|
||||
### Accessibility Tests
|
||||
- [ ] Keyboard navigation
|
||||
- [ ] Screen reader (NVDA)
|
||||
- [ ] Screen reader (JAWS)
|
||||
- [ ] Screen reader (VoiceOver)
|
||||
- [ ] Color contrast
|
||||
- [ ] Focus indicators
|
||||
- [ ] Error announcements
|
||||
|
||||
## Design Assets
|
||||
|
||||
### Figma/Sketch Files
|
||||
(Not applicable - designed directly in code with Tailwind)
|
||||
|
||||
### Design Tokens
|
||||
- Defined in Tailwind config
|
||||
- Consistent with design system
|
||||
- Reusable across all components
|
||||
|
||||
### Iconography
|
||||
- SVG icons inline
|
||||
- Heroicons (outline and solid)
|
||||
- Consistent stroke width
|
||||
- Accessible with proper ARIA labels
|
||||
|
||||
---
|
||||
|
||||
**Note:** This visual guide is for reference and documentation purposes. The actual implementation is in the Vue components using TailwindCSS classes.
|
||||
7
frontend/src/views/auth/index.ts
Normal file
7
frontend/src/views/auth/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Authentication Views
|
||||
* Export all authentication-related views
|
||||
*/
|
||||
|
||||
export { default as LoginView } from './LoginView.vue';
|
||||
export { default as RegisterView } from './RegisterView.vue';
|
||||
Reference in New Issue
Block a user