feat: auth hook and login page (Task 9)

- useAuth: session token from URL/localStorage, /api/auth/me validation, logout
- LoginPage: email form with POST /api/auth/request, error handling
- App.tsx: use useAuth hook, LoginPage, 'sent' confirmation route
- Fix: auto-route to game covers both 'login' and 'sent' routes when authenticated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 02:10:38 +00:00
parent 2dd6402c86
commit 7c9386f125
3 changed files with 144 additions and 52 deletions
+54
View File
@@ -0,0 +1,54 @@
import { useState, useEffect, useCallback } from 'react'
type Profile = { id: number; email: string; display_name: string | null }
type AuthState = { profile: Profile | null; token: string | null; loading: boolean }
export function useAuth() {
const [state, setState] = useState<AuthState>({ profile: null, token: null, loading: true })
const validate = useCallback(async (token: string) => {
const r = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (!r.ok) throw new Error('invalid session')
const profile: Profile = await r.json()
return profile
}, [])
useEffect(() => {
// Check URL for ?session= from magic link redirect
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('session')
if (urlToken) {
localStorage.setItem('session', urlToken)
window.history.replaceState({}, '', window.location.pathname)
}
const token = urlToken || localStorage.getItem('session')
if (!token) {
setState({ profile: null, token: null, loading: false })
return
}
validate(token)
.then(profile => setState({ profile, token, loading: false }))
.catch(() => {
localStorage.removeItem('session')
setState({ profile: null, token: null, loading: false })
})
}, [validate])
const logout = useCallback(async () => {
const token = localStorage.getItem('session')
if (token) {
await fetch('/api/auth/session', {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {})
localStorage.removeItem('session')
}
setState({ profile: null, token: null, loading: false })
}, [])
return { ...state, logout }
}