From 5424bf90862006fdd4d0780b1bc577b7d94ba352 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 30 Jan 2026 10:59:04 -0600 Subject: [PATCH] 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 --- frontend/.env.development | 11 + frontend/.env.production | 11 + frontend/CLAUDE.md | 4 +- frontend/PROJECT_PLAN_FRONTEND.json | 6 +- .../project_plans/PHASE_F0_foundation.json | 194 +++++++++++++++++ frontend/src/config.spec.ts | 98 +++++++++ frontend/src/config.ts | 59 +++++ frontend/src/pages/AuthCallbackPage.vue | 37 ++++ frontend/src/pages/DecksPage.vue | 36 ++++ frontend/src/pages/GamePage.vue | 26 +++ frontend/src/pages/PlayPage.vue | 63 ++++++ frontend/src/pages/ProfilePage.vue | 58 +++++ frontend/src/pages/StarterSelectionPage.vue | 42 ++++ frontend/src/router/guards.spec.ts | 203 ++++++++++++++++++ frontend/src/router/guards.ts | 100 +++++++++ frontend/src/router/index.ts | 137 +++++++++--- frontend/src/stores/auth.ts | 5 +- 17 files changed, 1053 insertions(+), 37 deletions(-) create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/project_plans/PHASE_F0_foundation.json create mode 100644 frontend/src/config.spec.ts create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/pages/AuthCallbackPage.vue create mode 100644 frontend/src/pages/DecksPage.vue create mode 100644 frontend/src/pages/GamePage.vue create mode 100644 frontend/src/pages/PlayPage.vue create mode 100644 frontend/src/pages/ProfilePage.vue create mode 100644 frontend/src/pages/StarterSelectionPage.vue create mode 100644 frontend/src/router/guards.spec.ts create mode 100644 frontend/src/router/guards.ts diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..4b8d81b --- /dev/null +++ b/frontend/.env.development @@ -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 diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..94f4ed1 --- /dev/null +++ b/frontend/.env.production @@ -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 diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 25c9fd6..327f1de 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -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:** diff --git a/frontend/PROJECT_PLAN_FRONTEND.json b/frontend/PROJECT_PLAN_FRONTEND.json index 3aee63a..44cb3e9 100644 --- a/frontend/PROJECT_PLAN_FRONTEND.json +++ b/frontend/PROJECT_PLAN_FRONTEND.json @@ -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": [], diff --git a/frontend/project_plans/PHASE_F0_foundation.json b/frontend/project_plans/PHASE_F0_foundation.json new file mode 100644 index 0000000..d54fd71 --- /dev/null +++ b/frontend/project_plans/PHASE_F0_foundation.json @@ -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)", + "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." + } + ] +} diff --git a/frontend/src/config.spec.ts b/frontend/src/config.spec.ts new file mode 100644 index 0000000..82fd68e --- /dev/null +++ b/frontend/src/config.spec.ts @@ -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('//') + }) +}) diff --git a/frontend/src/config.ts b/frontend/src/config.ts new file mode 100644 index 0000000..9037bc4 --- /dev/null +++ b/frontend/src/config.ts @@ -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 diff --git a/frontend/src/pages/AuthCallbackPage.vue b/frontend/src/pages/AuthCallbackPage.vue new file mode 100644 index 0000000..83e82fd --- /dev/null +++ b/frontend/src/pages/AuthCallbackPage.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/pages/DecksPage.vue b/frontend/src/pages/DecksPage.vue new file mode 100644 index 0000000..faacc53 --- /dev/null +++ b/frontend/src/pages/DecksPage.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/pages/GamePage.vue b/frontend/src/pages/GamePage.vue new file mode 100644 index 0000000..4c677c2 --- /dev/null +++ b/frontend/src/pages/GamePage.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/pages/PlayPage.vue b/frontend/src/pages/PlayPage.vue new file mode 100644 index 0000000..83d2b0b --- /dev/null +++ b/frontend/src/pages/PlayPage.vue @@ -0,0 +1,63 @@ + + + diff --git a/frontend/src/pages/ProfilePage.vue b/frontend/src/pages/ProfilePage.vue new file mode 100644 index 0000000..ca707f8 --- /dev/null +++ b/frontend/src/pages/ProfilePage.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/pages/StarterSelectionPage.vue b/frontend/src/pages/StarterSelectionPage.vue new file mode 100644 index 0000000..b005675 --- /dev/null +++ b/frontend/src/pages/StarterSelectionPage.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/router/guards.spec.ts b/frontend/src/router/guards.spec.ts new file mode 100644 index 0000000..55d9e06 --- /dev/null +++ b/frontend/src/router/guards.spec.ts @@ -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 { + 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() + }) +}) diff --git a/frontend/src/router/guards.ts b/frontend/src/router/guards.ts new file mode 100644 index 0000000..73bf1f6 --- /dev/null +++ b/frontend/src/router/guards.ts @@ -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' + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 862d0de..143f60f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index fa132bc..e324970 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -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', () => {