Files
backspace/packages/web/src/components/JoinPage.tsx
T
Jannis Braun a4be895670 fix: JoinPage identity card + equal-weight other-instance option
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.
2026-03-13 03:26:32 +01:00

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>
);
}