From f63e8be6009475f4d85285a82256b02d827aae98 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 30 Jan 2026 11:07:25 -0600 Subject: [PATCH] Add Pinia stores for auth, user, and UI (F0-004) - Refactor auth store for OAuth flow (access/refresh tokens) - Add token refresh logic and expiry tracking - Create user store for profile data and linked accounts - Create UI store for loading overlay, toasts, and modals - Update router guard tests for new auth store structure - Add comprehensive tests (45 total passing) Phase F0 progress: 5/8 tasks complete Co-Authored-By: Claude Opus 4.5 --- .../project_plans/PHASE_F0_foundation.json | 6 +- frontend/src/router/guards.spec.ts | 50 +++- frontend/src/stores/auth.spec.ts | 145 +++++++++- frontend/src/stores/auth.ts | 191 +++++++++---- frontend/src/stores/index.ts | 2 + frontend/src/stores/ui.spec.ts | 264 ++++++++++++++++++ frontend/src/stores/ui.ts | 183 ++++++++++++ frontend/src/stores/user.spec.ts | 115 ++++++++ frontend/src/stores/user.ts | 162 +++++++++++ 9 files changed, 1045 insertions(+), 73 deletions(-) create mode 100644 frontend/src/stores/ui.spec.ts create mode 100644 frontend/src/stores/ui.ts create mode 100644 frontend/src/stores/user.spec.ts create mode 100644 frontend/src/stores/user.ts diff --git a/frontend/project_plans/PHASE_F0_foundation.json b/frontend/project_plans/PHASE_F0_foundation.json index d54fd71..a04dacf 100644 --- a/frontend/project_plans/PHASE_F0_foundation.json +++ b/frontend/project_plans/PHASE_F0_foundation.json @@ -6,7 +6,7 @@ "created": "2026-01-30", "lastUpdated": "2026-01-30", "totalTasks": 8, - "completedTasks": 4, + "completedTasks": 5, "status": "in_progress" }, "tasks": [ @@ -79,8 +79,8 @@ "description": "Create store structure with persistence", "category": "stores", "priority": 4, - "completed": false, - "tested": false, + "completed": true, + "tested": true, "dependencies": ["F0-001"], "files": [ {"path": "src/stores/auth.ts", "status": "modify"}, diff --git a/frontend/src/router/guards.spec.ts b/frontend/src/router/guards.spec.ts index 55d9e06..a96d43f 100644 --- a/frontend/src/router/guards.spec.ts +++ b/frontend/src/router/guards.spec.ts @@ -35,8 +35,7 @@ describe('requireAuth', () => { * The redirect should include the original path so users can be * returned there after logging in. */ - const auth = useAuthStore() - auth.token = null + // Auth store starts with null accessToken (not authenticated) const to = createMockRoute({ path: '/collection', fullPath: '/collection' }) const from = createMockRoute() @@ -58,7 +57,11 @@ describe('requireAuth', () => { * to the requested route without redirection. */ const auth = useAuthStore() - auth.token = 'valid-token' + auth.setTokens({ + accessToken: 'valid-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600000, + }) const to = createMockRoute({ path: '/collection' }) const from = createMockRoute() @@ -83,7 +86,11 @@ describe('requireGuest', () => { * since they don't need to log in again. */ const auth = useAuthStore() - auth.token = 'valid-token' + auth.setTokens({ + accessToken: 'valid-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600000, + }) const to = createMockRoute({ path: '/login' }) const from = createMockRoute() @@ -101,8 +108,7 @@ describe('requireGuest', () => { * Users who are not logged in should be able to reach the * login page and other guest-only routes. */ - const auth = useAuthStore() - auth.token = null + // Auth store starts with null accessToken (not authenticated) const to = createMockRoute({ path: '/login' }) const from = createMockRoute() @@ -128,8 +134,12 @@ describe('requireStarter', () => { * cause a redirect loop). */ const auth = useAuthStore() - auth.token = 'valid-token' - auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false } + auth.setTokens({ + accessToken: 'valid-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600000, + }) + auth.setUser({ id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false }) const to = createMockRoute({ path: '/starter' }) const from = createMockRoute() @@ -148,8 +158,12 @@ describe('requireStarter', () => { * main app. This ensures they have cards to play with. */ const auth = useAuthStore() - auth.token = 'valid-token' - auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false } + auth.setTokens({ + accessToken: 'valid-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600000, + }) + auth.setUser({ id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false }) const to = createMockRoute({ path: '/collection' }) const from = createMockRoute() @@ -168,8 +182,12 @@ describe('requireStarter', () => { * have full access to collection, decks, and gameplay. */ const auth = useAuthStore() - auth.token = 'valid-token' - auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: true } + auth.setTokens({ + accessToken: 'valid-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600000, + }) + auth.setUser({ id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: true }) const to = createMockRoute({ path: '/collection' }) const from = createMockRoute() @@ -189,8 +207,12 @@ describe('requireStarter', () => { * user data and may redirect later if needed. */ const auth = useAuthStore() - auth.token = 'valid-token' - auth.user = null + auth.setTokens({ + accessToken: 'valid-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3600000, + }) + // user is null by default const to = createMockRoute({ path: '/collection' }) const from = createMockRoute() diff --git a/frontend/src/stores/auth.spec.ts b/frontend/src/stores/auth.spec.ts index 8a7cb1a..41c5fc0 100644 --- a/frontend/src/stores/auth.spec.ts +++ b/frontend/src/stores/auth.spec.ts @@ -12,14 +12,15 @@ describe('useAuthStore', () => { /** * Test that the auth store initializes in an unauthenticated state. * - * New users should not be authenticated until they log in, + * New users should not be authenticated until they complete OAuth flow, * ensuring protected routes are inaccessible by default. */ const store = useAuthStore() expect(store.isAuthenticated).toBe(false) expect(store.user).toBeNull() - expect(store.token).toBeNull() + expect(store.accessToken).toBeNull() + expect(store.refreshToken).toBeNull() }) it('tracks loading state', () => { @@ -39,10 +40,148 @@ describe('useAuthStore', () => { * Test that the error state ref is accessible. * * Components need to display error messages when - * login or registration fails. + * token refresh or other auth operations fail. */ const store = useAuthStore() expect(store.error).toBeNull() }) + + it('setTokens updates auth state', () => { + /** + * Test that setTokens correctly stores OAuth tokens. + * + * After OAuth callback, tokens are extracted from URL and + * stored via setTokens. This should update all token refs + * and make isAuthenticated return true. + */ + const store = useAuthStore() + + store.setTokens({ + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Date.now() + 3600000, + }) + + expect(store.accessToken).toBe('test-access-token') + expect(store.refreshToken).toBe('test-refresh-token') + expect(store.isAuthenticated).toBe(true) + }) + + it('setUser updates user data', () => { + /** + * Test that setUser correctly stores user profile data. + * + * After fetching user profile, setUser is called to store + * the basic user info needed for guards and UI display. + */ + const store = useAuthStore() + + store.setUser({ + id: 'user-123', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.png', + hasStarterDeck: true, + }) + + expect(store.user).toEqual({ + id: 'user-123', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.png', + hasStarterDeck: true, + }) + }) + + it('logout clears all auth state', async () => { + /** + * Test that logout clears all authentication state. + * + * When user logs out, all tokens and user data should be + * cleared to prevent unauthorized access. + */ + const store = useAuthStore() + + // Set up authenticated state + store.setTokens({ + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Date.now() + 3600000, + }) + store.setUser({ + id: 'user-123', + displayName: 'Test User', + avatarUrl: null, + hasStarterDeck: false, + }) + + // Logout (without server call in test) + await store.logout(false) + + expect(store.accessToken).toBeNull() + expect(store.refreshToken).toBeNull() + expect(store.user).toBeNull() + expect(store.isAuthenticated).toBe(false) + }) + + it('isTokenExpired returns true when no expiry set', () => { + /** + * Test that tokens without expiry are considered expired. + * + * If expiresAt is null (e.g., before tokens are set), the + * token should be treated as expired to force refresh. + */ + const store = useAuthStore() + + expect(store.isTokenExpired).toBe(true) + }) + + it('isTokenExpired returns false for future expiry', () => { + /** + * Test that tokens with future expiry are not expired. + * + * Valid tokens should not trigger refresh attempts. + */ + const store = useAuthStore() + + store.setTokens({ + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Date.now() + 3600000, // 1 hour from now + }) + + expect(store.isTokenExpired).toBe(false) + }) + + it('isTokenExpired returns true for past expiry', () => { + /** + * Test that tokens with past expiry are considered expired. + * + * Expired tokens should trigger refresh before API calls. + */ + const store = useAuthStore() + + store.setTokens({ + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + expiresAt: Date.now() - 1000, // 1 second ago + }) + + expect(store.isTokenExpired).toBe(true) + }) + + it('getOAuthUrl returns correct provider URL', () => { + /** + * Test that OAuth URLs are correctly generated. + * + * The login page uses these URLs to redirect users to + * OAuth providers for authentication. + */ + const store = useAuthStore() + + const googleUrl = store.getOAuthUrl('google') + const discordUrl = store.getOAuthUrl('discord') + + expect(googleUrl).toContain('/api/auth/google') + expect(discordUrl).toContain('/api/auth/discord') + }) }) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e324970..0765371 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,6 +1,14 @@ +/** + * Authentication store for OAuth-based login. + * + * Manages access tokens, refresh tokens, and basic user info. + * The backend uses OAuth (Google/Discord) - no username/password. + */ import { ref, computed } from 'vue' import { defineStore } from 'pinia' +import { config } from '@/config' + export interface User { id: string displayName: string @@ -8,106 +16,183 @@ export interface User { hasStarterDeck: boolean } +export interface AuthTokens { + accessToken: string + refreshToken: string + expiresAt: number +} + export const useAuthStore = defineStore('auth', () => { + // State const user = ref(null) - const token = ref(null) + const accessToken = ref(null) + const refreshToken = ref(null) + const expiresAt = ref(null) const isLoading = ref(false) const error = ref(null) - const isAuthenticated = computed(() => !!token.value) + // Getters + const isAuthenticated = computed(() => !!accessToken.value) + + const isTokenExpired = computed(() => { + if (!expiresAt.value) return true + // Consider expired 30 seconds before actual expiry for safety + return Date.now() >= expiresAt.value - 30000 + }) + + /** + * Set tokens after OAuth callback. + * + * Called from AuthCallbackPage after extracting tokens from URL fragment. + */ + function setTokens(tokens: AuthTokens): void { + accessToken.value = tokens.accessToken + refreshToken.value = tokens.refreshToken + expiresAt.value = tokens.expiresAt + } + + /** + * Set user data after fetching profile. + */ + function setUser(userData: User): void { + user.value = userData + } + + /** + * Refresh the access token using the refresh token. + * + * Returns true if refresh succeeded, false otherwise. + * On failure, user is logged out. + */ + async function refreshAccessToken(): Promise { + if (!refreshToken.value) { + logout() + return false + } - async function login(username: string, password: string): Promise { isLoading.value = true error.value = null try { - const response = await fetch('/api/auth/login', { + const response = await fetch(`${config.apiBaseUrl}/api/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), + body: JSON.stringify({ refresh_token: refreshToken.value }), }) if (!response.ok) { - const data = await response.json() - throw new Error(data.detail || 'Login failed') + throw new Error('Token refresh failed') } const data = await response.json() - token.value = data.access_token - user.value = data.user - // Note: localStorage is used for token persistence across page reloads. - // This is standard for SPAs but tokens are accessible to XSS. The backend - // should use short-lived access tokens + httpOnly refresh cookies for - // production. See: https://auth0.com/docs/secure/security-guidance/data-security/token-storage - localStorage.setItem('auth_token', data.access_token) + accessToken.value = data.access_token + // Refresh token may or may not be rotated + if (data.refresh_token) { + refreshToken.value = data.refresh_token + } + // Calculate expiry (backend should send expires_in in seconds) + if (data.expires_in) { + expiresAt.value = Date.now() + data.expires_in * 1000 + } return true } catch (e) { - error.value = e instanceof Error ? e.message : 'Login failed' + error.value = e instanceof Error ? e.message : 'Token refresh failed' + logout() return false } finally { isLoading.value = false } } - async function register( - username: string, - email: string, - password: string - ): Promise { - isLoading.value = true - error.value = null + /** + * Get a valid access token, refreshing if needed. + * + * Returns null if not authenticated or refresh fails. + */ + async function getValidToken(): Promise { + if (!accessToken.value) return null - try { - const response = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, email, password }), - }) + if (isTokenExpired.value) { + const success = await refreshAccessToken() + if (!success) return null + } - if (!response.ok) { - const data = await response.json() - throw new Error(data.detail || 'Registration failed') + return accessToken.value + } + + /** + * Log out the user. + * + * Optionally calls the backend to revoke the refresh token. + */ + async function logout(revokeOnServer = true): Promise { + if (revokeOnServer && refreshToken.value) { + try { + await fetch(`${config.apiBaseUrl}/api/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken.value}`, + }, + }) + } catch { + // Ignore errors - we're logging out anyway } - - // Auto-login after registration - return await login(username, password) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Registration failed' - return false - } finally { - isLoading.value = false } - } - function logout() { + // Clear state user.value = null - token.value = null - localStorage.removeItem('auth_token') + accessToken.value = null + refreshToken.value = null + expiresAt.value = null + error.value = null } - // Restore token from localStorage on init - function init() { - const storedToken = localStorage.getItem('auth_token') - if (storedToken) { - token.value = storedToken - // TODO: Validate token and fetch user profile + /** + * Initialize auth state on app startup. + * + * Tokens are persisted via pinia-plugin-persistedstate. + * If we have tokens, validate them by refreshing. + */ + async function init(): Promise { + if (accessToken.value && refreshToken.value) { + // Try to refresh to validate tokens + if (isTokenExpired.value) { + await refreshAccessToken() + } } } + /** + * Get OAuth login URL for a provider. + */ + function getOAuthUrl(provider: 'google' | 'discord'): string { + return `${config.apiBaseUrl}/api/auth/${provider}` + } + return { + // State user, - token, + accessToken, + refreshToken, + expiresAt, isLoading, error, + // Getters isAuthenticated, - login, - register, + isTokenExpired, + // Actions + setTokens, + setUser, + refreshAccessToken, + getValidToken, logout, init, + getOAuthUrl, } }, { persist: { - pick: ['token'], + pick: ['accessToken', 'refreshToken', 'expiresAt', 'user'], }, }) diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 098cd3f..1742164 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -8,4 +8,6 @@ export default pinia // Re-export stores for convenience export { useAuthStore } from './auth' +export { useUserStore } from './user' +export { useUiStore } from './ui' export { useGameStore } from './game' diff --git a/frontend/src/stores/ui.spec.ts b/frontend/src/stores/ui.spec.ts new file mode 100644 index 0000000..bd9d9cb --- /dev/null +++ b/frontend/src/stores/ui.spec.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +import { useUiStore } from './ui' + +describe('useUiStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.useFakeTimers() + }) + + describe('loading state', () => { + it('starts with no loading', () => { + /** + * Test that the UI store initializes without loading state. + * + * The app should not show a loading overlay on startup. + */ + const store = useUiStore() + + expect(store.isLoading).toBe(false) + expect(store.loadingCount).toBe(0) + expect(store.loadingMessage).toBeNull() + }) + + it('showLoading increments count', () => { + /** + * Test that showLoading enables the loading state. + * + * Components call showLoading to display a loading overlay + * during async operations. + */ + const store = useUiStore() + + store.showLoading() + + expect(store.isLoading).toBe(true) + expect(store.loadingCount).toBe(1) + }) + + it('showLoading sets message', () => { + /** + * Test that showLoading can set a custom message. + * + * Different operations may want to show different + * loading messages to the user. + */ + const store = useUiStore() + + store.showLoading('Loading cards...') + + expect(store.loadingMessage).toBe('Loading cards...') + }) + + it('hideLoading decrements count', () => { + /** + * Test that hideLoading decrements the loading count. + * + * Multiple components may show loading simultaneously, + * so we track count rather than boolean. + */ + const store = useUiStore() + + store.showLoading() + store.showLoading() + store.hideLoading() + + expect(store.isLoading).toBe(true) + expect(store.loadingCount).toBe(1) + + store.hideLoading() + + expect(store.isLoading).toBe(false) + expect(store.loadingCount).toBe(0) + }) + + it('hideLoading clears message when count reaches zero', () => { + /** + * Test that the loading message is cleared when loading ends. + * + * Stale messages shouldn't persist after loading completes. + */ + const store = useUiStore() + + store.showLoading('Loading...') + store.hideLoading() + + expect(store.loadingMessage).toBeNull() + }) + + it('forceHideLoading resets all loading state', () => { + /** + * Test that forceHideLoading clears all loading state. + * + * This is useful for error recovery when loading count + * gets out of sync. + */ + const store = useUiStore() + + store.showLoading('Loading...') + store.showLoading() + store.showLoading() + store.forceHideLoading() + + expect(store.isLoading).toBe(false) + expect(store.loadingCount).toBe(0) + expect(store.loadingMessage).toBeNull() + }) + }) + + describe('toast notifications', () => { + it('starts with no toasts', () => { + /** + * Test that the UI store initializes without toasts. + * + * No toast notifications should be shown on startup. + */ + const store = useUiStore() + + expect(store.toasts).toEqual([]) + }) + + it('showToast adds a toast', () => { + /** + * Test that showToast creates a toast notification. + * + * Toasts are used to show success, error, and info + * messages to the user. + */ + const store = useUiStore() + + const id = store.showToast('success', 'Operation completed!') + + expect(store.toasts).toHaveLength(1) + expect(store.toasts[0]).toMatchObject({ + id, + type: 'success', + message: 'Operation completed!', + }) + }) + + it('showToast auto-dismisses after duration', () => { + /** + * Test that toasts auto-dismiss after their duration. + * + * Toasts should disappear automatically so users don't + * have to manually dismiss them. + */ + const store = useUiStore() + + store.showToast('info', 'Auto dismiss test', 3000) + + expect(store.toasts).toHaveLength(1) + + vi.advanceTimersByTime(3000) + + expect(store.toasts).toHaveLength(0) + }) + + it('dismissToast removes a specific toast', () => { + /** + * Test that dismissToast removes a toast by ID. + * + * Users may want to manually dismiss toasts before + * they auto-dismiss. + */ + const store = useUiStore() + + const id1 = store.showToast('success', 'Toast 1', 0) // No auto-dismiss + const id2 = store.showToast('error', 'Toast 2', 0) + + store.dismissToast(id1) + + expect(store.toasts).toHaveLength(1) + expect(store.toasts[0].id).toBe(id2) + }) + + it('dismissAllToasts clears all toasts', () => { + /** + * Test that dismissAllToasts removes all toasts. + * + * Useful for clearing notifications when navigating + * to a new page. + */ + const store = useUiStore() + + store.showToast('success', 'Toast 1', 0) + store.showToast('error', 'Toast 2', 0) + store.showToast('info', 'Toast 3', 0) + + store.dismissAllToasts() + + expect(store.toasts).toHaveLength(0) + }) + + it('convenience methods create correct toast types', () => { + /** + * Test that convenience methods set correct toast types. + * + * showSuccess, showError, showWarning, showInfo should + * create toasts with the appropriate type. + */ + const store = useUiStore() + + store.showSuccess('Success!', 0) + store.showError('Error!', 0) + store.showWarning('Warning!', 0) + store.showInfo('Info!', 0) + + expect(store.toasts[0].type).toBe('success') + expect(store.toasts[1].type).toBe('error') + expect(store.toasts[2].type).toBe('warning') + expect(store.toasts[3].type).toBe('info') + }) + }) + + describe('modal state', () => { + it('starts with closed modal', () => { + /** + * Test that the UI store initializes with no modal open. + * + * Modals should only open when explicitly triggered. + */ + const store = useUiStore() + + expect(store.modal.isOpen).toBe(false) + expect(store.modal.component).toBeNull() + expect(store.modal.props).toEqual({}) + }) + + it('openModal sets modal state', () => { + /** + * Test that openModal configures and shows a modal. + * + * The modal system uses component names and props to + * dynamically render modal content. + */ + const store = useUiStore() + + store.openModal('CardDetailModal', { cardId: '123' }) + + expect(store.modal.isOpen).toBe(true) + expect(store.modal.component).toBe('CardDetailModal') + expect(store.modal.props).toEqual({ cardId: '123' }) + }) + + it('closeModal resets modal state', () => { + /** + * Test that closeModal hides and resets the modal. + * + * After closing, modal state should be fully reset + * for the next use. + */ + const store = useUiStore() + + store.openModal('ConfirmModal', { message: 'Are you sure?' }) + store.closeModal() + + expect(store.modal.isOpen).toBe(false) + expect(store.modal.component).toBeNull() + expect(store.modal.props).toEqual({}) + }) + }) +}) diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts new file mode 100644 index 0000000..383923a --- /dev/null +++ b/frontend/src/stores/ui.ts @@ -0,0 +1,183 @@ +/** + * UI store for global UI state. + * + * Manages loading overlays, toast notifications, and modal state. + * Components can use this store to show/hide UI elements globally. + */ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +export type ToastType = 'success' | 'error' | 'warning' | 'info' + +export interface Toast { + id: string + type: ToastType + message: string + duration: number +} + +export interface ModalState { + isOpen: boolean + component: string | null + props: Record +} + +export const useUiStore = defineStore('ui', () => { + // Loading state + const loadingCount = ref(0) + const loadingMessage = ref(null) + + // Toast state + const toasts = ref([]) + let toastIdCounter = 0 + + // Modal state + const modal = ref({ + isOpen: false, + component: null, + props: {}, + }) + + // Getters + const isLoading = computed(() => loadingCount.value > 0) + + /** + * Show the loading overlay. + * + * Multiple calls stack - each showLoading() needs a hideLoading(). + */ + function showLoading(message?: string): void { + loadingCount.value++ + if (message) { + loadingMessage.value = message + } + } + + /** + * Hide the loading overlay. + * + * Only hides when all showLoading() calls have been balanced. + */ + function hideLoading(): void { + if (loadingCount.value > 0) { + loadingCount.value-- + } + if (loadingCount.value === 0) { + loadingMessage.value = null + } + } + + /** + * Force hide the loading overlay regardless of count. + */ + function forceHideLoading(): void { + loadingCount.value = 0 + loadingMessage.value = null + } + + /** + * Show a toast notification. + * + * @param type - Toast type (success, error, warning, info) + * @param message - Message to display + * @param duration - Duration in milliseconds (default: 5000) + * @returns Toast ID for manual dismissal + */ + function showToast(type: ToastType, message: string, duration = 5000): string { + const id = `toast-${++toastIdCounter}` + const toast: Toast = { id, type, message, duration } + + toasts.value.push(toast) + + // Auto-dismiss after duration + if (duration > 0) { + setTimeout(() => { + dismissToast(id) + }, duration) + } + + return id + } + + /** + * Dismiss a toast by ID. + */ + function dismissToast(id: string): void { + const index = toasts.value.findIndex(t => t.id === id) + if (index !== -1) { + toasts.value.splice(index, 1) + } + } + + /** + * Dismiss all toasts. + */ + function dismissAllToasts(): void { + toasts.value = [] + } + + // Convenience methods for toast types + function showSuccess(message: string, duration?: number): string { + return showToast('success', message, duration) + } + + function showError(message: string, duration?: number): string { + return showToast('error', message, duration) + } + + function showWarning(message: string, duration?: number): string { + return showToast('warning', message, duration) + } + + function showInfo(message: string, duration?: number): string { + return showToast('info', message, duration) + } + + /** + * Open a modal. + * + * @param component - Component name to render + * @param props - Props to pass to the component + */ + function openModal(component: string, props: Record = {}): void { + modal.value = { + isOpen: true, + component, + props, + } + } + + /** + * Close the current modal. + */ + function closeModal(): void { + modal.value = { + isOpen: false, + component: null, + props: {}, + } + } + + return { + // Loading state + loadingCount, + loadingMessage, + isLoading, + showLoading, + hideLoading, + forceHideLoading, + // Toast state + toasts, + showToast, + dismissToast, + dismissAllToasts, + showSuccess, + showError, + showWarning, + showInfo, + // Modal state + modal, + openModal, + closeModal, + } +}) diff --git a/frontend/src/stores/user.spec.ts b/frontend/src/stores/user.spec.ts new file mode 100644 index 0000000..5fda337 --- /dev/null +++ b/frontend/src/stores/user.spec.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +import { useUserStore } from './user' + +describe('useUserStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('starts with no profile', () => { + /** + * Test that the user store initializes without profile data. + * + * Before fetching the user profile, the store should have + * null profile and sensible defaults for computed properties. + */ + const store = useUserStore() + + expect(store.profile).toBeNull() + expect(store.displayName).toBe('Unknown') + expect(store.avatarUrl).toBeUndefined() + expect(store.hasStarterDeck).toBe(false) + expect(store.linkedAccounts).toEqual([]) + }) + + it('tracks loading state', () => { + /** + * Test that the loading state ref is accessible. + * + * Components need to check loading state to show spinners + * while profile data is being fetched. + */ + const store = useUserStore() + + expect(store.isLoading).toBe(false) + }) + + it('tracks error state', () => { + /** + * Test that the error state ref is accessible. + * + * Components need to display error messages when + * profile operations fail. + */ + const store = useUserStore() + + expect(store.error).toBeNull() + }) + + it('clear resets all profile state', () => { + /** + * Test that clear removes all profile data. + * + * When user logs out, clear() should reset the store + * to its initial state. + */ + const store = useUserStore() + + // Manually set profile (simulating fetchProfile success) + store.profile = { + id: 'user-123', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.png', + hasStarterDeck: true, + createdAt: '2026-01-01T00:00:00Z', + linkedAccounts: [ + { + provider: 'google', + providerUserId: 'google-123', + email: 'test@example.com', + linkedAt: '2026-01-01T00:00:00Z', + }, + ], + } + store.error = 'Some error' + + store.clear() + + expect(store.profile).toBeNull() + expect(store.error).toBeNull() + }) + + it('computed properties reflect profile data', () => { + /** + * Test that computed properties derive from profile correctly. + * + * Components use these computed properties for display, + * so they must accurately reflect the profile data. + */ + const store = useUserStore() + + store.profile = { + id: 'user-123', + displayName: 'Test User', + avatarUrl: 'https://example.com/avatar.png', + hasStarterDeck: true, + createdAt: '2026-01-01T00:00:00Z', + linkedAccounts: [ + { + provider: 'discord', + providerUserId: 'discord-456', + email: null, + linkedAt: '2026-01-01T00:00:00Z', + }, + ], + } + + expect(store.displayName).toBe('Test User') + expect(store.avatarUrl).toBe('https://example.com/avatar.png') + expect(store.hasStarterDeck).toBe(true) + expect(store.linkedAccounts).toHaveLength(1) + expect(store.linkedAccounts[0].provider).toBe('discord') + }) +}) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts new file mode 100644 index 0000000..4891370 --- /dev/null +++ b/frontend/src/stores/user.ts @@ -0,0 +1,162 @@ +/** + * User profile store. + * + * Manages extended user profile data beyond basic auth info. + * Includes linked accounts, collection stats, and profile settings. + */ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' + +import { config } from '@/config' +import { useAuthStore } from './auth' + +export interface LinkedAccount { + provider: 'google' | 'discord' + providerUserId: string + email: string | null + linkedAt: string +} + +export interface UserProfile { + id: string + displayName: string + avatarUrl: string | null + hasStarterDeck: boolean + createdAt: string + linkedAccounts: LinkedAccount[] +} + +export const useUserStore = defineStore('user', () => { + // State + const profile = ref(null) + const isLoading = ref(false) + const error = ref(null) + + // Getters + const displayName = computed(() => profile.value?.displayName ?? 'Unknown') + const avatarUrl = computed(() => profile.value?.avatarUrl) + const hasStarterDeck = computed(() => profile.value?.hasStarterDeck ?? false) + const linkedAccounts = computed(() => profile.value?.linkedAccounts ?? []) + + /** + * Fetch the current user's profile from the API. + */ + async function fetchProfile(): Promise { + const auth = useAuthStore() + const token = await auth.getValidToken() + + if (!token) { + error.value = 'Not authenticated' + return false + } + + isLoading.value = true + error.value = null + + try { + const response = await fetch(`${config.apiBaseUrl}/api/users/me`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }) + + if (!response.ok) { + throw new Error('Failed to fetch profile') + } + + const data = await response.json() + profile.value = { + id: data.id, + displayName: data.display_name, + avatarUrl: data.avatar_url, + hasStarterDeck: data.has_starter_deck, + createdAt: data.created_at, + linkedAccounts: data.linked_accounts?.map((acc: Record) => ({ + provider: acc.provider, + providerUserId: acc.provider_user_id, + email: acc.email, + linkedAt: acc.linked_at, + })) ?? [], + } + + // Also update auth store's user ref for guards + auth.setUser({ + id: profile.value.id, + displayName: profile.value.displayName, + avatarUrl: profile.value.avatarUrl, + hasStarterDeck: profile.value.hasStarterDeck, + }) + + return true + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to fetch profile' + return false + } finally { + isLoading.value = false + } + } + + /** + * Update the user's display name. + */ + async function updateDisplayName(newName: string): Promise { + const auth = useAuthStore() + const token = await auth.getValidToken() + + if (!token) { + error.value = 'Not authenticated' + return false + } + + isLoading.value = true + error.value = null + + try { + const response = await fetch(`${config.apiBaseUrl}/api/users/me`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ display_name: newName }), + }) + + if (!response.ok) { + throw new Error('Failed to update profile') + } + + // Refetch to get updated data + await fetchProfile() + return true + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to update profile' + return false + } finally { + isLoading.value = false + } + } + + /** + * Clear profile data on logout. + */ + function clear(): void { + profile.value = null + error.value = null + } + + return { + // State + profile, + isLoading, + error, + // Getters + displayName, + avatarUrl, + hasStarterDeck, + linkedAccounts, + // Actions + fetchProfile, + updateDisplayName, + clear, + } +})