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:
parent
25cb22eb84
commit
5424bf9086
11
frontend/.env.development
Normal file
11
frontend/.env.development
Normal 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
11
frontend/.env.production
Normal 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
|
||||
@ -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:**
|
||||
|
||||
@ -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": [],
|
||||
|
||||
194
frontend/project_plans/PHASE_F0_foundation.json
Normal file
194
frontend/project_plans/PHASE_F0_foundation.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
98
frontend/src/config.spec.ts
Normal file
98
frontend/src/config.spec.ts
Normal 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
59
frontend/src/config.ts
Normal 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
|
||||
37
frontend/src/pages/AuthCallbackPage.vue
Normal file
37
frontend/src/pages/AuthCallbackPage.vue
Normal 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>
|
||||
36
frontend/src/pages/DecksPage.vue
Normal file
36
frontend/src/pages/DecksPage.vue
Normal 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>
|
||||
26
frontend/src/pages/GamePage.vue
Normal file
26
frontend/src/pages/GamePage.vue
Normal 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>
|
||||
63
frontend/src/pages/PlayPage.vue
Normal file
63
frontend/src/pages/PlayPage.vue
Normal 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>
|
||||
58
frontend/src/pages/ProfilePage.vue
Normal file
58
frontend/src/pages/ProfilePage.vue
Normal 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>
|
||||
42
frontend/src/pages/StarterSelectionPage.vue
Normal file
42
frontend/src/pages/StarterSelectionPage.vue
Normal 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>
|
||||
203
frontend/src/router/guards.spec.ts
Normal file
203
frontend/src/router/guards.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
100
frontend/src/router/guards.ts
Normal file
100
frontend/src/router/guards.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user