diff --git a/client/src/App.tsx b/client/src/App.tsx index 35dae06..06a6ec4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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('login') - const [sessionToken, setSessionToken] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - // 1. Check for ?session= 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('login') if (loading) { return (
-

- Loading quest... -

+

Loading quest...

) } + // 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 (
- {route === 'login' && ( -
-

MorseQuest

-

Login page coming in Task 9.

- + {activeRoute === 'login' && ( + setRoute('sent')} /> + )} + {activeRoute === 'sent' && ( +
+

Check your email

+

We sent a magic link to your inbox. Click it to begin your quest!

)} - {route === 'onboarding' && ( + {activeRoute === 'onboarding' && (
-

Onboarding

+

Welcome, {profile?.display_name || 'Adventurer'}!

Mnemonic builder coming in Task 10.

)} - {route === 'game' && ( + {activeRoute === 'game' && (
-

Practice

+

Practice

+

Hello, {profile?.display_name || profile?.email || 'Adventurer'}!

Game loop coming in Task 11.

-
)} - {route === 'admin' && ( + {activeRoute === 'admin' && (
-

Admin

+

Admin

Admin panel coming in Task 12.

)} diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts new file mode 100644 index 0000000..4ce2f19 --- /dev/null +++ b/client/src/hooks/useAuth.ts @@ -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({ 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 } +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx new file mode 100644 index 0000000..3666d3f --- /dev/null +++ b/client/src/pages/LoginPage.tsx @@ -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(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 ( +
+

MorseQuest

+

+ Learn Morse code, adventurer. Enter your email to begin your quest. +

+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + disabled={submitting} + required + style={{ marginBottom: '1rem' }} + /> + {error && ( +

{error}

+ )} + +
+
+ ) +}