The link was bouncing back to JoinPage because AuthRedirect on /login saw the existing token and redirected right back. Now calls logout() first so the login form actually renders.
487 lines
20 KiB
TypeScript
487 lines
20 KiB
TypeScript
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';
|
|
|
|
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<InvitePreview | null>(null);
|
|
const [previewError, setPreviewError] = useState('');
|
|
const [isLoadingPreview, setIsLoadingPreview] = useState(true);
|
|
|
|
const [phase, setPhase] = useState<JoinPhase>('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 {
|
|
setError(err instanceof Error ? err.message : 'Failed to join space');
|
|
}
|
|
} 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 {
|
|
setError(err instanceof Error ? err.message : 'Failed to connect');
|
|
}
|
|
} 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) {
|
|
setError(err instanceof Error ? err.message : 'Failed to log in');
|
|
} 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@thisHost
|
|
const thisHost = window.location.host;
|
|
const code = parsed?.code || rawInviteCode || '';
|
|
const qualifiedCode = `${code}@${thisHost}`;
|
|
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 (
|
|
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
|
<div className="text-center relative z-10">
|
|
<svg className="animate-spin w-10 h-10 text-accent-primary mx-auto mb-4" viewBox="0 0 24 24" fill="none">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
<p className="text-txt-tertiary">Loading invite...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state — invalid/expired invite
|
|
if (previewError || !preview) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
|
<div className="w-full max-w-[420px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10 text-center">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-accent-rose/10 flex items-center justify-center">
|
|
<svg className="w-8 h-8 text-accent-rose" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
</svg>
|
|
</div>
|
|
<h1 className="text-xl font-bold text-txt-primary mb-2">Invalid Invite</h1>
|
|
<p className="text-txt-secondary text-sm mb-6">
|
|
{previewError || 'This invite link is invalid or has expired.'}
|
|
</p>
|
|
{token ? (
|
|
<button
|
|
onClick={() => navigate('/channels/@me')}
|
|
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
|
>
|
|
Back to Backspace
|
|
</button>
|
|
) : (
|
|
<Link
|
|
to="/login"
|
|
className="inline-block px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
|
|
>
|
|
Log In
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Main invite page
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-surface-base relative">
|
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
|
|
<div className="w-full max-w-[420px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
|
|
{/* Space preview */}
|
|
<div className="text-center mb-6">
|
|
<div className="flex justify-center mb-4">
|
|
<Avatar
|
|
src={preview.icon ? (parsed?.origin ? `${parsed.origin}/api/uploads/${preview.icon}` : preview.icon) : null}
|
|
name={preview.spaceName}
|
|
size={72}
|
|
avatarColor={preview.avatarColor}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">You've been invited to join</p>
|
|
<h1 className="text-2xl font-bold text-txt-primary">{preview.spaceName}</h1>
|
|
{preview.description && (
|
|
<p className="text-txt-secondary text-sm mt-2">{preview.description}</p>
|
|
)}
|
|
<div className="flex items-center justify-center gap-4 mt-3 text-xs text-txt-tertiary">
|
|
<span className="flex items-center gap-1">
|
|
<span className="w-2 h-2 rounded-full bg-txt-tertiary/40" />
|
|
{preview.memberCount} {preview.memberCount === 1 ? 'member' : 'members'}
|
|
</span>
|
|
<span>{preview.instanceName}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Phase: preview — main join UI */}
|
|
{phase === 'preview' && (
|
|
<>
|
|
{token && user ? (
|
|
/* Authenticated user — show identity card + join */
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3 bg-surface-input rounded-lg p-3">
|
|
<Avatar
|
|
src={user.avatar || null}
|
|
name={user.displayName || user.username}
|
|
size={40}
|
|
avatarColor={user.avatarColor}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-txt-primary font-medium text-sm truncate">{user.displayName || user.username}</p>
|
|
<p className="text-txt-tertiary text-xs truncate">@{user.username}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleJoin}
|
|
disabled={isJoining}
|
|
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isJoining ? 'Joining...' : `Join as ${user.displayName || user.username}`}
|
|
</button>
|
|
<p className="text-center text-xs text-txt-tertiary">
|
|
Not you?{' '}
|
|
<button
|
|
type="button"
|
|
onClick={() => { logout(); navigate(`/login${redirectParam}`); }}
|
|
className="text-accent-primary hover:underline"
|
|
>
|
|
Log in
|
|
</button>
|
|
</p>
|
|
</div>
|
|
) : token ? (
|
|
/* Token exists but user still loading */
|
|
<button
|
|
disabled
|
|
className="w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Loading...
|
|
</button>
|
|
) : (
|
|
/* Unauthenticated user */
|
|
<div className="space-y-3">
|
|
<Link
|
|
to={`/login${redirectParam}`}
|
|
className="block w-full py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors text-center"
|
|
>
|
|
Log in to join
|
|
</Link>
|
|
<Link
|
|
to={`/register${redirectParam}`}
|
|
className="block w-full py-2.5 bg-surface-input hover:bg-surface-input/80 text-txt-primary font-medium rounded transition-colors text-center"
|
|
>
|
|
Create an account
|
|
</Link>
|
|
|
|
{/* Divider */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-border-subtle" />
|
|
<span className="text-xs text-txt-tertiary">or</span>
|
|
<div className="flex-1 h-px bg-border-subtle" />
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => { setPhase('other-instance'); setError(''); }}
|
|
className="block w-full py-2.5 bg-surface-input hover:bg-surface-input/80 text-txt-primary font-medium rounded transition-colors text-center"
|
|
>
|
|
I use another instance
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Phase: other-instance — domain input for federation redirect */}
|
|
{phase === 'other-instance' && (
|
|
<form onSubmit={handleOtherInstanceRedirect}>
|
|
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">
|
|
Your instance domain
|
|
</label>
|
|
<div className="flex gap-2 mb-1.5">
|
|
<input
|
|
type="text"
|
|
value={otherDomain}
|
|
onChange={(e) => setOtherDomain(e.target.value)}
|
|
placeholder="e.g. my-instance.com"
|
|
className="flex-1 px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!otherDomain.trim()}
|
|
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
|
>
|
|
Go
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-txt-tertiary mb-4">
|
|
You'll be redirected to your home instance to complete joining.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setPhase('preview'); setOtherDomain(''); setError(''); }}
|
|
className="px-4 py-2.5 text-txt-tertiary hover:text-txt-secondary text-sm transition-colors"
|
|
>
|
|
Back
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{/* Phase: connect — password prompt for federation */}
|
|
{phase === 'connect' && (
|
|
<form onSubmit={handleConnect}>
|
|
{/* Identity card */}
|
|
<div className="flex items-center gap-3 bg-surface-input rounded-lg p-3 mb-3">
|
|
<Avatar
|
|
src={user?.avatar || null}
|
|
name={user?.displayName || user?.username || '?'}
|
|
size={40}
|
|
avatarColor={user?.avatarColor}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-txt-primary font-medium text-sm truncate">{user?.displayName || user?.username}</p>
|
|
<p className="text-txt-tertiary text-xs truncate">@{user?.username}</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-txt-tertiary text-xs mb-4">
|
|
Connecting to <span className="text-txt-secondary font-medium">{hostDisplay}</span>
|
|
</p>
|
|
<div className="mb-4">
|
|
<label className="block text-xs text-txt-tertiary mb-1">
|
|
Enter your password to connect
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="Your account password"
|
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
|
disabled={isJoining}
|
|
autoFocus
|
|
/>
|
|
<p className="text-xs text-txt-tertiary mt-1">
|
|
Your password is verified locally, then used to create or access your account on the remote instance.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setPhase('preview'); setPassword(''); setError(''); }}
|
|
className="px-4 py-2.5 text-txt-tertiary hover:text-txt-secondary text-sm transition-colors"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isJoining || !password}
|
|
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isJoining ? 'Connecting...' : 'Connect & Join'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Phase: fallback — different password on remote */}
|
|
{phase === 'fallback' && (
|
|
<form onSubmit={handleFallbackLogin}>
|
|
<div className="mb-3 p-2 bg-accent-amber/10 border border-accent-amber/30 rounded text-xs text-accent-amber">
|
|
An account already exists on {hostDisplay} with a different password. Enter the credentials you used on that instance.
|
|
</div>
|
|
<div className="mb-4 space-y-3">
|
|
<div>
|
|
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
|
<input
|
|
type="text"
|
|
value={fallbackUsername}
|
|
onChange={(e) => setFallbackUsername(e.target.value)}
|
|
placeholder="Your username on this instance"
|
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
|
disabled={isJoining}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
|
|
<input
|
|
type="password"
|
|
value={fallbackPassword}
|
|
onChange={(e) => setFallbackPassword(e.target.value)}
|
|
placeholder="Password on the remote instance"
|
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
|
|
disabled={isJoining}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => { setPhase('connect'); setFallbackPassword(''); setError(''); }}
|
|
className="px-4 py-2.5 text-txt-tertiary hover:text-txt-secondary text-sm transition-colors"
|
|
>
|
|
Back
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isJoining || !fallbackUsername || !fallbackPassword}
|
|
className="flex-1 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isJoining ? 'Logging in...' : 'Login & Join'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|