diff --git a/frontend/project_plans/PHASE_F1_authentication.json b/frontend/project_plans/PHASE_F1_authentication.json new file mode 100644 index 0000000..6cbcfa2 --- /dev/null +++ b/frontend/project_plans/PHASE_F1_authentication.json @@ -0,0 +1,379 @@ +{ + "meta": { + "phaseId": "PHASE_F1", + "name": "Authentication Flow", + "version": "1.0.0", + "created": "2026-01-30", + "lastUpdated": "2026-01-30", + "totalTasks": 10, + "completedTasks": 0, + "status": "not_started", + "description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization." + }, + "dependencies": { + "phases": ["PHASE_F0"], + "backend": [ + "GET /api/auth/google - Start Google OAuth", + "GET /api/auth/discord - Start Discord OAuth", + "GET /api/auth/{provider}/callback - OAuth callback (returns tokens in URL fragment)", + "POST /api/auth/refresh - Refresh access token", + "POST /api/auth/logout - Revoke refresh token", + "POST /api/auth/logout-all - Revoke all tokens (requires auth)", + "GET /api/users/me - Get current user profile", + "PATCH /api/users/me - Update profile", + "GET /api/users/me/linked-accounts - List linked OAuth accounts", + "GET /api/users/me/starter-status - Check if user has starter deck", + "POST /api/users/me/starter-deck - Select starter deck", + "GET /api/auth/link/google - Link Google account (requires auth)", + "GET /api/auth/link/discord - Link Discord account (requires auth)", + "DELETE /api/users/me/link/{provider} - Unlink OAuth provider" + ] + }, + "tasks": [ + { + "id": "F1-001", + "name": "Update LoginPage for OAuth", + "description": "Replace username/password form with OAuth provider buttons", + "category": "pages", + "priority": 1, + "completed": false, + "tested": false, + "dependencies": [], + "files": [ + {"path": "src/pages/LoginPage.vue", "status": "modify"} + ], + "details": [ + "Remove username/password form (not used - OAuth only)", + "Add Google OAuth button with branded styling", + "Add Discord OAuth button with branded styling", + "Handle redirect to OAuth provider via auth store", + "Show error messages from URL query params (oauth_failed)", + "Responsive design for mobile/desktop", + "Add loading state during redirect" + ], + "acceptance": [ + "Page shows two OAuth buttons: Google and Discord", + "Clicking button redirects to backend OAuth endpoint", + "Error messages from failed OAuth are displayed", + "No username/password fields visible" + ] + }, + { + "id": "F1-002", + "name": "Implement AuthCallbackPage", + "description": "Handle OAuth callback and extract tokens from URL fragment", + "category": "pages", + "priority": 2, + "completed": false, + "tested": false, + "dependencies": ["F1-001"], + "files": [ + {"path": "src/pages/AuthCallbackPage.vue", "status": "modify"} + ], + "details": [ + "Parse URL hash fragment for access_token, refresh_token, expires_in", + "Handle error query params (error, message)", + "Store tokens in auth store using setTokens()", + "Fetch user profile after successful auth", + "Check if user needs starter deck selection", + "Redirect to starter selection if no starter, else to dashboard", + "Show appropriate loading/error states", + "Handle edge cases (missing tokens, network errors)" + ], + "acceptance": [ + "Successfully extracts tokens from URL fragment", + "Stores tokens in auth store (persisted)", + "Fetches user profile after auth", + "Redirects to /starter if user has no starter deck", + "Redirects to / (dashboard) if user has starter deck", + "Shows error message if OAuth failed" + ] + }, + { + "id": "F1-003", + "name": "Create useAuth composable", + "description": "Vue composable for auth operations with loading/error states", + "category": "composables", + "priority": 3, + "completed": false, + "tested": false, + "dependencies": ["F1-002"], + "files": [ + {"path": "src/composables/useAuth.ts", "status": "create"}, + {"path": "src/composables/useAuth.spec.ts", "status": "create"} + ], + "details": [ + "Wrap auth store operations with loading/error handling", + "Provide initiateOAuth(provider) helper", + "Provide handleCallback() for AuthCallbackPage", + "Provide logout() with redirect to login", + "Provide logoutAll() for all-device logout", + "Track isInitialized state for app startup", + "Auto-fetch profile on initialization if tokens exist" + ], + "acceptance": [ + "initiateOAuth() redirects to correct OAuth URL", + "handleCallback() extracts tokens and fetches profile", + "logout() clears state and redirects to login", + "Loading and error states are properly tracked" + ] + }, + { + "id": "F1-004", + "name": "Implement app auth initialization", + "description": "Initialize auth state on app startup", + "category": "setup", + "priority": 4, + "completed": false, + "tested": false, + "dependencies": ["F1-003"], + "files": [ + {"path": "src/App.vue", "status": "modify"}, + {"path": "src/main.ts", "status": "modify"} + ], + "details": [ + "Call auth.init() on app startup (in main.ts or App.vue)", + "Show loading state while initializing auth", + "Validate existing tokens by refreshing if expired", + "Fetch user profile if authenticated", + "Handle initialization errors gracefully", + "Block navigation until auth is initialized" + ], + "acceptance": [ + "App shows loading spinner during auth init", + "Expired tokens are refreshed automatically", + "User profile is fetched if authenticated", + "Invalid/expired refresh tokens trigger logout", + "Navigation guards work after init completes" + ] + }, + { + "id": "F1-005", + "name": "Implement StarterSelectionPage", + "description": "Complete starter deck selection with API integration", + "category": "pages", + "priority": 5, + "completed": false, + "tested": false, + "dependencies": ["F1-003"], + "files": [ + {"path": "src/pages/StarterSelectionPage.vue", "status": "modify"}, + {"path": "src/composables/useStarter.ts", "status": "create"}, + {"path": "src/composables/useStarter.spec.ts", "status": "create"} + ], + "details": [ + "Display 5 starter deck options: grass, fire, water, psychic, lightning", + "Show deck preview (card count, theme description)", + "Handle deck selection with confirmation", + "Call POST /api/users/me/starter-deck on selection", + "Show loading state during API call", + "Handle errors (already selected, network error)", + "Redirect to dashboard on success", + "Update auth store hasStarterDeck flag" + ], + "starterTypes": [ + {"type": "grass", "name": "Forest Guardians", "description": "Growth and healing focused deck"}, + {"type": "fire", "name": "Flame Warriors", "description": "Aggressive damage-focused deck"}, + {"type": "water", "name": "Tidal Force", "description": "Balanced control and damage"}, + {"type": "psychic", "name": "Mind Masters", "description": "Status effects and manipulation"}, + {"type": "lightning", "name": "Storm Riders", "description": "Fast, high-damage strikes"} + ], + "acceptance": [ + "5 starter deck options displayed with themes", + "Selection calls API with correct starter_type", + "Success updates user state and redirects to /", + "Errors are displayed to user", + "Already-selected error handled gracefully" + ] + }, + { + "id": "F1-006", + "name": "Implement ProfilePage", + "description": "User profile management with linked accounts", + "category": "pages", + "priority": 6, + "completed": false, + "tested": false, + "dependencies": ["F1-003"], + "files": [ + {"path": "src/pages/ProfilePage.vue", "status": "modify"}, + {"path": "src/composables/useProfile.ts", "status": "create"}, + {"path": "src/composables/useProfile.spec.ts", "status": "create"}, + {"path": "src/components/profile/LinkedAccountCard.vue", "status": "create"}, + {"path": "src/components/profile/DisplayNameEditor.vue", "status": "create"} + ], + "details": [ + "Display user avatar and display name", + "Editable display name with save button", + "List linked OAuth accounts (Google, Discord)", + "Link additional OAuth provider button", + "Unlink OAuth provider (if not primary)", + "Logout button (current session)", + "Logout All button (all devices)", + "Show active session count" + ], + "acceptance": [ + "Profile displays user info correctly", + "Display name can be edited and saved", + "Linked accounts are displayed", + "Can link additional OAuth provider", + "Can unlink non-primary provider", + "Logout works correctly", + "Logout All works correctly" + ] + }, + { + "id": "F1-007", + "name": "Update navigation for auth state", + "description": "Update NavSidebar and NavBottomTabs for auth state", + "category": "components", + "priority": 7, + "completed": false, + "tested": false, + "dependencies": ["F1-003"], + "files": [ + {"path": "src/components/NavSidebar.vue", "status": "modify"}, + {"path": "src/components/NavBottomTabs.vue", "status": "modify"} + ], + "details": [ + "Show user avatar in nav if available", + "Use actual display name instead of placeholder", + "Ensure logout button triggers proper logout flow", + "Handle loading state during logout" + ], + "acceptance": [ + "Nav shows actual user avatar if available", + "Nav shows actual display name", + "Logout triggers full logout flow with redirect" + ] + }, + { + "id": "F1-008", + "name": "Implement account linking flow", + "description": "Allow users to link additional OAuth providers", + "category": "features", + "priority": 8, + "completed": false, + "tested": false, + "dependencies": ["F1-006"], + "files": [ + {"path": "src/composables/useAccountLinking.ts", "status": "create"}, + {"path": "src/composables/useAccountLinking.spec.ts", "status": "create"}, + {"path": "src/pages/LinkCallbackPage.vue", "status": "create"} + ], + "details": [ + "Add route for /auth/link/callback to handle linking callbacks", + "Initiate linking via GET /api/auth/link/{provider}", + "Handle success/error query params on callback", + "Refresh linked accounts list after linking", + "Show success toast on link complete", + "Handle errors (already linked, etc.)" + ], + "acceptance": [ + "Can initiate link from profile page", + "Link callback handles success and error", + "Linked accounts list updates after linking", + "Appropriate feedback shown to user" + ] + }, + { + "id": "F1-009", + "name": "Add requireStarter guard implementation", + "description": "Implement the starter deck navigation guard", + "category": "router", + "priority": 9, + "completed": false, + "tested": false, + "dependencies": ["F1-005"], + "files": [ + {"path": "src/router/guards.ts", "status": "modify"}, + {"path": "src/router/guards.spec.ts", "status": "modify"} + ], + "details": [ + "requireStarter checks if user has selected starter deck", + "If no starter, redirect to /starter page", + "Check auth.user?.hasStarterDeck flag", + "If flag is undefined, fetch starter status from API", + "Cache result to avoid repeated API calls" + ], + "acceptance": [ + "Users without starter deck are redirected to /starter", + "Users with starter deck can access protected routes", + "Guard waits for auth initialization before checking", + "API is called only when needed" + ] + }, + { + "id": "F1-010", + "name": "Write integration tests for auth flow", + "description": "End-to-end tests for complete auth flow", + "category": "testing", + "priority": 10, + "completed": false, + "tested": false, + "dependencies": ["F1-001", "F1-002", "F1-003", "F1-004", "F1-005"], + "files": [ + {"path": "src/pages/LoginPage.spec.ts", "status": "create"}, + {"path": "src/pages/AuthCallbackPage.spec.ts", "status": "create"}, + {"path": "src/pages/StarterSelectionPage.spec.ts", "status": "create"}, + {"path": "src/pages/ProfilePage.spec.ts", "status": "create"} + ], + "details": [ + "Test LoginPage OAuth button redirects", + "Test AuthCallbackPage token extraction", + "Test AuthCallbackPage error handling", + "Test StarterSelectionPage deck selection flow", + "Test ProfilePage display and edit operations", + "Test navigation guards with various auth states", + "Mock API responses for all tests" + ], + "acceptance": [ + "All page components have test files", + "Tests cover happy path and error cases", + "Tests mock API calls appropriately", + "All tests pass" + ] + } + ], + "apiContracts": { + "oauthCallback": { + "description": "Backend redirects to frontend with tokens in URL fragment", + "format": "/auth/callback#access_token={token}&refresh_token={token}&expires_in={seconds}", + "errorFormat": "/auth/callback?error={code}&message={message}" + }, + "tokenResponse": { + "accessToken": "JWT access token (short-lived)", + "refreshToken": "Opaque refresh token (long-lived)", + "expiresIn": "Access token expiry in seconds" + }, + "userProfile": { + "id": "UUID", + "display_name": "string", + "avatar_url": "string | null", + "has_starter_deck": "boolean", + "created_at": "ISO datetime", + "linked_accounts": [ + { + "provider": "google | discord", + "email": "string | null", + "linked_at": "ISO datetime" + } + ] + }, + "starterDeck": { + "request": { + "starter_type": "grass | fire | water | psychic | lightning" + }, + "response": "DeckResponse with is_starter=true" + } + }, + "notes": [ + "OAuth flow uses URL fragment (hash) for tokens, not query params, for security", + "Tokens are persisted via pinia-plugin-persistedstate", + "Auth store already has most functionality, composables add loading/error handling", + "LoginPage currently has username/password form which needs to be replaced", + "AuthCallbackPage currently just redirects to home - needs full implementation", + "StarterSelectionPage has placeholder UI - needs API integration", + "ProfilePage needs to be created from scratch" + ] +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue deleted file mode 100644 index 09c7f19..0000000 --- a/frontend/src/components/AppHeader.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/frontend/src/components/ui/ErrorBoundary.spec.ts b/frontend/src/components/ui/ErrorBoundary.spec.ts new file mode 100644 index 0000000..ca6e4d0 --- /dev/null +++ b/frontend/src/components/ui/ErrorBoundary.spec.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' + +import ErrorBoundary from './ErrorBoundary.vue' + +describe('ErrorBoundary', () => { + describe('normal operation', () => { + it('renders slot content when no error', () => { + /** + * Test that child content is displayed normally. + * + * When no errors occur, the error boundary should be + * transparent and render its slot content as-is. + */ + const wrapper = mount(ErrorBoundary, { + slots: { + default: '
Hello World
', + }, + }) + + expect(wrapper.find('.child').exists()).toBe(true) + expect(wrapper.text()).toContain('Hello World') + }) + }) + + describe('error handling', () => { + it('shows fallback UI when child throws', async () => { + /** + * Test that errors in children are caught and handled. + * + * When a child component throws, the error boundary should + * display a friendly fallback UI instead of crashing. + */ + const ThrowingComponent = defineComponent({ + setup() { + throw new Error('Test error') + }, + render() { + return h('div', 'Should not render') + }, + }) + + const wrapper = mount(ErrorBoundary, { + slots: { + default: h(ThrowingComponent), + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('Oops!') + expect(wrapper.text()).toContain('Something went wrong') + expect(wrapper.find('.child').exists()).toBe(false) + }) + + it('shows custom fallback message', async () => { + /** + * Test that custom fallback messages are displayed. + * + * Components can customize the error message to provide + * context-specific guidance to users. + */ + const ThrowingComponent = defineComponent({ + setup() { + throw new Error('Test error') + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(ErrorBoundary, { + props: { + fallbackMessage: 'Failed to load game data.', + }, + slots: { + default: h(ThrowingComponent), + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('Failed to load game data.') + }) + + it('emits error event when catching', async () => { + /** + * Test that error events are emitted for parent handling. + * + * Parent components may want to log errors or take + * additional recovery actions. + */ + const ThrowingComponent = defineComponent({ + setup() { + throw new Error('Test error message') + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(ErrorBoundary, { + slots: { + default: h(ThrowingComponent), + }, + }) + + await flushPromises() + + const errorEvents = wrapper.emitted('error') + expect(errorEvents).toBeTruthy() + expect(errorEvents![0][0]).toBeInstanceOf(Error) + expect((errorEvents![0][0] as Error).message).toBe('Test error message') + }) + }) + + describe('recovery', () => { + it('has retry button', async () => { + /** + * Test that retry button is available. + * + * Users should be able to attempt recovery without + * refreshing the entire page. + */ + const ThrowingComponent = defineComponent({ + setup() { + throw new Error('Test error') + }, + render() { + return h('div') + }, + }) + + const wrapper = mount(ErrorBoundary, { + slots: { + default: h(ThrowingComponent), + }, + }) + + await flushPromises() + + const retryButton = wrapper.find('button') + expect(retryButton.exists()).toBe(true) + expect(retryButton.text()).toContain('Try Again') + }) + + it('resets error state on retry', async () => { + /** + * Test that clicking retry clears the error state. + * + * After retry, the boundary should attempt to re-render + * the slot content. + */ + let shouldThrow = true + const ConditionalThrowComponent = defineComponent({ + setup() { + if (shouldThrow) { + throw new Error('Test error') + } + }, + render() { + return h('div', { class: 'success' }, 'Success!') + }, + }) + + const wrapper = mount(ErrorBoundary, { + slots: { + default: h(ConditionalThrowComponent), + }, + }) + + await flushPromises() + + // Error state active + expect(wrapper.text()).toContain('Oops!') + + // Disable throwing and retry + shouldThrow = false + await wrapper.find('button').trigger('click') + await flushPromises() + + // Should attempt to render slot again + // Note: The slot is still the throwing component from mount time, + // so we just verify error state is cleared + expect(wrapper.text()).not.toContain('Oops!') + }) + }) +}) diff --git a/frontend/src/components/ui/ErrorBoundary.vue b/frontend/src/components/ui/ErrorBoundary.vue new file mode 100644 index 0000000..3155ca1 --- /dev/null +++ b/frontend/src/components/ui/ErrorBoundary.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/pages/LoginPage.spec.ts b/frontend/src/pages/LoginPage.spec.ts new file mode 100644 index 0000000..c2bac98 --- /dev/null +++ b/frontend/src/pages/LoginPage.spec.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createWebHistory } from 'vue-router' +import { setActivePinia, createPinia } from 'pinia' + +import LoginPage from './LoginPage.vue' +import { useAuthStore } from '@/stores/auth' + +// Mock window.location +const mockLocation = { + href: '', +} +Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, +}) + +describe('LoginPage', () => { + let router: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + mockLocation.href = '' + + router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', name: 'Login', component: LoginPage }, + { path: '/', name: 'Home', component: { template: '
Home
' } }, + ], + }) + }) + + describe('rendering', () => { + it('shows OAuth buttons', () => { + /** + * Test that OAuth provider buttons are displayed. + * + * Users should see clear options to login with Google + * or Discord - no username/password fields. + */ + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + expect(wrapper.text()).toContain('Continue with Google') + expect(wrapper.text()).toContain('Continue with Discord') + }) + + it('does not show username/password fields', () => { + /** + * Test that traditional auth fields are absent. + * + * This is an OAuth-only app - no username/password + * inputs should exist. + */ + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + expect(wrapper.find('input[type="text"]').exists()).toBe(false) + expect(wrapper.find('input[type="password"]').exists()).toBe(false) + }) + }) + + describe('OAuth flow', () => { + it('redirects to Google OAuth on button click', async () => { + /** + * Test that clicking Google button starts OAuth flow. + * + * The button should redirect the browser to the backend's + * Google OAuth endpoint. + */ + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + const auth = useAuthStore() + const expectedUrl = auth.getOAuthUrl('google') + + const googleButton = wrapper.findAll('button').find(b => b.text().includes('Google')) + expect(googleButton).toBeTruthy() + + await googleButton!.trigger('click') + + expect(mockLocation.href).toBe(expectedUrl) + }) + + it('redirects to Discord OAuth on button click', async () => { + /** + * Test that clicking Discord button starts OAuth flow. + * + * The button should redirect the browser to the backend's + * Discord OAuth endpoint. + */ + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + const auth = useAuthStore() + const expectedUrl = auth.getOAuthUrl('discord') + + const discordButton = wrapper.findAll('button').find(b => b.text().includes('Discord')) + expect(discordButton).toBeTruthy() + + await discordButton!.trigger('click') + + expect(mockLocation.href).toBe(expectedUrl) + }) + + it('shows loading state after button click', async () => { + /** + * Test that loading indicator appears during redirect. + * + * While the browser is redirecting, users should see + * feedback that something is happening. + */ + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + const googleButton = wrapper.findAll('button').find(b => b.text().includes('Google')) + await googleButton!.trigger('click') + + expect(wrapper.text()).toContain('Redirecting to login') + }) + + it('disables buttons while loading', async () => { + /** + * Test that buttons are disabled during redirect. + * + * Prevents users from clicking multiple times while + * the OAuth redirect is in progress. + */ + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + const googleButton = wrapper.findAll('button').find(b => b.text().includes('Google')) + await googleButton!.trigger('click') + + const buttons = wrapper.findAll('button') + buttons.forEach(button => { + expect(button.attributes('disabled')).toBeDefined() + }) + }) + }) + + describe('error handling', () => { + it('shows error from query params', async () => { + /** + * Test that OAuth errors are displayed. + * + * When OAuth fails, the callback redirects back to login + * with error details in query params. + */ + await router.push('/login?error=oauth_failed&message=Invalid%20credentials') + await router.isReady() + + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('Invalid credentials') + }) + + it('shows default error message when message is missing', async () => { + /** + * Test that a default error message is shown. + * + * If the error query param is present but message is + * missing, show a generic error. + */ + await router.push('/login?error=oauth_failed') + await router.isReady() + + const wrapper = mount(LoginPage, { + global: { + plugins: [router], + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('Authentication failed') + }) + }) +}) diff --git a/frontend/src/pages/LoginPage.vue b/frontend/src/pages/LoginPage.vue index d716395..88a2233 100644 --- a/frontend/src/pages/LoginPage.vue +++ b/frontend/src/pages/LoginPage.vue @@ -1,93 +1,118 @@ diff --git a/frontend/src/pages/RegisterPage.vue b/frontend/src/pages/RegisterPage.vue deleted file mode 100644 index 9b5e395..0000000 --- a/frontend/src/pages/RegisterPage.vue +++ /dev/null @@ -1,142 +0,0 @@ - - -