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:
parent
5424bf9086
commit
f63e8be600
@ -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"},
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<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 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
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
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<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
|
||||
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<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 {
|
||||
// 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'],
|
||||
},
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
264
frontend/src/stores/ui.spec.ts
Normal file
264
frontend/src/stores/ui.spec.ts
Normal 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
183
frontend/src/stores/ui.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
115
frontend/src/stores/user.spec.ts
Normal file
115
frontend/src/stores/user.spec.ts
Normal 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
162
frontend/src/stores/user.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user