Fix audit issues: OAuth login, remove dead code, add error boundary
Code audit fixes: - Update LoginPage for OAuth (Google/Discord buttons, no password) - Delete RegisterPage.vue (OAuth-only app) - Delete AppHeader.vue (superseded by NavSidebar, had bugs) - Add ErrorBoundary component for graceful error handling Also adds Phase F1 (Authentication Flow) plan with 10 tasks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0dc52f74bc
commit
913b1e7eae
379
frontend/project_plans/PHASE_F1_authentication.json
Normal file
379
frontend/project_plans/PHASE_F1_authentication.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,78 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
|
||||||
const username = computed(() => authStore.user?.username)
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
authStore.logout()
|
|
||||||
router.push({ name: 'home' })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header class="bg-surface-dark border-b border-gray-700">
|
|
||||||
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
|
|
||||||
<RouterLink
|
|
||||||
to="/"
|
|
||||||
class="text-xl font-bold text-primary-light"
|
|
||||||
>
|
|
||||||
Mantimon TCG
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<nav class="flex items-center gap-6">
|
|
||||||
<template v-if="isAuthenticated">
|
|
||||||
<RouterLink
|
|
||||||
to="/campaign"
|
|
||||||
class="text-gray-300 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Campaign
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/collection"
|
|
||||||
class="text-gray-300 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Collection
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/deck-builder"
|
|
||||||
class="text-gray-300 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Decks
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 ml-4 pl-4 border-l border-gray-600">
|
|
||||||
<span class="text-gray-400">{{ username }}</span>
|
|
||||||
<button
|
|
||||||
class="text-gray-400 hover:text-white transition-colors"
|
|
||||||
@click="handleLogout"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<RouterLink
|
|
||||||
to="/login"
|
|
||||||
class="text-gray-300 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/register"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
Sign Up
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
189
frontend/src/components/ui/ErrorBoundary.spec.ts
Normal file
189
frontend/src/components/ui/ErrorBoundary.spec.ts
Normal file
@ -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: '<div class="child">Hello World</div>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
77
frontend/src/components/ui/ErrorBoundary.vue
Normal file
77
frontend/src/components/ui/ErrorBoundary.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Error boundary component.
|
||||||
|
*
|
||||||
|
* Catches errors in child components and displays a fallback UI
|
||||||
|
* instead of crashing the entire application. Provides a retry
|
||||||
|
* button to attempt recovery.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onErrorCaptured } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Custom fallback message */
|
||||||
|
fallbackMessage?: string
|
||||||
|
}>(), {
|
||||||
|
fallbackMessage: 'Something went wrong. Please try again.',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
error: [error: Error, info: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const hasError = ref(false)
|
||||||
|
const errorMessage = ref<string | null>(null)
|
||||||
|
const isDev = computed(() => import.meta.env.DEV)
|
||||||
|
|
||||||
|
onErrorCaptured((error: Error, _instance, info: string) => {
|
||||||
|
hasError.value = true
|
||||||
|
errorMessage.value = error.message
|
||||||
|
|
||||||
|
// Emit error for parent handling/logging
|
||||||
|
emit('error', error, info)
|
||||||
|
|
||||||
|
// Log in development
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('ErrorBoundary caught:', error)
|
||||||
|
console.error('Component info:', info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent error from propagating
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
hasError.value = false
|
||||||
|
errorMessage.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="hasError"
|
||||||
|
class="flex flex-col items-center justify-center p-8 text-center"
|
||||||
|
>
|
||||||
|
<div class="text-4xl mb-4">
|
||||||
|
⚠
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-200 mb-2">
|
||||||
|
Oops!
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 mb-4 max-w-md">
|
||||||
|
{{ props.fallbackMessage }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="errorMessage && isDev"
|
||||||
|
class="text-error text-sm mb-4 font-mono bg-error/10 px-3 py-2 rounded"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="handleRetry"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot v-else />
|
||||||
|
</template>
|
||||||
204
frontend/src/pages/LoginPage.spec.ts
Normal file
204
frontend/src/pages/LoginPage.spec.ts
Normal file
@ -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<typeof createRouter>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
mockLocation.href = ''
|
||||||
|
|
||||||
|
router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', name: 'Login', component: LoginPage },
|
||||||
|
{ path: '/', name: 'Home', component: { template: '<div>Home</div>' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,93 +1,118 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
/**
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
* Login page with OAuth provider buttons.
|
||||||
|
*
|
||||||
|
* Users authenticate via Google or Discord OAuth. No username/password.
|
||||||
|
* Redirects to the OAuth provider which will callback to /auth/callback.
|
||||||
|
*/
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const username = ref('')
|
const isLoading = ref(false)
|
||||||
const password = ref('')
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
async function handleSubmit() {
|
// Check for OAuth error from callback redirect
|
||||||
const success = await authStore.login(username.value, password.value)
|
onMounted(() => {
|
||||||
if (success) {
|
const errorParam = route.query.error as string | undefined
|
||||||
const redirect = route.query.redirect as string
|
const messageParam = route.query.message as string | undefined
|
||||||
router.push(redirect || '/campaign')
|
|
||||||
|
if (errorParam) {
|
||||||
|
error.value = messageParam || 'Authentication failed. Please try again.'
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function loginWithGoogle(): void {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
window.location.href = auth.getOAuthUrl('google')
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginWithDiscord(): void {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
window.location.href = auth.getOAuthUrl('discord')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-16">
|
<div class="w-full">
|
||||||
<div class="max-w-md mx-auto">
|
<div class="card bg-surface-dark p-8">
|
||||||
<h1 class="text-3xl font-bold text-center mb-8">
|
<h1 class="text-2xl font-bold text-center mb-2">
|
||||||
Login
|
Welcome Back
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="text-gray-400 text-center mb-8">
|
||||||
|
Sign in to continue your journey
|
||||||
|
</p>
|
||||||
|
|
||||||
<form
|
<!-- Error message -->
|
||||||
class="space-y-6"
|
|
||||||
@submit.prevent="handleSubmit"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="authStore.error"
|
v-if="error"
|
||||||
class="bg-error/20 text-error p-4 rounded"
|
class="bg-error/20 text-error p-4 rounded-lg mb-6 text-sm"
|
||||||
>
|
>
|
||||||
{{ authStore.error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- OAuth buttons -->
|
||||||
<label
|
<div class="space-y-4">
|
||||||
for="username"
|
<button
|
||||||
class="block text-sm font-medium mb-2"
|
type="button"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white text-gray-900 rounded-lg font-medium hover:bg-gray-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
@click="loginWithGoogle"
|
||||||
>
|
>
|
||||||
Username
|
<svg
|
||||||
</label>
|
class="w-5 h-5"
|
||||||
<input
|
viewBox="0 0 24 24"
|
||||||
id="username"
|
|
||||||
v-model="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
|
||||||
>
|
>
|
||||||
</div>
|
<path
|
||||||
|
fill="currentColor"
|
||||||
<div>
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
<label
|
/>
|
||||||
for="password"
|
<path
|
||||||
class="block text-sm font-medium mb-2"
|
fill="#34A853"
|
||||||
>
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
Password
|
/>
|
||||||
</label>
|
<path
|
||||||
<input
|
fill="#FBBC05"
|
||||||
id="password"
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
v-model="password"
|
/>
|
||||||
type="password"
|
<path
|
||||||
required
|
fill="#EA4335"
|
||||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
>
|
/>
|
||||||
</div>
|
</svg>
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
:disabled="authStore.isLoading"
|
:disabled="isLoading"
|
||||||
class="btn btn-primary w-full py-3"
|
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-[#5865F2] text-white rounded-lg font-medium hover:bg-[#4752C4] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
@click="loginWithDiscord"
|
||||||
>
|
>
|
||||||
{{ authStore.isLoading ? 'Logging in...' : 'Login' }}
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||||
|
</svg>
|
||||||
|
Continue with Discord
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<p class="mt-6 text-center text-gray-400">
|
<!-- Loading indicator -->
|
||||||
Don't have an account?
|
<div
|
||||||
<RouterLink
|
v-if="isLoading"
|
||||||
to="/register"
|
class="mt-6 text-center text-gray-400 text-sm"
|
||||||
class="text-primary-light hover:underline"
|
|
||||||
>
|
>
|
||||||
Sign up
|
Redirecting to login...
|
||||||
</RouterLink>
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const username = ref('')
|
|
||||||
const email = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const localError = ref<string | null>(null)
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
localError.value = null
|
|
||||||
|
|
||||||
if (password.value !== confirmPassword.value) {
|
|
||||||
localError.value = 'Passwords do not match'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.value.length < 8) {
|
|
||||||
localError.value = 'Password must be at least 8 characters'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await authStore.register(
|
|
||||||
username.value,
|
|
||||||
email.value,
|
|
||||||
password.value
|
|
||||||
)
|
|
||||||
if (success) {
|
|
||||||
router.push('/campaign')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container mx-auto px-4 py-16">
|
|
||||||
<div class="max-w-md mx-auto">
|
|
||||||
<h1 class="text-3xl font-bold text-center mb-8">
|
|
||||||
Create Account
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="space-y-6"
|
|
||||||
@submit.prevent="handleSubmit"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="localError || authStore.error"
|
|
||||||
class="bg-error/20 text-error p-4 rounded"
|
|
||||||
>
|
|
||||||
{{ localError || authStore.error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="username"
|
|
||||||
class="block text-sm font-medium mb-2"
|
|
||||||
>
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
v-model="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="email"
|
|
||||||
class="block text-sm font-medium mb-2"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
v-model="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="password"
|
|
||||||
class="block text-sm font-medium mb-2"
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="confirmPassword"
|
|
||||||
class="block text-sm font-medium mb-2"
|
|
||||||
>
|
|
||||||
Confirm Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="confirmPassword"
|
|
||||||
v-model="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="authStore.isLoading"
|
|
||||||
class="btn btn-primary w-full py-3"
|
|
||||||
>
|
|
||||||
{{ authStore.isLoading ? 'Creating account...' : 'Create Account' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="mt-6 text-center text-gray-400">
|
|
||||||
Already have an account?
|
|
||||||
<RouterLink
|
|
||||||
to="/login"
|
|
||||||
class="text-primary-light hover:underline"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</RouterLink>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
Loading…
Reference in New Issue
Block a user