Spaces:
Paused
Paused
phuc1st
test: implement integration tests (L2-4), Pinia store unit tests (L5), and Playwright E2E tests (L6)
d73f1da | /** | |
| * 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, | |
| } | |
| }) | |