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:
Cal Corum 2026-01-30 11:42:26 -06:00
parent 0dc52f74bc
commit 913b1e7eae
7 changed files with 942 additions and 288 deletions

View 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"
]
}

View File

@ -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>

View 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!')
})
})
})

View 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">
&#x26A0;
</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>

View 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')
})
})
})

View File

@ -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" <div
@submit.prevent="handleSubmit" v-if="error"
class="bg-error/20 text-error p-4 rounded-lg mb-6 text-sm"
> >
<div {{ error }}
v-if="authStore.error" </div>
class="bg-error/20 text-error p-4 rounded"
<!-- 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"
> >
{{ authStore.error }} <svg
</div> class="w-5 h-5"
viewBox="0 0 24 24"
<div>
<label
for="username"
class="block text-sm font-medium mb-2"
> >
Username <path
</label> fill="currentColor"
<input 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"
id="username" />
v-model="username" <path
type="text" fill="#34A853"
required 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"
class="w-full px-4 py-2 bg-surface rounded border border-gray-600 focus:border-primary focus:outline-none" />
> <path
</div> 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"
<div> />
<label <path
for="password" fill="#EA4335"
class="block text-sm font-medium mb-2" 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"
> />
Password </svg>
</label> Continue with Google
<input </button>
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>
<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" >
> Redirecting to login...
Sign up </div>
</RouterLink>
</p>
</div> </div>
</div> </div>
</template> </template>

View File

@ -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>