First commit

This commit is contained in:
shaw
2025-12-18 13:50:39 +08:00
parent 569f4882e5
commit 642842c29e
218 changed files with 86902 additions and 0 deletions

View 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 statetoken 已使用,清除以避免重复使用)
initialTurnstileToken.value = '';
showResendTurnstile.value = false;
resendTurnstileToken.value = '';
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } };
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
} else if (err.message) {
errorMessage.value = err.message;
} else {
errorMessage.value = 'Failed to send verification code. Please try again.';
}
appStore.showError(errorMessage.value);
} finally {
isSendingCode.value = false;
}
}
// ==================== Handlers ====================
async function handleResendCode(): Promise<void> {
// If turnstile is enabled and we haven't shown it yet, show it
if (turnstileEnabled.value && !showResendTurnstile.value) {
showResendTurnstile.value = true;
return;
}
// If turnstile is enabled but no token yet, wait
if (turnstileEnabled.value && !resendTurnstileToken.value) {
errors.value.turnstile = 'Please complete the verification';
return;
}
await sendCode();
}
function validateForm(): boolean {
errors.value.code = '';
if (!verifyCode.value.trim()) {
errors.value.code = 'Verification code is required';
return false;
}
if (!/^\d{6}$/.test(verifyCode.value.trim())) {
errors.value.code = 'Please enter a valid 6-digit code';
return false;
}
return true;
}
async function handleVerify(): Promise<void> {
errorMessage.value = '';
if (!validateForm()) {
return;
}
isLoading.value = true;
try {
// Register with verification code
await authStore.register({
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined,
});
// Clear session data
sessionStorage.removeItem('register_data');
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
// Redirect to dashboard
await router.push('/dashboard');
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } };
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
} else if (err.message) {
errorMessage.value = err.message;
} else {
errorMessage.value = 'Verification failed. Please try again.';
}
appStore.showError(errorMessage.value);
} finally {
isLoading.value = false;
}
}
function handleBack(): void {
// Clear session data
sessionStorage.removeItem('register_data');
// Go back to registration
router.push('/register');
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View 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>

View 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)

View 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>

View 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/)

View 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.

View 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';