Implement OAuth callback with token handling and profile fetch (F1-002)
Complete the AuthCallbackPage to handle OAuth redirects by parsing tokens from URL fragment, fetching user profile, and redirecting based on starter deck status. Includes open-redirect protection and comprehensive tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
09844cbf3f
commit
f687909f91
@ -7,8 +7,8 @@
|
||||
"projectName": "Mantimon TCG - Frontend",
|
||||
"description": "Vue 3 + Phaser 3 frontend for pocket.manticorum.com - real-time multiplayer TCG with campaign mode",
|
||||
"totalPhases": 8,
|
||||
"completedPhases": 0,
|
||||
"status": "Phase F0 in progress"
|
||||
"completedPhases": 1,
|
||||
"status": "Phase F1 in progress"
|
||||
},
|
||||
|
||||
"techStack": {
|
||||
@ -124,7 +124,8 @@
|
||||
{
|
||||
"id": "PHASE_F0",
|
||||
"name": "Project Foundation",
|
||||
"status": "in_progress",
|
||||
"status": "COMPLETE",
|
||||
"completedDate": "2026-01-30",
|
||||
"description": "Scaffolding, tooling, core infrastructure, API client setup",
|
||||
"estimatedDays": "3-5",
|
||||
"dependencies": [],
|
||||
@ -240,7 +241,7 @@
|
||||
{
|
||||
"id": "PHASE_F1",
|
||||
"name": "Authentication",
|
||||
"status": "NOT_STARTED",
|
||||
"status": "in_progress",
|
||||
"description": "OAuth login flow, token management, protected routes",
|
||||
"estimatedDays": "2-3",
|
||||
"dependencies": ["PHASE_F0"],
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
"created": "2026-01-30",
|
||||
"lastUpdated": "2026-01-30",
|
||||
"totalTasks": 10,
|
||||
"completedTasks": 0,
|
||||
"status": "not_started",
|
||||
"completedTasks": 2,
|
||||
"status": "in_progress",
|
||||
"description": "Complete OAuth authentication flow including login, callback handling, starter deck selection, profile management, and app initialization."
|
||||
},
|
||||
"dependencies": {
|
||||
@ -36,8 +36,8 @@
|
||||
"description": "Replace username/password form with OAuth provider buttons",
|
||||
"category": "pages",
|
||||
"priority": 1,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{"path": "src/pages/LoginPage.vue", "status": "modify"}
|
||||
@ -64,8 +64,8 @@
|
||||
"description": "Handle OAuth callback and extract tokens from URL fragment",
|
||||
"category": "pages",
|
||||
"priority": 2,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"completed": true,
|
||||
"tested": true,
|
||||
"dependencies": ["F1-001"],
|
||||
"files": [
|
||||
{"path": "src/pages/AuthCallbackPage.vue", "status": "modify"}
|
||||
|
||||
437
frontend/src/pages/AuthCallbackPage.spec.ts
Normal file
437
frontend/src/pages/AuthCallbackPage.spec.ts
Normal file
@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
|
||||
import AuthCallbackPage from './AuthCallbackPage.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// Mock the API client
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { apiClient } from '@/api/client'
|
||||
|
||||
describe('AuthCallbackPage', () => {
|
||||
let router: ReturnType<typeof createRouter>
|
||||
let mockLocation: { hash: string; pathname: string }
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Mock window.location
|
||||
mockLocation = { hash: '', pathname: '/auth/callback' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: mockLocation,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Mock history.replaceState
|
||||
vi.spyOn(window.history, 'replaceState').mockImplementation(() => {})
|
||||
|
||||
// Reset mocks
|
||||
vi.mocked(apiClient.get).mockReset()
|
||||
|
||||
router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/auth/callback', name: 'AuthCallback', component: AuthCallbackPage },
|
||||
{ path: '/login', name: 'Login', component: { template: '<div>Login</div>' } },
|
||||
{ path: '/', name: 'Dashboard', component: { template: '<div>Dashboard</div>' } },
|
||||
{ path: '/starter', name: 'StarterSelection', component: { template: '<div>Starter</div>' } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('token parsing', () => {
|
||||
it('extracts tokens from URL hash fragment', async () => {
|
||||
/**
|
||||
* Test that tokens are correctly parsed from the URL hash.
|
||||
*
|
||||
* The OAuth provider redirects back with tokens in the fragment
|
||||
* (after #) for security. We must parse access_token, refresh_token,
|
||||
* and expires_in from this fragment.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const auth = useAuthStore()
|
||||
expect(auth.accessToken).toBe('abc123')
|
||||
expect(auth.refreshToken).toBe('xyz789')
|
||||
expect(auth.expiresAt).toBeGreaterThan(Date.now())
|
||||
})
|
||||
|
||||
it('shows error when tokens are missing', async () => {
|
||||
/**
|
||||
* Test error handling for malformed OAuth response.
|
||||
*
|
||||
* If the URL fragment is missing required tokens, we should
|
||||
* show an error rather than crash or behave unexpectedly.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123' // Missing refresh_token and expires_in
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Authentication Failed')
|
||||
expect(wrapper.text()).toContain('Missing tokens')
|
||||
})
|
||||
|
||||
it('shows error when hash is empty', async () => {
|
||||
/**
|
||||
* Test error handling when no hash fragment is present.
|
||||
*
|
||||
* If the OAuth provider redirects without tokens (empty hash),
|
||||
* we should show an appropriate error.
|
||||
*/
|
||||
mockLocation.hash = ''
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Authentication Failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling from OAuth', () => {
|
||||
it('redirects to login with error from query params', async () => {
|
||||
/**
|
||||
* Test that OAuth errors are forwarded to the login page.
|
||||
*
|
||||
* When OAuth fails, the backend redirects to callback with
|
||||
* error details in query params. We should forward these
|
||||
* to the login page for display.
|
||||
*/
|
||||
await router.push('/auth/callback?error=access_denied&message=User%20cancelled')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('Login')
|
||||
expect(router.currentRoute.value.query.error).toBe('access_denied')
|
||||
expect(router.currentRoute.value.query.message).toBe('User cancelled')
|
||||
})
|
||||
|
||||
it('uses default error message when message param is missing', async () => {
|
||||
/**
|
||||
* Test fallback error message.
|
||||
*
|
||||
* If the backend only provides an error code without a message,
|
||||
* we should use a sensible default message.
|
||||
*/
|
||||
await router.push('/auth/callback?error=unknown_error')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('Login')
|
||||
expect(router.currentRoute.value.query.message).toBe('Authentication failed. Please try again.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user profile handling', () => {
|
||||
it('fetches user profile after storing tokens', async () => {
|
||||
/**
|
||||
* Test that user profile is fetched after successful token storage.
|
||||
*
|
||||
* After storing tokens, we need to fetch the user's profile
|
||||
* to know their display name, avatar, and starter deck status.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/users/me')
|
||||
|
||||
const auth = useAuthStore()
|
||||
expect(auth.user).toEqual({
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
hasStarterDeck: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to starter selection if user has no starter deck', async () => {
|
||||
/**
|
||||
* Test redirect flow for new users without starter decks.
|
||||
*
|
||||
* New users must select a starter deck before accessing the main app.
|
||||
* If has_starter_deck is false, redirect to the selection page.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'New User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: false,
|
||||
})
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('StarterSelection')
|
||||
})
|
||||
|
||||
it('redirects to dashboard if user has starter deck', async () => {
|
||||
/**
|
||||
* Test redirect flow for existing users with starter decks.
|
||||
*
|
||||
* Users who already have a starter deck should go directly
|
||||
* to the dashboard after successful login.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Existing User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('Dashboard')
|
||||
})
|
||||
|
||||
it('redirects to intended destination after login', async () => {
|
||||
/**
|
||||
* Test redirect to originally requested page.
|
||||
*
|
||||
* If a user was redirected to login while trying to access
|
||||
* a protected route, they should return there after auth.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await router.push('/auth/callback?redirect=/decks')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/decks')
|
||||
})
|
||||
})
|
||||
|
||||
describe('API errors', () => {
|
||||
it('shows error when profile fetch fails', async () => {
|
||||
/**
|
||||
* Test error handling for profile fetch failures.
|
||||
*
|
||||
* If we successfully get tokens but fail to fetch the profile
|
||||
* (network error, server error, etc.), show an error state.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Authentication Failed')
|
||||
expect(wrapper.text()).toContain('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('security', () => {
|
||||
it('rejects protocol-relative redirect URLs', async () => {
|
||||
/**
|
||||
* Test that protocol-relative URLs are rejected as redirects.
|
||||
*
|
||||
* An attacker could try to use //evil.com as a redirect, which
|
||||
* passes a naive startsWith('/') check but redirects to another domain.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await router.push('/auth/callback?redirect=//evil.com')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
// Should go to dashboard, not the malicious redirect
|
||||
expect(router.currentRoute.value.name).toBe('Dashboard')
|
||||
})
|
||||
|
||||
it('clears tokens from URL after parsing', async () => {
|
||||
/**
|
||||
* Test that tokens are removed from browser history.
|
||||
*
|
||||
* Tokens in the URL should be cleared after parsing to prevent
|
||||
* them from appearing in browser history or being logged.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
id: 'user-1',
|
||||
display_name: 'Test User',
|
||||
avatar_url: null,
|
||||
has_starter_deck: true,
|
||||
})
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||
null,
|
||||
'',
|
||||
'/auth/callback'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI states', () => {
|
||||
it('shows processing state while handling callback', async () => {
|
||||
/**
|
||||
* Test loading state during authentication.
|
||||
*
|
||||
* Users should see a loading indicator while tokens are being
|
||||
* processed and profile is being fetched.
|
||||
*/
|
||||
mockLocation.hash = '#access_token=abc123&refresh_token=xyz789&expires_in=3600'
|
||||
|
||||
// Don't resolve the promise immediately
|
||||
vi.mocked(apiClient.get).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Completing login')
|
||||
expect(wrapper.text()).toContain('Please wait')
|
||||
})
|
||||
|
||||
it('shows back to login button on error', async () => {
|
||||
/**
|
||||
* Test error recovery UI.
|
||||
*
|
||||
* When authentication fails, users should have a clear
|
||||
* way to return to the login page and try again.
|
||||
*/
|
||||
mockLocation.hash = '' // No tokens = error
|
||||
|
||||
await router.push('/auth/callback')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AuthCallbackPage, {
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(button.text()).toContain('Back to Login')
|
||||
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.name).toBe('Login')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -3,35 +3,235 @@
|
||||
* OAuth callback page.
|
||||
*
|
||||
* Handles the redirect from OAuth providers (Google, Discord).
|
||||
* Extracts tokens from URL fragment and stores them in auth store.
|
||||
* Extracts tokens from URL fragment, stores them, fetches user
|
||||
* profile, and redirects to the appropriate destination.
|
||||
*
|
||||
* Success URL format: /auth/callback#access_token={token}&refresh_token={token}&expires_in={seconds}
|
||||
* Error URL format: /auth/callback?error={code}&message={message}
|
||||
*/
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiClient } from '@/api/client'
|
||||
import type { User } from '@/stores/auth'
|
||||
|
||||
interface UserResponse {
|
||||
id: string
|
||||
display_name: string
|
||||
avatar_url: string | null
|
||||
has_starter_deck: boolean
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const status = ref<'processing' | 'error'>('processing')
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Parse tokens from URL hash fragment.
|
||||
*
|
||||
* The backend redirects with tokens in the fragment (after #) for security,
|
||||
* since fragments are not sent to servers in HTTP requests.
|
||||
*/
|
||||
function parseTokensFromHash(): { accessToken: string; refreshToken: string; expiresIn: number } | null {
|
||||
const hash = window.location.hash.substring(1) // Remove leading #
|
||||
if (!hash) return null
|
||||
|
||||
const params = new URLSearchParams(hash)
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
const expiresIn = params.get('expires_in')
|
||||
|
||||
if (!accessToken || !refreshToken || !expiresIn) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: parseInt(expiresIn, 10),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for error in URL query params.
|
||||
*/
|
||||
function parseErrorFromQuery(): { error: string; message: string } | null {
|
||||
const error = route.query.error as string | undefined
|
||||
const message = route.query.message as string | undefined
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error,
|
||||
message: message || 'Authentication failed. Please try again.',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile from API.
|
||||
*/
|
||||
async function fetchUserProfile(): Promise<UserResponse> {
|
||||
return apiClient.get<UserResponse>('/api/users/me')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response to auth store User type.
|
||||
*/
|
||||
function transformUser(response: UserResponse): User {
|
||||
return {
|
||||
id: response.id,
|
||||
displayName: response.display_name,
|
||||
avatarUrl: response.avatar_url,
|
||||
hasStarterDeck: response.has_starter_deck,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the OAuth callback process.
|
||||
*/
|
||||
async function handleCallback(): Promise<void> {
|
||||
// First, check for errors from failed OAuth
|
||||
const errorInfo = parseErrorFromQuery()
|
||||
if (errorInfo) {
|
||||
// Redirect back to login with error message
|
||||
router.push({
|
||||
name: 'Login',
|
||||
query: { error: errorInfo.error, message: errorInfo.message },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse tokens from URL fragment
|
||||
const tokens = parseTokensFromHash()
|
||||
if (!tokens) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = 'Invalid authentication response. Missing tokens.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Store tokens in auth store
|
||||
auth.setTokens({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: Date.now() + tokens.expiresIn * 1000,
|
||||
})
|
||||
|
||||
// Clear the hash from URL for security (tokens shouldn't linger in browser history)
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
|
||||
// Fetch user profile
|
||||
const userResponse = await fetchUserProfile()
|
||||
const user = transformUser(userResponse)
|
||||
auth.setUser(user)
|
||||
|
||||
// Redirect based on starter deck status
|
||||
if (!user.hasStarterDeck) {
|
||||
router.push({ name: 'StarterSelection' })
|
||||
} else {
|
||||
// Check if there was an intended destination before login
|
||||
const redirect = route.query.redirect as string | undefined
|
||||
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||
router.push(redirect)
|
||||
} else {
|
||||
router.push({ name: 'Dashboard' })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
status.value = 'error'
|
||||
if (error instanceof Error) {
|
||||
errorMessage.value = error.message
|
||||
} else {
|
||||
errorMessage.value = 'Failed to complete authentication. Please try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// TODO: Extract tokens from URL hash fragment
|
||||
// TODO: Store tokens in auth store
|
||||
// TODO: Fetch user profile
|
||||
// TODO: Redirect to intended destination or home
|
||||
|
||||
// Placeholder: redirect to home after brief delay
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 1000)
|
||||
handleCallback()
|
||||
})
|
||||
|
||||
function goToLogin(): void {
|
||||
router.push({ name: 'Login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-lg">
|
||||
<div class="flex flex-col items-center justify-center min-h-[50vh] p-4">
|
||||
<!-- Processing state -->
|
||||
<div
|
||||
v-if="status === 'processing'"
|
||||
class="text-center"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<svg
|
||||
class="animate-spin h-8 w-8 text-primary mx-auto"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-lg font-medium">
|
||||
Completing login...
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Please wait
|
||||
<div class="text-sm text-gray-400 mt-2">
|
||||
Please wait while we set up your session
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="text-center max-w-md"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<svg
|
||||
class="h-12 w-12 text-error mx-auto"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-lg font-medium text-error mb-2">
|
||||
Authentication Failed
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mb-6">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<button
|
||||
class="px-6 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
@click="goToLogin"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user