feat(pitch-deck): add passwordless investor auth, audit logs, snapshots & PWA
Some checks failed
CI / go-lint (pull_request) Failing after 17s
CI / python-lint (pull_request) Failing after 12s
CI / nodejs-lint (pull_request) Failing after 7s
CI / test-go-consent (pull_request) Failing after 11s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 11s
CI / Deploy (pull_request) Has been skipped
Some checks failed
CI / go-lint (pull_request) Failing after 17s
CI / python-lint (pull_request) Failing after 12s
CI / nodejs-lint (pull_request) Failing after 7s
CI / test-go-consent (pull_request) Failing after 11s
CI / test-python-voice (pull_request) Failing after 11s
CI / test-bqas (pull_request) Failing after 11s
CI / Deploy (pull_request) Has been skipped
Implement a complete investor access system for the pitch deck: - Passwordless magic link auth (jose JWT + nodemailer SMTP) - Per-investor audit logging (slide views, assumption changes, chat) - Financial model snapshot persistence (auto-save/restore per investor) - PWA support (manifest, service worker, offline caching, icons) - Security safeguards (watermark overlay, rate limiting, anti-scraping headers, content protection, single-session enforcement) - Admin API for invite/revoke/audit-log management - Integrated into docker-compose.coolify.yml for production deployment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,9 @@ import { AnimatePresence } from 'framer-motion'
|
||||
import { useSlideNavigation } from '@/lib/hooks/useSlideNavigation'
|
||||
import { useKeyboard } from '@/lib/hooks/useKeyboard'
|
||||
import { usePitchData } from '@/lib/hooks/usePitchData'
|
||||
import { useAuditTracker } from '@/lib/hooks/useAuditTracker'
|
||||
import { Language, PitchData } from '@/lib/types'
|
||||
import { Investor } from '@/lib/hooks/useAuth'
|
||||
|
||||
import ParticleBackground from './ParticleBackground'
|
||||
import ProgressBar from './ProgressBar'
|
||||
@@ -14,6 +16,7 @@ import NavigationFAB from './NavigationFAB'
|
||||
import ChatFAB from './ChatFAB'
|
||||
import SlideOverview from './SlideOverview'
|
||||
import SlideContainer from './SlideContainer'
|
||||
import Watermark from './Watermark'
|
||||
|
||||
import CoverSlide from './slides/CoverSlide'
|
||||
import ProblemSlide from './slides/ProblemSlide'
|
||||
@@ -38,13 +41,22 @@ import AIPipelineSlide from './slides/AIPipelineSlide'
|
||||
interface PitchDeckProps {
|
||||
lang: Language
|
||||
onToggleLanguage: () => void
|
||||
investor: Investor | null
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
export default function PitchDeck({ lang, onToggleLanguage, investor, onLogout }: PitchDeckProps) {
|
||||
const { data, loading, error } = usePitchData()
|
||||
const nav = useSlideNavigation()
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
|
||||
// Audit tracking
|
||||
useAuditTracker({
|
||||
investorId: investor?.id || null,
|
||||
currentSlide: nav.currentSlide,
|
||||
enabled: !!investor,
|
||||
})
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
@@ -117,7 +129,7 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
case 'team':
|
||||
return <TeamSlide lang={lang} team={data.team} />
|
||||
case 'financials':
|
||||
return <FinancialsSlide lang={lang} />
|
||||
return <FinancialsSlide lang={lang} investorId={investor?.id || null} />
|
||||
case 'the-ask':
|
||||
return <TheAskSlide lang={lang} funding={data.funding} />
|
||||
case 'ai-qa':
|
||||
@@ -140,10 +152,16 @@ export default function PitchDeck({ lang, onToggleLanguage }: PitchDeckProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen relative overflow-hidden bg-gradient-to-br from-slate-950 via-[#0a0a1a] to-slate-950">
|
||||
<div
|
||||
className="h-screen relative overflow-hidden bg-gradient-to-br from-slate-950 via-[#0a0a1a] to-slate-950 select-none"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<ParticleBackground />
|
||||
<ProgressBar current={nav.currentIndex} total={nav.totalSlides} />
|
||||
|
||||
{/* Investor watermark */}
|
||||
{investor && <Watermark text={investor.email} />}
|
||||
|
||||
<SlideContainer slideKey={nav.currentSlide} direction={nav.direction}>
|
||||
{renderSlide()}
|
||||
</SlideContainer>
|
||||
|
||||
36
pitch-deck/components/Watermark.tsx
Normal file
36
pitch-deck/components/Watermark.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
interface WatermarkProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
export default function Watermark({ text }: WatermarkProps) {
|
||||
if (!text) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none z-50 overflow-hidden select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="text-white/[0.03] text-2xl font-mono whitespace-nowrap tracking-widest"
|
||||
style={{
|
||||
transform: 'rotate(-35deg) scale(1.5)',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Repeat the watermark text in a grid pattern */}
|
||||
{Array.from({ length: 7 }, (_, row) => (
|
||||
<div key={row} className="my-16">
|
||||
{Array.from({ length: 3 }, (_, col) => (
|
||||
<span key={col} className="mx-12">{text}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,11 +20,12 @@ type FinTab = 'overview' | 'guv' | 'cashflow'
|
||||
|
||||
interface FinancialsSlideProps {
|
||||
lang: Language
|
||||
investorId: string | null
|
||||
}
|
||||
|
||||
export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
||||
export default function FinancialsSlide({ lang, investorId }: FinancialsSlideProps) {
|
||||
const i = t(lang)
|
||||
const fm = useFinancialModel()
|
||||
const fm = useFinancialModel(investorId)
|
||||
const [activeTab, setActiveTab] = useState<FinTab>('overview')
|
||||
const de = lang === 'de'
|
||||
|
||||
@@ -268,6 +269,26 @@ export default function FinancialsSlide({ lang }: FinancialsSlideProps) {
|
||||
{de ? 'Berechne...' : 'Computing...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshot status + reset */}
|
||||
{investorId && (
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-white/5">
|
||||
<span className="text-[9px] text-white/30">
|
||||
{fm.snapshotStatus === 'saving' && (de ? 'Speichere...' : 'Saving...')}
|
||||
{fm.snapshotStatus === 'saved' && (de ? 'Ihre Aenderungen gespeichert' : 'Your changes saved')}
|
||||
{fm.snapshotStatus === 'restored' && (de ? 'Ihre Werte geladen' : 'Your values restored')}
|
||||
{fm.snapshotStatus === 'default' && (de ? 'Standardwerte' : 'Defaults')}
|
||||
</span>
|
||||
{fm.snapshotStatus !== 'default' && (
|
||||
<button
|
||||
onClick={() => fm.activeScenarioId && fm.resetToDefaults(fm.activeScenarioId)}
|
||||
className="text-[9px] text-white/40 hover:text-white/70 transition-colors"
|
||||
>
|
||||
{de ? 'Zuruecksetzen' : 'Reset to defaults'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeInView>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user