Implement auth composables and starter selection (F1-003, F1-004, F1-005)

Features:
- Add useAuth composable with OAuth flow and token management
- Add useStarter composable with API integration and dev mock fallback
- Implement app auth initialization blocking navigation until ready
- Complete StarterSelectionPage with 5 themed deck options

Bug fixes:
- Fix CORS by adding localhost:3001 to allowed origins
- Fix OAuth URL to include redirect_uri parameter
- Fix emoji rendering in nav components (use actual chars, not escapes)
- Fix requireStarter guard timing by allowing navigation from /starter
- Fix starter "already selected" detection for 400 status code

Documentation:
- Update dev-server skill to use `docker compose` (newer CLI syntax)
- Update .env.example with port 3001 in CORS comment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 15:36:14 -06:00
parent f687909f91
commit 3cc8d6645e
17 changed files with 2974 additions and 163 deletions

View File

@ -1,6 +1,6 @@
# Dev Server Skill
Start both frontend and backend development servers with pre-flight checks.
Start the complete Mantimon TCG development environment including all services.
## Usage
@ -9,9 +9,9 @@ Start both frontend and backend development servers with pre-flight checks.
```
**Commands:**
- `start` (default) - Run checks and start both servers
- `stop` - Stop all running dev servers
- `status` - Check if servers are running
- `start` (default) - Start all services (Docker infra + backend + frontend)
- `stop` - Stop all running services
- `status` - Check status of all services
## Instructions
@ -19,144 +19,225 @@ When this skill is invoked, follow these steps:
### 1. Pre-flight Checks
Run all checks in parallel where possible:
#### Environment Variables
Check that required env files exist and have valid values:
**Frontend** (`frontend/.env.development`):
- `VITE_API_BASE_URL` - Must be a valid URL
- `VITE_WS_URL` - Must be a valid URL
- `VITE_OAUTH_REDIRECT_URI` - Must be a valid URL
**Backend** (`backend/.env` or environment):
- `DATABASE_URL` - PostgreSQL connection string
- `REDIS_URL` - Redis connection string
- `JWT_SECRET` - Must be set (check length >= 32)
#### Port Availability
Check if default ports are available:
- Frontend: 5173, 3000, 3001 (Vite will auto-increment)
- Backend: 8000, 8001
Run checks to understand current state:
```bash
# Check port availability
lsof -i :8000 2>/dev/null | grep LISTEN
lsof -i :5173 2>/dev/null | grep LISTEN
# Check Docker services
cd /mnt/NV2/Development/mantimon-tcg/backend
docker compose ps 2>/dev/null | grep -E "mantimon-(postgres|redis|adminer)" || echo "Docker services: not running"
# Check application servers
curl -s http://localhost:8001/health 2>/dev/null && echo "Backend: running" || echo "Backend: not running"
curl -s http://localhost:3001 >/dev/null 2>&1 && echo "Frontend: running" || echo "Frontend: not running"
# Check environment files
[ -f "/mnt/NV2/Development/mantimon-tcg/frontend/.env.development" ] && echo "Frontend .env: OK" || echo "Frontend .env: MISSING"
[ -f "/mnt/NV2/Development/mantimon-tcg/backend/.env" ] && echo "Backend .env: OK" || echo "Backend .env: MISSING"
# Check dependencies
[ -d "/mnt/NV2/Development/mantimon-tcg/frontend/node_modules" ] && echo "node_modules: OK" || echo "node_modules: MISSING"
[ -d "/mnt/NV2/Development/mantimon-tcg/backend/.venv" ] && echo "Backend venv: OK" || echo "Backend venv: MISSING"
```
#### Dependencies
Verify dependencies are installed:
### 2. Start Infrastructure Services (Docker Compose)
Start PostgreSQL, Redis, and Adminer using the existing docker compose file:
```bash
cd /mnt/NV2/Development/mantimon-tcg/backend
docker compose up -d
# Wait for services to be healthy
echo "Waiting for services to be ready..."
sleep 5
# Verify services are up
docker compose ps
```
### 3. Install Dependencies (if needed)
```bash
# Frontend
[ -d "frontend/node_modules" ] || echo "Run: cd frontend && npm install"
if [ ! -d "/mnt/NV2/Development/mantimon-tcg/frontend/node_modules" ]; then
echo "Installing frontend dependencies..."
cd /mnt/NV2/Development/mantimon-tcg/frontend && npm install
fi
# Backend
[ -f "backend/.venv/bin/activate" ] || echo "Run: cd backend && uv sync"
if [ ! -d "/mnt/NV2/Development/mantimon-tcg/backend/.venv" ]; then
echo "Installing backend dependencies..."
cd /mnt/NV2/Development/mantimon-tcg/backend && uv sync
fi
```
#### Database/Redis
Check if PostgreSQL and Redis are accessible:
### 4. Run Database Migrations
```bash
# PostgreSQL (if DATABASE_URL is set)
pg_isready -d "$DATABASE_URL" 2>/dev/null
# Redis (if REDIS_URL is set)
redis-cli -u "$REDIS_URL" ping 2>/dev/null
cd /mnt/NV2/Development/mantimon-tcg/backend && uv run alembic upgrade head
```
### 2. Report Issues
If any checks fail, display a clear report:
```markdown
## Pre-flight Check Results
| Check | Status | Action Required |
|-------|--------|-----------------|
| Frontend .env | MISSING | Create frontend/.env.development |
| Port 8000 | IN USE | Kill process or use --port 8001 |
| node_modules | MISSING | Run: cd frontend && npm install |
| PostgreSQL | OK | - |
```
If there are blocking issues, ask the user how to proceed.
### 3. Start Servers
### 5. Start Application Servers
Start both servers in the background:
```bash
# Start backend first (frontend depends on it)
cd backend && uv run uvicorn app.main:app --reload --port $BACKEND_PORT 2>&1 &
# Start backend (port 8001)
cd /mnt/NV2/Development/mantimon-tcg/backend && uv run uvicorn app.main:app --reload --port 8001 2>&1 &
# Wait for backend to be ready
sleep 3
for i in {1..15}; do
curl -s http://localhost:8001/health 2>/dev/null && break
sleep 1
done
# Start frontend
cd frontend && npm run dev 2>&1 &
# Start frontend (port 3001)
cd /mnt/NV2/Development/mantimon-tcg/frontend && npm run dev 2>&1 &
# Wait for frontend to be ready
for i in {1..15}; do
curl -s http://localhost:3001 >/dev/null 2>&1 && break
sleep 1
done
```
### 4. Verify and Display Status
### 6. Display Status
Wait for both servers to be ready, then display:
After starting, display the status table:
```markdown
## Dev Environment Ready
## Dev Environment Status
| Service | URL | Status |
|---------|-----|--------|
| Frontend | http://localhost:5173/ | Running |
| Backend API | http://localhost:8000/ | Running |
| Backend Docs | http://localhost:8000/docs | Running |
| PostgreSQL | localhost:5432 | Connected |
| Redis | localhost:6379 | Connected |
| Service | URL/Port | Status |
|---------|----------|--------|
| Frontend | http://localhost:3001 | Running |
| Backend API | http://localhost:8001 | Running |
| API Docs | http://localhost:8001/docs | Running |
| PostgreSQL | localhost:5433 | Running |
| Redis | localhost:6380 | Running |
| Adminer | http://localhost:8090 | Running |
**Quick Links:**
- App: http://localhost:5173/
- API Docs: http://localhost:8000/docs
- Health Check: http://localhost:8000/api/health
- App: http://localhost:3001
- API Docs: http://localhost:8001/docs
- Health Check: http://localhost:8001/health
- Database Admin: http://localhost:8090 (System: PostgreSQL, Server: mantimon-postgres, User: mantimon, Pass: mantimon)
**OAuth Status:**
- Discord: [Configured/Not configured]
- Google: [Configured/Not configured]
```
### 5. Stop Command
Check OAuth configuration:
```bash
# Check if Discord is configured (has actual values, not placeholder)
grep -E "^DISCORD_CLIENT_ID=.+" /mnt/NV2/Development/mantimon-tcg/backend/.env 2>/dev/null | grep -v "your-client-id" && \
echo "Discord OAuth: Configured" || echo "Discord OAuth: Not configured"
# Check if Google is configured
grep -E "^GOOGLE_CLIENT_ID=.+" /mnt/NV2/Development/mantimon-tcg/backend/.env 2>/dev/null | grep -v "your-client-id" && \
echo "Google OAuth: Configured" || echo "Google OAuth: Not configured"
```
### 7. Stop Command
When `stop` is requested:
```bash
pkill -f "uvicorn app.main:app"
pkill -f "vite"
# Stop application servers
pkill -f "uvicorn app.main:app" 2>/dev/null
pkill -f "vite" 2>/dev/null
echo "Application servers stopped."
```
### 6. Status Command
Then ask: "Stop Docker containers (PostgreSQL/Redis/Adminer) too? They can be left running for faster restarts."
When `status` is requested, check running processes and display current state.
## Common Gotchas
The skill should check for and warn about:
1. **Port mismatch** - Frontend .env pointing to wrong backend port
2. **Missing OAuth config** - Google/Discord credentials not set
3. **Database not migrated** - Check if alembic migrations are pending
4. **Redis not running** - Socket.IO requires Redis for pub/sub
5. **Old processes** - Zombie uvicorn/vite processes from crashed sessions
6. **TypeScript errors** - Run `npm run typecheck` before starting
## Environment Variable Reference
### Frontend (.env.development)
If yes:
```bash
VITE_API_BASE_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
VITE_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback
cd /mnt/NV2/Development/mantimon-tcg/backend && docker compose down
```
### Backend (.env)
### 8. Status Command
When `status` is requested, check and display all services without starting anything:
```bash
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/mantimon
REDIS_URL=redis://localhost:6379/0
JWT_SECRET=your-secret-key-at-least-32-characters
GOOGLE_CLIENT_ID=optional
GOOGLE_CLIENT_SECRET=optional
DISCORD_CLIENT_ID=optional
DISCORD_CLIENT_SECRET=optional
cd /mnt/NV2/Development/mantimon-tcg/backend
echo "=== Docker Services ==="
docker compose ps
echo ""
echo "=== Application Servers ==="
curl -s http://localhost:8001/health 2>/dev/null && echo "Backend: healthy" || echo "Backend: not running"
curl -s http://localhost:3001 >/dev/null 2>&1 && echo "Frontend: running" || echo "Frontend: not running"
```
## Port Configuration
| Service | Port | Notes |
|---------|------|-------|
| Frontend | 3001 | Vite dev server |
| Backend | 8001 | FastAPI with uvicorn |
| PostgreSQL | 5433 | Non-standard to avoid conflicts |
| Redis | 6380 | Non-standard to avoid conflicts |
| Adminer | 8090 | Database admin UI |
## Environment Files
### Frontend (`frontend/.env.development`)
```bash
VITE_API_BASE_URL=http://localhost:8001
VITE_WS_URL=http://localhost:8001
VITE_OAUTH_REDIRECT_URI=http://localhost:3001/auth/callback
```
### Backend (`backend/.env`)
```bash
# Database
DATABASE_URL=postgresql+asyncpg://mantimon:mantimon@localhost:5433/mantimon
# Redis
REDIS_URL=redis://localhost:6380/0
# Security
SECRET_KEY=dev-secret-key-change-in-production
# OAuth (optional for dev)
DISCORD_CLIENT_ID=your-client-id
DISCORD_CLIENT_SECRET=your-client-secret
# GOOGLE_CLIENT_ID=your-client-id
# GOOGLE_CLIENT_SECRET=your-client-secret
# Base URL for OAuth callbacks
BASE_URL=http://localhost:8001
```
## Troubleshooting
### Port already in use
```bash
lsof -i :8001 # Find process
kill -9 <PID> # Kill it
```
### Docker services not starting
```bash
cd backend
docker compose logs # Check what went wrong
docker compose down && docker compose up -d # Restart
```
### Database migrations failed
```bash
cd backend && uv run alembic downgrade -1 && uv run alembic upgrade head
```
### Clear all and start fresh
```bash
cd backend
docker compose down -v # Remove volumes too
docker compose up -d
uv run alembic upgrade head
```

View File

@ -68,7 +68,8 @@ JWT_REFRESH_EXPIRE_DAYS=7
# CORS
# =============================================================================
# Comma-separated list of allowed origins
# CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Default: http://localhost:3000,http://localhost:3001,http://localhost:5173
# CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:5173
# =============================================================================
# GAME SETTINGS

View File

@ -150,7 +150,7 @@ class Settings(BaseSettings):
# CORS
cors_origins: list[str] = Field(
default=["http://localhost:3000", "http://localhost:5173"],
default=["http://localhost:3000", "http://localhost:3001", "http://localhost:5173"],
description="Allowed CORS origins",
)

View File

@ -4,9 +4,9 @@
"name": "Authentication Flow",
"version": "1.0.0",
"created": "2026-01-30",
"lastUpdated": "2026-01-30",
"lastUpdated": "2026-01-30T15:00:00Z",
"totalTasks": 10,
"completedTasks": 2,
"completedTasks": 5,
"status": "in_progress",
"description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization."
},
@ -95,8 +95,8 @@
"description": "Vue composable for auth operations with loading/error states",
"category": "composables",
"priority": 3,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["F1-002"],
"files": [
{"path": "src/composables/useAuth.ts", "status": "create"},
@ -124,8 +124,8 @@
"description": "Initialize auth state on app startup",
"category": "setup",
"priority": 4,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["F1-003"],
"files": [
{"path": "src/App.vue", "status": "modify"},
@ -153,8 +153,8 @@
"description": "Complete starter deck selection with API integration",
"category": "pages",
"priority": 5,
"completed": false,
"tested": false,
"completed": true,
"tested": true,
"dependencies": ["F1-003"],
"files": [
{"path": "src/pages/StarterSelectionPage.vue", "status": "modify"},

234
frontend/src/App.spec.ts Normal file
View File

@ -0,0 +1,234 @@
/**
* Tests for App.vue root component.
*
* Verifies that auth initialization happens on app startup and that
* the loading state is properly displayed during initialization.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { defineComponent, h, nextTick } from 'vue'
// Track initialization calls and control resolution
let initializeResolve: ((value: boolean) => void) | null = null
let initializeCalled = false
// Mock useAuth composable
vi.mock('@/composables/useAuth', () => ({
useAuth: () => ({
initialize: vi.fn(() => {
initializeCalled = true
return new Promise<boolean>((resolve) => {
initializeResolve = resolve
})
}),
isInitialized: { value: false },
isAuthenticated: { value: false },
user: { value: null },
isLoading: { value: false },
error: { value: null },
}),
}))
// Mock vue-router
vi.mock('vue-router', () => ({
useRoute: () => ({
meta: { layout: 'default' },
name: 'Dashboard',
path: '/',
}),
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
}))
import App from './App.vue'
// Create stub components for layouts
const DefaultLayoutStub = defineComponent({
name: 'DefaultLayout',
setup(_, { slots }) {
return () => h('div', { class: 'default-layout' }, slots.default?.())
},
})
const MinimalLayoutStub = defineComponent({
name: 'MinimalLayout',
setup(_, { slots }) {
return () => h('div', { class: 'minimal-layout' }, slots.default?.())
},
})
const GameLayoutStub = defineComponent({
name: 'GameLayout',
setup(_, { slots }) {
return () => h('div', { class: 'game-layout' }, slots.default?.())
},
})
const LoadingOverlayStub = defineComponent({
name: 'LoadingOverlay',
setup() {
return () => h('div', { class: 'loading-overlay-mock' })
},
})
const ToastContainerStub = defineComponent({
name: 'ToastContainer',
setup() {
return () => h('div', { class: 'toast-container-mock' })
},
})
describe('App.vue', () => {
beforeEach(() => {
setActivePinia(createPinia())
initializeCalled = false
initializeResolve = null
})
afterEach(() => {
vi.clearAllMocks()
})
const mountApp = () => {
return mount(App, {
global: {
stubs: {
RouterView: true,
DefaultLayout: DefaultLayoutStub,
MinimalLayout: MinimalLayoutStub,
GameLayout: GameLayoutStub,
LoadingOverlay: LoadingOverlayStub,
ToastContainer: ToastContainerStub,
// Also stub async component wrappers
AsyncComponentWrapper: true,
Suspense: false,
},
},
})
}
describe('auth initialization', () => {
it('shows loading spinner while auth is initializing', async () => {
/**
* Test loading state during auth initialization.
*
* When the app starts, it should show a loading spinner while
* auth tokens are being validated. This prevents navigation guards
* from running before auth state is known.
*/
const wrapper = mountApp()
// Should show loading text initially
expect(wrapper.text()).toContain('Loading...')
wrapper.unmount()
})
it('calls initialize on mount', async () => {
/**
* Test that auth initialization is triggered on mount.
*
* The initialize() function must be called to validate tokens,
* refresh if needed, and fetch the user profile.
*/
const wrapper = mountApp()
// Wait for onMounted to run
await nextTick()
expect(initializeCalled).toBe(true)
wrapper.unmount()
})
it('hides loading spinner after initialization completes', async () => {
/**
* Test transition from loading to content.
*
* Once auth initialization completes, the loading spinner should
* be hidden and the main app content should be rendered.
*/
const wrapper = mountApp()
// Wait for onMounted
await nextTick()
// Should show loading initially
expect(wrapper.text()).toContain('Loading...')
// Resolve initialization (successful)
expect(initializeResolve).not.toBeNull()
initializeResolve!(true)
// Wait for state update
await flushPromises()
await nextTick()
// Loading text should be gone
expect(wrapper.text()).not.toContain('Loading...')
wrapper.unmount()
})
})
describe('initialization state machine', () => {
it('starts in initializing state and transitions to initialized', async () => {
/**
* Test the auth initialization state machine.
*
* The app should start showing the loading state and transition
* to the main content after initialization completes.
*/
const wrapper = mountApp()
await nextTick()
// Before initialization completes
expect(wrapper.find('.bg-gray-900').exists()).toBe(true)
// Complete initialization (successful)
initializeResolve!(true)
await flushPromises()
await nextTick()
// After initialization - the fixed loading div should be gone
// (checking that the loading screen specific class combo is gone)
const loadingScreen = wrapper.find('.fixed.inset-0.z-50.bg-gray-900')
expect(loadingScreen.exists()).toBe(false)
wrapper.unmount()
})
it('renders app content even when initialization returns false (auth failed)', async () => {
/**
* Test that app renders correctly when auth initialization fails.
*
* When initialize() returns false (e.g., tokens invalid, network error
* during profile fetch), the app should still transition out of the
* loading state and render the main content. Navigation guards will
* handle redirecting unauthenticated users to login.
*/
const wrapper = mountApp()
await nextTick()
// Should show loading initially
expect(wrapper.text()).toContain('Loading...')
// Complete initialization with failure (auth failed)
initializeResolve!(false)
await flushPromises()
await nextTick()
// Loading screen should be gone - app renders even on auth failure
const loadingScreen = wrapper.find('.fixed.inset-0.z-50.bg-gray-900')
expect(loadingScreen.exists()).toBe(false)
// Loading text should be gone
expect(wrapper.text()).not.toContain('Loading...')
wrapper.unmount()
})
})
})

View File

@ -2,14 +2,21 @@
/**
* Root application component.
*
* Renders the appropriate layout based on the current route's meta.layout
* property. Also includes global UI components (loading overlay, toasts).
* Handles auth initialization on startup and renders the appropriate layout
* based on the current route's meta.layout property.
*
* Auth initialization:
* - Shows loading spinner while validating tokens
* - Refreshes expired tokens automatically
* - Fetches user profile if authenticated
* - Blocks navigation until initialization completes
*/
import { computed, defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import LoadingOverlay from '@/components/ui/LoadingOverlay.vue'
import ToastContainer from '@/components/ui/ToastContainer.vue'
import { useAuth } from '@/composables/useAuth'
// Lazy-load layouts to reduce initial bundle size
const DefaultLayout = defineAsyncComponent(() => import('@/layouts/DefaultLayout.vue'))
@ -17,6 +24,10 @@ const MinimalLayout = defineAsyncComponent(() => import('@/layouts/MinimalLayout
const GameLayout = defineAsyncComponent(() => import('@/layouts/GameLayout.vue'))
const route = useRoute()
const { initialize } = useAuth()
// Track if auth initialization is in progress
const isAuthInitializing = ref(true)
type LayoutType = 'default' | 'minimal' | 'game'
@ -30,12 +41,43 @@ const currentLayout = computed(() => {
const layout = (route.meta.layout as LayoutType) || 'default'
return layoutComponents[layout] || layoutComponents.default
})
// Initialize auth on app mount
onMounted(async () => {
try {
await initialize()
} finally {
isAuthInitializing.value = false
}
})
</script>
<template>
<component :is="currentLayout">
<RouterView />
</component>
<!-- Show loading spinner while auth initializes -->
<div
v-if="isAuthInitializing"
class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900"
>
<div class="flex flex-col items-center gap-4">
<!-- Spinner -->
<div class="relative">
<div class="w-12 h-12 border-4 border-primary/30 rounded-full" />
<div
class="absolute inset-0 w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin"
/>
</div>
<p class="text-gray-300 text-sm">
Loading...
</p>
</div>
</div>
<!-- Main app content (only after auth is initialized) -->
<template v-else>
<component :is="currentLayout">
<RouterView />
</component>
</template>
<!-- Global UI components -->
<LoadingOverlay />

View File

@ -18,11 +18,11 @@ interface NavTab {
}
const tabs: NavTab[] = [
{ path: '/', name: 'Dashboard', label: 'Home', icon: '\uD83C\uDFE0' },
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '\u2694\uFE0F' },
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '\uD83C\uDCCF' },
{ path: '/collection', name: 'Collection', label: 'Cards', icon: '\uD83D\uDCDA' },
{ path: '/profile', name: 'Profile', label: 'Profile', icon: '\uD83D\uDC64' },
{ path: '/', name: 'Dashboard', label: 'Home', icon: '🏠' },
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '⚔️' },
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '🃏' },
{ path: '/collection', name: 'Collection', label: 'Cards', icon: '📚' },
{ path: '/profile', name: 'Profile', label: 'Profile', icon: '👤' },
]
function isActive(tab: NavTab): boolean {

View File

@ -27,11 +27,11 @@ interface NavItem {
}
const navItems: NavItem[] = [
{ path: '/', name: 'Dashboard', label: 'Home', icon: '\uD83C\uDFE0' },
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '\u2694\uFE0F' },
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '\uD83C\uDCCF' },
{ path: '/collection', name: 'Collection', label: 'Collection', icon: '\uD83D\uDCDA' },
{ path: '/campaign', name: 'Campaign', label: 'Campaign', icon: '\uD83C\uDFC6' },
{ path: '/', name: 'Dashboard', label: 'Home', icon: '🏠' },
{ path: '/play', name: 'PlayMenu', label: 'Play', icon: '⚔️' },
{ path: '/decks', name: 'DeckList', label: 'Decks', icon: '🃏' },
{ path: '/collection', name: 'Collection', label: 'Collection', icon: '📚' },
{ path: '/campaign', name: 'Campaign', label: 'Campaign', icon: '🏆' },
]
function isActive(item: NavItem): boolean {
@ -89,7 +89,7 @@ async function handleLogout(): Promise<void> {
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-surface-light hover:text-white transition-colors"
:class="{ 'bg-primary/20 text-primary-light': route.path === '/profile' }"
>
<span class="text-lg">\uD83D\uDC64</span>
<span class="text-lg">👤</span>
<span class="flex-1 truncate">{{ displayName }}</span>
</RouterLink>
@ -97,7 +97,7 @@ async function handleLogout(): Promise<void> {
class="flex items-center gap-3 w-full px-4 py-3 mt-2 rounded-lg text-gray-400 hover:bg-surface-light hover:text-white transition-colors"
@click="handleLogout"
>
<span class="text-lg">\uD83D\uDEAA</span>
<span class="text-lg">🚪</span>
<span>Logout</span>
</button>
</div>

View File

@ -0,0 +1,955 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
// Create mock router instance
const mockRouter = {
push: vi.fn(),
currentRoute: { value: { name: 'AuthCallback', path: '/auth/callback' } },
}
// Mock vue-router (hoisted)
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => ({ query: {} }),
}))
// Mock the API client
vi.mock('@/api/client', () => ({
apiClient: {
get: vi.fn(),
},
}))
// Mock the config
vi.mock('@/config', () => ({
config: {
apiBaseUrl: 'http://localhost:8000',
wsUrl: 'http://localhost:8000',
oauthRedirectUri: 'http://localhost:5173/auth/callback',
isDev: true,
isProd: false,
},
}))
import { apiClient } from '@/api/client'
import { useAuth } from './useAuth'
describe('useAuth', () => {
let mockLocation: { hash: string; pathname: string; search: string; href: string }
beforeEach(() => {
setActivePinia(createPinia())
// Reset mock router
mockRouter.push.mockReset()
// Mock window.location with a property descriptor that allows href assignment
mockLocation = {
hash: '',
pathname: '/auth/callback',
search: '',
href: 'http://localhost:5173/auth/callback',
}
// Delete and redefine to avoid conflicts
delete (window as unknown as Record<string, unknown>).location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
configurable: true,
})
// Mock history.replaceState
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
// Mock fetch for logout calls
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
})
// Reset mocks
vi.mocked(apiClient.get).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('starts with unauthenticated state', () => {
/**
* Test that useAuth starts in an unauthenticated state.
*
* Before OAuth flow or initialization completes, the composable
* should report that the user is not authenticated.
*/
const { isAuthenticated, isInitialized, user, error } = useAuth()
expect(isAuthenticated.value).toBe(false)
expect(isInitialized.value).toBe(false)
expect(user.value).toBeNull()
expect(error.value).toBeNull()
})
it('starts with isLoading as false', () => {
/**
* Test initial loading state.
*
* The composable should not be in a loading state until
* an async operation is initiated.
*/
const { isLoading } = useAuth()
expect(isLoading.value).toBe(false)
})
})
describe('initiateOAuth', () => {
it('redirects to Google OAuth URL with redirect_uri', () => {
/**
* Test Google OAuth initiation.
*
* When initiating Google OAuth, the browser should be redirected
* to the backend's Google OAuth endpoint with a redirect_uri param,
* which will then redirect to Google's consent screen.
*/
const { initiateOAuth } = useAuth()
initiateOAuth('google')
expect(mockLocation.href).toContain('/api/auth/google')
expect(mockLocation.href).toContain('redirect_uri=')
})
it('redirects to Discord OAuth URL with redirect_uri', () => {
/**
* Test Discord OAuth initiation.
*
* When initiating Discord OAuth, the browser should be redirected
* to the backend's Discord OAuth endpoint with a redirect_uri param,
* which will then redirect to Discord's consent screen.
*/
const { initiateOAuth } = useAuth()
initiateOAuth('discord')
expect(mockLocation.href).toContain('/api/auth/discord')
expect(mockLocation.href).toContain('redirect_uri=')
})
it('clears any existing error', () => {
/**
* Test error clearing on OAuth initiation.
*
* Starting a new OAuth flow should clear any previous errors
* so users don't see stale error messages.
*/
const auth = useAuth()
auth.initiateOAuth('google')
expect(auth.error.value).toBeNull()
})
})
describe('handleCallback', () => {
it('successfully extracts tokens from URL hash', async () => {
/**
* Test token extraction from OAuth callback.
*
* The OAuth provider redirects back with tokens in the URL fragment.
* handleCallback must parse these tokens and store them correctly.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.user?.id).toBe('user-1')
expect(result.user?.displayName).toBe('Test User')
})
it('stores tokens in auth store', async () => {
/**
* Test that tokens are persisted in the auth store.
*
* After extracting tokens, they must be stored in the auth store
* so they can be used for subsequent API requests.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
const authStore = useAuthStore()
expect(authStore.accessToken).toBe('abc123')
expect(authStore.refreshToken).toBe('xyz789')
expect(authStore.expiresAt).toBeGreaterThan(Date.now())
})
it('fetches user profile after storing tokens', async () => {
/**
* Test profile fetch after token storage.
*
* Once tokens are stored, we need to fetch the user's profile
* to know their display name, avatar, and starter deck status.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: 'https://example.com/avatar.png',
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
const authStore = useAuthStore()
expect(authStore.user).toEqual({
id: 'user-1',
displayName: 'Test User',
avatarUrl: 'https://example.com/avatar.png',
hasStarterDeck: true,
})
})
it('returns needsStarter=true for users without starter deck', async () => {
/**
* Test starter deck status in callback result.
*
* The callback result should indicate if the user needs to select
* a starter deck, allowing the caller to redirect appropriately.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'New User',
avatar_url: null,
has_starter_deck: false,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.needsStarter).toBe(true)
})
it('returns needsStarter=false for users with starter deck', async () => {
/**
* Test starter deck status for existing users.
*
* Users who already have a starter deck should have needsStarter=false,
* allowing them to proceed directly to the dashboard.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Existing User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(true)
expect(result.needsStarter).toBe(false)
})
it('returns error for missing tokens in hash', async () => {
/**
* Test error handling for malformed OAuth response.
*
* If the URL fragment is missing required tokens, handleCallback
* should return an error result, not throw an exception.
*/
mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toContain('Missing tokens')
})
it('returns error for empty hash', async () => {
/**
* Test error handling for empty hash fragment.
*
* If the OAuth provider redirects without any tokens,
* handleCallback should return an appropriate error.
*/
mockLocation.hash = ''
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
it('returns error from OAuth provider query params', async () => {
/**
* Test error forwarding from OAuth provider.
*
* When OAuth fails, the backend redirects with error info
* in query params. handleCallback should extract and return this.
*/
mockLocation.hash = ''
mockLocation.search = '?error=access_denied&message=User%20cancelled'
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('User cancelled')
})
it('uses default error message when message param is missing', async () => {
/**
* Test fallback error message.
*
* If the backend only provides an error code without a message,
* we should use a sensible default.
*/
mockLocation.hash = ''
mockLocation.search = '?error=unknown_error'
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('Authentication failed. Please try again.')
})
it('returns error when profile fetch fails', async () => {
/**
* Test error handling for profile fetch failures.
*
* If we successfully get tokens but fail to fetch the profile,
* handleCallback should return an error result.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
const { handleCallback } = useAuth()
const result = await handleCallback()
expect(result.success).toBe(false)
expect(result.error).toBe('Network error')
})
it('clears tokens from URL after parsing', async () => {
/**
* Test security cleanup of URL.
*
* Tokens in the URL should be cleared after parsing to prevent
* them from appearing in browser history or being logged.
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { handleCallback } = useAuth()
await handleCallback()
expect(window.history.replaceState).toHaveBeenCalledWith(
null,
'',
'/auth/callback'
)
})
})
describe('logout', () => {
it('calls auth store logout with server revocation', async () => {
/**
* Test logout with server-side token revocation.
*
* When logging out, we should revoke the refresh token on the
* server to prevent it from being used if somehow compromised.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const logoutSpy = vi.spyOn(authStore, 'logout')
const { logout } = useAuth()
await logout()
expect(logoutSpy).toHaveBeenCalledWith(true)
})
it('clears auth state after logout', async () => {
/**
* Test state clearing after logout.
*
* All authentication state should be cleared so the user
* is properly logged out and cannot access protected resources.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { logout, isAuthenticated, user } = useAuth()
await logout(false) // Don't redirect in test
expect(isAuthenticated.value).toBe(false)
expect(user.value).toBeNull()
})
it('redirects to login by default', async () => {
/**
* Test default redirect behavior after logout.
*
* After logging out, users should be redirected to the login page
* by default so they can log in again if desired.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logout } = useAuth()
await logout()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
})
it('skips redirect when redirectToLogin is false', async () => {
/**
* Test optional redirect suppression.
*
* Sometimes we want to log out without redirecting (e.g., when
* the user is already on the login page or when handling errors).
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logout } = useAuth()
await logout(false)
expect(mockRouter.push).not.toHaveBeenCalled()
})
})
describe('logoutAll', () => {
it('calls logout-all endpoint', async () => {
/**
* Test all-device logout API call.
*
* logoutAll should call the special endpoint that revokes
* all refresh tokens for the user, logging them out everywhere.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logoutAll } = useAuth()
await logoutAll(false)
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:8000/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-token',
}),
})
)
})
it('clears local state even if server call fails', async () => {
/**
* Test graceful handling of server errors.
*
* Even if the server call to revoke all tokens fails, we should
* still clear local state so the user is logged out locally.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
const { logoutAll, isAuthenticated } = useAuth()
await logoutAll(false)
expect(isAuthenticated.value).toBe(false)
})
it('redirects to login by default', async () => {
/**
* Test default redirect after all-device logout.
*
* After logging out from all devices, users should be
* redirected to the login page.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
const { logoutAll } = useAuth()
await logoutAll()
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Login' })
})
})
describe('initialize', () => {
it('sets isInitialized to true after completion', async () => {
/**
* Test initialization completion flag.
*
* After initialize() completes, isInitialized should be true
* so navigation guards know it's safe to check auth state.
*/
const { initialize, isInitialized } = useAuth()
expect(isInitialized.value).toBe(false)
await initialize()
expect(isInitialized.value).toBe(true)
})
it('returns true when authenticated with valid tokens', async () => {
/**
* Test initialization with existing valid session.
*
* If tokens are already stored and valid, initialization should
* return true and keep the user logged in.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { initialize } = useAuth()
const result = await initialize()
expect(result).toBe(true)
})
it('returns false when not authenticated', async () => {
/**
* Test initialization without existing session.
*
* If no tokens are stored, initialization should return false
* indicating the user needs to log in.
*/
const { initialize } = useAuth()
const result = await initialize()
expect(result).toBe(false)
})
it('fetches profile if tokens exist but user is null', async () => {
/**
* Test profile fetch during initialization.
*
* If we have tokens persisted but no user data (e.g., after
* page reload), we should fetch the profile during init.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Note: user is NOT set
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
const { initialize } = useAuth()
await initialize()
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
expect(authStore.user).toEqual({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
})
it('logs out if profile fetch fails during initialization', async () => {
/**
* Test handling of invalid tokens during initialization.
*
* If tokens exist but profile fetch fails (invalid tokens, etc.),
* we should clear the invalid tokens and return false.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
vi.mocked(apiClient.get).mockRejectedValue(new Error('Unauthorized'))
const { initialize, isAuthenticated } = useAuth()
const result = await initialize()
expect(result).toBe(false)
expect(isAuthenticated.value).toBe(false)
})
it('only runs once (returns cached result on second call)', async () => {
/**
* Test initialization idempotency.
*
* Multiple calls to initialize() should only run the
* initialization logic once. Subsequent calls should
* return the same result immediately.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
const { initialize, isInitialized } = useAuth()
await initialize()
expect(isInitialized.value).toBe(true)
// Call again - should return immediately
const result = await initialize()
expect(result).toBe(true)
})
})
describe('fetchProfile', () => {
it('fetches and updates user profile', async () => {
/**
* Test manual profile refresh.
*
* Components may need to refresh the user profile (e.g., after
* updating display name). fetchProfile should update the store.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
vi.mocked(apiClient.get).mockResolvedValue({
id: 'user-1',
display_name: 'Updated Name',
avatar_url: 'https://example.com/new-avatar.png',
has_starter_deck: true,
})
const { fetchProfile } = useAuth()
const result = await fetchProfile()
expect(result.displayName).toBe('Updated Name')
expect(authStore.user?.displayName).toBe('Updated Name')
})
it('throws error when not authenticated', async () => {
/**
* Test unauthenticated profile fetch rejection.
*
* fetchProfile should throw an error if called when not
* authenticated, rather than making a doomed API call.
*/
const { fetchProfile } = useAuth()
await expect(fetchProfile()).rejects.toThrow('Not authenticated')
})
})
describe('clearError', () => {
it('clears the error state', async () => {
/**
* Test error clearing.
*
* After displaying an error, components may want to clear it
* (e.g., when user dismisses the error or tries again).
*/
// First cause an error
mockLocation.hash = ''
const { handleCallback, clearError, error } = useAuth()
await handleCallback()
// Error should be set
expect(error.value).toBeDefined()
// Clear it
clearError()
expect(error.value).toBeNull()
})
})
describe('loading states', () => {
it('isLoading is true during handleCallback', async () => {
/**
* Test loading state during callback processing.
*
* Components should show loading indicators while the callback
* is being processed (token storage, profile fetch).
*/
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
// Create a deferred promise to control when the API responds
let resolveApi: (value: unknown) => void
vi.mocked(apiClient.get).mockImplementation(
() => new Promise((resolve) => { resolveApi = resolve })
)
const { handleCallback, isLoading } = useAuth()
// Start callback (don't await)
const promise = handleCallback()
// Should be loading
expect(isLoading.value).toBe(true)
// Resolve the API call
resolveApi!({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
it('isLoading is true during logout', async () => {
/**
* Test loading state during logout.
*
* Components should disable logout buttons and show feedback
* while the logout operation is in progress.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Create a deferred promise
let resolveFetch: () => void
global.fetch = vi.fn().mockImplementation(
() => new Promise((resolve) => {
resolveFetch = () => resolve({ ok: true })
})
)
const { logout, isLoading } = useAuth()
// Start logout (don't await)
const promise = logout(false)
// Should be loading
expect(isLoading.value).toBe(true)
// Resolve the fetch
resolveFetch!()
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
it('isLoading is true during initialize', async () => {
/**
* Test loading state during initialization.
*
* The app should show a loading screen while auth state
* is being initialized on startup.
*/
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
// Note: NOT setting user, so init will need to fetch profile
// Create a deferred promise that we control
let resolveApi: (value: unknown) => void = () => {}
const apiPromise = new Promise((resolve) => {
resolveApi = resolve
})
vi.mocked(apiClient.get).mockReturnValue(apiPromise as Promise<unknown>)
const { initialize, isLoading } = useAuth()
// Start init (don't await)
const promise = initialize()
// Wait a tick for async operations to start
await new Promise((r) => setTimeout(r, 0))
// Should be loading (needs to fetch profile)
expect(isLoading.value).toBe(true)
// Resolve the API call
resolveApi({
id: 'user-1',
display_name: 'Test User',
avatar_url: null,
has_starter_deck: true,
})
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
})
describe('computed properties', () => {
it('isAuthenticated reflects auth store state', () => {
/**
* Test isAuthenticated reactivity.
*
* The isAuthenticated computed should automatically update
* when the auth store's authentication state changes.
*/
const authStore = useAuthStore()
const { isAuthenticated } = useAuth()
expect(isAuthenticated.value).toBe(false)
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
expect(isAuthenticated.value).toBe(true)
})
it('user reflects auth store state', () => {
/**
* Test user computed reactivity.
*
* The user computed should automatically update when the
* auth store's user data changes.
*/
const authStore = useAuthStore()
const { user } = useAuth()
expect(user.value).toBeNull()
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
expect(user.value?.displayName).toBe('Test User')
})
it('error combines local and store errors', () => {
/**
* Test error state composition.
*
* The error computed should show local errors (from composable
* operations) or store errors (from token refresh, etc.).
*/
const authStore = useAuthStore()
const { error } = useAuth()
expect(error.value).toBeNull()
// Store error should be reflected
authStore.error = 'Store error'
expect(error.value).toBe('Store error')
})
})
})

View File

@ -0,0 +1,372 @@
/**
* Authentication composable for OAuth-based login flow.
*
* Provides a higher-level API on top of the auth store, with:
* - Loading and error state management
* - OAuth flow helpers (initiate, callback)
* - Logout with navigation
* - App initialization with token validation
*
* Use this composable in components instead of accessing the auth store directly
* for operations that involve async operations or navigation.
*/
import { ref, computed, readonly } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import type { User } from '@/stores/auth'
import { apiClient } from '@/api/client'
import { config } from '@/config'
/** OAuth provider types supported by the backend */
export type OAuthProvider = 'google' | 'discord'
/** User profile response from the backend API */
interface UserProfileResponse {
id: string
display_name: string
avatar_url: string | null
has_starter_deck: boolean
}
/** Parsed tokens from OAuth callback URL */
interface ParsedTokens {
accessToken: string
refreshToken: string
expiresIn: number
}
/** Result of handleCallback operation */
export interface CallbackResult {
success: boolean
user?: User
error?: string
needsStarter?: boolean
}
/**
* Parse tokens from URL hash fragment.
*
* The backend redirects with tokens in the fragment (after #) for security,
* since fragments are not sent to servers in HTTP requests.
*/
function parseTokensFromHash(): ParsedTokens | null {
const hash = window.location.hash.substring(1)
if (!hash) return null
const params = new URLSearchParams(hash)
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
const expiresIn = params.get('expires_in')
if (!accessToken || !refreshToken || !expiresIn) {
return null
}
return {
accessToken,
refreshToken,
expiresIn: parseInt(expiresIn, 10),
}
}
/**
* Parse error from URL query params.
*
* When OAuth fails, the backend redirects with error info in query params.
*/
function parseErrorFromQuery(): { error: string; message: string } | null {
const params = new URLSearchParams(window.location.search)
const error = params.get('error')
const message = params.get('message')
if (error) {
return {
error,
message: message || 'Authentication failed. Please try again.',
}
}
return null
}
/**
* Transform API user profile to auth store User type.
*/
function transformUserProfile(response: UserProfileResponse): User {
return {
id: response.id,
displayName: response.display_name,
avatarUrl: response.avatar_url,
hasStarterDeck: response.has_starter_deck,
}
}
/**
* Authentication composable.
*
* Wraps the auth store with loading/error state management and provides
* higher-level methods for OAuth flows.
*
* @example
* ```vue
* <script setup lang="ts">
* import { useAuth } from '@/composables/useAuth'
*
* const { isAuthenticated, isLoading, initiateOAuth, logout } = useAuth()
*
* function handleGoogleLogin() {
* initiateOAuth('google')
* }
* </script>
* ```
*/
export function useAuth() {
const router = useRouter()
const authStore = useAuthStore()
// Local state for this composable instance
const isInitializing = ref(false)
const isInitialized = ref(false)
const isProcessingCallback = ref(false)
const isLoggingOut = ref(false)
const localError = ref<string | null>(null)
// Computed properties from store
const isAuthenticated = computed(() => authStore.isAuthenticated)
const user = computed(() => authStore.user)
const isLoading = computed(
() => authStore.isLoading || isInitializing.value || isProcessingCallback.value || isLoggingOut.value
)
const error = computed(() => localError.value || authStore.error)
/**
* Initiate OAuth login flow.
*
* Redirects the user to the backend OAuth endpoint, which then redirects
* to the OAuth provider (Google/Discord). After authentication, the user
* is redirected back to /auth/callback with tokens.
*
* @param provider - OAuth provider to use ('google' or 'discord')
*/
function initiateOAuth(provider: OAuthProvider): void {
localError.value = null
const url = authStore.getOAuthUrl(provider)
window.location.href = url
}
/**
* Handle OAuth callback.
*
* Called from AuthCallbackPage after being redirected from OAuth provider.
* Extracts tokens from URL, fetches user profile, and determines redirect.
*
* @returns Result indicating success/failure and redirect info
*/
async function handleCallback(): Promise<CallbackResult> {
isProcessingCallback.value = true
localError.value = null
try {
// Check for OAuth errors first
const errorInfo = parseErrorFromQuery()
if (errorInfo) {
return {
success: false,
error: errorInfo.message,
}
}
// Parse tokens from URL fragment
const tokens = parseTokensFromHash()
if (!tokens) {
return {
success: false,
error: 'Invalid authentication response. Missing tokens.',
}
}
// Store tokens in auth store
authStore.setTokens({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: Date.now() + tokens.expiresIn * 1000,
})
// Clear tokens from URL for security
window.history.replaceState(null, '', window.location.pathname)
// Fetch user profile
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
const user = transformUserProfile(profileResponse)
authStore.setUser(user)
return {
success: true,
user,
needsStarter: !user.hasStarterDeck,
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to complete authentication.'
localError.value = errorMessage
return {
success: false,
error: errorMessage,
}
} finally {
isProcessingCallback.value = false
}
}
/**
* Log out the current user.
*
* Revokes the refresh token on the server, clears local state,
* and redirects to the login page.
*
* @param redirectToLogin - Whether to redirect to login page (default: true)
*/
async function logout(redirectToLogin = true): Promise<void> {
isLoggingOut.value = true
localError.value = null
try {
await authStore.logout(true)
if (redirectToLogin) {
router.push({ name: 'Login' })
}
} finally {
isLoggingOut.value = false
}
}
/**
* Log out from all devices.
*
* Revokes all refresh tokens for the user, effectively logging them out
* from all sessions. This requires an additional API call.
*
* @param redirectToLogin - Whether to redirect to login page (default: true)
*/
async function logoutAll(redirectToLogin = true): Promise<void> {
isLoggingOut.value = true
localError.value = null
try {
// Call the logout-all endpoint to revoke all tokens
const token = await authStore.getValidToken()
if (token) {
await fetch(`${config.apiBaseUrl}/api/auth/logout-all`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
}
// Clear local state without making another server call
await authStore.logout(false)
if (redirectToLogin) {
router.push({ name: 'Login' })
}
} catch {
// Even if server call fails, clear local state
await authStore.logout(false)
if (redirectToLogin) {
router.push({ name: 'Login' })
}
} finally {
isLoggingOut.value = false
}
}
/**
* Initialize authentication state.
*
* Should be called once on app startup. Validates existing tokens
* and fetches user profile if authenticated.
*
* @returns Whether initialization succeeded with a valid session
*/
async function initialize(): Promise<boolean> {
if (isInitialized.value) {
return authStore.isAuthenticated
}
isInitializing.value = true
localError.value = null
try {
// First, let the store validate/refresh tokens
await authStore.init()
// If we have tokens but no user data, fetch the profile
if (authStore.isAuthenticated && !authStore.user) {
try {
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
const user = transformUserProfile(profileResponse)
authStore.setUser(user)
} catch {
// Failed to fetch profile - tokens may be invalid
await authStore.logout(false)
return false
}
}
return authStore.isAuthenticated
} finally {
isInitializing.value = false
isInitialized.value = true
}
}
/**
* Fetch the current user's profile from the API.
*
* Updates the user in the auth store with fresh data.
*
* @returns The updated user profile
* @throws Error if not authenticated or fetch fails
*/
async function fetchProfile(): Promise<User> {
if (!authStore.isAuthenticated) {
throw new Error('Not authenticated')
}
const profileResponse = await apiClient.get<UserProfileResponse>('/api/users/me')
const user = transformUserProfile(profileResponse)
authStore.setUser(user)
return user
}
/**
* Clear any error state.
*/
function clearError(): void {
localError.value = null
}
return {
// State (readonly to prevent external mutation)
isAuthenticated,
isInitialized: readonly(isInitialized),
isLoading,
error,
user,
// Actions
initiateOAuth,
handleCallback,
logout,
logoutAll,
initialize,
fetchProfile,
clearError,
}
}
export type UseAuth = ReturnType<typeof useAuth>

View File

@ -0,0 +1,630 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
// Create mock router instance
const mockRouter = {
push: vi.fn(),
currentRoute: { value: { name: 'StarterSelection', path: '/starter' } },
}
// Mock vue-router (hoisted)
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => ({ query: {} }),
}))
// Mock the API client
vi.mock('@/api/client', () => ({
apiClient: {
post: vi.fn(),
},
}))
// Store isDev state in a way that can be modified between tests
let isDevMode = true
// Mock config with a getter for isDev so we can control it
vi.mock('@/config', () => ({
config: {
apiBaseUrl: 'http://localhost:8000',
wsUrl: 'http://localhost:8000',
oauthRedirectUri: 'http://localhost:5173/auth/callback',
get isDev() { return isDevMode },
isProd: false,
},
}))
import { apiClient } from '@/api/client'
import { ApiError } from '@/api/types'
import { useStarter, STARTER_DECKS, type StarterType } from './useStarter'
describe('useStarter', () => {
beforeEach(() => {
setActivePinia(createPinia())
// Reset mock router
mockRouter.push.mockReset()
// Reset API mock
vi.mocked(apiClient.post).mockReset()
// Set up authenticated user in auth store
const authStore = useAuthStore()
authStore.setTokens({
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
})
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: false,
})
// Set DEV mode for mock fallback tests
isDevMode = true
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('STARTER_DECKS constant', () => {
it('contains exactly 5 starter deck options', () => {
/**
* Test that all 5 starter types are defined.
*
* The game offers exactly 5 starter deck types corresponding
* to the main Pokemon types available at launch.
*/
expect(STARTER_DECKS).toHaveLength(5)
})
it('includes grass, fire, water, psychic, and lightning types', () => {
/**
* Test that all expected starter types are present.
*
* These specific types were chosen to provide variety in
* playstyle: healing (grass), aggro (fire), control (water),
* status (psychic), and speed (lightning).
*/
const types = STARTER_DECKS.map(d => d.type)
expect(types).toContain('grass')
expect(types).toContain('fire')
expect(types).toContain('water')
expect(types).toContain('psychic')
expect(types).toContain('lightning')
})
it('each deck has required properties', () => {
/**
* Test starter deck data structure.
*
* Each starter deck must have a type, name, description,
* and card count for display in the selection UI.
*/
for (const deck of STARTER_DECKS) {
expect(deck.type).toBeDefined()
expect(deck.name).toBeDefined()
expect(deck.description).toBeDefined()
expect(deck.cardCount).toBe(40)
}
})
})
describe('initial state', () => {
it('starts with isLoading as false', () => {
/**
* Test initial loading state.
*
* The composable should not be in a loading state until
* a selection operation is initiated.
*/
const { isLoading } = useStarter()
expect(isLoading.value).toBe(false)
})
it('starts with no error', () => {
/**
* Test initial error state.
*
* There should be no error until an operation fails.
*/
const { error } = useStarter()
expect(error.value).toBeNull()
})
it('starts with no selected type', () => {
/**
* Test initial selection state.
*
* No deck type should be selected until the user picks one.
*/
const { selectedType } = useStarter()
expect(selectedType.value).toBeNull()
})
it('provides starterDecks constant', () => {
/**
* Test that starter decks are exposed.
*
* Components need access to the deck options for rendering.
*/
const { starterDecks } = useStarter()
expect(starterDecks).toBe(STARTER_DECKS)
})
it('hasStarterDeck reflects auth store user state', () => {
/**
* Test hasStarterDeck computed from auth store.
*
* The composable should expose the user's starter deck status
* from the auth store for conditional rendering.
*/
const authStore = useAuthStore()
const { hasStarterDeck } = useStarter()
expect(hasStarterDeck.value).toBe(false)
authStore.setUser({
id: 'user-1',
displayName: 'Test User',
avatarUrl: null,
hasStarterDeck: true,
})
expect(hasStarterDeck.value).toBe(true)
})
})
describe('selectStarter - success', () => {
it('calls API with correct starter type', async () => {
/**
* Test API call payload.
*
* The API must receive the correct starter_type to assign
* the right deck to the user.
*/
vi.mocked(apiClient.post).mockResolvedValue({
id: 'deck-1',
name: 'Forest Guardians',
cards: [],
isValid: true,
cardCount: 40,
})
const { selectStarter } = useStarter()
await selectStarter('grass')
expect(apiClient.post).toHaveBeenCalledWith('/api/users/me/starter-deck', {
starter_type: 'grass',
})
})
it('updates auth store hasStarterDeck on success', async () => {
/**
* Test auth store update on success.
*
* After selecting a starter deck, the auth store must be updated
* so navigation guards and other components know the user has a deck.
*/
vi.mocked(apiClient.post).mockResolvedValue({
id: 'deck-1',
name: 'Flame Warriors',
cards: [],
isValid: true,
cardCount: 40,
})
const authStore = useAuthStore()
const { selectStarter } = useStarter()
expect(authStore.user?.hasStarterDeck).toBe(false)
await selectStarter('fire')
expect(authStore.user?.hasStarterDeck).toBe(true)
})
it('redirects to dashboard on success', async () => {
/**
* Test navigation after successful selection.
*
* After selecting a starter deck, the user should be taken
* to the main dashboard to begin playing.
*/
vi.mocked(apiClient.post).mockResolvedValue({
id: 'deck-1',
name: 'Tidal Force',
cards: [],
isValid: true,
cardCount: 40,
})
const { selectStarter } = useStarter()
await selectStarter('water')
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Dashboard' })
})
it('returns success result with deck', async () => {
/**
* Test success result structure.
*
* The result should include the deck data for potential
* display or further processing.
*/
const mockDeck = {
id: 'deck-1',
name: 'Mind Masters',
cards: [],
isValid: true,
cardCount: 40,
}
vi.mocked(apiClient.post).mockResolvedValue(mockDeck)
const { selectStarter } = useStarter()
const result = await selectStarter('psychic')
expect(result.success).toBe(true)
expect(result.deck).toEqual(mockDeck)
expect(result.error).toBeUndefined()
})
it('sets selectedType during operation', async () => {
/**
* Test selected type tracking.
*
* The selectedType should be set during the operation so
* the UI can show which deck is being selected (e.g., loading indicator).
*/
vi.mocked(apiClient.post).mockResolvedValue({
id: 'deck-1',
name: 'Storm Riders',
cards: [],
isValid: true,
cardCount: 40,
})
const { selectStarter, selectedType } = useStarter()
await selectStarter('lightning')
// After completion, selectedType should still reflect what was selected
expect(selectedType.value).toBe('lightning')
})
})
describe('selectStarter - error handling', () => {
it('handles 409 conflict (already has starter)', async () => {
/**
* Test handling of "already has starter" error.
*
* If the user somehow tries to select a starter when they already
* have one, the API returns 409. We should handle this gracefully
* by updating state and redirecting anyway.
*/
const conflictError = new ApiError(409, 'Conflict', 'User already has starter deck', 'ALREADY_HAS_STARTER')
vi.mocked(apiClient.post).mockRejectedValue(conflictError)
const authStore = useAuthStore()
const { selectStarter } = useStarter()
const result = await selectStarter('grass')
// Should still count as "success" in terms of outcome
expect(result.success).toBe(true)
expect(authStore.user?.hasStarterDeck).toBe(true)
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Dashboard' })
})
it('sets error state on API failure', async () => {
/**
* Test error state on API failure.
*
* When the API returns an error (other than 409), we should
* set the error state so the UI can display it.
*/
const serverError = new ApiError(500, 'Internal Server Error', 'Database error')
vi.mocked(apiClient.post).mockRejectedValue(serverError)
// Disable dev mode for this test
isDevMode = false
const { selectStarter, error } = useStarter()
await selectStarter('fire')
expect(error.value).toBe('Database error')
})
it('returns error result on failure', async () => {
/**
* Test error result structure.
*
* The result should indicate failure and include the error
* message for display.
*/
const serverError = new ApiError(422, 'Unprocessable Entity', 'Invalid starter type')
vi.mocked(apiClient.post).mockRejectedValue(serverError)
// Disable dev mode for this test
isDevMode = false
const { selectStarter } = useStarter()
const result = await selectStarter('water')
expect(result.success).toBe(false)
expect(result.error).toBe('Invalid starter type')
expect(result.deck).toBeUndefined()
})
it('does not redirect on failure', async () => {
/**
* Test no navigation on failure.
*
* When selection fails, the user should stay on the selection
* page so they can try again or see the error.
*/
const serverError = new ApiError(500, 'Internal Server Error', 'Server down')
vi.mocked(apiClient.post).mockRejectedValue(serverError)
// Disable dev mode for this test
isDevMode = false
const { selectStarter } = useStarter()
await selectStarter('psychic')
expect(mockRouter.push).not.toHaveBeenCalled()
})
it('handles unknown errors', async () => {
/**
* Test handling of non-ApiError exceptions.
*
* Network errors and other unexpected errors should be
* handled gracefully with a generic message.
*/
vi.mocked(apiClient.post).mockRejectedValue(new Error('Network failed'))
const { selectStarter, error } = useStarter()
const result = await selectStarter('lightning')
expect(result.success).toBe(false)
expect(error.value).toBe('Network failed')
})
})
describe('selectStarter - dev mock fallback', () => {
it('uses mock when API returns 404 in dev mode', async () => {
/**
* Test dev mock fallback on 404.
*
* When the backend endpoint doesn't exist yet (404), we should
* fall back to a mock response in development mode to allow
* testing the full UI flow.
*/
const notFoundError = new ApiError(404, 'Not Found', 'Endpoint not found')
vi.mocked(apiClient.post).mockRejectedValue(notFoundError)
const authStore = useAuthStore()
const { selectStarter } = useStarter()
const result = await selectStarter('grass')
expect(result.success).toBe(true)
expect(result.wasMocked).toBe(true)
expect(result.deck?.name).toBe('Forest Guardians')
expect(authStore.user?.hasStarterDeck).toBe(true)
expect(mockRouter.push).toHaveBeenCalledWith({ name: 'Dashboard' })
})
it('uses mock when API returns 500 in dev mode', async () => {
/**
* Test dev mock fallback on server error.
*
* Server errors in dev mode should also trigger the mock
* since the backend may be unstable during development.
*/
const serverError = new ApiError(500, 'Internal Server Error', 'Database not configured')
vi.mocked(apiClient.post).mockRejectedValue(serverError)
const { selectStarter } = useStarter()
const result = await selectStarter('fire')
expect(result.success).toBe(true)
expect(result.wasMocked).toBe(true)
expect(result.deck?.name).toBe('Flame Warriors')
})
it('does not use mock in production mode', async () => {
/**
* Test no mock in production.
*
* The mock fallback should ONLY work in development mode.
* Production errors should be reported to the user.
*/
isDevMode = false
const serverError = new ApiError(500, 'Internal Server Error', 'Server error')
vi.mocked(apiClient.post).mockRejectedValue(serverError)
const { selectStarter, error } = useStarter()
const result = await selectStarter('water')
expect(result.success).toBe(false)
expect(result.wasMocked).toBeUndefined()
expect(error.value).toBe('Server error')
})
it('creates mock deck with correct type', async () => {
/**
* Test mock deck generation.
*
* The mock deck should match the selected type so the UI
* can display the correct deck name and theme.
*/
const notFoundError = new ApiError(404, 'Not Found')
vi.mocked(apiClient.post).mockRejectedValue(notFoundError)
const { selectStarter } = useStarter()
const result = await selectStarter('lightning')
expect(result.deck?.name).toBe('Storm Riders')
expect(result.deck?.id).toContain('lightning')
})
it('logs warning when using mock', async () => {
/**
* Test console warning for mock usage.
*
* Developers should be warned when the mock is being used
* so they know the real API isn't being hit.
*/
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const notFoundError = new ApiError(404, 'Not Found')
vi.mocked(apiClient.post).mockRejectedValue(notFoundError)
const { selectStarter } = useStarter()
await selectStarter('psychic')
expect(warnSpy).toHaveBeenCalledWith('[useStarter] API not available, using mock response')
})
})
describe('loading states', () => {
it('isLoading is true during selectStarter', async () => {
/**
* Test loading state during selection.
*
* Components should show loading indicators while the
* selection is being processed.
*/
let resolveApi: (value: unknown) => void
vi.mocked(apiClient.post).mockImplementation(
() => new Promise((resolve) => { resolveApi = resolve })
)
const { selectStarter, isLoading } = useStarter()
// Start selection (don't await)
const promise = selectStarter('grass')
// Should be loading
expect(isLoading.value).toBe(true)
// Resolve the API call
resolveApi!({
id: 'deck-1',
name: 'Forest Guardians',
cards: [],
isValid: true,
cardCount: 40,
})
await promise
// Should no longer be loading
expect(isLoading.value).toBe(false)
})
it('isLoading is false after error', async () => {
/**
* Test loading state cleared on error.
*
* Even if the operation fails, loading should be set to false
* so the user can try again.
*/
// Disable dev mode so error isn't caught by mock
isDevMode = false
vi.mocked(apiClient.post).mockRejectedValue(new Error('Failed'))
const { selectStarter, isLoading } = useStarter()
await selectStarter('fire')
expect(isLoading.value).toBe(false)
})
})
describe('clearError', () => {
it('clears the error state', async () => {
/**
* Test error clearing.
*
* After displaying an error, users may want to dismiss it
* before trying again.
*/
// Disable dev mode so error isn't caught by mock
isDevMode = false
vi.mocked(apiClient.post).mockRejectedValue(new Error('Some error'))
const { selectStarter, error, clearError } = useStarter()
await selectStarter('water')
expect(error.value).toBe('Some error')
clearError()
expect(error.value).toBeNull()
})
})
describe('edge cases', () => {
it('handles user being null in auth store', async () => {
/**
* Test handling of missing user.
*
* If somehow the user is null (shouldn't happen on starter page),
* the operation should still work without crashing.
*/
const authStore = useAuthStore()
authStore.setUser(null as unknown as Parameters<typeof authStore.setUser>[0])
vi.mocked(apiClient.post).mockResolvedValue({
id: 'deck-1',
name: 'Mind Masters',
cards: [],
isValid: true,
cardCount: 40,
})
const { selectStarter } = useStarter()
const result = await selectStarter('psychic')
// Should succeed without throwing
expect(result.success).toBe(true)
})
it('all 5 starter types can be selected', async () => {
/**
* Test all starter types work.
*
* Each of the 5 starter types should be selectable without
* any type errors or special handling issues.
*/
vi.mocked(apiClient.post).mockResolvedValue({
id: 'deck-1',
name: 'Test Deck',
cards: [],
isValid: true,
cardCount: 40,
})
const starterTypes: StarterType[] = ['grass', 'fire', 'water', 'psychic', 'lightning']
for (const type of starterTypes) {
const { selectStarter } = useStarter()
const result = await selectStarter(type)
expect(result.success).toBe(true)
}
})
})
})

View File

@ -0,0 +1,254 @@
/**
* Composable for starter deck selection.
*
* Provides state and actions for the starter deck selection flow that
* new users must complete before accessing the main app.
*
* Includes a development mock fallback when the backend API is not yet
* available, allowing the full UI flow to be tested.
*/
import { ref, computed, readonly } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { apiClient } from '@/api/client'
import { ApiError } from '@/api/types'
import { config } from '@/config'
import type { CardType, Deck } from '@/types'
/** Starter deck type options */
export type StarterType = Extract<CardType, 'grass' | 'fire' | 'water' | 'psychic' | 'lightning'>
/** Starter deck metadata for display */
export interface StarterDeck {
type: StarterType
name: string
description: string
cardCount: number
}
/** API request payload for selecting a starter deck */
interface SelectStarterRequest {
starter_type: StarterType
}
/** Predefined starter deck options */
export const STARTER_DECKS: StarterDeck[] = [
{
type: 'grass',
name: 'Forest Guardians',
description: 'Growth and healing focused deck',
cardCount: 40,
},
{
type: 'fire',
name: 'Flame Warriors',
description: 'Aggressive damage-focused deck',
cardCount: 40,
},
{
type: 'water',
name: 'Tidal Force',
description: 'Balanced control and damage',
cardCount: 40,
},
{
type: 'psychic',
name: 'Mind Masters',
description: 'Status effects and manipulation',
cardCount: 40,
},
{
type: 'lightning',
name: 'Storm Riders',
description: 'Fast, high-damage strikes',
cardCount: 40,
},
]
/** Result of starter deck selection */
export interface SelectStarterResult {
success: boolean
deck?: Deck
error?: string
wasMocked?: boolean
}
/**
* Development mock for starter deck selection.
*
* Simulates a successful API response when the backend endpoint
* is not yet available. Only used when the real API fails.
*/
function createMockDeck(starterType: StarterType): Deck {
const starter = STARTER_DECKS.find(s => s.type === starterType)
return {
id: `mock-starter-${starterType}`,
name: starter?.name ?? `${starterType} Starter`,
cards: [],
isValid: true,
cardCount: 40,
}
}
/**
* Starter deck selection composable.
*
* Manages the state and API interactions for selecting a starter deck.
* Includes loading/error states and handles the post-selection navigation.
*
* @example
* ```vue
* <script setup lang="ts">
* import { useStarter, STARTER_DECKS } from '@/composables/useStarter'
*
* const { isLoading, error, selectStarter } = useStarter()
*
* async function handleSelect(type: StarterType) {
* await selectStarter(type)
* }
* </script>
* ```
*/
export function useStarter() {
const router = useRouter()
const authStore = useAuthStore()
// Local state
const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedType = ref<StarterType | null>(null)
// Computed
const hasStarterDeck = computed(() => authStore.user?.hasStarterDeck ?? false)
/**
* Select a starter deck.
*
* Calls the backend API to assign the selected starter deck to the user.
* On success, updates the auth store and redirects to the dashboard.
*
* If the API is not available (404/500), falls back to a mock response
* in development mode to allow testing the UI flow.
*
* @param starterType - The type of starter deck to select
* @returns Result indicating success/failure
*/
async function selectStarter(starterType: StarterType): Promise<SelectStarterResult> {
isLoading.value = true
error.value = null
selectedType.value = starterType
try {
// Attempt real API call
const deck = await apiClient.post<Deck>('/api/users/me/starter-deck', {
starter_type: starterType,
} satisfies SelectStarterRequest)
// Update auth store to reflect starter deck selection
if (authStore.user) {
authStore.setUser({
...authStore.user,
hasStarterDeck: true,
})
}
// Redirect to dashboard
router.push({ name: 'Dashboard' })
return {
success: true,
deck,
}
} catch (e) {
// Handle specific error cases
if (e instanceof ApiError) {
// Already has starter deck (backend returns 400 with detail message)
const isAlreadySelected =
e.status === 409 ||
e.status === 400 && e.detail?.toLowerCase().includes('already selected') ||
e.code === 'ALREADY_HAS_STARTER'
if (isAlreadySelected) {
// Update local state and redirect anyway
if (authStore.user) {
authStore.setUser({
...authStore.user,
hasStarterDeck: true,
})
}
router.push({ name: 'Dashboard' })
return {
success: true,
error: 'You already have a starter deck.',
}
}
// API not available - use dev mock in non-production
if ((e.isNotFound || e.isServerError) && config.isDev) {
console.warn('[useStarter] API not available, using mock response')
const mockDeck = createMockDeck(starterType)
// Update auth store with mock data
if (authStore.user) {
authStore.setUser({
...authStore.user,
hasStarterDeck: true,
})
}
// Redirect to dashboard
router.push({ name: 'Dashboard' })
return {
success: true,
deck: mockDeck,
wasMocked: true,
}
}
// Other API errors
error.value = e.detail || e.message || 'Failed to select starter deck'
return {
success: false,
error: error.value,
}
}
// Unknown error
const errorMessage = e instanceof Error ? e.message : 'An unexpected error occurred'
error.value = errorMessage
return {
success: false,
error: errorMessage,
}
} finally {
isLoading.value = false
}
}
/**
* Clear the error state.
*/
function clearError(): void {
error.value = null
}
return {
// State (readonly to prevent external mutation)
isLoading: readonly(isLoading),
error: readonly(error),
selectedType: readonly(selectedType),
hasStarterDeck,
// Constants
starterDecks: STARTER_DECKS,
// Actions
selectStarter,
clearError,
}
}
export type UseStarter = ReturnType<typeof useStarter>

View File

@ -3,7 +3,6 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import pinia from './stores'
import { useAuthStore } from './stores/auth'
import './assets/main.css'
@ -12,8 +11,7 @@ const app = createApp(App)
app.use(pinia)
app.use(router)
// Initialize auth state from localStorage
const authStore = useAuthStore()
authStore.init()
// Note: Auth initialization is handled in App.vue to properly show loading state
// and block navigation until auth is validated
app.mount('#app')

View File

@ -3,40 +3,263 @@
* Starter deck selection page.
*
* New users must select a starter deck before accessing the main app.
* Displays 5 themed starter deck options to choose from.
* Displays 5 themed starter deck options (grass, fire, water, psychic, lightning)
* with visual previews and descriptions.
*
* Selection triggers an API call to assign the deck, with a mock fallback
* in development mode if the backend isn't ready.
*/
import { ref } from 'vue'
import { useStarter, type StarterType, type StarterDeck } from '@/composables/useStarter'
const { isLoading, error, selectedType, starterDecks, selectStarter, clearError } = useStarter()
// Confirmation state
const pendingSelection = ref<StarterType | null>(null)
const showConfirmation = ref(false)
/**
* Type-specific colors for card borders and accents.
*/
const typeColors: Record<StarterType, { border: string; bg: string; text: string }> = {
grass: {
border: 'border-green-500',
bg: 'bg-green-500',
text: 'text-green-500',
},
fire: {
border: 'border-orange-500',
bg: 'bg-orange-500',
text: 'text-orange-500',
},
water: {
border: 'border-blue-500',
bg: 'bg-blue-500',
text: 'text-blue-500',
},
psychic: {
border: 'border-pink-500',
bg: 'bg-pink-500',
text: 'text-pink-500',
},
lightning: {
border: 'border-yellow-500',
bg: 'bg-yellow-500',
text: 'text-yellow-500',
},
}
/**
* Type-specific icons (using emoji for now, can replace with custom icons).
*/
const typeIcons: Record<StarterType, string> = {
grass: '\u{1F33F}', // Herb
fire: '\u{1F525}', // Fire
water: '\u{1F4A7}', // Droplet
psychic: '\u{1F52E}', // Crystal ball
lightning: '\u{26A1}', // Lightning
}
/**
* Handle deck card click - show confirmation dialog.
*/
function handleDeckClick(deck: StarterDeck): void {
if (isLoading.value) return
pendingSelection.value = deck.type
showConfirmation.value = true
clearError()
}
/**
* Confirm selection and call API.
*/
async function confirmSelection(): Promise<void> {
if (!pendingSelection.value) return
showConfirmation.value = false
await selectStarter(pendingSelection.value)
}
/**
* Cancel selection confirmation.
*/
function cancelSelection(): void {
showConfirmation.value = false
pendingSelection.value = null
}
/**
* Get the pending deck for confirmation display.
*/
function getPendingDeck(): StarterDeck | undefined {
return starterDecks.find(d => d.type === pendingSelection.value)
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-4">
<div class="flex min-h-screen items-center justify-center bg-gray-900 p-4">
<div class="w-full max-w-4xl">
<h1 class="mb-8 text-center text-2xl font-bold">
Choose Your Starter Deck
</h1>
<!-- Header -->
<div class="mb-8 text-center">
<h1 class="mb-2 text-3xl font-bold text-white">
Choose Your Starter Deck
</h1>
<p class="text-gray-400">
Select a starter deck to begin your journey. You'll earn more cards
and build custom decks as you progress.
</p>
</div>
<p class="mb-8 text-center text-gray-600">
Select a starter deck to begin your journey. You'll be able to earn more
cards and build custom decks as you progress.
</p>
<!-- TODO: Display 5 starter deck options with previews -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
<div
v-for="i in 5"
:key="i"
class="cursor-pointer rounded-lg border-2 border-gray-200 p-4 text-center transition hover:border-blue-500"
<!-- Error message -->
<div
v-if="error"
class="mb-6 rounded-lg bg-red-900/50 p-4 text-center text-red-200"
role="alert"
>
<p>{{ error }}</p>
<button
class="mt-2 text-sm text-red-300 underline hover:text-red-100"
@click="clearError"
>
<div class="mb-2 text-4xl">
🃏
Dismiss
</button>
</div>
<!-- Deck grid -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<button
v-for="deck in starterDecks"
:key="deck.type"
class="group relative flex flex-col items-center rounded-xl border-2 bg-gray-800 p-6 transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
:class="[
typeColors[deck.type].border,
isLoading && selectedType === deck.type
? 'ring-2 ring-white'
: 'hover:shadow-lg hover:shadow-current/20',
]"
:disabled="isLoading"
@click="handleDeckClick(deck)"
>
<!-- Loading spinner overlay -->
<div
v-if="isLoading && selectedType === deck.type"
class="absolute inset-0 flex items-center justify-center rounded-xl bg-gray-900/70"
>
<div class="h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent" />
</div>
<div class="font-medium">
Starter Deck {{ i }}
<!-- Type icon -->
<div
class="mb-3 flex h-16 w-16 items-center justify-center rounded-full text-4xl"
:class="typeColors[deck.type].bg"
>
{{ typeIcons[deck.type] }}
</div>
<div class="text-sm text-gray-500">
Coming soon
<!-- Deck name -->
<h2 class="mb-1 text-lg font-semibold text-white">
{{ deck.name }}
</h2>
<!-- Type badge -->
<span
class="mb-2 rounded-full px-2 py-0.5 text-xs font-medium capitalize"
:class="[typeColors[deck.type].bg, 'text-white']"
>
{{ deck.type }}
</span>
<!-- Description -->
<p class="text-center text-sm text-gray-400">
{{ deck.description }}
</p>
<!-- Card count -->
<p class="mt-2 text-xs text-gray-500">
{{ deck.cardCount }} cards
</p>
</button>
</div>
<!-- Help text -->
<p class="mt-8 text-center text-sm text-gray-500">
This choice is permanent for your account, but you'll be able to build
many more decks with cards you earn!
</p>
</div>
<!-- Confirmation Modal -->
<Teleport to="body">
<div
v-if="showConfirmation"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
@click.self="cancelSelection"
>
<div
class="w-full max-w-md rounded-xl border-2 bg-gray-800 p-6 shadow-2xl"
:class="pendingSelection ? typeColors[pendingSelection].border : ''"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
>
<h2
id="confirm-title"
class="mb-4 text-xl font-bold text-white"
>
Confirm Selection
</h2>
<div
v-if="getPendingDeck()"
class="mb-6"
>
<div class="flex items-center gap-4">
<div
class="flex h-12 w-12 items-center justify-center rounded-full text-2xl"
:class="pendingSelection ? typeColors[pendingSelection].bg : ''"
>
{{ pendingSelection ? typeIcons[pendingSelection] : '' }}
</div>
<div>
<p class="font-semibold text-white">
{{ getPendingDeck()?.name }}
</p>
<p class="text-sm text-gray-400">
{{ getPendingDeck()?.description }}
</p>
</div>
</div>
</div>
<p class="mb-6 text-gray-300">
Are you sure you want to choose the
<strong
class="capitalize"
:class="pendingSelection ? typeColors[pendingSelection].text : ''"
>{{ pendingSelection }}</strong>
starter deck? This choice cannot be changed.
</p>
<div class="flex gap-3">
<button
class="flex-1 rounded-lg bg-gray-700 px-4 py-2 font-medium text-white transition hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500"
@click="cancelSelection"
>
Cancel
</button>
<button
class="flex-1 rounded-lg px-4 py-2 font-medium text-white transition focus:outline-none focus:ring-2"
:class="[
pendingSelection ? typeColors[pendingSelection].bg : 'bg-blue-500',
'hover:opacity-90',
]"
@click="confirmSelection"
>
Confirm
</button>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>

View File

@ -3,6 +3,10 @@
*
* These guards control access to routes based on authentication state
* and user profile status (e.g., starter deck selection).
*
* Important: These guards work in conjunction with App.vue's auth initialization.
* App.vue blocks rendering until auth is initialized, so by the time these guards
* run, the auth state is already validated.
*/
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
@ -13,6 +17,9 @@ import { useAuthStore } from '@/stores/auth'
*
* Redirects unauthenticated users to /login with a redirect query param
* so they can be sent back after logging in.
*
* Note: Auth initialization happens in App.vue before navigation guards run,
* so isAuthenticated reflects the true auth state (tokens validated/refreshed).
*/
export function requireAuth(
to: RouteLocationNormalized,
@ -63,7 +70,7 @@ export function requireGuest(
*/
export function requireStarter(
to: RouteLocationNormalized,
_from: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
): void {
const auth = useAuthStore()
@ -74,6 +81,13 @@ export function requireStarter(
return
}
// Allow navigation FROM starter page - user just completed selection
// The store update may not have propagated yet, but the action succeeded
if (from.path === '/starter') {
next()
return
}
// Check if user has selected starter deck
// This will be populated after fetching user profile
if (auth.user && !auth.user.hasStarterDeck) {

View File

@ -169,12 +169,13 @@ describe('useAuthStore', () => {
expect(store.isTokenExpired).toBe(true)
})
it('getOAuthUrl returns correct provider URL', () => {
it('getOAuthUrl returns correct provider URL with redirect_uri', () => {
/**
* Test that OAuth URLs are correctly generated.
*
* The login page uses these URLs to redirect users to
* OAuth providers for authentication.
* OAuth providers for authentication. The redirect_uri
* tells the backend where to send the user after OAuth.
*/
const store = useAuthStore()
@ -182,6 +183,8 @@ describe('useAuthStore', () => {
const discordUrl = store.getOAuthUrl('discord')
expect(googleUrl).toContain('/api/auth/google')
expect(googleUrl).toContain('redirect_uri=')
expect(discordUrl).toContain('/api/auth/discord')
expect(discordUrl).toContain('redirect_uri=')
})
})

View File

@ -166,9 +166,13 @@ export const useAuthStore = defineStore('auth', () => {
/**
* Get OAuth login URL for a provider.
*
* Includes the redirect_uri so the backend knows where to send
* the user after OAuth completes.
*/
function getOAuthUrl(provider: 'google' | 'discord'): string {
return `${config.apiBaseUrl}/api/auth/${provider}`
const redirectUri = encodeURIComponent(config.oauthRedirectUri)
return `${config.apiBaseUrl}/api/auth/${provider}?redirect_uri=${redirectUri}`
}
return {