feat: implement user authentication, profile management, and email verification with i18n support
This commit is contained in:
parent
06db182c46
commit
b2365a4c6a
6 changed files with 271 additions and 17 deletions
|
|
@ -7,12 +7,14 @@
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: any; // userData (firstName, lastName, phone, etc.)
|
modelValue: any; // userData (firstName, lastName, phone, etc.)
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
verifying?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: any): void;
|
(e: 'update:modelValue', value: any): void;
|
||||||
(e: 'submit'): void;
|
(e: 'submit'): void;
|
||||||
(e: 'upload', file: File): void;
|
(e: 'upload', file: File): void;
|
||||||
|
(e: 'verify'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -153,9 +155,28 @@ const handleFileUpload = (event: Event) => {
|
||||||
class="premium-q-input"
|
class="premium-q-input"
|
||||||
:rules="emailRules"
|
:rules="emailRules"
|
||||||
hide-bottom-space
|
hide-bottom-space
|
||||||
disable
|
readonly
|
||||||
:hint="$t('profile.emailHint')"
|
:hint="$t('profile.emailHint')"
|
||||||
/>
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<div v-if="modelValue.emailVerifiedAt" class="flex items-center gap-1">
|
||||||
|
<q-icon name="check_circle" color="positive" />
|
||||||
|
<span class="text-green-600 text-xs font-bold">{{ $t('profile.emailVerified') || 'ยืนยันแล้ว' }}</span>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="negative"
|
||||||
|
icon="mark_email_read"
|
||||||
|
:label="$t('profile.verifyEmail') || 'ยืนยัน'"
|
||||||
|
class="text-sm font-bold"
|
||||||
|
:loading="verifying"
|
||||||
|
@click="$emit('verify')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ interface User {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
|
email_verified_at?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
role: {
|
role: {
|
||||||
|
|
@ -19,6 +20,8 @@ interface User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Interface สำหรับข้อมูลตอบกลับตอน Login
|
// Interface สำหรับข้อมูลตอบกลับตอน Login
|
||||||
interface loginResponse {
|
interface loginResponse {
|
||||||
token: string
|
token: string
|
||||||
|
|
@ -367,8 +370,13 @@ export const useAuth = () => {
|
||||||
const sendVerifyEmail = async () => {
|
const sendVerifyEmail = async () => {
|
||||||
if (!token.value) return { success: false, error: 'Token missing' }
|
if (!token.value) return { success: false, error: 'Token missing' }
|
||||||
|
|
||||||
|
interface VerifyEmailResponse {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
const doSend = async () => {
|
const doSend = async () => {
|
||||||
return await $fetch<{code: number, message: string}>(`${API_BASE_URL}/user/send-verify-email`, {
|
return await $fetch<VerifyEmailResponse>(`${API_BASE_URL}/user/send-verify-email`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.value}`
|
Authorization: `Bearer ${token.value}`
|
||||||
|
|
@ -378,7 +386,7 @@ export const useAuth = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await doSend()
|
const res = await doSend()
|
||||||
return { success: true, message: res.message }
|
return { success: true, message: res.message, code: res.code }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.statusCode === 400) {
|
if (err.statusCode === 400) {
|
||||||
return { success: false, error: 'Email already verified', code: 400 }
|
return { success: false, error: 'Email already verified', code: 400 }
|
||||||
|
|
@ -388,7 +396,7 @@ export const useAuth = () => {
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
try {
|
try {
|
||||||
const res = await doSend()
|
const res = await doSend()
|
||||||
return { success: true, message: res.message }
|
return { success: true, message: res.message, code: res.code }
|
||||||
} catch (retryErr: any) {
|
} catch (retryErr: any) {
|
||||||
return { success: false, error: retryErr.data?.message || 'Failed to send verification email' }
|
return { success: false, error: retryErr.data?.message || 'Failed to send verification email' }
|
||||||
}
|
}
|
||||||
|
|
@ -401,6 +409,43 @@ export const useAuth = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ฟังก์ชันยืนยันอีเมลด้วย Token
|
||||||
|
const verifyEmail = async (token: string) => {
|
||||||
|
try {
|
||||||
|
interface VerifyResponse {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await $fetch<VerifyResponse>(`${API_BASE_URL}/user/verify-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { token }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle HTTP 200 but logical error if API behaves that way (defensive)
|
||||||
|
if (res && typeof res.code === 'number' && res.code !== 200) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: res.message,
|
||||||
|
code: res.code,
|
||||||
|
error: res.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: res.message, code: res.code }
|
||||||
|
} catch (err: any) {
|
||||||
|
// Prioritize backend 'code' field, then HTTP status
|
||||||
|
const status = err.data?.code || err.statusCode || err.response?.status || err.status
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.data?.message || err.message || 'Verification failed',
|
||||||
|
code: status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ฟังก์ชันออกจากระบบ (Logout)
|
// ฟังก์ชันออกจากระบบ (Logout)
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
token.value = null
|
token.value = null
|
||||||
|
|
@ -427,6 +472,7 @@ export const useAuth = () => {
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email: user.value.email,
|
email: user.value.email,
|
||||||
|
emailVerifiedAt: user.value.email_verified_at,
|
||||||
phone: user.value.profile?.phone || '',
|
phone: user.value.profile?.phone || '',
|
||||||
photoURL: user.value.profile?.avatar_url || '',
|
photoURL: user.value.profile?.avatar_url || '',
|
||||||
role: user.value.role,
|
role: user.value.role,
|
||||||
|
|
@ -443,6 +489,7 @@ export const useAuth = () => {
|
||||||
uploadAvatar,
|
uploadAvatar,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
logout,
|
logout,
|
||||||
sendVerifyEmail
|
sendVerifyEmail,
|
||||||
|
verifyEmail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,11 @@
|
||||||
"updateError": "Failed to update profile",
|
"updateError": "Failed to update profile",
|
||||||
"passwordSuccess": "Password changed successfully",
|
"passwordSuccess": "Password changed successfully",
|
||||||
"passwordError": "Failed to change password",
|
"passwordError": "Failed to change password",
|
||||||
"verifyEmail": "Verify Email",
|
"verifyEmail": "Please verify email",
|
||||||
"verifyEmailSuccess": "Verification email sent successfully",
|
"verifyEmailSuccess": "Verification email sent successfully",
|
||||||
"verifyEmailError": "Failed to send verification email",
|
"verifyEmailError": "Failed to send verification email",
|
||||||
"emailAlreadyVerified": "Your email is already verified"
|
"emailAlreadyVerified": "Your email is already verified",
|
||||||
|
"emailVerified": "Email Verified"
|
||||||
},
|
},
|
||||||
"userMenu": {
|
"userMenu": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
|
@ -133,7 +134,12 @@
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Log In",
|
"login": "Log In",
|
||||||
"getStarted": "Get Started"
|
"getStarted": "Get Started",
|
||||||
|
"verifyingEmail": "Verifying Email...",
|
||||||
|
"emailVerified": "Email Verified Successfully!",
|
||||||
|
"emailVerifiedDesc": "Your account has been successfully verified.",
|
||||||
|
"invalidToken": "Invalid verification token",
|
||||||
|
"tokenExpired": "Token expired or invalid"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "Language / ภาษา",
|
"label": "Language / ภาษา",
|
||||||
|
|
@ -151,7 +157,9 @@
|
||||||
"passwordTooShort": "At least 6 characters",
|
"passwordTooShort": "At least 6 characters",
|
||||||
"passwordsDoNotMatch": "Passwords do not match",
|
"passwordsDoNotMatch": "Passwords do not match",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"back": "Back"
|
"back": "Back",
|
||||||
|
"backToHome": "Back to Home",
|
||||||
|
"error": "Error"
|
||||||
},
|
},
|
||||||
"classroom": {
|
"classroom": {
|
||||||
"backToDashboard": "Back to My Courses",
|
"backToDashboard": "Back to My Courses",
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,11 @@
|
||||||
"updateError": "เกิดข้อผิดพลาดในการบันทึกข้อมูลส่วนตัว",
|
"updateError": "เกิดข้อผิดพลาดในการบันทึกข้อมูลส่วนตัว",
|
||||||
"passwordSuccess": "เปลี่ยนรหัสผ่านเรียบร้อยแล้ว",
|
"passwordSuccess": "เปลี่ยนรหัสผ่านเรียบร้อยแล้ว",
|
||||||
"passwordError": "เปลี่ยนรหัสผ่านไม่สำเร็จ",
|
"passwordError": "เปลี่ยนรหัสผ่านไม่สำเร็จ",
|
||||||
"verifyEmail": "ยืนยันอีเมล",
|
"verifyEmail": "กรุณายืนยันอีเมล",
|
||||||
"verifyEmailSuccess": "ส่งอีเมลยืนยันสำเร็จ",
|
"verifyEmailSuccess": "ส่งอีเมลยืนยันสำเร็จ",
|
||||||
"verifyEmailError": "ส่งอีเมลไม่สำเร็จ",
|
"verifyEmailError": "ส่งอีเมลไม่สำเร็จ",
|
||||||
"emailAlreadyVerified": "อีเมลของคุณได้รับการยืนยันแล้ว"
|
"emailAlreadyVerified": "อีเมลของคุณได้รับการยืนยันแล้ว",
|
||||||
|
"emailVerified": "ยืนยันอีเมลเสร็จสิ้น"
|
||||||
},
|
},
|
||||||
"userMenu": {
|
"userMenu": {
|
||||||
"home": "หน้าหลัก",
|
"home": "หน้าหลัก",
|
||||||
|
|
@ -133,7 +134,12 @@
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "เข้าสู่ระบบ",
|
"login": "เข้าสู่ระบบ",
|
||||||
"getStarted": "เริ่มต้นใช้งาน"
|
"getStarted": "เริ่มต้นใช้งาน",
|
||||||
|
"verifyingEmail": "กำลังยืนยันอีเมล...",
|
||||||
|
"emailVerified": "ยืนยันอีเมลสำเร็จ!",
|
||||||
|
"emailVerifiedDesc": "บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว",
|
||||||
|
"invalidToken": "Token ยืนยันตัวตนไม่ถูกต้อง",
|
||||||
|
"tokenExpired": "Token หมดอายุหรือล้มเหลว"
|
||||||
},
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"label": "ภาษา / Language",
|
"label": "ภาษา / Language",
|
||||||
|
|
@ -152,7 +158,9 @@
|
||||||
"passwordTooShort": "รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร",
|
"passwordTooShort": "รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร",
|
||||||
"passwordsDoNotMatch": "รหัสผ่านใหม่ไม่ตรงกัน",
|
"passwordsDoNotMatch": "รหัสผ่านใหม่ไม่ตรงกัน",
|
||||||
"next": "ถัดไป",
|
"next": "ถัดไป",
|
||||||
"back": "ย้อนกลับ"
|
"back": "ย้อนกลับ",
|
||||||
|
"backToHome": "กลับสู่หน้าหลัก",
|
||||||
|
"error": "เกิดข้อผิดพลาด"
|
||||||
},
|
},
|
||||||
"classroom": {
|
"classroom": {
|
||||||
"backToDashboard": "กลับไปคอร์สของฉัน",
|
"backToDashboard": "กลับไปคอร์สของฉัน",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ useHead({
|
||||||
title: 'ตั้งค่าบัญชี - e-Learning'
|
title: 'ตั้งค่าบัญชี - e-Learning'
|
||||||
})
|
})
|
||||||
|
|
||||||
const { currentUser, updateUserProfile, changePassword, uploadAvatar, sendVerifyEmail } = useAuth()
|
const { currentUser, updateUserProfile, changePassword, uploadAvatar, sendVerifyEmail, fetchUserProfile } = useAuth()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -40,9 +40,25 @@ const userData = ref({
|
||||||
phone: currentUser.value?.phone || '',
|
phone: currentUser.value?.phone || '',
|
||||||
createdAt: formatDate(currentUser.value?.createdAt),
|
createdAt: formatDate(currentUser.value?.createdAt),
|
||||||
photoURL: currentUser.value?.photoURL || '',
|
photoURL: currentUser.value?.photoURL || '',
|
||||||
prefix: currentUser.value?.prefix?.th || ''
|
prefix: currentUser.value?.prefix?.th || '',
|
||||||
|
emailVerifiedAt: currentUser.value?.emailVerifiedAt
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Watch for changes in global user state (e.g. after avatar upload)
|
||||||
|
watch(() => currentUser.value, (newUser) => {
|
||||||
|
if (newUser) {
|
||||||
|
userData.value.photoURL = newUser.photoURL || ''
|
||||||
|
userData.value.firstName = newUser.firstName
|
||||||
|
userData.value.lastName = newUser.lastName
|
||||||
|
userData.value.prefix = newUser.prefix?.th || ''
|
||||||
|
userData.value.email = newUser.email
|
||||||
|
userData.value.phone = newUser.phone || ''
|
||||||
|
userData.value.emailVerifiedAt = newUser.emailVerifiedAt
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
const passwordForm = reactive({
|
const passwordForm = reactive({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
|
|
@ -184,7 +200,8 @@ watch(() => currentUser.value, (newUser) => {
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await fetchUserProfile(true)
|
||||||
isHydrated.value = true
|
isHydrated.value = true
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -269,8 +286,10 @@ onMounted(() => {
|
||||||
<ProfileEditForm
|
<ProfileEditForm
|
||||||
v-model="userData"
|
v-model="userData"
|
||||||
:loading="isProfileSaving"
|
:loading="isProfileSaving"
|
||||||
|
:verifying="isSendingVerify"
|
||||||
@submit="handleUpdateProfile"
|
@submit="handleUpdateProfile"
|
||||||
@upload="handleFileUpload"
|
@upload="handleFileUpload"
|
||||||
|
@verify="handleSendVerifyEmail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
151
Frontend-Learner/pages/verify-email.vue
Normal file
151
Frontend-Learner/pages/verify-email.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* @file verify-email.vue
|
||||||
|
* @description Page for handling email verification process.
|
||||||
|
* Displays loading state while processing token, then shows success or error message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { verifyEmail } = useAuth()
|
||||||
|
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isSuccess = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const token = route.query.token as string
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
isLoading.value = false
|
||||||
|
isSuccess.value = false
|
||||||
|
errorMessage.value = t('auth.invalidToken') || 'Token ไม่ถูกต้อง'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call verify API
|
||||||
|
const result = await verifyEmail(token)
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
isSuccess.value = true
|
||||||
|
} else {
|
||||||
|
isSuccess.value = false
|
||||||
|
if (result.code === 400) {
|
||||||
|
errorMessage.value = t('profile.emailAlreadyVerified') || 'อีเมลได้รับการยืนยันแล้ว'
|
||||||
|
// Treat as success visually or show specific message?
|
||||||
|
// Requirement says "check mark" for done.
|
||||||
|
// If already verified, maybe show success-like state with "Already Verified" message.
|
||||||
|
isSuccess.value = true // Let's show checkmark but with specific message
|
||||||
|
} else if (result.code === 401) {
|
||||||
|
errorMessage.value = t('auth.tokenExpired') || 'Token หมดอายุหรือล้มเหลว'
|
||||||
|
} else {
|
||||||
|
errorMessage.value = result.error || 'ยืนยันอีเมลไม่สำเร็จ'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const navigateToHome = () => {
|
||||||
|
router.push('/dashboard/profile')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 bg-slate-50 dark:bg-[#0f172a]">
|
||||||
|
<div class="auth-card max-w-md w-full space-y-8 p-8 rounded-2xl text-center">
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="flex flex-col items-center justify-center py-8">
|
||||||
|
<q-spinner-dots size="4rem" color="primary" />
|
||||||
|
<h2 class="mt-6 text-xl font-bold text-slate-900 dark:text-white animate-pulse">
|
||||||
|
{{ $t('auth.verifyingEmail') || 'กำลังยืนยันอีเมล...' }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success State -->
|
||||||
|
<div v-else-if="isSuccess" class="flex flex-col items-center animate-bounce-in">
|
||||||
|
<div class="w-24 h-24 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6">
|
||||||
|
<q-icon name="check_circle" class="text-6xl text-green-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-3xl font-black text-slate-900 dark:text-white mb-2">
|
||||||
|
{{ errorMessage && errorMessage !== '' ? (errorMessage) : ($t('auth.emailVerified') || 'ยืนยันอีเมลสำเร็จ!') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-slate-500 dark:text-slate-400 mb-8">
|
||||||
|
{{ $t('auth.emailVerifiedDesc') || 'บัญชีของคุณได้รับการยืนยันเรียบร้อยแล้ว' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
rounded
|
||||||
|
color="primary"
|
||||||
|
class="w-full py-3 font-bold text-lg shadow-lg shadow-blue-500/30"
|
||||||
|
:label="$t('common.backToHome') || 'กลับสู่หน้าหลัก'"
|
||||||
|
@click="navigateToHome"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else class="flex flex-col items-center animate-shake">
|
||||||
|
<div class="w-24 h-24 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-6">
|
||||||
|
<q-icon name="error" class="text-6xl text-red-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-black text-slate-900 dark:text-white mb-2">
|
||||||
|
{{ $t('common.error') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-red-500 font-medium mb-8">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
rounded
|
||||||
|
color="slate-700"
|
||||||
|
class="w-full py-3 font-bold text-lg"
|
||||||
|
label="ลองใหม่อีกครั้ง"
|
||||||
|
@click="router.push('/')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.animate-bounce-in {
|
||||||
|
animation: bounceIn 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shake {
|
||||||
|
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
@apply bg-white border-slate-100 shadow-xl;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
.dark .auth-card {
|
||||||
|
@apply bg-[#1e293b] border-white/5 shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounceIn {
|
||||||
|
0% { transform: scale(0.3); opacity: 0; }
|
||||||
|
50% { transform: scale(1.05); opacity: 1; }
|
||||||
|
70% { transform: scale(0.9); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
10%, 90% { transform: translate3d(-1px, 0, 0); }
|
||||||
|
20%, 80% { transform: translate3d(2px, 0, 0); }
|
||||||
|
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
|
||||||
|
40%, 60% { transform: translate3d(4px, 0, 0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue