CLAUDE: Fix cookie security and SSR data fetching for iPad/Safari
Resolved WebSocket connection issues and games list loading on iPad: - cookies.py: Added is_secure_context() to set Secure flag when accessed via HTTPS even in development mode (Safari requires this) - useWebSocket.ts: Changed auto-connect from immediate watcher to onMounted hook for safer SSR hydration - games/index.vue: Replaced onMounted + fetchGames() with useAsyncData for SSR data fetching with proper cookie forwarding - Updated COOKIE_AUTH_IMPLEMENTATION.md with new issues and solutions - Updated composables/CLAUDE.md with auto-connect pattern documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6e3aad9fdf
commit
9f88317b79
338
COOKIE_AUTH_IMPLEMENTATION.md
Normal file
338
COOKIE_AUTH_IMPLEMENTATION.md
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
# Cookie-Based Authentication Implementation
|
||||||
|
|
||||||
|
**Date**: 2025-11-27
|
||||||
|
**Status**: Complete
|
||||||
|
|
||||||
|
This document captures the full implementation of HttpOnly cookie-based authentication, including all issues encountered and their solutions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → Nginx Proxy Manager → Nuxt SSR → Backend API
|
||||||
|
(gameplay-demo.manticorum.com)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
1. User clicks "Continue with Discord" (anchor tag, NOT JavaScript)
|
||||||
|
2. Browser navigates to `/api/auth/discord/login`
|
||||||
|
3. Backend creates state in Redis, redirects to Discord OAuth
|
||||||
|
4. User authorizes on Discord
|
||||||
|
5. Discord redirects to `/api/auth/discord/callback/server`
|
||||||
|
6. Backend validates, creates JWT, sets HttpOnly cookies with `path="/"`
|
||||||
|
7. Backend redirects to frontend `/games`
|
||||||
|
8. Nuxt SSR middleware forwards cookies to `/api/auth/me`
|
||||||
|
9. Backend validates cookie, returns user info
|
||||||
|
10. Page renders with authenticated state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Implementation Details
|
||||||
|
|
||||||
|
### 1. Cookie Configuration (Backend)
|
||||||
|
|
||||||
|
**File**: `backend/app/utils/cookies.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
response.set_cookie(
|
||||||
|
key="pd_access_token",
|
||||||
|
value=access_token,
|
||||||
|
max_age=3600, # 1 hour
|
||||||
|
httponly=True,
|
||||||
|
secure=is_secure_context(), # True for HTTPS even in dev
|
||||||
|
samesite="lax",
|
||||||
|
path="/", # CRITICAL: Must be "/" not "/api"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why `is_secure_context()` instead of `is_production()`?**
|
||||||
|
- Safari/iOS requires `Secure=true` for cookies over HTTPS connections
|
||||||
|
- Even in development, if accessed via HTTPS (through proxy), cookies need `Secure=true`
|
||||||
|
- `is_secure_context()` returns `True` if `APP_ENV=production` OR `FRONTEND_URL` starts with `https://`
|
||||||
|
|
||||||
|
**Why `path="/"`?**
|
||||||
|
- Cookies with `path="/api"` are only sent to `/api/*` routes
|
||||||
|
- When browser requests `/games`, cookie isn't sent
|
||||||
|
- SSR server receives no cookie to forward
|
||||||
|
- Auth check fails → redirect loop
|
||||||
|
|
||||||
|
### 2. Login Button (Frontend)
|
||||||
|
|
||||||
|
**File**: `frontend-sba/pages/auth/login.vue`
|
||||||
|
|
||||||
|
**MUST use anchor tag, NOT JavaScript button:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- CORRECT: Works on iPad Safari and through proxy -->
|
||||||
|
<a :href="discordLoginUrl" class="...">
|
||||||
|
Continue with Discord
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- WRONG: JavaScript may be blocked -->
|
||||||
|
<button @click="handleLogin">
|
||||||
|
Continue with Discord
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why?**
|
||||||
|
- iPad Safari blocks async JavaScript on certain pages
|
||||||
|
- Nginx Proxy Manager may interfere with JS execution
|
||||||
|
- Anchor tags work reliably in all scenarios
|
||||||
|
|
||||||
|
### 3. Auth Middleware (Frontend)
|
||||||
|
|
||||||
|
**File**: `frontend-sba/middleware/auth.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
return // Already authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call backend to verify cookies
|
||||||
|
const isAuthed = await authStore.checkAuth()
|
||||||
|
|
||||||
|
if (!isAuthed) {
|
||||||
|
return navigateTo('/auth/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Auth Store - Cookie Forwarding for SSR
|
||||||
|
|
||||||
|
**File**: `frontend-sba/store/auth.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function checkAuth(): Promise<boolean> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
|
||||||
|
// SSR: Forward cookies from incoming request
|
||||||
|
if (import.meta.server) {
|
||||||
|
const event = useRequestEvent()
|
||||||
|
const cookieHeader = event?.node.req.headers.cookie
|
||||||
|
if (cookieHeader) {
|
||||||
|
headers['Cookie'] = cookieHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await $fetch('/api/auth/me', {
|
||||||
|
credentials: 'include', // Client-side
|
||||||
|
headers, // Server-side cookie forwarding
|
||||||
|
})
|
||||||
|
|
||||||
|
// ... handle response
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. API Calls - Use Cookies, Not Tokens
|
||||||
|
|
||||||
|
**All API calls must use `credentials: 'include'`:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CORRECT
|
||||||
|
const response = await $fetch('/api/games/', {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
// WRONG - authStore.token no longer exists
|
||||||
|
const response = await $fetch('/api/games/', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${authStore.token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues & Solutions
|
||||||
|
|
||||||
|
### Issue 1: "Continue with Discord" Does Nothing
|
||||||
|
|
||||||
|
**Symptom**: Clicking login button has no effect
|
||||||
|
|
||||||
|
**Cause**: Using `<button @click>` instead of `<a href>`
|
||||||
|
|
||||||
|
**Solution**: Use anchor tag with computed URL:
|
||||||
|
```vue
|
||||||
|
<a :href="discordLoginUrl">Continue with Discord</a>
|
||||||
|
|
||||||
|
const discordLoginUrl = computed(() => {
|
||||||
|
return `${config.public.apiUrl}/api/auth/discord/login?return_url=${encodeURIComponent(returnUrl)}`
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 2: Redirect Loop After Login
|
||||||
|
|
||||||
|
**Symptom**: OAuth succeeds but keeps redirecting to login
|
||||||
|
|
||||||
|
**Cause**: Cookie path is `/api`, so cookies aren't sent with `/games` requests
|
||||||
|
|
||||||
|
**Solution**: Set cookie path to `/` in `backend/app/utils/cookies.py`
|
||||||
|
|
||||||
|
### Issue 3: SSR Returns 401 Unauthorized
|
||||||
|
|
||||||
|
**Symptom**: `/api/auth/me` returns 401 during SSR
|
||||||
|
|
||||||
|
**Cause**: SSR doesn't automatically forward browser cookies
|
||||||
|
|
||||||
|
**Solution**: Manually forward cookies in `checkAuth()`:
|
||||||
|
```typescript
|
||||||
|
if (import.meta.server) {
|
||||||
|
const event = useRequestEvent()
|
||||||
|
headers['Cookie'] = event?.node.req.headers.cookie
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 4: "Loading games..." Spins Forever
|
||||||
|
|
||||||
|
**Symptom**: Games page never loads data
|
||||||
|
|
||||||
|
**Cause**: API calls using `authStore.token` which no longer exists
|
||||||
|
|
||||||
|
**Solution**: Replace with `credentials: 'include'`
|
||||||
|
|
||||||
|
### Issue 5: Zombie Backend Processes
|
||||||
|
|
||||||
|
**Symptom**: "Address already in use" error
|
||||||
|
|
||||||
|
**Cause**: Old Python processes not killed by stop script
|
||||||
|
|
||||||
|
**Solution**: Updated `stop-services.sh` to:
|
||||||
|
- Kill `python.*app.main` processes
|
||||||
|
- Kill any process on port 8000
|
||||||
|
|
||||||
|
### Issue 6: WebSocket "Disconnected" on iPad/Safari
|
||||||
|
|
||||||
|
**Symptom**: WebSocket shows "Disconnected from server. Attempting to reconnect..."
|
||||||
|
|
||||||
|
**Cause**: Cookies have `Secure=false` but site is accessed via HTTPS. Safari rejects non-Secure cookies over HTTPS.
|
||||||
|
|
||||||
|
**Solution**: Changed `is_production()` to `is_secure_context()` in `backend/app/utils/cookies.py`:
|
||||||
|
```python
|
||||||
|
def is_secure_context() -> bool:
|
||||||
|
if getattr(settings, "app_env", "development") == "production":
|
||||||
|
return True
|
||||||
|
frontend_url = getattr(settings, "frontend_url", "")
|
||||||
|
return frontend_url.startswith("https://")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 7: Games List "Loading games..." Forever (Client Hydration)
|
||||||
|
|
||||||
|
**Symptom**: Games list page shows loading spinner forever
|
||||||
|
|
||||||
|
**Cause**: `onMounted` hook only runs on client-side after hydration. If client-side JavaScript has issues, the hook never runs.
|
||||||
|
|
||||||
|
**Solution**: Use `useAsyncData` instead of `onMounted` for data fetching:
|
||||||
|
```typescript
|
||||||
|
const { pending, error: fetchError, refresh } = await useAsyncData(
|
||||||
|
'games-list',
|
||||||
|
async () => {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (import.meta.server) {
|
||||||
|
const event = useRequestEvent()
|
||||||
|
const cookieHeader = event?.node.req.headers.cookie
|
||||||
|
if (cookieHeader) {
|
||||||
|
headers['Cookie'] = cookieHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await $fetch('/api/games/', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/app/utils/cookies.py` | Cookie path `/api` → `/`, `is_secure_context()` for HTTPS detection |
|
||||||
|
| `frontend-sba/pages/auth/login.vue` | Button → Anchor tag |
|
||||||
|
| `frontend-sba/middleware/auth.ts` | Calls `checkAuth()` directly |
|
||||||
|
| `frontend-sba/store/auth.ts` | SSR cookie forwarding |
|
||||||
|
| `frontend-sba/pages/games/index.vue` | `useAsyncData` with SSR cookie forwarding |
|
||||||
|
| `frontend-sba/pages/games/[id].vue` | Uses `checkAuth()` flow |
|
||||||
|
| `frontend-sba/composables/useWebSocket.ts` | Auto-connect via `onMounted` (not immediate watcher) |
|
||||||
|
| `stop-services.sh` | Better process cleanup |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
After any auth-related changes:
|
||||||
|
|
||||||
|
1. [ ] Clear browser cookies completely
|
||||||
|
2. [ ] Navigate to `/games` - should redirect to login
|
||||||
|
3. [ ] Click "Continue with Discord" - should redirect to Discord
|
||||||
|
4. [ ] Authorize - should redirect back to `/games`
|
||||||
|
5. [ ] Games should load (not spin forever)
|
||||||
|
6. [ ] Click on a game - should load game page
|
||||||
|
7. [ ] WebSocket should connect (no "Attempting to reconnect")
|
||||||
|
8. [ ] Refresh page - should stay authenticated
|
||||||
|
9. [ ] Test on iPad Safari if possible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Requirements
|
||||||
|
|
||||||
|
### Backend `.env`
|
||||||
|
```bash
|
||||||
|
DISCORD_SERVER_REDIRECT_URI=https://gameplay-demo.manticorum.com/api/auth/discord/callback/server
|
||||||
|
FRONTEND_URL=https://gameplay-demo.manticorum.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend `.env`
|
||||||
|
```bash
|
||||||
|
NUXT_PUBLIC_API_URL=https://gameplay-demo.manticorum.com
|
||||||
|
NUXT_PUBLIC_WS_URL=https://gameplay-demo.manticorum.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Discord Developer Portal
|
||||||
|
Add redirect URI:
|
||||||
|
```
|
||||||
|
https://gameplay-demo.manticorum.com/api/auth/discord/callback/server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
1. **HttpOnly cookies** - Cannot be accessed by JavaScript (XSS protection)
|
||||||
|
2. **Secure flag** - Only sent over HTTPS in production
|
||||||
|
3. **SameSite=Lax** - CSRF protection while allowing OAuth redirects
|
||||||
|
4. **Path="/"** - Sent with all requests (required for SSR)
|
||||||
|
5. **Short-lived access token** - 1 hour expiration
|
||||||
|
6. **Refresh token** - 7 days, restricted to `/api/auth` path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Check if cookies are set:
|
||||||
|
```bash
|
||||||
|
# Backend logs after OAuth callback
|
||||||
|
tail -f logs/backend.log | grep -i cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check if cookies are forwarded:
|
||||||
|
```bash
|
||||||
|
# Frontend logs during SSR
|
||||||
|
tail -f logs/frontend.log | grep -i "SSR\|cookie\|checkAuth"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check cookie in browser:
|
||||||
|
- Safari: Develop → Show Web Inspector → Storage → Cookies
|
||||||
|
- Chrome: DevTools → Application → Cookies
|
||||||
|
|
||||||
|
### Verify backend receives cookie:
|
||||||
|
```bash
|
||||||
|
curl -v https://gameplay-demo.manticorum.com/api/auth/me \
|
||||||
|
-H "Cookie: pd_access_token=<token>"
|
||||||
|
```
|
||||||
@ -20,9 +20,22 @@ ACCESS_TOKEN_MAX_AGE = 60 * 60 # 1 hour
|
|||||||
REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
|
||||||
def is_production() -> bool:
|
def is_secure_context() -> bool:
|
||||||
"""Check if running in production environment."""
|
"""
|
||||||
return getattr(settings, "app_env", "development") == "production"
|
Check if cookies should use Secure flag.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- APP_ENV is 'production', OR
|
||||||
|
- FRONTEND_URL starts with 'https://'
|
||||||
|
|
||||||
|
This ensures cookies work correctly when accessing via HTTPS
|
||||||
|
even in development mode.
|
||||||
|
"""
|
||||||
|
if getattr(settings, "app_env", "development") == "production":
|
||||||
|
return True
|
||||||
|
# Also enable Secure if frontend URL is HTTPS (common in dev with tunnels/proxies)
|
||||||
|
frontend_url = getattr(settings, "frontend_url", "")
|
||||||
|
return frontend_url.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
def set_auth_cookies(
|
def set_auth_cookies(
|
||||||
@ -44,15 +57,15 @@ def set_auth_cookies(
|
|||||||
access_token: JWT access token
|
access_token: JWT access token
|
||||||
refresh_token: JWT refresh token
|
refresh_token: JWT refresh token
|
||||||
"""
|
"""
|
||||||
# Access token - short-lived, sent to all /api endpoints
|
# Access token - short-lived, sent to all requests (needed for SSR cookie forwarding)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=ACCESS_TOKEN_COOKIE,
|
key=ACCESS_TOKEN_COOKIE,
|
||||||
value=access_token,
|
value=access_token,
|
||||||
max_age=ACCESS_TOKEN_MAX_AGE,
|
max_age=ACCESS_TOKEN_MAX_AGE,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=is_production(),
|
secure=is_secure_context(),
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
path="/api",
|
path="/", # Root path so cookies are sent with all requests including SSR
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh token - long-lived, restricted to auth endpoints only
|
# Refresh token - long-lived, restricted to auth endpoints only
|
||||||
@ -61,7 +74,7 @@ def set_auth_cookies(
|
|||||||
value=refresh_token,
|
value=refresh_token,
|
||||||
max_age=REFRESH_TOKEN_MAX_AGE,
|
max_age=REFRESH_TOKEN_MAX_AGE,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=is_production(),
|
secure=is_secure_context(),
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
path="/api/auth",
|
path="/api/auth",
|
||||||
)
|
)
|
||||||
@ -74,5 +87,5 @@ def clear_auth_cookies(response: Response) -> None:
|
|||||||
Args:
|
Args:
|
||||||
response: FastAPI Response object
|
response: FastAPI Response object
|
||||||
"""
|
"""
|
||||||
response.delete_cookie(key=ACCESS_TOKEN_COOKIE, path="/api")
|
response.delete_cookie(key=ACCESS_TOKEN_COOKIE, path="/")
|
||||||
response.delete_cookie(key=REFRESH_TOKEN_COOKIE, path="/api/auth")
|
response.delete_cookie(key=REFRESH_TOKEN_COOKIE, path="/api/auth")
|
||||||
|
|||||||
@ -30,6 +30,27 @@ socketInstance.on('dice_rolled') → gameStore.setPendingRoll()
|
|||||||
|
|
||||||
**Singleton Pattern**: Socket instance is module-level, shared across all `useWebSocket()` calls.
|
**Singleton Pattern**: Socket instance is module-level, shared across all `useWebSocket()` calls.
|
||||||
|
|
||||||
|
**Auto-Connect Pattern**: Uses `onMounted` hook (not `immediate: true` watcher) to connect when already authenticated. This is safer for SSR hydration:
|
||||||
|
```typescript
|
||||||
|
// Watch for auth changes (not immediate)
|
||||||
|
watch(() => authStore.isAuthenticated, (authenticated) => {
|
||||||
|
if (authenticated && !isConnected.value) {
|
||||||
|
connect()
|
||||||
|
} else if (!authenticated && isConnected.value) {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initial connection on client-side mount
|
||||||
|
onMounted(() => {
|
||||||
|
if (authStore.isAuthenticated && !isConnected.value) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why not `immediate: true`?** Using `immediate: true` with `import.meta.client` guard can cause SSR hydration issues. The `onMounted` hook only runs on client-side, making it safer.
|
||||||
|
|
||||||
### useGameActions.ts
|
### useGameActions.ts
|
||||||
**Purpose**: Wraps WebSocket emits with type safety and validation.
|
**Purpose**: Wraps WebSocket emits with type safety and validation.
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
* - Integration with game store
|
* - Integration with game store
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed, watch, onUnmounted, readonly } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, readonly } from 'vue'
|
||||||
import type { Socket } from 'socket.io-client';
|
import type { Socket } from 'socket.io-client';
|
||||||
import { io } from 'socket.io-client'
|
import { io } from 'socket.io-client'
|
||||||
import type {
|
import type {
|
||||||
@ -470,25 +470,33 @@ export function useWebSocket() {
|
|||||||
// Watchers
|
// Watchers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Auto-connect when authenticated
|
// Watch for auth changes (not immediate - onMounted handles initial state)
|
||||||
watch(
|
watch(
|
||||||
() => authStore.isAuthenticated,
|
() => authStore.isAuthenticated,
|
||||||
(authenticated) => {
|
(authenticated) => {
|
||||||
if (authenticated && !isConnected.value) {
|
if (authenticated && !isConnected.value && !isConnecting.value) {
|
||||||
connect()
|
connect()
|
||||||
startHeartbeat()
|
startHeartbeat()
|
||||||
} else if (!authenticated && isConnected.value) {
|
} else if (!authenticated && isConnected.value) {
|
||||||
disconnect()
|
disconnect()
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{ immediate: false }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
// Initial connection on client-side mount (handles SSR hydration case)
|
||||||
|
onMounted(() => {
|
||||||
|
if (authStore.isAuthenticated && !isConnected.value && !isConnecting.value) {
|
||||||
|
console.log('[WebSocket] Auto-connecting on mount (already authenticated)')
|
||||||
|
connect()
|
||||||
|
startHeartbeat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
console.log('[WebSocket] Component unmounted, cleaning up')
|
console.log('[WebSocket] Component unmounted, cleaning up')
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
<p class="text-red-600 text-sm mt-2">{{ error }}</p>
|
<p class="text-red-600 text-sm mt-2">{{ error }}</p>
|
||||||
<button
|
<button
|
||||||
class="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
class="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
|
||||||
@click="fetchGames"
|
@click="refresh"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
@ -205,28 +205,6 @@ const loading = ref(true)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isCreatingQuickGame = ref(false)
|
const isCreatingQuickGame = ref(false)
|
||||||
|
|
||||||
// Fetch games from API (client-side only)
|
|
||||||
async function fetchGames() {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
const response = await $fetch<any[]>(`${config.public.apiUrl}/api/games/`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${authStore.token}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
games.value = response
|
|
||||||
console.log('[Games Page] Fetched games:', games.value)
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[Games Page] Failed to fetch games:', err)
|
|
||||||
error.value = err.message || 'Failed to load games'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick-create a demo game with pre-configured lineups
|
// Quick-create a demo game with pre-configured lineups
|
||||||
async function handleQuickCreate() {
|
async function handleQuickCreate() {
|
||||||
try {
|
try {
|
||||||
@ -239,9 +217,7 @@ async function handleQuickCreate() {
|
|||||||
`${config.public.apiUrl}/api/games/quick-create`,
|
`${config.public.apiUrl}/api/games/quick-create`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'include', // Send HttpOnly cookies
|
||||||
Authorization: `Bearer ${authStore.token}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -266,10 +242,44 @@ const completedGames = computed(() => {
|
|||||||
return games.value?.filter(g => g.status === 'completed' || g.status === 'final') || []
|
return games.value?.filter(g => g.status === 'completed' || g.status === 'final') || []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch on mount (client-side)
|
// Fetch games using useAsyncData (works on both SSR and client)
|
||||||
onMounted(() => {
|
const { pending, error: fetchError, refresh } = await useAsyncData(
|
||||||
fetchGames()
|
'games-list',
|
||||||
})
|
async () => {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (import.meta.server) {
|
||||||
|
const event = useRequestEvent()
|
||||||
|
const cookieHeader = event?.node.req.headers.cookie
|
||||||
|
if (cookieHeader) {
|
||||||
|
headers['Cookie'] = cookieHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await $fetch<any[]>(`${config.public.apiUrl}/api/games/`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
games.value = response
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
{
|
||||||
|
server: true, // Fetch on server
|
||||||
|
lazy: false, // Block rendering until done
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync loading state with pending
|
||||||
|
watch(pending, (isPending) => {
|
||||||
|
loading.value = isPending
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Sync error state
|
||||||
|
watch(fetchError, (err) => {
|
||||||
|
if (err) {
|
||||||
|
error.value = err.message || 'Failed to load games'
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user