import React, { useState, useEffect, useMemo } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useAuthStore } from '../stores/authStore'; import { useSpaceStore, NotConnectedError } from '../stores/spaceStore'; import { useInstanceStore, DifferentPasswordError } from '../stores/instanceStore'; import { api, createApiClient } from '../api/client'; import { parseInviteInput } from '../utils/inviteParser'; import { Avatar } from './ui/Avatar'; import type { InvitePreview } from '@backspace/shared'; type JoinPhase = 'preview' | 'connect' | 'fallback' | 'other-instance' | 'already-member'; export function JoinPage() { const { inviteCode: rawInviteCode } = useParams<{ inviteCode: string }>(); const navigate = useNavigate(); const token = useAuthStore((s) => s.token); const user = useAuthStore((s) => s.user); const loadUser = useAuthStore((s) => s.loadUser); const isAuthLoading = useAuthStore((s) => s.isLoading); const logout = useAuthStore((s) => s.logout); const joinByCode = useSpaceStore((s) => s.joinByCode); const connectToRemote = useInstanceStore((s) => s.connectToRemote); const loginToRemote = useInstanceStore((s) => s.loginToRemote); // Hydrate auth store when token exists — useAuth() can't be used here because // it hard-redirects to /login when there's no token, and JoinPage is a public route. useEffect(() => { if (token && !user && !isAuthLoading) { loadUser(); } }, [token, user, isAuthLoading, loadUser]); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(''); const [isLoadingPreview, setIsLoadingPreview] = useState(true); const [phase, setPhase] = useState('preview'); const [error, setError] = useState(''); const [isJoining, setIsJoining] = useState(false); // Federation connect state const [password, setPassword] = useState(''); const [fallbackUsername, setFallbackUsername] = useState(''); const [fallbackPassword, setFallbackPassword] = useState(''); // Other instance state const [otherDomain, setOtherDomain] = useState(''); // Parse the invite code from the URL const parsed = useMemo(() => { if (!rawInviteCode) return null; try { return parseInviteInput(rawInviteCode); } catch { return null; } }, [rawInviteCode]); // Fetch preview on mount useEffect(() => { if (!rawInviteCode) { setPreviewError('No invite code provided'); setIsLoadingPreview(false); return; } if (!parsed) { setPreviewError('Invalid invite code'); setIsLoadingPreview(false); return; } setIsLoadingPreview(true); setPreviewError(''); const fetchPreview = async () => { try { let client; if (parsed.origin) { client = createApiClient(parsed.origin, () => null); } else { client = api; } const data = await client.spaces.invitePreview(parsed.code); setPreview(data); } catch (err) { setPreviewError(err instanceof Error ? err.message : 'Failed to load invite'); } finally { setIsLoadingPreview(false); } }; fetchPreview(); }, [rawInviteCode, parsed]); // Join handler const handleJoin = async () => { if (!parsed) return; setError(''); setIsJoining(true); try { const space = await joinByCode(parsed.code, parsed.origin || undefined); navigate(`/channels/${space.id}`); } catch (err) { if (err instanceof NotConnectedError) { setPhase('connect'); setError(''); } else { const msg = err instanceof Error ? err.message : 'Failed to join space'; if (msg.toLowerCase().includes('already a member')) { setPhase('already-member'); setError(''); } else { setError(msg); } } } finally { setIsJoining(false); } }; // Federation connect handler const handleConnect = async (e: React.FormEvent) => { e.preventDefault(); if (!parsed?.origin) return; setError(''); setIsJoining(true); try { await connectToRemote(parsed.origin, password, user?.displayName || undefined); const space = await joinByCode(parsed.code, parsed.origin); navigate(`/channels/${space.id}`); } catch (err) { if (err instanceof DifferentPasswordError) { setPhase('fallback'); setFallbackUsername(err.remoteUsername); setFallbackPassword(''); setError(''); } else { const msg = err instanceof Error ? err.message : 'Failed to connect'; if (msg.toLowerCase().includes('already a member')) { setPhase('already-member'); setError(''); } else { setError(msg); } } } finally { setIsJoining(false); } }; // Fallback login handler const handleFallbackLogin = async (e: React.FormEvent) => { e.preventDefault(); if (!parsed?.origin) return; setError(''); setIsJoining(true); try { await loginToRemote(parsed.origin, fallbackUsername, fallbackPassword); const space = await joinByCode(parsed.code, parsed.origin); navigate(`/channels/${space.id}`); } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to log in'; if (msg.toLowerCase().includes('already a member')) { setPhase('already-member'); setError(''); } else { setError(msg); } } finally { setIsJoining(false); } }; // Other instance redirect const handleOtherInstanceRedirect = (e: React.FormEvent) => { e.preventDefault(); const domain = otherDomain.trim(); if (!domain) return; // Build the qualified invite code: code@originHost // If the invite is already qualified (arrived via redirect), preserve the original origin const originHost = parsed?.origin ? new URL(parsed.origin).host : window.location.host; const code = parsed?.code || rawInviteCode || ''; const qualifiedCode = `${code}@${originHost}`; const targetUrl = `https://${domain}/join/${encodeURIComponent(qualifiedCode)}`; window.location.href = targetUrl; }; let hostDisplay = ''; try { if (parsed?.origin) hostDisplay = new URL(parsed.origin).host; } catch { /* ignore */ } const redirectParam = rawInviteCode ? `?redirect=/join/${encodeURIComponent(rawInviteCode)}` : ''; // Loading state if (isLoadingPreview) { return (

Loading invite...

); } // Error state — invalid/expired invite if (previewError || !preview) { return (

Invalid Invite

{previewError || 'This invite link is invalid or has expired.'}

{token ? ( ) : ( Log In )}
); } // Main invite page return (
{/* Space preview */}

You've been invited to join

{preview.spaceName}

{preview.description && (

{preview.description}

)}
{preview.memberCount} {preview.memberCount === 1 ? 'member' : 'members'} {preview.instanceName}
{error && (
{error}
)} {/* Phase: preview — main join UI */} {phase === 'preview' && ( <> {token && user ? ( /* Authenticated user — show identity card + join */

{user.displayName || user.username}

@{user.username}

Not you?{' '} {' · '}

) : token ? ( /* Token exists but user still loading */ ) : ( /* Unauthenticated user */
Log in to join Create an account {/* Divider */}
or
)} )} {/* Phase: other-instance — domain input for federation redirect */} {phase === 'other-instance' && (
setOtherDomain(e.target.value)} placeholder="e.g. my-instance.com" className="input-standard flex-1 py-2.5" autoFocus />

You'll be redirected to your home instance to complete joining.

)} {/* Phase: connect — password prompt for federation */} {phase === 'connect' && (
{/* Identity card */}

{user?.displayName || user?.username}

@{user?.username}

Connecting to {hostDisplay}

setPassword(e.target.value)} placeholder="Your account password" className="input-standard w-full py-2.5" disabled={isJoining} autoFocus autoComplete="current-password" />

Your password is verified locally, then used to create or access your account on the remote instance.

)} {/* Phase: already-member — green success with auto-redirect */} {phase === 'already-member' && preview && ( )} {/* Phase: fallback — different password on remote */} {phase === 'fallback' && (
An account already exists on {hostDisplay} with a different password. Enter the credentials you used on that instance.
setFallbackUsername(e.target.value)} placeholder="Your username on this instance" className="input-standard w-full py-2.5" disabled={isJoining} autoComplete="username" />
setFallbackPassword(e.target.value)} placeholder="Password on the remote instance" className="input-standard w-full py-2.5" disabled={isJoining} autoFocus autoComplete="current-password" />
)}
); } function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string; spaceId: string; navigate: (path: string) => void }) { useEffect(() => { const timer = setTimeout(() => navigate(`/channels/${spaceId}`), 2000); return () => clearTimeout(timer); }, [spaceId, navigate]); return (

You're already in {spaceName}!

Redirecting you now...

); }