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
+26 -52
View File
@@ -1,82 +1,56 @@
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { useAuth } from './hooks/useAuth'
import { LoginPage } from './pages/LoginPage'
import './index.css'
type Route = 'login' | 'onboarding' | 'game' | 'admin'
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
export default function App() {
const [route, setRoute] = useState<Route>('login')
const [sessionToken, setSessionToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// 1. Check for ?session=<token> in URL (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)
}
// 2. Try stored token
const stored = urlToken || localStorage.getItem('session')
if (!stored) { setLoading(false); return }
// 3. Validate token with /api/auth/me
fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${stored}` },
})
.then(r => {
if (r.ok) {
setSessionToken(stored)
// TODO in Task 9: check if onboarding complete, route accordingly
setRoute('game')
} else {
localStorage.removeItem('session')
}
})
.catch(() => localStorage.removeItem('session'))
.finally(() => setLoading(false))
}, [])
const { profile, token, loading, logout } = useAuth()
const [route, setRoute] = useState<AppRoute>('login')
if (loading) {
return (
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
<p style={{ fontFamily: 'var(--font-header)', color: 'var(--color-forest)' }}>
Loading quest...
</p>
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
</div>
)
}
// If authenticated and still on an unauthenticated route, go to game
// (Task 10 will refine this to check onboarding completion)
const activeRoute: AppRoute = profile && (route === 'login' || route === 'sent') ? 'game' : route
return (
<div className="app-shell">
{route === 'login' && (
<div>
<h1 style={{ marginBottom: '1rem' }}>MorseQuest</h1>
<p>Login page coming in Task 9.</p>
<button className="btn" onClick={() => setRoute('game')}>
Continue as Guest (dev)
</button>
{activeRoute === 'login' && (
<LoginPage onLoginSent={() => setRoute('sent')} />
)}
{activeRoute === 'sent' && (
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>Check your email</h2>
<p>We sent a magic link to your inbox. Click it to begin your quest!</p>
</div>
)}
{route === 'onboarding' && (
{activeRoute === 'onboarding' && (
<div>
<h1>Onboarding</h1>
<h1 style={{ fontFamily: 'var(--font-header)' }}>Welcome, {profile?.display_name || 'Adventurer'}!</h1>
<p>Mnemonic builder coming in Task 10.</p>
</div>
)}
{route === 'game' && (
{activeRoute === 'game' && (
<div>
<h1>Practice</h1>
<h1 style={{ fontFamily: 'var(--font-header)' }}>Practice</h1>
<p>Hello, {profile?.display_name || profile?.email || 'Adventurer'}!</p>
<p>Game loop coming in Task 11.</p>
<button className="btn" onClick={() => { localStorage.removeItem('session'); setRoute('login') }}>
<button className="btn" style={{ marginTop: '1rem' }} onClick={() => { logout(); setRoute('login') }}>
Log out
</button>
</div>
)}
{route === 'admin' && (
{activeRoute === 'admin' && (
<div>
<h1>Admin</h1>
<h1 style={{ fontFamily: 'var(--font-header)' }}>Admin</h1>
<p>Admin panel coming in Task 12.</p>
</div>
)}