Add avatar/name/username identity card in the federation connect phase so users can confirm their account before entering their password. Promote "I use another instance" from a hidden <details> collapsible to an equal-weight button with an "or" divider, using a dedicated 'other-instance' phase with Back navigation.
454 lines
18 KiB
TypeScript
454 lines
18 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 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}` : `/api/uploads/${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 ? (
|
|
/* Authenticated user */
|
|
<button
|
|
onClick={handleJoin}
|
|
disabled={isJoining || isAuthLoading}
|
|
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"
|
|
>
|
|
{isAuthLoading ? 'Loading...' : isJoining ? 'Joining...' : 'Join Space'}
|
|
</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 ? `/api/uploads/${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>
|
|
);
|
|
}
|