style(frontend): 统一 Views 模块代码风格

- 移除语句末尾分号,规范代码格式
- 优化组件结构和类型定义
- 改进视图文档和示例
- 提升代码一致性
This commit is contained in:
ianshaw
2025-12-25 08:41:36 -08:00
parent f79b0f0fad
commit 5763f5ced3
25 changed files with 5374 additions and 2439 deletions

View File

@@ -3,20 +3,32 @@
<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>
<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>
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
v-if="!hasRegisterData"
class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/50 dark:bg-amber-900/20"
>
<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
class="h-5 w-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">
@@ -30,9 +42,7 @@
<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>
<label for="code" class="input-label text-center"> Verification Code </label>
<input
id="code"
v-model="verifyCode"
@@ -42,24 +52,35 @@
inputmode="numeric"
maxlength="6"
:disabled="isLoading"
class="input text-center text-xl tracking-[0.5em] font-mono py-3"
class="input py-3 text-center font-mono text-xl tracking-[0.5em]"
: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>
<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
v-if="codeSent"
class="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-800/50 dark:bg-green-900/20"
>
<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
class="h-5 w-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">
@@ -77,7 +98,7 @@
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
@@ -86,12 +107,22 @@
<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"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<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
class="h-5 w-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">
@@ -102,22 +133,40 @@
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || !verifyCode"
class="btn btn-primary w-full"
>
<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"
class="-ml-1 mr-2 h-4 w-4 animate-spin 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>
<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
v-else
class="mr-2 h-5 w-5"
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>
@@ -128,7 +177,7 @@
v-if="countdown > 0"
type="button"
disabled
class="text-sm text-gray-400 dark:text-dark-500 cursor-not-allowed"
class="cursor-not-allowed text-sm text-gray-400 dark:text-dark-500"
>
Resend code in {{ countdown }}s
</button>
@@ -136,8 +185,10 @@
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"
:disabled="
isSendingCode || (turnstileEnabled && showResendTurnstile && !resendTurnstileToken)
"
class="text-sm text-primary-600 transition-colors hover:text-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:text-primary-400 dark:hover:text-primary-300"
>
<span v-if="isSendingCode">Sending...</span>
<span v-else-if="turnstileEnabled && !showResendTurnstile">Click to resend code</span>
@@ -151,10 +202,20 @@
<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"
class="flex items-center gap-2 text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-gray-300"
>
<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
class="h-4 w-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>
@@ -163,163 +224,162 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { AuthLayout } from '@/components/layout';
import TurnstileWidget from '@/components/TurnstileWidget.vue';
import { useAuthStore, useAppStore } from '@/stores';
import { getPublicSettings, sendVerifyCode } from '@/api/auth';
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { AuthLayout } from '@/components/layout'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, sendVerifyCode } from '@/api/auth'
// ==================== Router & Stores ====================
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
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;
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);
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');
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 turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const resendTurnstileToken = ref<string>('')
const showResendTurnstile = ref<boolean>(false)
const errors = ref({
code: '',
turnstile: '',
});
turnstile: ''
})
// ==================== Lifecycle ====================
onMounted(async () => {
// Load registration data from sessionStorage
const registerDataStr = sessionStorage.getItem('register_data');
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);
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;
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';
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);
console.error('Failed to load public settings:', error)
}
// Auto-send verification code if we have valid data
if (hasRegisterData.value) {
await sendCode();
await sendCode()
}
});
})
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
clearInterval(countdownTimer)
countdownTimer = null
}
});
})
// ==================== Countdown ====================
function startCountdown(seconds: number): void {
countdown.value = seconds;
countdown.value = seconds
if (countdownTimer) {
clearInterval(countdownTimer);
clearInterval(countdownTimer)
}
countdownTimer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--;
countdown.value--
} else {
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
clearInterval(countdownTimer)
countdownTimer = null
}
}
}, 1000);
}, 1000)
}
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
resendTurnstileToken.value = token;
errors.value.turnstile = '';
resendTurnstileToken.value = token
errors.value.turnstile = ''
}
function onTurnstileExpire(): void {
resendTurnstileToken.value = '';
errors.value.turnstile = 'Verification expired, please try again';
resendTurnstileToken.value = ''
errors.value.turnstile = 'Verification expired, please try again'
}
function onTurnstileError(): void {
resendTurnstileToken.value = '';
errors.value.turnstile = 'Verification failed, please try again';
resendTurnstileToken.value = ''
errors.value.turnstile = 'Verification failed, please try again'
}
// ==================== Send Code ====================
async function sendCode(): Promise<void> {
isSendingCode.value = true;
errorMessage.value = '';
isSendingCode.value = true
errorMessage.value = ''
try {
const response = await sendVerifyCode({
email: email.value,
// 优先使用重发时新获取的 token因为初始 token 可能已被使用)
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined,
});
turnstile_token: resendTurnstileToken.value || initialTurnstileToken.value || undefined
})
codeSent.value = true;
startCountdown(response.countdown);
codeSent.value = true
startCountdown(response.countdown)
// Reset turnstile statetoken 已使用,清除以避免重复使用)
initialTurnstileToken.value = '';
showResendTurnstile.value = false;
resendTurnstileToken.value = '';
initialTurnstileToken.value = ''
showResendTurnstile.value = false
resendTurnstileToken.value = ''
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } };
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message;
errorMessage.value = err.message
} else {
errorMessage.value = 'Failed to send verification code. Please try again.';
errorMessage.value = 'Failed to send verification code. Please try again.'
}
appStore.showError(errorMessage.value);
appStore.showError(errorMessage.value)
} finally {
isSendingCode.value = false;
isSendingCode.value = false
}
}
@@ -328,43 +388,43 @@ async function sendCode(): Promise<void> {
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;
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;
errors.value.turnstile = 'Please complete the verification'
return
}
await sendCode();
await sendCode()
}
function validateForm(): boolean {
errors.value.code = '';
errors.value.code = ''
if (!verifyCode.value.trim()) {
errors.value.code = 'Verification code is required';
return false;
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;
errors.value.code = 'Please enter a valid 6-digit code'
return false
}
return true;
return true
}
async function handleVerify(): Promise<void> {
errorMessage.value = '';
errorMessage.value = ''
if (!validateForm()) {
return;
return
}
isLoading.value = true;
isLoading.value = true
try {
// Register with verification code
@@ -372,40 +432,40 @@ async function handleVerify(): Promise<void> {
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined,
});
turnstile_token: initialTurnstileToken.value || undefined
})
// Clear session data
sessionStorage.removeItem('register_data');
sessionStorage.removeItem('register_data')
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.')
// Redirect to dashboard
await router.push('/dashboard');
await router.push('/dashboard')
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string } } };
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message;
errorMessage.value = err.message
} else {
errorMessage.value = 'Verification failed. Please try again.';
errorMessage.value = 'Verification failed. Please try again.'
}
appStore.showError(errorMessage.value);
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false;
isLoading.value = false
}
}
function handleBack(): void {
// Clear session data
sessionStorage.removeItem('register_data');
sessionStorage.removeItem('register_data')
// Go back to registration
router.push('/register');
router.push('/register')
}
</script>

View File

@@ -19,9 +19,19 @@
{{ 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" />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<svg
class="h-5 w-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
@@ -47,9 +57,19 @@
{{ 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" />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<svg
class="h-5 w-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
@@ -66,14 +86,40 @@
<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"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<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
v-if="showPassword"
class="h-5 w-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
v-else
class="h-5 w-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>
@@ -91,7 +137,7 @@
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
@@ -100,12 +146,22 @@
<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"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<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
class="h-5 w-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">
@@ -123,15 +179,37 @@
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
class="-ml-1 mr-2 h-4 w-4 animate-spin 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>
<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
v-else
class="mr-2 h-5 w-5"
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>
@@ -144,7 +222,7 @@
{{ 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"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signUp') }}
</router-link>
@@ -154,162 +232,162 @@
</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';
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();
const { t } = useI18n()
// ==================== Router & Stores ====================
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false);
const errorMessage = ref<string>('');
const showPassword = ref<boolean>(false);
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>('');
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null);
const turnstileToken = ref<string>('');
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
const formData = reactive({
email: '',
password: '',
});
password: ''
})
const errors = reactive({
email: '',
password: '',
turnstile: '',
});
turnstile: ''
})
// ==================== Lifecycle ====================
onMounted(async () => {
try {
const settings = await getPublicSettings();
turnstileEnabled.value = settings.turnstile_enabled;
turnstileSiteKey.value = settings.turnstile_site_key || '';
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);
console.error('Failed to load public settings:', error)
}
});
})
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token;
errors.turnstile = '';
turnstileToken.value = token
errors.turnstile = ''
}
function onTurnstileExpire(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification expired, please try again';
turnstileToken.value = ''
errors.turnstile = 'Verification expired, please try again'
}
function onTurnstileError(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification failed, please try again';
turnstileToken.value = ''
errors.turnstile = 'Verification failed, please try again'
}
// ==================== Validation ====================
function validateForm(): boolean {
// Reset errors
errors.email = '';
errors.password = '';
errors.turnstile = '';
errors.email = ''
errors.password = ''
errors.turnstile = ''
let isValid = true;
let isValid = true
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required';
isValid = false;
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;
errors.email = 'Please enter a valid email address'
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required';
isValid = false;
errors.password = 'Password is required'
isValid = false
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
isValid = false;
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;
errors.turnstile = 'Please complete the verification'
isValid = false
}
return isValid;
return isValid
}
// ==================== Form Handlers ====================
async function handleLogin(): Promise<void> {
// Clear previous error
errorMessage.value = '';
errorMessage.value = ''
// Validate form
if (!validateForm()) {
return;
return
}
isLoading.value = true;
isLoading.value = true
try {
// Call auth store login
await authStore.login({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
});
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
// Show success toast
appStore.showSuccess('Login successful! Welcome back.');
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);
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 = '';
turnstileRef.value.reset()
turnstileToken.value = ''
}
// Handle login error
const err = error as { message?: string; response?: { data?: { detail?: string } } };
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message;
errorMessage.value = err.message
} else {
errorMessage.value = 'Login failed. Please check your credentials and try again.';
errorMessage.value = 'Login failed. Please check your credentials and try again.'
}
// Also show error toast
appStore.showError(errorMessage.value);
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false;
isLoading.value = false
}
}
</script>

View File

@@ -9,6 +9,7 @@ This directory contains Vue 3 authentication views for the Sub2API frontend appl
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
@@ -18,26 +19,30 @@ Login page for existing users to authenticate.
- Link to registration page for new users
**Usage:**
```vue
<template>
<LoginView />
</template>
<script setup lang="ts">
import { LoginView } from '@/views/auth';
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
@@ -49,6 +54,7 @@ import { LoginView } from '@/views/auth';
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)
@@ -60,22 +66,25 @@ Registration page for new users to create accounts.
- Link to login page for existing users
**Usage:**
```vue
<template>
<RegisterView />
</template>
<script setup lang="ts">
import { RegisterView } from '@/views/auth';
import { RegisterView } from '@/views/auth'
</script>
```
**Route:**
- Path: `/register`
- Name: `Register`
- Meta: `{ requiresAuth: false }`
**Validation Rules:**
- Username:
- Required
- 3-50 characters
@@ -92,6 +101,7 @@ import { RegisterView } from '@/views/auth';
- 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
@@ -131,6 +141,7 @@ Both views follow a consistent structure:
### State Management
Both views use:
- `useAuthStore()` - For authentication actions (login, register)
- `useAppStore()` - For toast notifications and UI feedback
- `useRouter()` - For navigation and redirects
@@ -138,12 +149,14 @@ Both views use:
### 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
@@ -151,6 +164,7 @@ Both views use:
### Styling
**Design System:**
- TailwindCSS utility classes
- Consistent color scheme (indigo primary)
- Responsive design
@@ -158,6 +172,7 @@ Both views use:
- Loading states with spinner animations
**Visual Feedback:**
- Red border on invalid fields
- Error messages below inputs
- Global error banner for API errors
@@ -167,13 +182,16 @@ Both views use:
## 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
@@ -185,11 +203,11 @@ Both views use:
```typescript
// User enters credentials
formData.username = 'john_doe';
formData.password = 'SecurePass123';
formData.username = 'john_doe'
formData.password = 'SecurePass123'
// Submit form
await handleLogin();
await handleLogin()
// On success:
// - authStore.login() called
@@ -207,13 +225,13 @@ await handleLogin();
```typescript
// User enters registration data
formData.username = 'jane_smith';
formData.email = 'jane@example.com';
formData.password = 'SecurePass123';
formData.confirmPassword = 'SecurePass123';
formData.username = 'jane_smith'
formData.email = 'jane@example.com'
formData.password = 'SecurePass123'
formData.confirmPassword = 'SecurePass123'
// Submit form
await handleRegister();
await handleRegister()
// On success:
// - authStore.register() called
@@ -233,10 +251,10 @@ await handleRegister();
```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';
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
@@ -252,8 +270,8 @@ errors.confirmPassword = 'Passwords do not match';
}
// Displayed as:
errorMessage.value = 'Username already exists';
appStore.showError('Username already exists');
errorMessage.value = 'Username already exists'
appStore.showError('Username already exists')
```
## Accessibility
@@ -269,18 +287,21 @@ appStore.showError('Username already exists');
## 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
@@ -289,6 +310,7 @@ appStore.showError('Username already exists');
## Future Enhancements
Potential improvements:
- OAuth/SSO integration (Google, GitHub)
- Two-factor authentication (2FA)
- Password strength meter

View File

@@ -12,11 +12,24 @@
</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
v-if="!registrationEnabled && settingsLoaded"
class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/50 dark:bg-amber-900/20"
>
<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
class="h-5 w-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">
@@ -33,9 +46,19 @@
{{ 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" />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<svg
class="h-5 w-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
@@ -61,9 +84,19 @@
{{ 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" />
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<svg
class="h-5 w-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
@@ -80,14 +113,40 @@
<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"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<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
v-if="showPassword"
class="h-5 w-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
v-else
class="h-5 w-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>
@@ -108,7 +167,7 @@
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text text-center mt-2">
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
@@ -117,12 +176,22 @@
<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"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<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
class="h-5 w-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">
@@ -140,17 +209,45 @@
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
class="-ml-1 mr-2 h-4 w-4 animate-spin 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>
<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
v-else
class="mr-2 h-5 w-5"
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')) }}
{{
isLoading
? t('auth.processing')
: emailVerifyEnabled
? t('auth.continue')
: t('auth.createAccount')
}}
</button>
</form>
</div>
@@ -161,7 +258,7 @@
{{ 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"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signIn') }}
</router-link>
@@ -171,189 +268,192 @@
</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';
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();
const { t } = useI18n()
// ==================== Router & Stores ====================
const router = useRouter();
const authStore = useAuthStore();
const appStore = useAppStore();
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);
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');
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 turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
const formData = reactive({
email: '',
password: '',
});
password: ''
})
const errors = reactive({
email: '',
password: '',
turnstile: '',
});
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';
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);
console.error('Failed to load public settings:', error)
} finally {
settingsLoaded.value = true;
settingsLoaded.value = true
}
});
})
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token;
errors.turnstile = '';
turnstileToken.value = token
errors.turnstile = ''
}
function onTurnstileExpire(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification expired, please try again';
turnstileToken.value = ''
errors.turnstile = 'Verification expired, please try again'
}
function onTurnstileError(): void {
turnstileToken.value = '';
errors.turnstile = 'Verification failed, please try again';
turnstileToken.value = ''
errors.turnstile = 'Verification failed, please try again'
}
// ==================== Validation ====================
function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
function validateForm(): boolean {
// Reset errors
errors.email = '';
errors.password = '';
errors.turnstile = '';
errors.email = ''
errors.password = ''
errors.turnstile = ''
let isValid = true;
let isValid = true
// Email validation
if (!formData.email.trim()) {
errors.email = 'Email is required';
isValid = false;
errors.email = 'Email is required'
isValid = false
} else if (!validateEmail(formData.email)) {
errors.email = 'Please enter a valid email address';
isValid = false;
errors.email = 'Please enter a valid email address'
isValid = false
}
// Password validation
if (!formData.password) {
errors.password = 'Password is required';
isValid = false;
errors.password = 'Password is required'
isValid = false
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
isValid = false;
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;
errors.turnstile = 'Please complete the verification'
isValid = false
}
return isValid;
return isValid
}
// ==================== Form Handlers ====================
async function handleRegister(): Promise<void> {
// Clear previous error
errorMessage.value = '';
errorMessage.value = ''
// Validate form
if (!validateForm()) {
return;
return
}
isLoading.value = true;
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,
}));
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;
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,
});
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
// Show success toast
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.');
appStore.showSuccess('Account created successfully! Welcome to ' + siteName.value + '.')
// Redirect to dashboard
await router.push('/dashboard');
await router.push('/dashboard')
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset();
turnstileToken.value = '';
turnstileRef.value.reset()
turnstileToken.value = ''
}
// Handle registration error
const err = error as { message?: string; response?: { data?: { detail?: string } } };
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail;
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message;
errorMessage.value = err.message
} else {
errorMessage.value = 'Registration failed. Please try again.';
errorMessage.value = 'Registration failed. Please try again.'
}
// Also show error toast
appStore.showError(errorMessage.value);
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false;
isLoading.value = false
}
}
</script>

View File

@@ -75,43 +75,43 @@ This document provides practical examples of how to use the authentication views
```typescript
// Method 1: Direct import
import LoginView from '@/views/auth/LoginView.vue';
import RegisterView from '@/views/auth/RegisterView.vue';
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';
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');
const LoginView = () => import('@/views/auth/LoginView.vue')
const RegisterView = () => import('@/views/auth/RegisterView.vue')
```
### Using in Router
```typescript
import { createRouter, createWebHistory } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresAuth: false },
meta: { requiresAuth: false }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/auth/RegisterView.vue'),
meta: { requiresAuth: false },
},
];
meta: { requiresAuth: false }
}
]
const router = createRouter({
history: createWebHistory(),
routes,
});
routes
})
export default router;
export default router
```
### Navigation to Auth Views
@@ -142,13 +142,13 @@ router.push({
### Programmatic Auth Flow
```typescript
import { useAuthStore } from '@/stores';
import { useAppStore } from '@/stores';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores'
import { useAppStore } from '@/stores'
import { useRouter } from 'vue-router'
const authStore = useAuthStore();
const appStore = useAppStore();
const router = useRouter();
const authStore = useAuthStore()
const appStore = useAppStore()
const router = useRouter()
// Login
async function login() {
@@ -156,12 +156,12 @@ async function login() {
await authStore.login({
username: 'john_doe',
password: 'MySecurePass123'
});
})
appStore.showSuccess('Login successful!');
router.push('/dashboard');
appStore.showSuccess('Login successful!')
router.push('/dashboard')
} catch (error) {
appStore.showError('Login failed. Please check your credentials.');
appStore.showError('Login failed. Please check your credentials.')
}
}
@@ -172,12 +172,12 @@ async function register() {
username: 'jane_smith',
email: 'jane@example.com',
password: 'SecurePass123'
});
})
appStore.showSuccess('Account created successfully!');
router.push('/dashboard');
appStore.showSuccess('Account created successfully!')
router.push('/dashboard')
} catch (error) {
appStore.showError('Registration failed. Please try again.');
appStore.showError('Registration failed. Please try again.')
}
}
```
@@ -236,7 +236,7 @@ async function register() {
{
response: {
data: {
detail: "Invalid username or password"
detail: 'Invalid username or password'
}
}
}
@@ -244,12 +244,13 @@ async function register() {
// Example 3: Network error
{
message: "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)
```
@@ -258,10 +259,10 @@ async function register() {
```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"
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
@@ -272,86 +273,86 @@ errors = {
### 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';
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()],
},
});
plugins: [createPinia()]
}
})
// Submit empty form
await wrapper.find('form').trigger('submit');
await wrapper.find('form').trigger('submit')
// Check for validation errors
expect(wrapper.text()).toContain('Username is required');
expect(wrapper.text()).toContain('Password is required');
});
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()],
},
});
plugins: [createPinia()]
}
})
// Fill in form
await wrapper.find('#username').setValue('john_doe');
await wrapper.find('#password').setValue('SecurePass123');
await wrapper.find('#username').setValue('john_doe')
await wrapper.find('#password').setValue('SecurePass123')
// Submit form
await wrapper.find('form').trigger('submit');
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';
import { test, expect } from '@playwright/test'
test('user can register successfully', async ({ page }) => {
// Navigate to register page
await page.goto('/register');
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');
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"]');
await page.click('button[type="submit"]')
// Wait for redirect to dashboard
await page.waitForURL('/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');
});
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');
await page.goto('/register')
// Enter mismatched passwords
await page.fill('#password', 'SecurePass123');
await page.fill('#confirmPassword', 'DifferentPass');
await page.fill('#password', 'SecurePass123')
await page.fill('#confirmPassword', 'DifferentPass')
// Submit form
await page.click('button[type="submit"]');
await page.click('button[type="submit"]')
// Verify error message
await expect(page.locator('text=Passwords do not match')).toBeVisible();
});
await expect(page.locator('text=Passwords do not match')).toBeVisible()
})
```
## Integration with Navigation Guards
@@ -359,15 +360,15 @@ test('shows validation errors for invalid inputs', async ({ page }) => {
### Router Guard Example
```typescript
import { useAuthStore } from '@/stores';
import { useAuthStore } from '@/stores'
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
const authStore = useAuthStore()
// Redirect authenticated users away from auth pages
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/dashboard');
return;
next('/dashboard')
return
}
// Redirect unauthenticated users to login
@@ -375,12 +376,12 @@ router.beforeEach((to, from, next) => {
next({
path: '/login',
query: { redirect: to.fullPath }
});
return;
})
return
}
next();
});
next()
})
```
## Customization Examples
@@ -393,16 +394,16 @@ async function handleLogin(): Promise<void> {
try {
await authStore.login({
username: formData.username,
password: formData.password,
});
password: formData.password
})
appStore.showSuccess('Login successful!');
appStore.showSuccess('Login successful!')
// Custom redirect logic
const isAdmin = authStore.isAdmin;
const redirectTo = isAdmin ? '/admin/dashboard' : '/dashboard';
const isAdmin = authStore.isAdmin
const redirectTo = isAdmin ? '/admin/dashboard' : '/dashboard'
await router.push(redirectTo);
await router.push(redirectTo)
} catch (error) {
// Error handling...
}
@@ -414,19 +415,20 @@ async function handleLogin(): Promise<void> {
```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);
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;
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;
errors.password =
'Password must be at least 12 characters with uppercase, lowercase, numbers, and special characters'
isValid = false
}
```
@@ -439,26 +441,27 @@ async function handleRegister(): Promise<void> {
await authStore.register({
username: formData.username,
email: formData.email,
password: formData.password,
});
password: formData.password
})
appStore.showSuccess('Account created successfully!');
await router.push('/dashboard');
appStore.showSuccess('Account created successfully!')
await router.push('/dashboard')
} catch (error: unknown) {
const err = error as { response?: { status?: number; data?: { detail?: string } } };
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.';
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.';
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.';
errorMessage.value = 'Server error. Please try again later.'
} else {
errorMessage.value = err.response?.data?.detail || 'Registration failed. Please try again.';
errorMessage.value = err.response?.data?.detail || 'Registration failed. Please try again.'
}
appStore.showError(errorMessage.value);
appStore.showError(errorMessage.value)
}
}
```
@@ -483,9 +486,7 @@ async function handleRegister(): Promise<void> {
```html
<!-- Proper labels for screen readers -->
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<label for="username" class="mb-1 block text-sm font-medium text-gray-700"> Username </label>
<input
id="username"
type="text"
@@ -548,10 +549,10 @@ import LoginView from '@/views/auth/LoginView.vue'; // ❌ Eager loaded
```typescript
// Solution: Initialize auth state on app mount
// In main.ts or App.vue
import { useAuthStore } from '@/stores';
import { useAuthStore } from '@/stores'
const authStore = useAuthStore();
authStore.checkAuth(); // Restore auth from localStorage
const authStore = useAuthStore()
authStore.checkAuth() // Restore auth from localStorage
```
### Issue: Redirect loop after login
@@ -559,12 +560,12 @@ authStore.checkAuth(); // Restore auth from localStorage
```typescript
// Solution: Check router guard logic
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
const authStore = useAuthStore()
// ✅ Correct: Check specific routes
if (authStore.isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/dashboard');
return;
next('/dashboard')
return
}
// ❌ Wrong: Blanket redirect
@@ -572,8 +573,8 @@ router.beforeEach((to, from, next) => {
// next('/dashboard'); // This causes loops!
// }
next();
});
next()
})
```
### Issue: Form not clearing after successful submission

View File

@@ -235,11 +235,13 @@ Centered: Both horizontally and vertically
## 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
@@ -249,16 +251,19 @@ Centered: Both horizontally and vertically
- **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`)
@@ -266,9 +271,11 @@ Centered: Both horizontally and vertically
## 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`
@@ -276,6 +283,7 @@ Centered: Both horizontally and vertically
- **Error text**: `0.875rem` (14px), `font-normal`
### Line Heights
- **Headings**: `1.5`
- **Body**: `1.5`
- **Helper text**: `1.25`
@@ -283,16 +291,19 @@ Centered: Both horizontally and vertically
## 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)
@@ -301,18 +312,21 @@ Centered: Both horizontally and vertically
### 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
@@ -322,6 +336,7 @@ opacity: 0.6
### Button States
**Default:**
```css
background: #4F46E5 (indigo-600)
text: #FFFFFF (white)
@@ -329,24 +344,28 @@ 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
@@ -356,12 +375,14 @@ cursor: not-allowed
### Link States
**Default:**
```css
color: #4F46E5 (indigo-600)
font-weight: 500 (medium)
```
**Hover:**
```css
color: #6366F1 (indigo-500)
transition: colors 150ms
@@ -372,6 +393,7 @@ transition: colors 150ms
### Breakpoints
**Mobile (< 640px):**
```
- Full width container
- Padding: 1rem (16px)
@@ -379,6 +401,7 @@ transition: colors 150ms
```
**Tablet (640px - 768px):**
```
- Max width: 28rem (448px)
- Centered layout
@@ -386,6 +409,7 @@ transition: colors 150ms
```
**Desktop (> 768px):**
```
- Max width: 28rem (448px)
- Centered layout
@@ -404,20 +428,27 @@ transition: colors 150ms
## 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); }
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
@@ -425,24 +456,28 @@ animation: spin 1s linear infinite;
## 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
@@ -451,6 +486,7 @@ animation: spin 1s linear infinite;
## 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"/>
@@ -459,6 +495,7 @@ animation: spin 1s linear infinite;
```
### 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"/>
@@ -468,6 +505,7 @@ animation: spin 1s linear infinite;
## Browser Compatibility
### Supported Browsers
- Chrome/Edge: Latest 2 versions
- Firefox: Latest 2 versions
- Safari: Latest 2 versions
@@ -475,6 +513,7 @@ animation: spin 1s linear infinite;
- Chrome Mobile: Latest 2 versions
### CSS Features Used
- Flexbox (full support)
- CSS Grid (full support)
- CSS Transitions (full support)
@@ -482,6 +521,7 @@ animation: spin 1s linear infinite;
- Gradient backgrounds (full support)
### JavaScript Features Used
- ES2015+ syntax
- Async/await
- Optional chaining
@@ -495,6 +535,7 @@ animation: spin 1s linear infinite;
## Dark Mode Considerations
**Future Enhancement:**
- Dark mode toggle in user preferences
- System preference detection
- Persistent dark mode setting
@@ -510,6 +551,7 @@ dark:border-gray-700
## Performance Metrics
### Target Metrics
- First Contentful Paint (FCP): < 1s
- Largest Contentful Paint (LCP): < 2.5s
- Time to Interactive (TTI): < 3s
@@ -517,6 +559,7 @@ dark:border-gray-700
- First Input Delay (FID): < 100ms
### Optimization Strategies
- Lazy load non-critical resources
- Minimize initial bundle size
- Use efficient animations (transform, opacity)
@@ -527,12 +570,14 @@ dark:border-gray-700
## 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
@@ -541,6 +586,7 @@ dark:border-gray-700
## Testing Checklist
### Visual Regression Tests
- [ ] Default state (login)
- [ ] Default state (register)
- [ ] Loading state
@@ -554,6 +600,7 @@ dark:border-gray-700
- [ ] Hover states
### Cross-browser Tests
- [ ] Chrome (Windows, Mac, Linux)
- [ ] Firefox (Windows, Mac, Linux)
- [ ] Safari (Mac, iOS)
@@ -562,6 +609,7 @@ dark:border-gray-700
- [ ] Safari Mobile (iOS)
### Accessibility Tests
- [ ] Keyboard navigation
- [ ] Screen reader (NVDA)
- [ ] Screen reader (JAWS)
@@ -573,14 +621,17 @@ dark:border-gray-700
## 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

View File

@@ -3,5 +3,5 @@
* Export all authentication-related views
*/
export { default as LoginView } from './LoginView.vue';
export { default as RegisterView } from './RegisterView.vue';
export { default as LoginView } from './LoginView.vue'
export { default as RegisterView } from './RegisterView.vue'