phuc1st
test: implement integration tests (L2-4), Pinia store unit tests (L5), and Playwright E2E tests (L6)
d73f1da
Raw
History Blame Contribute Delete
9.39 kB
/**
* src/stores/auth.js
* ───────────────────
* Pinia store for authentication state.
* Handles login, register, logout, profile fetch, and token persistence.
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { clearTokens, getAccessToken, setTokens as setClientTokens } from '@/api/tokenStore.js'
import { authService } from '@/services/authService.js'
export const useAuthStore = defineStore('auth', () => {
// ── State ────────────────────────────────────────────────────────────────
const token = ref(getAccessToken())
const profile = ref(null)
const loading = ref(false)
const error = ref(null)
// ── Getters ──────────────────────────────────────────────────────────────
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => profile.value?.role === 'admin')
const userName = computed(() =>
profile.value?.full_name || profile.value?.email || 'User'
)
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Extract a human-readable error message from an Axios error.
* Handles both:
* - FastAPI 400 → { detail: "string message" }
* - FastAPI 422 → { detail: [{ msg: "...", loc: [...] }] }
*/
function _extractError(err, fallback = 'Đã xảy ra lỗi.') {
const detail = err.response?.data?.detail
if (!detail) return fallback
if (typeof detail === 'string') return detail
if (Array.isArray(detail) && detail.length > 0) {
// Map Pydantic error locs to readable field names
const fieldMap = { password: 'Mật khẩu', email: 'Email', full_name: 'Họ tên' }
return detail.map((d) => {
const field = fieldMap[d.loc?.[d.loc.length - 1]] || ''
const msg = d.msg || ''
// Clean up pydantic messages
const clean = msg
.replace('String should have at least', 'Tối thiểu')
.replace('characters', 'ký tự')
.replace('Value error,', '')
.trim()
return field ? `${field}: ${clean}` : clean
}).join(' | ')
}
return fallback
}
function setTokens(accessToken) {
token.value = accessToken
setClientTokens(accessToken)
}
function clearAuth() {
token.value = null
profile.value = null
error.value = null
clearTokens()
}
/** Local-only reset (session expired, force logout — no server call). */
async function resetLocalAuth() {
clearAuth()
await _clearUserStores()
}
async function _redirectToLogin() {
const { default: router } = await import('@/router/index.js')
if (router.currentRoute.value.path !== '/login') {
await router.push('/login')
}
}
async function _clearUserStores() {
try {
const { useFullExamStore } = await import('@/stores/fullExam.js')
useFullExamStore().clear()
} catch {
/* non-fatal */
}
}
// ── Actions ───────────────────────────────────────────────────────────────
/**
* Register a new user.
* Returns { needsVerification: true, email } if email OTP is required.
* Returns true on unexpected auto-login (future-proof).
*/
async function register(email, password, fullName) {
loading.value = true
error.value = null
try {
const data = await authService.register({
email,
password,
full_name: fullName,
})
// Backend now returns { needs_verification, email } — no token yet.
if (data.needs_verification) {
return { needsVerification: true, email: data.email }
}
// Auto-login if no verification needed
await login(email, password)
return true
} catch (err) {
error.value = _extractError(err, 'Đăng ký thất bại.')
return false
} finally {
loading.value = false
}
}
/** Verify email OTP and auto-login on success. */
async function verifyEmail(email, code) {
loading.value = true
error.value = null
try {
const data = await authService.verifyEmail(email, code)
setTokens(data.access_token)
await fetchProfile()
return true
} catch (err) {
error.value = _extractError(err, 'Mã xác minh không đúng.')
return false
} finally {
loading.value = false
}
}
/** Re-send the email verification OTP. */
async function resendVerification(email) {
return authService.resendVerification(email)
}
/** Authenticate with Google OAuth authorization code. */
async function googleAuth(code, redirectUri) {
loading.value = true
error.value = null
try {
const data = await authService.googleAuth(code, redirectUri)
setTokens(data.access_token)
await fetchProfile()
return true
} catch (err) {
error.value = _extractError(err, 'Đăng nhập Google thất bại.')
return false
} finally {
loading.value = false
}
}
/** Log in with email + password. */
async function login(email, password) {
loading.value = true
error.value = null
try {
const data = await authService.login({ email, password })
setTokens(data.access_token)
await fetchProfile()
return true
} catch (err) {
const detail = err.response?.data?.detail || ''
if (detail === 'email_not_verified') {
return 'not_verified'
}
error.value = _extractError(err, 'Sai email hoặc mật khẩu.')
return false
} finally {
loading.value = false
}
}
/** Log out — revoke refresh cookie server-side when possible, then go to login. */
async function logout() {
try {
await authService.logout()
} catch {
/* ignore network errors on logout */
}
await resetLocalAuth()
await _redirectToLogin()
}
/** Fetch current user profile from the API. */
async function fetchProfile() {
try {
profile.value = await authService.getProfile()
} catch {
// Profile fetch failure is non-fatal during login
}
}
/**
* Called by the router guard on every navigation.
* - If we already have a token (sessionStorage survived reload) → still need
* to ensure profile is loaded (it's in-memory only).
* - If no token → try the httpOnly refresh cookie → get a new access token.
*/
async function refreshSession() {
if (token.value) {
// Token exists (e.g. after a same-tab reload from sessionStorage).
// Profile is in-memory only → re-fetch it if missing.
if (!profile.value) {
await fetchProfile()
}
return true
}
// No token in memory/sessionStorage → use httpOnly refresh cookie.
try {
const data = await authService.refresh()
setTokens(data.access_token)
await fetchProfile()
return true
} catch {
clearAuth()
return false
}
}
/**
* Gọi activity ping — cập nhật streak khi user mở app.
* Gọi sau khi fetchProfile() để đảm bảo profile đã load.
*/
async function activityPing() {
try {
const data = await authService.activityPing()
// Cập nhật streak trong profile nếu thay đổi
if (profile.value && data.streak !== undefined) {
profile.value = { ...profile.value, streak: data.streak }
}
return data
} catch {
// Activity ping failure là non-fatal
}
}
/** Upload avatar và refresh profile */
async function uploadAvatar(file) {
loading.value = true
error.value = null
try {
await authService.uploadAvatar(file)
await fetchProfile()
return true
} catch (err) {
error.value = _extractError(err, 'Upload thất bại.')
return false
} finally {
loading.value = false
}
}
/** Update profile fields. */
async function updateProfile(payload) {
loading.value = true
error.value = null
try {
profile.value = await authService.updateProfile(payload)
return true
} catch (err) {
error.value = _extractError(err, 'Cập nhật thất bại.')
return false
} finally {
loading.value = false
}
}
async function changePassword(currentPassword, newPassword) {
loading.value = true
error.value = null
try {
await authService.changePassword(currentPassword, newPassword)
return true
} catch (err) {
error.value = _extractError(err, 'Đổi mật khẩu thất bại.')
return false
} finally {
loading.value = false
}
}
return {
token, profile, loading, error,
isAuthenticated, isAdmin, userName,
register, login, logout, fetchProfile, refreshSession, updateProfile, activityPing, uploadAvatar,
changePassword, verifyEmail, resendVerification, googleAuth, resetLocalAuth,
}
})