Add environment config and Vue Router with guards (F0-003, F0-008)

- Add environment configuration with type-safe config.ts
- Implement navigation guards (requireAuth, requireGuest, requireStarter)
- Update router to match sitePlan routes and layouts
- Create placeholder pages for all sitePlan routes
- Update auth store User interface for OAuth flow
- Add phase plan tracking for F0

Phase F0 progress: 4/8 tasks complete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-30 10:59:04 -06:00
parent 25cb22eb84
commit 5424bf9086
17 changed files with 1053 additions and 37 deletions

11
frontend/.env.development Normal file
View File

@ -0,0 +1,11 @@
# Development environment configuration
# These values are used when running `npm run dev`
# Backend API base URL (FastAPI server)
VITE_API_BASE_URL=http://localhost:8000
# WebSocket URL (Socket.IO server - same as API in development)
VITE_WS_URL=http://localhost:8000
# OAuth redirect URI (must match OAuth provider configuration)
VITE_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback

11
frontend/.env.production Normal file
View File

@ -0,0 +1,11 @@
# Production environment configuration
# These values are used when running `npm run build`
# Backend API base URL
VITE_API_BASE_URL=https://api.pocket.manticorum.com
# WebSocket URL (Socket.IO server)
VITE_WS_URL=https://api.pocket.manticorum.com
# OAuth redirect URI (must match OAuth provider configuration)
VITE_OAUTH_REDIRECT_URI=https://pocket.manticorum.com/auth/callback

View File

@ -387,8 +387,8 @@ VITE_API_BASE_URL=http://localhost:8000
VITE_WS_URL=http://localhost:8000
# .env.production
VITE_API_BASE_URL=https://api.play.mantimon.com
VITE_WS_URL=https://api.play.mantimon.com
VITE_API_BASE_URL=https://api.pocket.manticorum.com
VITE_WS_URL=https://api.pocket.manticorum.com
```
**Access in code:**

View File

@ -5,10 +5,10 @@
"lastUpdated": "2026-01-30",
"planType": "master",
"projectName": "Mantimon TCG - Frontend",
"description": "Vue 3 + Phaser 3 frontend for play.mantimon.com - real-time multiplayer TCG with campaign mode",
"description": "Vue 3 + Phaser 3 frontend for pocket.manticorum.com - real-time multiplayer TCG with campaign mode",
"totalPhases": 8,
"completedPhases": 0,
"status": "Planning complete, ready to start Phase F0"
"status": "Phase F0 in progress"
},
"techStack": {
@ -124,7 +124,7 @@
{
"id": "PHASE_F0",
"name": "Project Foundation",
"status": "NOT_STARTED",
"status": "in_progress",
"description": "Scaffolding, tooling, core infrastructure, API client setup",
"estimatedDays": "3-5",
"dependencies": [],

View File

@ -0,0 +1,194 @@
{
"meta": {
"phaseId": "PHASE_F0",
"name": "Project Foundation",
"version": "1.0.0",
"created": "2026-01-30",
"lastUpdated": "2026-01-30",
"totalTasks": 8,
"completedTasks": 4,
"status": "in_progress"
},
"tasks": [
{
"id": "F0-001",
"name": "Initialize Vite project",
"description": "Create Vue 3 + TypeScript project with Vite",
"category": "setup",
"priority": 1,
"completed": true,
"tested": true,
"dependencies": [],
"files": [
{"path": "package.json", "status": "create"},
{"path": "vite.config.ts", "status": "create"},
{"path": "tsconfig.json", "status": "create"}
],
"details": [
"npm create vite@latest . -- --template vue-ts",
"Configure path aliases (@/ for src/)",
"Set up TypeScript strict mode"
],
"notes": "Completed in initial scaffold commit b9b803d"
},
{
"id": "F0-002",
"name": "Install and configure Tailwind",
"description": "Set up Tailwind CSS with custom theme",
"category": "styling",
"priority": 2,
"completed": true,
"tested": true,
"dependencies": ["F0-001"],
"files": [
{"path": "tailwind.config.js", "status": "create"},
{"path": "src/assets/main.css", "status": "create"}
],
"details": [
"Install tailwindcss, postcss, autoprefixer",
"Configure content paths",
"Add Mantimon color palette (Pokemon-inspired)"
],
"notes": "Completed in initial scaffold - using Tailwind v4 with @tailwindcss/postcss"
},
{
"id": "F0-003",
"name": "Set up Vue Router",
"description": "Configure routing with guards and lazy loading",
"category": "setup",
"priority": 3,
"completed": true,
"tested": true,
"dependencies": ["F0-001"],
"files": [
{"path": "src/router/index.ts", "status": "modify"},
{"path": "src/router/guards.ts", "status": "create"}
],
"details": [
"Define route structure per sitePlan",
"Create auth guard (requireAuth, requireGuest)",
"Create starter guard (redirect if no starter deck)",
"Configure lazy loading for heavy routes",
"Extract guards to separate file"
],
"notes": "Partial - router/index.ts exists with basic routes and inline guard. Needs guards.ts extraction and starter deck guard."
},
{
"id": "F0-004",
"name": "Set up Pinia stores",
"description": "Create store structure with persistence",
"category": "stores",
"priority": 4,
"completed": false,
"tested": false,
"dependencies": ["F0-001"],
"files": [
{"path": "src/stores/auth.ts", "status": "modify"},
{"path": "src/stores/user.ts", "status": "create"},
{"path": "src/stores/ui.ts", "status": "create"}
],
"details": [
"Install pinia and pinia-plugin-persistedstate (done)",
"Update auth store for OAuth flow (not username/password)",
"Create user store skeleton (display_name, avatar, linked accounts)",
"Create UI store (loading states, toasts, modals)"
],
"notes": "Partial - auth.ts exists but uses username/password pattern instead of OAuth. Needs user.ts and ui.ts."
},
{
"id": "F0-005",
"name": "Create API client",
"description": "HTTP client with auth token injection and refresh",
"category": "api",
"priority": 5,
"completed": false,
"tested": false,
"dependencies": ["F0-001", "F0-004"],
"files": [
{"path": "src/api/client.ts", "status": "create"},
{"path": "src/api/types.ts", "status": "create"}
],
"details": [
"Create fetch wrapper with base URL from config",
"Inject Authorization header from auth store",
"Handle 401 responses with automatic token refresh",
"Type API responses (ApiError, ApiResponse<T>)",
"Add request/response interceptor pattern"
],
"notes": "Not started. Critical for all backend communication."
},
{
"id": "F0-006",
"name": "Create Socket.IO client",
"description": "WebSocket connection manager",
"category": "api",
"priority": 6,
"completed": false,
"tested": false,
"dependencies": ["F0-001", "F0-004"],
"files": [
{"path": "src/socket/client.ts", "status": "create"},
{"path": "src/socket/types.ts", "status": "create"}
],
"details": [
"Install socket.io-client (done)",
"Create connection manager singleton",
"Configure auth token in handshake",
"Set up reconnection with exponential backoff",
"Create typed event emitters for game namespace"
],
"notes": "Not started. socket.io-client is installed."
},
{
"id": "F0-007",
"name": "Create app shell",
"description": "Basic layout with navigation",
"category": "components",
"priority": 7,
"completed": false,
"tested": false,
"dependencies": ["F0-001", "F0-002", "F0-003"],
"files": [
{"path": "src/App.vue", "status": "modify"},
{"path": "src/layouts/DefaultLayout.vue", "status": "create"},
{"path": "src/layouts/MinimalLayout.vue", "status": "create"},
{"path": "src/layouts/GameLayout.vue", "status": "create"},
{"path": "src/components/NavSidebar.vue", "status": "create"},
{"path": "src/components/NavBottomTabs.vue", "status": "create"},
{"path": "src/components/ui/LoadingOverlay.vue", "status": "create"},
{"path": "src/components/ui/ToastContainer.vue", "status": "create"}
],
"details": [
"Create DefaultLayout with sidebar (desktop) / bottom tabs (mobile)",
"Create MinimalLayout for login/auth pages (centered, no nav)",
"Create GameLayout for match page (full viewport, no nav)",
"Responsive nav: NavSidebar for md+, NavBottomTabs for mobile",
"Loading overlay component tied to UI store",
"Toast notification container tied to UI store"
],
"notes": "Partial - App.vue and AppHeader.vue exist but don't match sitePlan layout system."
},
{
"id": "F0-008",
"name": "Environment configuration",
"description": "Configure environment variables",
"category": "setup",
"priority": 8,
"completed": true,
"tested": true,
"dependencies": ["F0-001"],
"files": [
{"path": ".env.development", "status": "create"},
{"path": ".env.production", "status": "create"},
{"path": "src/config.ts", "status": "create"}
],
"details": [
"Create .env.development with local API URLs",
"Create .env.production with production API URLs",
"Create type-safe config.ts that reads VITE_* vars",
"Define: VITE_API_BASE_URL, VITE_WS_URL, VITE_OAUTH_REDIRECT_URI"
],
"notes": "Not started. Needed before API client can work properly."
}
]
}

View File

@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest'
import { config, apiUrl } from './config'
describe('config', () => {
it('exports required configuration properties', () => {
/**
* Test that the config object contains all required properties.
*
* The API client, Socket.IO client, and OAuth flow all depend on
* these configuration values being present. Missing values would
* cause runtime errors in those modules.
*/
expect(config).toHaveProperty('apiBaseUrl')
expect(config).toHaveProperty('wsUrl')
expect(config).toHaveProperty('oauthRedirectUri')
expect(config).toHaveProperty('isDev')
expect(config).toHaveProperty('isProd')
})
it('has string values for URL properties', () => {
/**
* Test that URL configuration values are strings.
*
* URL properties must be strings to be used in fetch() calls
* and Socket.IO connection. Non-string values would cause
* type errors or runtime failures.
*/
expect(typeof config.apiBaseUrl).toBe('string')
expect(typeof config.wsUrl).toBe('string')
expect(typeof config.oauthRedirectUri).toBe('string')
})
it('has boolean values for environment flags', () => {
/**
* Test that environment flags are booleans.
*
* isDev and isProd are used in conditional logic throughout
* the app. They must be booleans for correct behavior in
* if statements and ternary expressions.
*/
expect(typeof config.isDev).toBe('boolean')
expect(typeof config.isProd).toBe('boolean')
})
it('has mutually exclusive isDev and isProd flags', () => {
/**
* Test that isDev and isProd are mutually exclusive.
*
* The app should never be in both development and production
* mode simultaneously. This would cause confusing behavior
* with logging, error handling, and API endpoints.
*/
expect(config.isDev).not.toBe(config.isProd)
})
})
describe('apiUrl', () => {
it('builds full URL from path with leading slash', () => {
/**
* Test that apiUrl correctly combines base URL and path.
*
* API calls use paths like '/api/users/me'. The apiUrl helper
* must correctly join these with the base URL without double
* slashes or missing slashes.
*/
const url = apiUrl('/api/users/me')
expect(url).toBe(`${config.apiBaseUrl}/api/users/me`)
})
it('builds full URL from path without leading slash', () => {
/**
* Test that apiUrl handles paths without leading slashes.
*
* Some code may pass paths without leading slashes. The helper
* should normalize these to produce correct URLs.
*/
const url = apiUrl('api/users/me')
expect(url).toBe(`${config.apiBaseUrl}/api/users/me`)
})
it('handles base URL with trailing slash', () => {
/**
* Test that apiUrl doesn't create double slashes in the path.
*
* If the base URL ends with a slash and the path starts with one,
* we'd get double slashes (e.g., http://localhost:8000//api).
* The helper should prevent this.
*/
const url = apiUrl('/api/test')
// Remove protocol prefix, then check for double slashes
const urlWithoutProtocol = url.replace(/^https?:\/\//, '')
expect(urlWithoutProtocol).not.toContain('//')
})
})

59
frontend/src/config.ts Normal file
View File

@ -0,0 +1,59 @@
/**
* Application configuration with type-safe access to environment variables.
*
* All VITE_* environment variables are exposed at build time via import.meta.env.
* This module provides a typed interface and runtime validation.
*/
interface AppConfig {
/** Backend API base URL (e.g., http://localhost:8000) */
apiBaseUrl: string
/** WebSocket server URL for Socket.IO (e.g., http://localhost:8000) */
wsUrl: string
/** OAuth redirect URI after authentication (e.g., http://localhost:5173/auth/callback) */
oauthRedirectUri: string
/** Whether running in development mode */
isDev: boolean
/** Whether running in production mode */
isProd: boolean
}
function getEnvVar(key: string, fallback?: string): string {
const value = import.meta.env[key] as string | undefined
if (value === undefined || value === '') {
if (fallback !== undefined) {
return fallback
}
throw new Error(`Missing required environment variable: ${key}`)
}
return value
}
/**
* Application configuration object.
*
* Values are read from environment variables at build time.
* In development, these come from .env.development.
* In production, these come from .env.production.
*/
export const config: AppConfig = {
apiBaseUrl: getEnvVar('VITE_API_BASE_URL', 'http://localhost:8000'),
wsUrl: getEnvVar('VITE_WS_URL', 'http://localhost:8000'),
oauthRedirectUri: getEnvVar('VITE_OAUTH_REDIRECT_URI', 'http://localhost:5173/auth/callback'),
isDev: import.meta.env.DEV,
isProd: import.meta.env.PROD,
}
/**
* Build the full API URL for an endpoint.
*
* @param path - API path (e.g., '/api/users/me')
* @returns Full URL (e.g., 'http://localhost:8000/api/users/me')
*/
export function apiUrl(path: string): string {
const base = config.apiBaseUrl.replace(/\/$/, '')
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${cleanPath}`
}
export default config

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
/**
* OAuth callback page.
*
* Handles the redirect from OAuth providers (Google, Discord).
* Extracts tokens from URL fragment and stores them in auth store.
*/
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
onMounted(() => {
// TODO: Extract tokens from URL hash fragment
// TODO: Store tokens in auth store
// TODO: Fetch user profile
// TODO: Redirect to intended destination or home
// Placeholder: redirect to home after brief delay
setTimeout(() => {
router.push('/')
}, 1000)
})
</script>
<template>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div class="mb-4 text-lg">
Completing login...
</div>
<div class="text-sm text-gray-500">
Please wait
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
/**
* Deck list page.
*
* Displays all user's decks with validation status.
* Allows creating new decks and editing/deleting existing ones.
*/
import { useRouter } from 'vue-router'
const router = useRouter()
function createDeck() {
router.push('/decks/new')
}
</script>
<template>
<div class="p-4 md:p-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">
My Decks
</h1>
<button
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
@click="createDeck"
>
New Deck
</button>
</div>
<!-- TODO: Fetch and display user's decks -->
<div class="text-gray-500">
No decks yet. Create your first deck to get started!
</div>
</div>
</template>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
/**
* Game page - full viewport Phaser canvas.
*
* This page hosts the Phaser game instance for active matches.
* Uses the 'game' layout (no navigation, full viewport).
*/
import { useRoute } from 'vue-router'
const route = useRoute()
const gameId = route.params.id as string
</script>
<template>
<div class="flex h-screen w-screen items-center justify-center bg-gray-900">
<div class="text-center text-white">
<h1 class="mb-4 text-2xl">
Game {{ gameId }}
</h1>
<p class="text-gray-400">
Phaser game canvas will be mounted here
</p>
<!-- TODO: Mount PhaserGame component here -->
</div>
</div>
</template>

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
/**
* Play menu page.
*
* Game mode selection - start a new game, view active games,
* or access campaign mode.
*/
</script>
<template>
<div class="p-4 md:p-6">
<h1 class="mb-6 text-2xl font-bold">
Play
</h1>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Quick Play -->
<div class="rounded-lg border p-6">
<h2 class="mb-2 text-lg font-semibold">
Quick Play
</h2>
<p class="mb-4 text-sm text-gray-600">
Start a match against another player
</p>
<button
class="w-full rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
disabled
>
Coming Soon
</button>
</div>
<!-- Campaign -->
<div class="rounded-lg border p-6">
<h2 class="mb-2 text-lg font-semibold">
Campaign
</h2>
<p class="mb-4 text-sm text-gray-600">
Challenge NPCs and earn medals
</p>
<router-link
to="/campaign"
class="block w-full rounded bg-green-600 px-4 py-2 text-center text-white hover:bg-green-700"
>
Enter Campaign
</router-link>
</div>
<!-- Active Games -->
<div class="rounded-lg border p-6">
<h2 class="mb-2 text-lg font-semibold">
Active Games
</h2>
<p class="mb-4 text-sm text-gray-600">
Resume your ongoing matches
</p>
<div class="text-sm text-gray-500">
No active games
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
/**
* User profile page.
*
* Displays user info, linked accounts, and logout option.
*/
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
function logout() {
auth.logout()
}
</script>
<template>
<div class="p-4 md:p-6">
<h1 class="mb-6 text-2xl font-bold">
Profile
</h1>
<div class="max-w-md space-y-6">
<!-- Avatar and Name -->
<div class="flex items-center gap-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-200 text-2xl">
{{ auth.user?.displayName?.charAt(0) || '?' }}
</div>
<div>
<div class="text-lg font-semibold">
{{ auth.user?.displayName || 'Unknown' }}
</div>
<div class="text-sm text-gray-500">
Player
</div>
</div>
</div>
<!-- Linked Accounts -->
<div class="rounded-lg border p-4">
<h2 class="mb-4 font-semibold">
Linked Accounts
</h2>
<div class="text-sm text-gray-500">
<!-- TODO: Display linked OAuth accounts -->
No linked accounts information available
</div>
</div>
<!-- Logout -->
<button
class="w-full rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
@click="logout"
>
Logout
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 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.
*/
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-4xl">
<h1 class="mb-8 text-center text-2xl font-bold">
Choose Your Starter Deck
</h1>
<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"
>
<div class="mb-2 text-4xl">
🃏
</div>
<div class="font-medium">
Starter Deck {{ i }}
</div>
<div class="text-sm text-gray-500">
Coming soon
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,203 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { requireAuth, requireGuest, requireStarter } from './guards'
// Helper to create mock route objects
function createMockRoute(overrides: Partial<RouteLocationNormalized> = {}): RouteLocationNormalized {
return {
path: '/',
name: undefined,
params: {},
query: {},
hash: '',
fullPath: '/',
matched: [],
meta: {},
redirectedFrom: undefined,
...overrides,
}
}
describe('requireAuth', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('redirects to login when not authenticated', () => {
/**
* Test that unauthenticated users are redirected to login.
*
* Protected routes must not be accessible without authentication.
* The redirect should include the original path so users can be
* returned there after logging in.
*/
const auth = useAuthStore()
auth.token = null
const to = createMockRoute({ path: '/collection', fullPath: '/collection' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireAuth(to, from, next)
expect(next).toHaveBeenCalledWith({
path: '/login',
query: { redirect: '/collection' },
})
})
it('allows access when authenticated', () => {
/**
* Test that authenticated users can access protected routes.
*
* When a user has a valid token, they should be allowed through
* to the requested route without redirection.
*/
const auth = useAuthStore()
auth.token = 'valid-token'
const to = createMockRoute({ path: '/collection' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireAuth(to, from, next)
expect(next).toHaveBeenCalledWith()
})
})
describe('requireGuest', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('redirects to home when authenticated', () => {
/**
* Test that authenticated users are redirected away from guest routes.
*
* Pages like /login shouldn't be accessible to logged-in users
* since they don't need to log in again.
*/
const auth = useAuthStore()
auth.token = 'valid-token'
const to = createMockRoute({ path: '/login' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireGuest(to, from, next)
expect(next).toHaveBeenCalledWith({ path: '/' })
})
it('allows access when not authenticated', () => {
/**
* Test that unauthenticated users can access guest routes.
*
* Users who are not logged in should be able to reach the
* login page and other guest-only routes.
*/
const auth = useAuthStore()
auth.token = null
const to = createMockRoute({ path: '/login' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireGuest(to, from, next)
expect(next).toHaveBeenCalledWith()
})
})
describe('requireStarter', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('allows access to /starter without checking', () => {
/**
* Test that the starter page itself is always accessible.
*
* Users must be able to reach /starter to select their deck,
* even if they haven't selected one yet (which would otherwise
* cause a redirect loop).
*/
const auth = useAuthStore()
auth.token = 'valid-token'
auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false }
const to = createMockRoute({ path: '/starter' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireStarter(to, from, next)
expect(next).toHaveBeenCalledWith()
})
it('redirects to /starter when user has no starter deck', () => {
/**
* Test that users without a starter deck are redirected.
*
* New users must select a starter deck before accessing the
* main app. This ensures they have cards to play with.
*/
const auth = useAuthStore()
auth.token = 'valid-token'
auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: false }
const to = createMockRoute({ path: '/collection' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireStarter(to, from, next)
expect(next).toHaveBeenCalledWith({ path: '/starter' })
})
it('allows access when user has starter deck', () => {
/**
* Test that users with a starter deck can access the main app.
*
* Once a user has selected their starter deck, they should
* have full access to collection, decks, and gameplay.
*/
const auth = useAuthStore()
auth.token = 'valid-token'
auth.user = { id: '1', displayName: 'Test', avatarUrl: null, hasStarterDeck: true }
const to = createMockRoute({ path: '/collection' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireStarter(to, from, next)
expect(next).toHaveBeenCalledWith()
})
it('allows access when user is not loaded yet', () => {
/**
* Test that the guard doesn't block when user data is pending.
*
* If the user object hasn't been fetched yet (null), we should
* allow the navigation to continue. The app will handle fetching
* user data and may redirect later if needed.
*/
const auth = useAuthStore()
auth.token = 'valid-token'
auth.user = null
const to = createMockRoute({ path: '/collection' })
const from = createMockRoute()
const next = vi.fn() as unknown as NavigationGuardNext
requireStarter(to, from, next)
expect(next).toHaveBeenCalledWith()
})
})

View File

@ -0,0 +1,100 @@
/**
* Navigation guards for Vue Router.
*
* These guards control access to routes based on authentication state
* and user profile status (e.g., starter deck selection).
*/
import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
/**
* Guard that requires authentication.
*
* Redirects unauthenticated users to /login with a redirect query param
* so they can be sent back after logging in.
*/
export function requireAuth(
to: RouteLocationNormalized,
_from: RouteLocationNormalized,
next: NavigationGuardNext
): void {
const auth = useAuthStore()
if (!auth.isAuthenticated) {
next({
path: '/login',
query: { redirect: to.fullPath },
})
} else {
next()
}
}
/**
* Guard that requires guest (unauthenticated) status.
*
* Redirects authenticated users away from pages like /login
* since they don't need to log in again.
*/
export function requireGuest(
_to: RouteLocationNormalized,
_from: RouteLocationNormalized,
next: NavigationGuardNext
): void {
const auth = useAuthStore()
if (auth.isAuthenticated) {
next({ path: '/' })
} else {
next()
}
}
/**
* Guard that requires starter deck selection.
*
* After authentication, users must select a starter deck before
* accessing the main app. This guard redirects users who haven't
* selected a starter to /starter.
*
* Note: This guard should be used AFTER requireAuth in the guard chain,
* as it assumes the user is already authenticated.
*/
export function requireStarter(
to: RouteLocationNormalized,
_from: RouteLocationNormalized,
next: NavigationGuardNext
): void {
const auth = useAuthStore()
// Skip check if going to starter page or if not authenticated
if (to.path === '/starter' || !auth.isAuthenticated) {
next()
return
}
// Check if user has selected starter deck
// This will be populated after fetching user profile
if (auth.user && !auth.user.hasStarterDeck) {
next({ path: '/starter' })
} else {
next()
}
}
/**
* Route meta type augmentation for TypeScript.
*/
declare module 'vue-router' {
interface RouteMeta {
/** Route requires authentication */
requiresAuth?: boolean
/** Route requires guest (unauthenticated) status */
requiresGuest?: boolean
/** Route requires starter deck selection (implies requiresAuth) */
requiresStarter?: boolean
/** Layout to use: 'default' | 'minimal' | 'game' */
layout?: 'default' | 'minimal' | 'game'
}
}

View File

@ -1,46 +1,103 @@
/**
* Vue Router configuration.
*
* Routes are organized by layout type (minimal, default, game) and
* authentication requirements. Guards are applied globally based on
* route meta properties.
*/
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { requireAuth, requireGuest, requireStarter } from './guards'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/pages/HomePage.vue'),
},
// ============================================
// Minimal Layout Routes (no navigation)
// ============================================
{
path: '/login',
name: 'login',
name: 'Login',
component: () => import('@/pages/LoginPage.vue'),
meta: { requiresGuest: true, layout: 'minimal' },
},
{
path: '/register',
name: 'register',
component: () => import('@/pages/RegisterPage.vue'),
path: '/auth/callback',
name: 'AuthCallback',
component: () => import('@/pages/AuthCallbackPage.vue'),
meta: { layout: 'minimal' },
},
{
path: '/campaign',
name: 'campaign',
component: () => import('@/pages/CampaignPage.vue'),
meta: { requiresAuth: true },
path: '/starter',
name: 'StarterSelection',
component: () => import('@/pages/StarterSelectionPage.vue'),
meta: { requiresAuth: true, layout: 'minimal' },
},
// ============================================
// Default Layout Routes (sidebar/bottom tabs)
// ============================================
{
path: '/',
name: 'Dashboard',
component: () => import('@/pages/HomePage.vue'),
meta: { requiresAuth: true, requiresStarter: true, layout: 'default' },
},
{
path: '/collection',
name: 'collection',
name: 'Collection',
component: () => import('@/pages/CollectionPage.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, requiresStarter: true, layout: 'default' },
},
{
path: '/deck-builder',
name: 'deck-builder',
path: '/decks',
name: 'DeckList',
component: () => import('@/pages/DecksPage.vue'),
meta: { requiresAuth: true, requiresStarter: true, layout: 'default' },
},
{
path: '/decks/new',
name: 'NewDeck',
component: () => import('@/pages/DeckBuilderPage.vue'),
meta: { requiresAuth: true },
meta: { requiresAuth: true, requiresStarter: true, layout: 'default' },
},
{
path: '/match/:id?',
name: 'match',
component: () => import('@/pages/MatchPage.vue'),
meta: { requiresAuth: true },
path: '/decks/:id',
name: 'EditDeck',
component: () => import('@/pages/DeckBuilderPage.vue'),
meta: { requiresAuth: true, requiresStarter: true, layout: 'default' },
},
{
path: '/play',
name: 'PlayMenu',
component: () => import('@/pages/PlayPage.vue'),
meta: { requiresAuth: true, requiresStarter: true, layout: 'default' },
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/pages/ProfilePage.vue'),
meta: { requiresAuth: true, layout: 'default' },
},
// ============================================
// Game Layout Routes (full viewport, no nav)
// ============================================
{
path: '/game/:id',
name: 'Game',
component: () => import('@/pages/GamePage.vue'),
meta: { requiresAuth: true, requiresStarter: true, layout: 'game' },
},
// ============================================
// Future Routes (Campaign - Phase 5+)
// ============================================
{
path: '/campaign',
name: 'Campaign',
component: () => import('@/pages/CampaignPage.vue'),
meta: { requiresAuth: true, requiresStarter: true, layout: 'default' },
},
]
@ -49,15 +106,35 @@ const router = createRouter({
routes,
})
// Navigation guard for auth
router.beforeEach((to, _from, next) => {
const isAuthenticated = localStorage.getItem('auth_token')
if (to.meta.requiresAuth && !isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else {
next()
// Global navigation guards
router.beforeEach((to, from, next) => {
// Check guest-only routes (e.g., login page)
if (to.meta.requiresGuest) {
requireGuest(to, from, next)
return
}
// Check auth-required routes
if (to.meta.requiresAuth) {
requireAuth(to, from, (result) => {
// If requireAuth redirected, stop here
if (result !== undefined && result !== true) {
next(result)
return
}
// Check starter deck requirement
if (to.meta.requiresStarter) {
requireStarter(to, from, next)
} else {
next()
}
})
return
}
// No guards needed
next()
})
export default router

View File

@ -3,8 +3,9 @@ import { defineStore } from 'pinia'
export interface User {
id: string
username: string
email: string
displayName: string
avatarUrl: string | null
hasStarterDeck: boolean
}
export const useAuthStore = defineStore('auth', () => {