Public Access
feat: initialize database and authentication routes in backend
This commit is contained in:
1 parent
be69db04a5
commit
eb013d4978
22 files changed
+1808
-8
No files matched your search
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ApiError } from '../utils/api'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
onForgotPassword: () => void
|
||||
}
|
||||
|
||||
type Tab = 'login' | 'register'
|
||||
|
||||
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
|
||||
const { login, register } = useAuth()
|
||||
const [tab, setTab] = useState<Tab>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const switchTab = (t: Tab) => {
|
||||
setTab(t)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const message = await register(email, password, displayName)
|
||||
setSuccess(message)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Registration failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-overlay" onClick={onClose}>
|
||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="auth-close" onClick={onClose} aria-label="Close">×</button>
|
||||
|
||||
<div className="auth-header">
|
||||
<div className="brand-mark" />
|
||||
<h2 className="auth-title">
|
||||
{tab === 'login' ? 'Welcome back' : 'Create account'}
|
||||
</h2>
|
||||
<p className="auth-subtitle">
|
||||
{tab === 'login'
|
||||
? 'Sign in to access your saved exams and results'
|
||||
: 'Create an account to save your progress'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-tabs">
|
||||
<button
|
||||
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
|
||||
onClick={() => switchTab('login')}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
|
||||
onClick={() => switchTab('register')}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
{success && <div className="auth-success">{success}</div>}
|
||||
|
||||
{!success && tab === 'login' && (
|
||||
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="login-email">Email</label>
|
||||
<input
|
||||
id="login-email"
|
||||
className="input"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-forgot"
|
||||
onClick={() => { onClose(); onForgotPassword() }}
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!success && tab === 'register' && (
|
||||
<form onSubmit={(e) => void handleRegister(e)} className="auth-form">
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="reg-name">Display Name</label>
|
||||
<input
|
||||
id="reg-name"
|
||||
className="input"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="reg-email">Email</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
className="input"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="reg-password">Password</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="reg-confirm">Confirm Password</label>
|
||||
<input
|
||||
id="reg-confirm"
|
||||
className="input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creating account…' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { apiFetch, ApiError } from '../utils/api'
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
export default function EmailVerify({ onDone }: Readonly<Props>) {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setMessage('No verification token found.')
|
||||
return
|
||||
}
|
||||
|
||||
apiFetch<{ message: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
|
||||
.then((data) => {
|
||||
setStatus('success')
|
||||
setMessage(data.message)
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
.catch((err) => {
|
||||
setStatus('error')
|
||||
setMessage(err instanceof ApiError ? err.message : 'Verification failed')
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="verify-page">
|
||||
<div className="verify-card">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<div className="verify-icon">⏳</div>
|
||||
<h2 className="verify-title">Verifying your email…</h2>
|
||||
<p className="verify-message">Please wait while we confirm your email address.</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div className="verify-icon success">✓</div>
|
||||
<h2 className="verify-title">Email Verified</h2>
|
||||
<p className="verify-message">{message}</p>
|
||||
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Sign In →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="verify-icon error">✗</div>
|
||||
<h2 className="verify-title">Verification Failed</h2>
|
||||
<p className="verify-message">{message}</p>
|
||||
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Back to Home
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { apiFetch, ApiError } from '../utils/api'
|
||||
|
||||
interface Props {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
type Step = 'request' | 'reset' | 'success'
|
||||
|
||||
export default function ResetPassword({ onDone }: Readonly<Props>) {
|
||||
const [step, setStep] = useState<Step>('request')
|
||||
const [email, setEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const t = params.get('token')
|
||||
if (t) {
|
||||
setToken(t)
|
||||
setStep('reset')
|
||||
window.history.replaceState({}, '', '/reset-password')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRequestReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const data = await apiFetch<{ message: string }>('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
setMessage(data.message)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Request failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await apiFetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, newPassword }),
|
||||
})
|
||||
setStep('success')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Password reset failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="verify-page">
|
||||
<div className="verify-card" style={{ maxWidth: 420 }}>
|
||||
{step === 'request' && !message && (
|
||||
<>
|
||||
<div className="verify-icon">🔑</div>
|
||||
<h2 className="verify-title">Reset Password</h2>
|
||||
<p className="verify-message">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
<form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="reset-email">Email</label>
|
||||
<input
|
||||
id="reset-email"
|
||||
className="input"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send Reset Link'}
|
||||
</button>
|
||||
<button type="button" className="auth-forgot" onClick={onDone}>
|
||||
← Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'request' && message && (
|
||||
<>
|
||||
<div className="verify-icon success">📧</div>
|
||||
<h2 className="verify-title">Check Your Email</h2>
|
||||
<p className="verify-message">{message}</p>
|
||||
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Back to Home
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'reset' && (
|
||||
<>
|
||||
<div className="verify-icon">🔐</div>
|
||||
<h2 className="verify-title">Set New Password</h2>
|
||||
<p className="verify-message">Enter your new password below.</p>
|
||||
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
|
||||
<form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="new-password">New Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
className="input"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-field">
|
||||
<label className="auth-label" htmlFor="confirm-new-password">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
className="input"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
|
||||
{submitting ? 'Resetting…' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<>
|
||||
<div className="verify-icon success">✓</div>
|
||||
<h2 className="verify-title">Password Updated</h2>
|
||||
<p className="verify-message">Your password has been reset successfully. You can now sign in with your new password.</p>
|
||||
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
|
||||
Sign In →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user