/** * InviteAcceptPage — handles `/invite/:code#key=`. * * Pulls the encrypted payload from `api.invites.use`, decrypts it * with the URL-fragment secret, stashes the decoded channel-key * map in sessionStorage under `pendingInviteKeys` / `pendingInviteCode`, * then forwards the user to the register page (or to `/channels/@me` * if they're already logged in). * * The fragment secret never hits the server — browsers don't send * `#…` to origin servers — so knowledge of it is the capability that * unlocks the invite payload. */ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useConvex } from 'convex/react'; import { api } from '../../../../../convex/_generated/api'; import { usePlatform } from '../../platform'; import { AuthLayout } from './AuthLayout'; export function InviteAcceptPage() { const { code } = useParams<{ code: string }>(); const navigate = useNavigate(); const convex = useConvex(); const { crypto } = usePlatform(); const [status, setStatus] = useState<'working' | 'error'>('working'); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; const run = async () => { if (!code) { setStatus('error'); setError('Missing invite code.'); return; } const hash = typeof window !== 'undefined' ? window.location.hash : ''; // Fragment looks like "#key=abcd…" const match = hash.match(/[#&]key=([^&]+)/); const secret = match ? match[1] : null; if (!secret) { setStatus('error'); setError( 'Invite link is missing its secret. Ask the sender for a fresh link.', ); return; } try { const result = await convex.query(api.invites.use, { code }); if (cancelled) return; if ('error' in result) { setStatus('error'); setError(result.error); return; } // Older invites used to send a placeholder empty payload. // Treat that as "no channel keys to import" and continue // so the user can still register with the code. let keys: Record = {}; if (result.encryptedPayload) { try { const blob = JSON.parse(result.encryptedPayload) as { c: string; i: string; t: string; }; const plaintext = await crypto.decryptData( blob.c, secret, blob.i, blob.t, ); keys = JSON.parse(plaintext) as Record; } catch { setStatus('error'); setError( 'Failed to decrypt invite payload. The secret may be wrong or the invite corrupted.', ); return; } } try { sessionStorage.setItem('pendingInviteCode', code); sessionStorage.setItem('pendingInviteKeys', JSON.stringify(keys)); } catch { /* storage blocked — flow still works if same tab */ } // If the user is already signed in, just take them home — // the invite keys are already theirs in that case. const loggedIn = !!sessionStorage.getItem('privateKey'); if (loggedIn) { navigate('/channels/@me', { replace: true }); } else { navigate('/register', { replace: true }); } } catch (err: any) { if (cancelled) return; setStatus('error'); setError(err?.message ?? 'Failed to accept invite.'); } }; void run(); return () => { cancelled = true; }; }, [code, convex, crypto, navigate]); return (

{status === 'working' ? 'Accepting invite…' : 'Invite Error'}

{error && (

{error}

)}
); } export default InviteAcceptPage;