Compare commits

...

2 Commits

Author SHA1 Message Date
86f7a513fe feat: add self-registration frontend
Some checks failed
Deploy Development / deploy (push) Failing after 25s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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>
2026-03-21 09:55:23 +01:00
c1562a27f4 feat: add self-registration with email verification
Backend:
- New endpoint: POST /api/auth/register
- New endpoint: GET /api/auth/verify/{token}
- Migration: Add email_verified, verification_token, verification_expires
- Helper: send_email() for reusable SMTP
- Validation: email format, password length (min 8), name
- Auto-login after verification (returns session token)
- Rate limit: 3 registrations per hour per IP

Features:
- Verification token valid for 24h
- Existing users marked as verified (grandfather clause)
- SMTP configured via .env (SMTP_HOST, SMTP_USER, SMTP_PASS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:53:11 +01:00
8 changed files with 516 additions and 3 deletions

View File

@ -0,0 +1,25 @@
-- ================================================================
-- Migration 003: Add Email Verification Fields
-- Version: v9c
-- Date: 2026-03-21
-- ================================================================
-- Add email verification columns to profiles table
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS verification_token TEXT,
ADD COLUMN IF NOT EXISTS verification_expires TIMESTAMP WITH TIME ZONE;
-- Create index for verification token lookups
CREATE INDEX IF NOT EXISTS idx_profiles_verification_token
ON profiles(verification_token)
WHERE verification_token IS NOT NULL;
-- Mark existing users with email as verified (grandfather clause)
UPDATE profiles
SET email_verified = TRUE
WHERE email IS NOT NULL AND email_verified IS NULL;
COMMENT ON COLUMN profiles.email_verified IS 'Whether email address has been verified';
COMMENT ON COLUMN profiles.verification_token IS 'One-time token for email verification';
COMMENT ON COLUMN profiles.verification_expires IS 'Verification token expiry (24h from creation)';

View File

@ -110,6 +110,12 @@ class PasswordResetConfirm(BaseModel):
new_password: str new_password: str
class RegisterRequest(BaseModel):
name: str
email: str
password: str
# ── Admin Models ────────────────────────────────────────────────────────────── # ── Admin Models ──────────────────────────────────────────────────────────────
class AdminProfileUpdate(BaseModel): class AdminProfileUpdate(BaseModel):

View File

@ -17,7 +17,7 @@ from slowapi.util import get_remote_address
from db import get_db, get_cursor from db import get_db, get_cursor
from auth import hash_pin, verify_pin, make_token, require_auth from auth import hash_pin, verify_pin, make_token, require_auth
from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
@ -174,3 +174,151 @@ def password_reset_confirm(req: PasswordResetConfirm):
cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",)) cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"} return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
# ── Helper: Send Email ────────────────────────────────────────────────────────
def send_email(to_email: str, subject: str, body: str):
"""Send email via SMTP (reusable helper)."""
try:
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_pass = os.getenv("SMTP_PASS")
smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
if not smtp_host or not smtp_user or not smtp_pass:
print("SMTP not configured, skipping email")
return False
msg = MIMEText(body)
msg['Subject'] = subject
msg['From'] = smtp_from
msg['To'] = to_email
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
return True
except Exception as e:
print(f"Email error: {e}")
return False
# ── Registration Endpoints ────────────────────────────────────────────────────
@router.post("/register")
@limiter.limit("3/hour")
async def register(req: RegisterRequest, request: Request):
"""Self-registration with email verification."""
email = req.email.lower().strip()
name = req.name.strip()
password = req.password
# Validation
if not email or '@' not in email:
raise HTTPException(400, "Ungültige E-Mail-Adresse")
if len(password) < 8:
raise HTTPException(400, "Passwort muss mindestens 8 Zeichen lang sein")
if not name or len(name) < 2:
raise HTTPException(400, "Name muss mindestens 2 Zeichen lang sein")
with get_db() as conn:
cur = get_cursor(conn)
# Check if email already exists
cur.execute("SELECT id FROM profiles WHERE email=%s", (email,))
if cur.fetchone():
raise HTTPException(400, "E-Mail-Adresse bereits registriert")
# Generate verification token
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now() + timedelta(hours=24)
# Create profile (inactive until verified)
profile_id = str(secrets.token_hex(16))
pin_hash = hash_pin(password)
cur.execute("""
INSERT INTO profiles (
id, name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires,
created
) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, CURRENT_TIMESTAMP)
""", (profile_id, name, email, pin_hash, verification_token, verification_expires))
# Send verification email
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
verify_url = f"{app_url}/verify?token={verification_token}"
email_body = f"""Hallo {name},
willkommen bei Mitai Jinkendo!
Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen:
{verify_url}
Der Link ist 24 Stunden gültig.
Dein Mitai Jinkendo Team
"""
send_email(email, "Willkommen bei Mitai Jinkendo E-Mail bestätigen", email_body)
return {
"ok": True,
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse."
}
@router.get("/verify/{token}")
async def verify_email(token: str):
"""Verify email address and activate account."""
with get_db() as conn:
cur = get_cursor(conn)
# Find profile with this verification token
cur.execute("""
SELECT id, name, email, email_verified, verification_expires
FROM profiles
WHERE verification_token=%s
""", (token,))
prof = cur.fetchone()
if not prof:
raise HTTPException(400, "Ungültiger Verifikations-Link")
if prof['email_verified']:
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
# Check if token expired
if prof['verification_expires'] and datetime.now() > prof['verification_expires']:
raise HTTPException(400, "Verifikations-Link abgelaufen. Bitte registriere dich erneut.")
# Mark as verified and clear token
cur.execute("""
UPDATE profiles
SET email_verified=TRUE, verification_token=NULL, verification_expires=NULL
WHERE id=%s
""", (prof['id'],))
# Create session (auto-login after verification)
session_token = make_token()
expires = datetime.now() + timedelta(days=30)
cur.execute("""
INSERT INTO sessions (token, profile_id, expires_at, created)
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
""", (session_token, prof['id'], expires))
return {
"ok": True,
"message": "E-Mail-Adresse erfolgreich bestätigt!",
"token": session_token,
"profile": {
"id": prof['id'],
"name": prof['name'],
"email": prof['email']
}
}

View File

@ -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}}>

View File

@ -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)'}}>

View 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>
)
}

View 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>
)
}

View File

@ -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