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">
|
||||
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'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function handleSubmit() {
|
||||
const success = await authStore.login(username.value, password.value)
|
||||
if (success) {
|
||||
const redirect = route.query.redirect as string
|
||||
router.push(redirect || '/campaign')
|
||||
// Check for OAuth error from callback redirect
|
||||
onMounted(() => {
|
||||
const errorParam = route.query.error as string | undefined
|
||||
const messageParam = route.query.message as string | undefined
|
||||
|
||||
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>
|
||||
|
||||
<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">
|
||||
Login
|
||||
<div class="w-full">
|
||||
<div class="card bg-surface-dark p-8">
|
||||
<h1 class="text-2xl font-bold text-center mb-2">
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p class="text-gray-400 text-center mb-8">
|
||||
Sign in to continue your journey
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="space-y-6"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- Error message -->
|
||||
<div
|
||||
v-if="authStore.error"
|
||||
class="bg-error/20 text-error p-4 rounded"
|
||||
v-if="error"
|
||||
class="bg-error/20 text-error p-4 rounded-lg mb-6 text-sm"
|
||||
>
|
||||
{{ authStore.error }}
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium mb-2"
|
||||
<!-- OAuth buttons -->
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
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
|
||||
</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"
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
</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>
|
||||
<path
|
||||
fill="currentColor"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
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"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.isLoading"
|
||||
class="btn btn-primary w-full py-3"
|
||||
type="button"
|
||||
:disabled="isLoading"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-gray-400">
|
||||
Don't have an account?
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="text-primary-light hover:underline"
|
||||
<!-- Loading indicator -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="mt-6 text-center text-gray-400 text-sm"
|
||||
>
|
||||
Sign up
|
||||
</RouterLink>
|
||||
</p>
|
||||
Redirecting to login...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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