Final Feature 9c #10
|
|
@ -323,3 +323,74 @@ async def verify_email(token: str):
|
||||||
"email": prof['email']
|
"email": prof['email']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-verification")
|
||||||
|
@limiter.limit("3/hour")
|
||||||
|
async def resend_verification(req: dict, request: Request):
|
||||||
|
"""Resend verification email for unverified account."""
|
||||||
|
email = req.get('email', '').strip().lower()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
raise HTTPException(400, "E-Mail-Adresse erforderlich")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Find profile by email
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, name, email, email_verified, verification_token, verification_expires
|
||||||
|
FROM profiles
|
||||||
|
WHERE email=%s
|
||||||
|
""", (email,))
|
||||||
|
|
||||||
|
prof = cur.fetchone()
|
||||||
|
|
||||||
|
if not prof:
|
||||||
|
# Don't leak info about existing emails
|
||||||
|
return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."}
|
||||||
|
|
||||||
|
if prof['email_verified']:
|
||||||
|
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
|
||||||
|
|
||||||
|
# Generate new verification token
|
||||||
|
verification_token = secrets.token_urlsafe(32)
|
||||||
|
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE profiles
|
||||||
|
SET verification_token=%s, verification_expires=%s
|
||||||
|
WHERE id=%s
|
||||||
|
""", (verification_token, verification_expires, prof['id']))
|
||||||
|
|
||||||
|
# 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 {prof['name']},
|
||||||
|
|
||||||
|
du hast eine neue Bestätigungs-E-Mail angefordert.
|
||||||
|
|
||||||
|
Bitte bestätige deine E-Mail-Adresse, indem du auf folgenden Link klickst:
|
||||||
|
|
||||||
|
{verify_url}
|
||||||
|
|
||||||
|
Dieser Link ist 24 Stunden gültig.
|
||||||
|
|
||||||
|
Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren.
|
||||||
|
|
||||||
|
Viele Grüße
|
||||||
|
Dein Mitai Jinkendo Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email(
|
||||||
|
to=email,
|
||||||
|
subject="Neue Bestätigungs-E-Mail - Mitai Jinkendo",
|
||||||
|
body=email_body
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send verification email: {e}")
|
||||||
|
raise HTTPException(500, "E-Mail konnte nicht versendet werden")
|
||||||
|
|
||||||
|
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,33 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
export default function EmailVerificationBanner({ profile }) {
|
export default function EmailVerificationBanner({ profile }) {
|
||||||
|
const [resending, setResending] = useState(false)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
// Only show if email is not verified
|
// Only show if email is not verified
|
||||||
if (!profile || profile.email_verified !== false) return null
|
if (!profile || profile.email_verified !== false) return null
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
if (!profile.email) return
|
||||||
|
|
||||||
|
setResending(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.resendVerification(profile.email)
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => setSuccess(false), 5000)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Fehler beim Versenden')
|
||||||
|
setTimeout(() => setError(null), 5000)
|
||||||
|
} finally {
|
||||||
|
setResending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#FFF4E6',
|
background: '#FFF4E6',
|
||||||
|
|
@ -11,7 +37,8 @@ export default function EmailVerificationBanner({ profile }) {
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 16
|
gap: 16,
|
||||||
|
flexWrap: 'wrap'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
|
|
@ -19,7 +46,7 @@ export default function EmailVerificationBanner({ profile }) {
|
||||||
}}>
|
}}>
|
||||||
📧
|
📧
|
||||||
</div>
|
</div>
|
||||||
<div style={{flex: 1}}>
|
<div style={{flex: 1, minWidth: 200}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
|
@ -34,9 +61,36 @@ export default function EmailVerificationBanner({ profile }) {
|
||||||
lineHeight: 1.4
|
lineHeight: 1.4
|
||||||
}}>
|
}}>
|
||||||
Bitte prüfe dein Postfach und klicke auf den Bestätigungslink.
|
Bitte prüfe dein Postfach und klicke auf den Bestätigungslink.
|
||||||
Ohne Bestätigung ist dein Account eingeschränkt.
|
{success && (
|
||||||
|
<span style={{color:'var(--accent)', fontWeight:600, marginLeft:4}}>
|
||||||
|
✓ Neue E-Mail versendet!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span style={{color:'var(--danger)', fontWeight:600, marginLeft:4}}>
|
||||||
|
✗ {error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={resending || success}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: success ? 'var(--accent)' : '#F59E0B',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
border: 'none',
|
||||||
|
cursor: resending || success ? 'not-allowed' : 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: resending || success ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resending ? 'Sende...' : success ? '✓ Versendet' : 'Neue E-Mail senden'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ export default function Verify() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
|
||||||
const [status, setStatus] = useState('loading') // loading | success | error
|
const [status, setStatus] = useState('loading') // loading | success | error | expired
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [resending, setResending] = useState(false)
|
||||||
|
const [resendSuccess, setResendSuccess] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const verify = async () => {
|
const verify = async () => {
|
||||||
|
|
@ -37,14 +40,41 @@ export default function Verify() {
|
||||||
setError('Verifizierung erfolgreich, aber Login fehlgeschlagen')
|
setError('Verifizierung erfolgreich, aber Login fehlgeschlagen')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error')
|
const errorMsg = err.message || 'Verifizierung fehlgeschlagen'
|
||||||
setError(err.message || 'Verifizierung fehlgeschlagen')
|
|
||||||
|
// Check if token expired
|
||||||
|
if (errorMsg.includes('abgelaufen') || errorMsg.includes('expired')) {
|
||||||
|
setStatus('expired')
|
||||||
|
setError(errorMsg)
|
||||||
|
} else {
|
||||||
|
setStatus('error')
|
||||||
|
setError(errorMsg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
verify()
|
verify()
|
||||||
}, [token, login, navigate])
|
}, [token, login, navigate])
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
if (!email.trim()) {
|
||||||
|
setError('Bitte E-Mail-Adresse eingeben')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setResending(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.resendVerification(email.trim().toLowerCase())
|
||||||
|
setResendSuccess(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'E-Mail konnte nicht versendet werden')
|
||||||
|
} finally {
|
||||||
|
setResending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -58,6 +88,95 @@ export default function Verify() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === 'expired') {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
maxWidth:420, margin:'0 auto', padding:'60px 20px',
|
||||||
|
textAlign:'center'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background:'#FFF4E6',
|
||||||
|
border:'2px solid #F59E0B',
|
||||||
|
borderRadius:16, padding:'40px 24px', marginBottom:24
|
||||||
|
}}>
|
||||||
|
<div style={{fontSize:48, marginBottom:16}}>⏰</div>
|
||||||
|
<h2 style={{marginBottom:12, color:'#D97706'}}>
|
||||||
|
Verifikations-Link abgelaufen
|
||||||
|
</h2>
|
||||||
|
<p style={{color:'var(--text2)', lineHeight:1.6, marginBottom:24}}>
|
||||||
|
Dieser Link ist leider nicht mehr gültig. Bitte fordere eine neue Bestätigungs-E-Mail an.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{resendSuccess ? (
|
||||||
|
<div style={{
|
||||||
|
background:'var(--accent-light)',
|
||||||
|
border:'1px solid var(--accent)',
|
||||||
|
borderRadius:8, padding:'16px', marginBottom:16
|
||||||
|
}}>
|
||||||
|
<div style={{fontSize:32, marginBottom:8}}>✓</div>
|
||||||
|
<div style={{fontWeight:600, color:'var(--accent-dark)', marginBottom:4}}>
|
||||||
|
E-Mail versendet!
|
||||||
|
</div>
|
||||||
|
<p style={{fontSize:13, color:'var(--text2)', margin:0}}>
|
||||||
|
Bitte prüfe dein Postfach.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="deine@email.de"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleResend()}
|
||||||
|
style={{width:'100%', boxSizing:'border-box', marginBottom:12}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background:'rgba(216,90,48,0.1)',
|
||||||
|
color:'#D85A30',
|
||||||
|
fontSize:13,
|
||||||
|
padding:'10px 14px',
|
||||||
|
borderRadius:8,
|
||||||
|
marginBottom:12,
|
||||||
|
border:'1px solid rgba(216,90,48,0.2)'
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={resending || !email.trim()}
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{marginBottom:12}}
|
||||||
|
>
|
||||||
|
{resending ? (
|
||||||
|
<><div className="spinner" style={{width:14,height:14}}/> Sende...</>
|
||||||
|
) : (
|
||||||
|
'Neue Bestätigungs-E-Mail senden'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{
|
||||||
|
background:'none', border:'none', cursor:'pointer',
|
||||||
|
fontSize:13, color:'var(--text3)', textDecoration:'underline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zurück zum Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -81,9 +200,21 @@ export default function Verify() {
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/register')}
|
onClick={() => navigate('/register')}
|
||||||
className="btn btn-primary btn-full"
|
className="btn btn-primary btn-full"
|
||||||
|
style={{marginBottom:12}}
|
||||||
>
|
>
|
||||||
Zur Registrierung
|
Zur Registrierung
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{
|
||||||
|
background:'none', border:'none', cursor:'pointer',
|
||||||
|
fontSize:13, color:'var(--text3)', textDecoration:'underline',
|
||||||
|
width:'100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zum Login
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ export const api = {
|
||||||
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})),
|
register: (name,email,password) => req('/auth/register',json({name,email,password})),
|
||||||
verifyEmail: (token) => req(`/auth/verify/${token}`),
|
verifyEmail: (token) => req(`/auth/verify/${token}`),
|
||||||
|
resendVerification: (email) => req('/auth/resend-verification',json({email})),
|
||||||
|
|
||||||
// v9c Subscription System
|
// v9c Subscription System
|
||||||
// User-facing
|
// User-facing
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user