feat: public invite landing page with federation redirect

Make /join/:code a public route with a standalone JoinPage that shows a
space preview and handles authenticated, unauthenticated, and cross-instance
users. Adds GET /api/spaces/invite/:code/preview (no auth) endpoint,
?redirect= param support on login/register, and cleans up dead invite
handling from AppLayout and JoinSpace modal.
This commit is contained in:
Jannis Braun
2026-03-13 03:04:52 +01:00
parent 07ef49eac0
commit c3617de832
9 changed files with 484 additions and 29 deletions
+27
View File
@@ -1085,6 +1085,33 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(spaceData);
});
// GET /api/spaces/invite/:code/preview — Public invite preview (no auth)
app.get<{ Params: { code: string } }>('/api/spaces/invite/:code/preview', async (request, reply) => {
const { code } = request.params;
const db = getDb();
const space = db.select().from(schema.spaces).where(eq(schema.spaces.inviteCode, code)).get();
if (!space) {
return reply.code(404).send({ error: 'Invalid invite code', statusCode: 404 });
}
const memberCount = db.select().from(schema.spaceMembers)
.where(eq(schema.spaceMembers.spaceId, space.id))
.all().length;
const settings = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
const instanceName = settings?.instanceName ?? 'Backspace';
return reply.code(200).send({
spaceName: space.name,
description: space.description ?? null,
icon: space.icon ?? null,
avatarColor: space.avatarColor ?? null,
memberCount,
instanceName,
});
});
// ─── Ban Management ───────────────────────────────────────────────────────
// GET /api/spaces/:id/bans - List bans
+9
View File
@@ -52,6 +52,15 @@ export interface Space {
createdAt: number;
}
export interface InvitePreview {
spaceName: string;
description: string | null;
icon: string | null;
avatarColor: AvatarColor | null;
memberCount: number;
instanceName: string;
}
export interface ExploreSpace {
id: string;
name: string;
+11 -7
View File
@@ -1,8 +1,9 @@
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Routes, Route, Navigate, useSearchParams } from 'react-router-dom';
import { LoginPage } from './components/auth/LoginPage';
import { RegisterPage } from './components/auth/RegisterPage';
import { AppLayout } from './components/layout/AppLayout';
import { JoinPage } from './components/JoinPage';
import { useAuthStore } from './stores/authStore';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -13,7 +14,14 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
function AuthRedirect({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (token) return <Navigate to="/channels/@me" replace />;
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
if (token) {
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
return <Navigate to={redirect} replace />;
}
return <Navigate to="/channels/@me" replace />;
}
return <>{children}</>;
}
@@ -46,11 +54,7 @@ export function App() {
/>
<Route
path="/join/:inviteCode"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
element={<JoinPage />}
/>
<Route
path="/explore"
+4
View File
@@ -38,6 +38,7 @@ import type {
Role,
SpaceLayoutItem,
SpaceFolder,
InvitePreview,
} from '@backspace/shared';
export class RateLimitError extends Error {
@@ -86,6 +87,7 @@ export class BackspaceApiClient {
ban: (spaceId: string, userId: string, reason?: string) => Promise<{ success: boolean }>;
unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>;
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<Space>;
invitePreview: (code: string) => Promise<InvitePreview>;
};
readonly channels: {
@@ -303,6 +305,8 @@ export class BackspaceApiClient {
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`),
transferOwnership: (spaceId: string, newOwnerId: string) =>
request<Space>('PATCH', `/spaces/${spaceId}/transfer-ownership`, { newOwnerId }),
invitePreview: (code: string) =>
request<InvitePreview>('GET', `/spaces/invite/${encodeURIComponent(code)}/preview`, undefined, false),
};
this.channels = {
+410
View File
@@ -0,0 +1,410 @@
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 joinByCode = useSpaceStore((s) => s.joinByCode);
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
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}
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 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>
</div>
)}
{/* Other instance section */}
<details className="mt-5 group">
<summary className="text-xs text-txt-tertiary hover:text-txt-secondary cursor-pointer select-none transition-colors">
I use Backspace on another instance
</summary>
<form onSubmit={handleOtherInstanceRedirect} className="mt-3">
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">
Your instance domain
</label>
<div className="flex gap-2">
<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"
/>
<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 mt-1.5">
You'll be redirected to your home instance to complete joining.
</p>
</form>
</details>
</>
)}
{/* Phase: connect — password prompt for federation */}
{phase === 'connect' && (
<form onSubmit={handleConnect}>
<p className="text-txt-secondary text-sm mb-4">
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
</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>
);
}
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { RateLimitError } from '../../api/client';
@@ -11,6 +11,8 @@ export function LoginPage() {
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
useEffect(() => {
if (retryAfter <= 0) return;
@@ -41,7 +43,11 @@ export function LoginPage() {
try {
await login(username.trim(), password);
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
navigate(redirect);
} else {
navigate('/channels/@me');
}
} catch (err) {
if (err instanceof RateLimitError) {
setRetryAfter(err.retryAfter);
@@ -118,7 +124,7 @@ export function LoginPage() {
<p className="mt-3 text-sm text-txt-tertiary">
Need an account?{' '}
<Link to="/register" className="text-accent-primary hover:underline">
<Link to={`/register${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
Register
</Link>
</p>
@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { ImageCropModal } from '../ui/ImageCropModal';
@@ -43,6 +43,8 @@ export function RegisterPage() {
const register = useAuthStore((s) => s.register);
const updateProfile = useAuthStore((s) => s.updateProfile);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirect = searchParams.get('redirect');
// Cleanup blob URL on unmount
useEffect(() => {
@@ -202,7 +204,11 @@ export function RegisterPage() {
}
}
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
navigate(redirect);
} else {
navigate('/channels/@me');
}
} catch (err) {
if (err instanceof RateLimitError) {
setRetryAfter(err.retryAfter);
@@ -318,7 +324,7 @@ export function RegisterPage() {
<p className="mt-3 text-sm text-txt-tertiary">
Already have an account?{' '}
<Link to="/login" className="text-accent-primary hover:underline">
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
Log In
</Link>
</p>
@@ -34,7 +34,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { AudioManager } from '../../audio/AudioManager';
export function AppLayout() {
const { spaceId, channelId, inviteCode } = useParams<{ spaceId?: string; channelId?: string; inviteCode?: string }>();
const { spaceId, channelId } = useParams<{ spaceId?: string; channelId?: string }>();
const navigate = useNavigate();
// Global interaction handler to resume AudioContext
@@ -91,7 +91,6 @@ export function AppLayout() {
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
@@ -200,12 +199,6 @@ export function AppLayout() {
}
}, [spaceId, setCurrentSpace, loadSpaceDetail, setShowDms]);
useEffect(() => {
if (inviteCode) {
openModal('joinSpace');
}
}, [inviteCode, openModal]);
useEffect(() => {
if (channelId) {
setCurrentChannel(channelId);
@@ -4,13 +4,12 @@ import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { parseInviteInput } from '../../utils/inviteParser';
type JoinPhase = 'input' | 'connect' | 'fallback';
export function JoinSpaceModal() {
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -31,12 +30,9 @@ export function JoinSpaceModal() {
const isOpen = activeModal === 'joinSpace';
// Pre-fill from URL param and reset state on open/close
// Reset state on close
useEffect(() => {
if (isOpen) {
if (urlInviteCode) setInviteCode(urlInviteCode);
} else {
// Reset all state when modal closes
if (!isOpen) {
setInviteCode('');
setError('');
setPhase('input');
@@ -46,7 +42,7 @@ export function JoinSpaceModal() {
setFallbackUsername('');
setFallbackPassword('');
}
}, [isOpen, urlInviteCode]);
}, [isOpen]);
const joinAndNavigate = async (code: string, origin?: string) => {
const space = await joinByCode(code, origin || undefined);