feat: add self-registration frontend
Components: - Register.jsx: Registration form with validation - Verify.jsx: Email verification page with auto-login - API calls: register(), verifyEmail() Features: - Form validation (name min 2, email format, password min 8, password confirm) - Success screen after registration (check email) - Auto-login after verification → redirect to dashboard - Error handling for invalid/expired tokens - Link to registration from login page Routes: - /register → public (no login required) - /verify?token=xxx → public - Pattern matches existing /reset-password handling UX: - Clean success/error states - Loading spinners - Auto-redirect after verify (2s) - "Jetzt registrieren" link on login Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c1562a27f4
commit
86f7a513fe
|
|
@ -8,6 +8,8 @@ import { Avatar } from './pages/ProfileSelect'
|
||||||
import SetupScreen from './pages/SetupScreen'
|
import SetupScreen from './pages/SetupScreen'
|
||||||
import { ResetPassword } from './pages/PasswordRecovery'
|
import { ResetPassword } from './pages/PasswordRecovery'
|
||||||
import LoginScreen from './pages/LoginScreen'
|
import LoginScreen from './pages/LoginScreen'
|
||||||
|
import Register from './pages/Register'
|
||||||
|
import Verify from './pages/Verify'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import CaptureHub from './pages/CaptureHub'
|
import CaptureHub from './pages/CaptureHub'
|
||||||
import WeightScreen from './pages/WeightScreen'
|
import WeightScreen from './pages/WeightScreen'
|
||||||
|
|
@ -59,9 +61,26 @@ function AppShell() {
|
||||||
}
|
}
|
||||||
}, [session?.profile_id])
|
}, [session?.profile_id])
|
||||||
|
|
||||||
// Handle password reset link
|
// Handle public pages (register, verify, reset-password)
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const resetToken = urlParams.get('reset-password') || (window.location.pathname === '/reset-password' ? urlParams.get('token') : null)
|
const currentPath = window.location.pathname
|
||||||
|
|
||||||
|
// Register page
|
||||||
|
if (currentPath === '/register') return (
|
||||||
|
<div style={{minHeight:'100vh',background:'var(--bg)',padding:24}}>
|
||||||
|
<Register/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify email page
|
||||||
|
if (currentPath === '/verify') return (
|
||||||
|
<div style={{minHeight:'100vh',background:'var(--bg)',padding:24}}>
|
||||||
|
<Verify/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password reset page
|
||||||
|
const resetToken = urlParams.get('reset-password') || (currentPath === '/reset-password' ? urlParams.get('token') : null)
|
||||||
if (resetToken) return (
|
if (resetToken) return (
|
||||||
<div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center',
|
<div style={{minHeight:'100vh',display:'flex',alignItems:'center',justifyContent:'center',
|
||||||
background:'var(--bg)',padding:24}}>
|
background:'var(--bg)',padding:24}}>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,22 @@ export default function LoginScreen() {
|
||||||
textAlign:'center',padding:'4px 0',textDecoration:'underline'}}>
|
textAlign:'center',padding:'4px 0',textDecoration:'underline'}}>
|
||||||
Passwort vergessen?
|
Passwort vergessen?
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop:24, paddingTop:20,
|
||||||
|
borderTop:'1px solid var(--border)',
|
||||||
|
textAlign:'center'
|
||||||
|
}}>
|
||||||
|
<span style={{color:'var(--text2)',fontSize:14}}>
|
||||||
|
Noch kein Account?{' '}
|
||||||
|
</span>
|
||||||
|
<a href="/register" style={{
|
||||||
|
color:'var(--accent)',fontWeight:600,fontSize:14,
|
||||||
|
textDecoration:'none'
|
||||||
|
}}>
|
||||||
|
Jetzt registrieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{textAlign:'center',marginTop:20,fontSize:11,color:'var(--text3)'}}>
|
<div style={{textAlign:'center',marginTop:20,fontSize:11,color:'var(--text3)'}}>
|
||||||
|
|
|
||||||
182
frontend/src/pages/Register.jsx
Normal file
182
frontend/src/pages/Register.jsx
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!name || name.length < 2) {
|
||||||
|
setError('Name muss mindestens 2 Zeichen lang sein')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
setError('Ungültige E-Mail-Adresse')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Passwort muss mindestens 8 Zeichen lang sein')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
setError('Passwörter stimmen nicht überein')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await api.register(name, email, password)
|
||||||
|
setSuccess(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Registrierung fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
maxWidth:420, margin:'0 auto', padding:'60px 20px',
|
||||||
|
textAlign:'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background:'var(--accent-light)',
|
||||||
|
border:'2px solid var(--accent)',
|
||||||
|
borderRadius:16, padding:'40px 24px', marginBottom:24
|
||||||
|
}}>
|
||||||
|
<div style={{fontSize:48, marginBottom:16}}>✓</div>
|
||||||
|
<h2 style={{marginBottom:12, color:'var(--accent-dark)'}}>
|
||||||
|
Registrierung erfolgreich!
|
||||||
|
</h2>
|
||||||
|
<p style={{color:'var(--text2)', lineHeight:1.6}}>
|
||||||
|
Wir haben dir eine E-Mail mit einem Bestätigungslink gesendet.
|
||||||
|
Bitte prüfe dein Postfach und bestätige deine E-Mail-Adresse.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link to="/login" className="btn btn-primary btn-full">
|
||||||
|
Zum Login
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<p style={{fontSize:12, color:'var(--text3)', marginTop:20}}>
|
||||||
|
Keine E-Mail erhalten? Prüfe auch deinen Spam-Ordner.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{maxWidth:420, margin:'0 auto', padding:'40px 20px'}}>
|
||||||
|
<h1 style={{textAlign:'center', marginBottom:8}}>Registrierung</h1>
|
||||||
|
<p style={{textAlign:'center', color:'var(--text2)', marginBottom:32}}>
|
||||||
|
Erstelle deinen Mitai Jinkendo Account
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="card" style={{padding:24}}>
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding:'12px 16px', background:'#FCEBEB',
|
||||||
|
border:'1px solid #D85A30', borderRadius:8,
|
||||||
|
color:'#D85A30', fontSize:14, marginBottom:20
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Dein Name"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">E-Mail *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Passwort *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Passwort bestätigen *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-input"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={e => setPasswordConfirm(e.target.value)}
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
disabled={loading}
|
||||||
|
style={{marginTop:8}}
|
||||||
|
>
|
||||||
|
{loading ? 'Registriere...' : 'Registrieren'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
textAlign:'center', marginTop:24,
|
||||||
|
paddingTop:20, borderTop:'1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
<span style={{color:'var(--text2)', fontSize:14}}>
|
||||||
|
Bereits registriert?{' '}
|
||||||
|
</span>
|
||||||
|
<Link to="/login" style={{
|
||||||
|
color:'var(--accent)', fontWeight:600, fontSize:14
|
||||||
|
}}>
|
||||||
|
Zum Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
fontSize:11, color:'var(--text3)', textAlign:'center',
|
||||||
|
marginTop:20, lineHeight:1.6
|
||||||
|
}}>
|
||||||
|
Mit der Registrierung akzeptierst du unsere Nutzungsbedingungen
|
||||||
|
und Datenschutzerklärung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
frontend/src/pages/Verify.jsx
Normal file
115
frontend/src/pages/Verify.jsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useState, useEffect, useContext } from 'react'
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { AuthContext } from '../context/AuthContext'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
export default function Verify() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const token = searchParams.get('token')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { login } = useContext(AuthContext)
|
||||||
|
|
||||||
|
const [status, setStatus] = useState('loading') // loading | success | error
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const verify = async () => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('error')
|
||||||
|
setError('Kein Verifikations-Token gefunden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.verifyEmail(token)
|
||||||
|
|
||||||
|
// Auto-login with returned token
|
||||||
|
if (result.token) {
|
||||||
|
login(result.token, result.profile)
|
||||||
|
setStatus('success')
|
||||||
|
|
||||||
|
// Redirect to dashboard after 2 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/dashboard')
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
setError('Verifizierung erfolgreich, aber Login fehlgeschlagen')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('error')
|
||||||
|
setError(err.message || 'Verifizierung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verify()
|
||||||
|
}, [token, login, navigate])
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
maxWidth:420, margin:'0 auto', padding:'60px 20px',
|
||||||
|
textAlign:'center'
|
||||||
|
}}>
|
||||||
|
<div className="spinner" style={{width:48, height:48, margin:'0 auto 24px'}}/>
|
||||||
|
<h2>E-Mail wird bestätigt...</h2>
|
||||||
|
<p style={{color:'var(--text2)'}}>Einen Moment bitte</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
maxWidth:420, margin:'0 auto', padding:'60px 20px',
|
||||||
|
textAlign:'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background:'#FCEBEB',
|
||||||
|
border:'2px solid #D85A30',
|
||||||
|
borderRadius:16, padding:'40px 24px', marginBottom:24
|
||||||
|
}}>
|
||||||
|
<div style={{fontSize:48, marginBottom:16}}>✗</div>
|
||||||
|
<h2 style={{marginBottom:12, color:'#D85A30'}}>
|
||||||
|
Verifizierung fehlgeschlagen
|
||||||
|
</h2>
|
||||||
|
<p style={{color:'var(--text2)', lineHeight:1.6}}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/register')}
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
>
|
||||||
|
Zur Registrierung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
maxWidth:420, margin:'0 auto', padding:'60px 20px',
|
||||||
|
textAlign:'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background:'var(--accent-light)',
|
||||||
|
border:'2px solid var(--accent)',
|
||||||
|
borderRadius:16, padding:'40px 24px', marginBottom:24
|
||||||
|
}}>
|
||||||
|
<div style={{fontSize:48, marginBottom:16}}>✓</div>
|
||||||
|
<h2 style={{marginBottom:12, color:'var(--accent-dark)'}}>
|
||||||
|
E-Mail bestätigt!
|
||||||
|
</h2>
|
||||||
|
<p style={{color:'var(--text2)', lineHeight:1.6}}>
|
||||||
|
Dein Account wurde erfolgreich aktiviert.
|
||||||
|
Du wirst gleich zum Dashboard weitergeleitet...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="spinner" style={{width:32, height:32, margin:'0 auto'}}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,8 @@ export const api = {
|
||||||
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
|
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
|
||||||
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
|
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
|
||||||
changePin: (pin) => req('/auth/pin',json({pin})),
|
changePin: (pin) => req('/auth/pin',json({pin})),
|
||||||
|
register: (name,email,password) => req('/auth/register',json({name,email,password})),
|
||||||
|
verifyEmail: (token) => req(`/auth/verify/${token}`),
|
||||||
|
|
||||||
// v9c Subscription System
|
// v9c Subscription System
|
||||||
// User-facing
|
// User-facing
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user