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:
+26
-52
@@ -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'
|
import './index.css'
|
||||||
|
|
||||||
type Route = 'login' | 'onboarding' | 'game' | 'admin'
|
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [route, setRoute] = useState<Route>('login')
|
const { profile, token, loading, logout } = useAuth()
|
||||||
const [sessionToken, setSessionToken] = useState<string | null>(null)
|
const [route, setRoute] = useState<AppRoute>('login')
|
||||||
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))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
|
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
|
||||||
<p style={{ fontFamily: 'var(--font-header)', color: 'var(--color-forest)' }}>
|
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
|
||||||
Loading quest...
|
|
||||||
</p>
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
{route === 'login' && (
|
{activeRoute === 'login' && (
|
||||||
<div>
|
<LoginPage onLoginSent={() => setRoute('sent')} />
|
||||||
<h1 style={{ marginBottom: '1rem' }}>MorseQuest</h1>
|
)}
|
||||||
<p>Login page coming in Task 9.</p>
|
{activeRoute === 'sent' && (
|
||||||
<button className="btn" onClick={() => setRoute('game')}>
|
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
|
||||||
Continue as Guest (dev)
|
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>Check your email</h2>
|
||||||
</button>
|
<p>We sent a magic link to your inbox. Click it to begin your quest!</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{route === 'onboarding' && (
|
{activeRoute === 'onboarding' && (
|
||||||
<div>
|
<div>
|
||||||
<h1>Onboarding</h1>
|
<h1 style={{ fontFamily: 'var(--font-header)' }}>Welcome, {profile?.display_name || 'Adventurer'}!</h1>
|
||||||
<p>Mnemonic builder coming in Task 10.</p>
|
<p>Mnemonic builder coming in Task 10.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{route === 'game' && (
|
{activeRoute === 'game' && (
|
||||||
<div>
|
<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>
|
<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
|
Log out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{route === 'admin' && (
|
{activeRoute === 'admin' && (
|
||||||
<div>
|
<div>
|
||||||
<h1>Admin</h1>
|
<h1 style={{ fontFamily: 'var(--font-header)' }}>Admin</h1>
|
||||||
<p>Admin panel coming in Task 12.</p>
|
<p>Admin panel coming in Task 12.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
type LoginPageProps = { onLoginSent: () => void }
|
||||||
|
|
||||||
|
export function LoginPage({ onLoginSent }: LoginPageProps) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!email.includes('@') || !email.includes('.')) {
|
||||||
|
setError('Please enter a valid email address.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/auth/request', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}))
|
||||||
|
throw new Error(body.error || 'Something went wrong')
|
||||||
|
}
|
||||||
|
onLoginSent()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Something went wrong')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 420, margin: '4rem auto 0' }}>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>MorseQuest</h1>
|
||||||
|
<p style={{ marginBottom: '2rem', color: 'var(--color-brown)' }}>
|
||||||
|
Learn Morse code, adventurer. Enter your email to begin your quest.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: 'crimson', marginBottom: '1rem', fontSize: '0.9rem' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
|
||||||
|
{submitting ? 'Sending...' : 'Send Magic Link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user