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",
|
"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"},
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
if (isTokenExpired.value) {
|
||||||
|
const success = await refreshAccessToken()
|
||||||
|
if (!success) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const response = await fetch('/api/auth/register', {
|
await fetch(`${config.apiBaseUrl}/api/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
body: JSON.stringify({ username, email, password }),
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken.value}`,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
if (!response.ok) {
|
// Ignore errors - we're logging out anyway
|
||||||
const data = await response.json()
|
|
||||||
throw new Error(data.detail || 'Registration failed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
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