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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 11:07:25 -06:00
parent 5424bf9086
commit f63e8be600
9 changed files with 1045 additions and 73 deletions

View File

@ -6,7 +6,7 @@
"created": "2026-01-30", "created": "2026-01-30",
"lastUpdated": "2026-01-30", "lastUpdated": "2026-01-30",
"totalTasks": 8, "totalTasks": 8,
"completedTasks": 4, "completedTasks": 5,
"status": "in_progress" "status": "in_progress"
}, },
"tasks": [ "tasks": [
@ -79,8 +79,8 @@
"description": "Create store structure with persistence", "description": "Create store structure with persistence",
"category": "stores", "category": "stores",
"priority": 4, "priority": 4,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": ["F0-001"], "dependencies": ["F0-001"],
"files": [ "files": [
{"path": "src/stores/auth.ts", "status": "modify"}, {"path": "src/stores/auth.ts", "status": "modify"},

View File

@ -35,8 +35,7 @@ describe('requireAuth', () => {
* The redirect should include the original path so users can be * The redirect should include the original path so users can be
* returned there after logging in. * returned there after logging in.
*/ */
const auth = useAuthStore() // Auth store starts with null accessToken (not authenticated)
auth.token = null
const to = createMockRoute({ path: '/collection', fullPath: '/collection' }) const to = createMockRoute({ path: '/collection', fullPath: '/collection' })
const from = createMockRoute() const from = createMockRoute()
@ -58,7 +57,11 @@ describe('requireAuth', () => {
* to the requested route without redirection. * to the requested route without redirection.
*/ */
const auth = useAuthStore() 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 to = createMockRoute({ path: '/collection' })
const from = createMockRoute() const from = createMockRoute()
@ -83,7 +86,11 @@ describe('requireGuest', () => {
* since they don't need to log in again. * since they don't need to log in again.
*/ */
const auth = useAuthStore() 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 to = createMockRoute({ path: '/login' })
const from = createMockRoute() const from = createMockRoute()
@ -101,8 +108,7 @@ describe('requireGuest', () => {
* Users who are not logged in should be able to reach the * Users who are not logged in should be able to reach the
* login page and other guest-only routes. * login page and other guest-only routes.
*/ */
const auth = useAuthStore() // Auth store starts with null accessToken (not authenticated)
auth.token = null
const to = createMockRoute({ path: '/login' }) const to = createMockRoute({ path: '/login' })
const from = createMockRoute() const from = createMockRoute()
@ -128,8 +134,12 @@ describe('requireStarter', () => {
* cause a redirect loop). * cause a redirect loop).
*/ */
const auth = useAuthStore() const auth = useAuthStore()
auth.token = 'valid-token' auth.setTokens({
auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false } 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 to = createMockRoute({ path: '/starter' })
const from = createMockRoute() const from = createMockRoute()
@ -148,8 +158,12 @@ describe('requireStarter', () => {
* main app. This ensures they have cards to play with. * main app. This ensures they have cards to play with.
*/ */
const auth = useAuthStore() const auth = useAuthStore()
auth.token = 'valid-token' auth.setTokens({
auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false } 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 to = createMockRoute({ path: '/collection' })
const from = createMockRoute() const from = createMockRoute()
@ -168,8 +182,12 @@ describe('requireStarter', () => {
* have full access to collection, decks, and gameplay. * have full access to collection, decks, and gameplay.
*/ */
const auth = useAuthStore() const auth = useAuthStore()
auth.token = 'valid-token' auth.setTokens({
auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: true } 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 to = createMockRoute({ path: '/collection' })
const from = createMockRoute() const from = createMockRoute()
@ -189,8 +207,12 @@ describe('requireStarter', () => {
* user data and may redirect later if needed. * user data and may redirect later if needed.
*/ */
const auth = useAuthStore() const auth = useAuthStore()
auth.token = 'valid-token' auth.setTokens({
auth.user = null accessToken: 'valid-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3600000,
})
// user is null by default
const to = createMockRoute({ path: '/collection' }) const to = createMockRoute({ path: '/collection' })
const from = createMockRoute() const from = createMockRoute()

View File

@ -12,14 +12,15 @@ describe('useAuthStore', () => {
/** /**
* Test that the auth store initializes in an unauthenticated state. * 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. * ensuring protected routes are inaccessible by default.
*/ */
const store = useAuthStore() const store = useAuthStore()
expect(store.isAuthenticated).toBe(false) expect(store.isAuthenticated).toBe(false)
expect(store.user).toBeNull() expect(store.user).toBeNull()
expect(store.token).toBeNull() expect(store.accessToken).toBeNull()
expect(store.refreshToken).toBeNull()
}) })
it('tracks loading state', () => { it('tracks loading state', () => {
@ -39,10 +40,148 @@ describe('useAuthStore', () => {
* Test that the error state ref is accessible. * Test that the error state ref is accessible.
* *
* Components need to display error messages when * Components need to display error messages when
* login or registration fails. * token refresh or other auth operations fail.
*/ */
const store = useAuthStore() const store = useAuthStore()
expect(store.error).toBeNull() 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')
})
}) })

View File

@ -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 { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { config } from '@/config'
export interface User { export interface User {
id: string id: string
displayName: string displayName: string
@ -8,106 +16,183 @@ export interface User {
hasStarterDeck: boolean hasStarterDeck: boolean
} }
export interface AuthTokens {
accessToken: string
refreshToken: string
expiresAt: number
}
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null) const user = ref<User | null>(null)
const token = ref<string | null>(null) const accessToken = ref<string | null>(null)
const refreshToken = ref<string | null>(null)
const expiresAt = ref<number | null>(null)
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(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<boolean> {
if (!refreshToken.value) {
logout()
return false
}
async function login(username: string, password: string): Promise<boolean> {
isLoading.value = true isLoading.value = true
error.value = null error.value = null
try { try {
const response = await fetch('/api/auth/login', { const response = await fetch(`${config.apiBaseUrl}/api/auth/refresh`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify({ refresh_token: refreshToken.value }),
}) })
if (!response.ok) { if (!response.ok) {
const data = await response.json() throw new Error('Token refresh failed')
throw new Error(data.detail || 'Login failed')
} }
const data = await response.json() const data = await response.json()
token.value = data.access_token accessToken.value = data.access_token
user.value = data.user // Refresh token may or may not be rotated
// Note: localStorage is used for token persistence across page reloads. if (data.refresh_token) {
// This is standard for SPAs but tokens are accessible to XSS. The backend refreshToken.value = data.refresh_token
// should use short-lived access tokens + httpOnly refresh cookies for }
// production. See: https://auth0.com/docs/secure/security-guidance/data-security/token-storage // Calculate expiry (backend should send expires_in in seconds)
localStorage.setItem('auth_token', data.access_token) if (data.expires_in) {
expiresAt.value = Date.now() + data.expires_in * 1000
}
return true return true
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed' error.value = e instanceof Error ? e.message : 'Token refresh failed'
logout()
return false return false
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
async function register( /**
username: string, * Get a valid access token, refreshing if needed.
email: string, *
password: string * Returns null if not authenticated or refresh fails.
): Promise<boolean> { */
isLoading.value = true async function getValidToken(): Promise<string | null> {
error.value = null if (!accessToken.value) return null
try { if (isTokenExpired.value) {
const response = await fetch('/api/auth/register', { const success = await refreshAccessToken()
method: 'POST', if (!success) return null
headers: { 'Content-Type': 'application/json' }, }
body: JSON.stringify({ username, email, password }),
})
if (!response.ok) { return accessToken.value
const data = await response.json() }
throw new Error(data.detail || 'Registration failed')
/**
* Log out the user.
*
* Optionally calls the backend to revoke the refresh token.
*/
async function logout(revokeOnServer = true): Promise<void> {
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 user.value = null
token.value = null accessToken.value = null
localStorage.removeItem('auth_token') refreshToken.value = null
expiresAt.value = null
error.value = null
} }
// Restore token from localStorage on init /**
function init() { * Initialize auth state on app startup.
const storedToken = localStorage.getItem('auth_token') *
if (storedToken) { * Tokens are persisted via pinia-plugin-persistedstate.
token.value = storedToken * If we have tokens, validate them by refreshing.
// TODO: Validate token and fetch user profile */
async function init(): Promise<void> {
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 { return {
// State
user, user,
token, accessToken,
refreshToken,
expiresAt,
isLoading, isLoading,
error, error,
// Getters
isAuthenticated, isAuthenticated,
login, isTokenExpired,
register, // Actions
setTokens,
setUser,
refreshAccessToken,
getValidToken,
logout, logout,
init, init,
getOAuthUrl,
} }
}, { }, {
persist: { persist: {
pick: ['token'], pick: ['accessToken', 'refreshToken', 'expiresAt', 'user'],
}, },
}) })

View File

@ -8,4 +8,6 @@ export default pinia
// Re-export stores for convenience // Re-export stores for convenience
export { useAuthStore } from './auth' export { useAuthStore } from './auth'
export { useUserStore } from './user'
export { useUiStore } from './ui'
export { useGameStore } from './game' export { useGameStore } from './game'

View File

@ -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({})
})
})
})

183
frontend/src/stores/ui.ts Normal file
View File

@ -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<string, unknown>
}
export const useUiStore = defineStore('ui', () => {
// Loading state
const loadingCount = ref(0)
const loadingMessage = ref<string | null>(null)
// Toast state
const toasts = ref<Toast[]>([])
let toastIdCounter = 0
// Modal state
const modal = ref<ModalState>({
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<string, unknown> = {}): 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,
}
})

View File

@ -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')
})
})

162
frontend/src/stores/user.ts Normal file
View File

@ -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<UserProfile | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(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<boolean> {
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<string, unknown>) => ({
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<boolean> {
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,
}
})