'use client'; import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import { TriangleAlert, X } from 'lucide-react'; import { decryptPrivateKey } from '@/lib/crypto/private-key-client'; import { keyStore, importPrivateKey } from '@/lib/crypto/user-signing'; import { useAuth } from '@/lib/contexts/AuthContext'; declare global { interface Window { turnstile?: { render: (element: string | HTMLElement, options: { sitekey: string; callback?: (token: string) => void; 'error-callback'?: () => void; 'expired-callback'?: () => void; }) => string; reset: (widgetId: string) => void; remove: (widgetId: string) => void; }; } } interface AuthScreenProps { modal?: boolean; onClose?: () => void; onSuccess?: () => void; } export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProps) { const router = useRouter(); const [mode, setMode] = useState<'login' | 'register' | 'import'>('login'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [handle, setHandle] = useState(''); const [displayName, setDisplayName] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false); const [nodeInfo, setNodeInfo] = useState<{ name: string; description: string; logoUrl?: string; isNsfw?: boolean; turnstileSiteKey?: string | null }>({ name: '', description: '' }); const [handleStatus, setHandleStatus] = useState<'idle' | 'checking' | 'available' | 'taken'>('idle'); const [ageVerified, setAgeVerified] = useState(false); const [turnstileToken, setTurnstileToken] = useState(null); const [turnstileLoaded, setTurnstileLoaded] = useState(false); const turnstileRef = useRef(null); const turnstileWidgetId = useRef(null); const { unlockIdentity, login } = useAuth(); const [importFile, setImportFile] = useState(null); const [importPassword, setImportPassword] = useState(''); const [importHandle, setImportHandle] = useState(''); const [acceptedCompliance, setAcceptedCompliance] = useState(false); const [importAgeVerified, setImportAgeVerified] = useState(false); const [importSuccess, setImportSuccess] = useState(null); // Fetch node info useEffect(() => { fetch('/api/node') .then(res => res.json()) .then(data => { setNodeInfo({ name: data.name || '', description: data.description || 'Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental. Synapsis aims to be neutral, resilient infrastructure for human and machine discourse, more like a protocol or nervous system than a social club.', logoUrl: data.logoUrl || undefined, isNsfw: data.isNsfw || false, turnstileSiteKey: data.turnstileSiteKey || null, }); // Update page title if (data.name && data.name !== 'Synapsis') { document.title = data.name; } setNodeInfoLoaded(true); }) .catch(() => { setNodeInfoLoaded(true); }); }, []); // Load Turnstile script if site key is available useEffect(() => { if (!nodeInfo.turnstileSiteKey) return; const script = document.createElement('script'); script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; script.async = true; script.defer = true; script.onload = () => setTurnstileLoaded(true); document.head.appendChild(script); return () => { document.head.removeChild(script); }; }, [nodeInfo.turnstileSiteKey]); // Render Turnstile widget when ready useEffect(() => { if (!turnstileLoaded || !nodeInfo.turnstileSiteKey || !turnstileRef.current || mode === 'import') return; // Clean up previous widget if (turnstileWidgetId.current && window.turnstile) { try { window.turnstile.remove(turnstileWidgetId.current); } catch (e) { // Ignore errors } } // Render new widget if (window.turnstile) { turnstileWidgetId.current = window.turnstile.render(turnstileRef.current, { sitekey: nodeInfo.turnstileSiteKey, callback: (token: string) => { setTurnstileToken(token); }, 'error-callback': () => { setTurnstileToken(null); }, 'expired-callback': () => { setTurnstileToken(null); }, }); } return () => { if (turnstileWidgetId.current && window.turnstile) { try { window.turnstile.remove(turnstileWidgetId.current); } catch (e) { // Ignore errors } } }; }, [turnstileLoaded, nodeInfo.turnstileSiteKey, mode]); // Handle availability check useEffect(() => { const checkHandle = mode === 'register' ? handle : (mode === 'import' ? importHandle : ''); if (!checkHandle || checkHandle.length < 3) { setHandleStatus('idle'); return; } const timer = setTimeout(async () => { setHandleStatus('checking'); try { const res = await fetch(`/api/auth/check-handle?handle=${checkHandle}`); const data = await res.json(); if (data.available) { setHandleStatus('available'); } else { setHandleStatus('taken'); } } catch { setHandleStatus('idle'); } }, 500); return () => clearTimeout(timer); }, [handle, importHandle, mode]); const handleImport = async (e: React.FormEvent) => { e.preventDefault(); if (!importFile || !importPassword || !importHandle || !acceptedCompliance) { setError('Please fill in all fields and accept the compliance agreement'); return; } if (nodeInfo.isNsfw && !importAgeVerified) { setError('You must verify your age to import an account on this node'); return; } setLoading(true); setError(''); setImportSuccess(null); try { const fileContent = await importFile.text(); const exportData = JSON.parse(fileContent); const res = await fetch('/api/account/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ exportData, password: importPassword, newHandle: importHandle, acceptedCompliance, }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || 'Import failed'); } setImportSuccess(data.message); // Soft navigation to preserve AuthContext/KeyStore state setTimeout(() => { router.refresh(); if (onSuccess) { onSuccess(); } else { router.push('/'); } }, 2000); } catch (err) { setError(err instanceof Error ? err.message : 'Import failed'); } finally { setLoading(false); } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); if (mode === 'register' && password !== confirmPassword) { setError('Passwords do not match'); return; } if (mode === 'register' && nodeInfo.isNsfw && !ageVerified) { setError('You must verify your age to register on this node'); return; } // Check if Turnstile is required but not completed if (nodeInfo.turnstileSiteKey && !turnstileToken) { setError('Please complete the verification challenge'); return; } setLoading(true); try { const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register'; // Only include turnstileToken if Turnstile is enabled (site key exists) const body = mode === 'login' ? { email, password, ...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {}) } : { email, password, handle, displayName, ...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {}) }; const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || 'Authentication failed'); } // Decrypt and store private key if available if (data.user?.privateKeyEncrypted) { try { const privateKeyDecrypted = await decryptPrivateKey( data.user.privateKeyEncrypted, password ); // Import and set in memory store // Remove PEM headers if present and clean whitespace let cleanKey = privateKeyDecrypted .replace(/-----BEGIN [A-Z ]+-----/, '') .replace(/-----END [A-Z ]+-----/, '') .replace(/\s/g, ''); const binaryDer = Buffer.from(cleanKey, 'base64'); const cryptoKey = await importPrivateKey(binaryDer); keyStore.setPrivateKey(cryptoKey); console.log('[Auth] Private key decrypted and stored successfully'); } catch (decryptError) { console.error('[Auth] Failed to decrypt private key:', decryptError); // Don't block login/registration if decryption fails - user can unlock later // The identity unlock prompt will be shown in the app } } else { if (process.env.NODE_ENV === 'development') console.log('[Auth] No encrypted private key returned from server'); } // Sync with global auth state if we have a key (or even if we don't, to trigger load) // But unlockIdentity specifically needs the key. // If data.user.privateKeyEncrypted is present, we try to unlock globally. if (data.user?.privateKeyEncrypted) { try { // Update AuthContext first so it has the user and key login(data.user); // Now unlock (passing user explicitly to avoid async state delay) await unlockIdentity(password, data.user); } catch (e) { console.error("Failed to auto-unlock identity:", e); } } // Soft navigation to preserve AuthContext/KeyStore state router.refresh(); if (onSuccess) { onSuccess(); } else { router.push('/'); } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred'); // Reset Turnstile on error if (turnstileWidgetId.current && window.turnstile) { window.turnstile.reset(turnstileWidgetId.current); setTurnstileToken(null); } } finally { setLoading(false); } }; const content = (
{/* Logo */}
{nodeInfoLoaded && ( <> {nodeInfo.logoUrl ? ( {nodeInfo.name ) : ( Synapsis )} {nodeInfo.name && nodeInfo.name !== 'Synapsis' && !nodeInfo.logoUrl && (
{nodeInfo.name}
)}

{nodeInfo.description}

)}
{/* Mode Switcher */}
{/* Form */} {mode !== 'import' ? (
{error && (
{error}
)} {mode === 'register' && ( <> {/* Row 1: Handle | Password */}
@ setHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} style={{ paddingLeft: '28px' }} placeholder="yourhandle" required minLength={3} maxLength={20} />
3-20 chars, a-z 0-9 _ {handleStatus === 'checking' && ( Checking... )} {handleStatus === 'available' && ( )} {handleStatus === 'taken' && ( Taken )}
setPassword(e.target.value)} placeholder="••••••••" required minLength={8} />
{/* Row 2: Display Name | Confirm Password */}
setDisplayName(e.target.value)} placeholder="Your Name" />
setConfirmPassword(e.target.value)} placeholder="••••••••" required minLength={8} />
setEmail(e.target.value)} placeholder="you@example.com" required />

You can connect your own S3-compatible storage when you first upload media.

)} {/* Login Mode - Show email/password only */} {mode === 'login' && ( <>
setEmail(e.target.value)} placeholder="you@example.com" required />
setPassword(e.target.value)} placeholder="••••••••" required minLength={8} />
)} {mode === 'register' && nodeInfo.isNsfw && (
)} {nodeInfo.turnstileSiteKey && (
)}
) : (
{error && (
{error}
)} {importSuccess && (
{importSuccess} Redirecting...
)}
setImportFile(e.target.files?.[0] || null)} style={{ display: 'none' }} />

You can connect your own S3-compatible storage when you first upload media.

setImportPassword(e.target.value)} placeholder="Enter the password for this account" required />
@ setImportHandle(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} style={{ paddingLeft: '28px' }} placeholder="yourhandle" required minLength={3} maxLength={20} />
3-20 chars {handleStatus === 'checking' && ( Checking... )} {handleStatus === 'available' && ( Available )} {handleStatus === 'taken' && ( Taken )}
{nodeInfo.isNsfw && (
)}
)} {!modal && (

← Back to home

)}
); if (modal) { return (
event.stopPropagation()} > {onClose && ( )}
{content}
); } return (
{content}
); } export default function LoginPage() { return ; }