fix: require button click to consume magic link token
Build pitch-deck / build-push-deploy (push) Successful in 1m54s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 47s
CI / test-python-voice (push) Successful in 37s
CI / test-bqas (push) Successful in 37s

Email security gateways follow GET redirects automatically and were
consuming the token before the investor clicked through. The verify page
now shows an 'Access Pitch Deck' button; the token is only consumed on
explicit click, which scanners cannot trigger.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-05-07 23:30:27 +02:00
parent 1ef22e6f95
commit 572052285c
+51 -35
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { Suspense, useEffect, useState } from 'react' import { Suspense, useEffect, useState, useCallback } from 'react'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
@@ -9,7 +9,7 @@ function VerifyContent() {
const router = useRouter() const router = useRouter()
const token = searchParams.get('token') const token = searchParams.get('token')
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying') const [status, setStatus] = useState<'ready' | 'verifying' | 'success' | 'error'>('ready')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
useEffect(() => { useEffect(() => {
@@ -19,38 +19,37 @@ function VerifyContent() {
return return
} }
async function verify() { // If the investor already has a valid session, skip the button entirely
try { fetch('/api/auth/me').then(res => {
// If the investor already has a valid session, skip token verification if (res.ok) router.push('/')
const sessionCheck = await fetch('/api/auth/me') })
if (sessionCheck.ok) {
router.push('/')
return
}
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (res.ok) {
setStatus('success')
setTimeout(() => router.push('/'), 1000)
} else {
const data = await res.json()
setStatus('error')
setErrorMsg(data.error || 'Verification failed.')
}
} catch {
setStatus('error')
setErrorMsg('Network error. Please try again.')
}
}
verify()
}, [token, router]) }, [token, router])
const handleAccess = useCallback(async () => {
if (!token || status === 'verifying') return
setStatus('verifying')
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (res.ok) {
setStatus('success')
setTimeout(() => router.push('/'), 1000)
} else {
const data = await res.json()
setStatus('error')
setErrorMsg(data.error || 'Verification failed.')
}
} catch {
setStatus('error')
setErrorMsg('Network error. Please try again.')
}
}, [token, status, router])
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
@@ -59,10 +58,28 @@ function VerifyContent() {
className="relative z-10 text-center max-w-md mx-auto px-6" className="relative z-10 text-center max-w-md mx-auto px-6"
> >
<div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 backdrop-blur-sm"> <div className="bg-white/[0.03] border border-white/[0.06] rounded-2xl p-8 backdrop-blur-sm">
{status === 'ready' && (
<>
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-indigo-500/10 flex items-center justify-center">
<svg className="w-8 h-8 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.894L15 14M3 8a2 2 0 012-2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" />
</svg>
</div>
<p className="text-white/80 font-medium mb-1">Your pitch deck is ready</p>
<p className="text-white/40 text-sm mb-6">Click below to access it.</p>
<button
onClick={handleAccess}
className="w-full bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white font-medium py-3 px-6 rounded-xl transition-all shadow-lg shadow-indigo-500/20"
>
Access Pitch Deck
</button>
</>
)}
{status === 'verifying' && ( {status === 'verifying' && (
<> <>
<div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <div className="w-12 h-12 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-white/60">Verifying your access link...</p> <p className="text-white/60">Verifying your access...</p>
</> </>
)} )}
@@ -115,11 +132,10 @@ export default function VerifyPage() {
<VerifyContent /> <VerifyContent />
</Suspense> </Suspense>
{/* Privacy Notice Footer */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-8 py-4 border-t border-white/5"> <div className="absolute bottom-0 left-0 right-0 z-10 px-8 py-4 border-t border-white/5">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<p className="text-[10px] text-white/20 leading-relaxed text-center"> <p className="text-[10px] text-white/20 leading-relaxed text-center">
<strong className="text-white/25">Datenschutzhinweis (Art. 13 DSGVO):</strong> Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie  soweit eingeladen  personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 1521 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).</p> <strong className="text-white/25">Datenschutzhinweis (Art. 13 DSGVO):</strong> Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie soweit eingeladen personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 1521 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).</p>
<p className="text-[10px] text-white/15 text-center mt-1"> <p className="text-[10px] text-white/15 text-center mt-1">
Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com
</p> </p>