From c3617de832f1704b292e3808e0b13b3523787f73 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:04:52 +0100 Subject: [PATCH] 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. --- packages/server/src/routes/spaces.ts | 27 ++ packages/shared/src/types.ts | 9 + packages/web/src/App.tsx | 18 +- packages/web/src/api/client.ts | 4 + packages/web/src/components/JoinPage.tsx | 410 ++++++++++++++++++ .../web/src/components/auth/LoginPage.tsx | 12 +- .../web/src/components/auth/RegisterPage.tsx | 12 +- .../web/src/components/layout/AppLayout.tsx | 9 +- .../web/src/components/modals/JoinSpace.tsx | 12 +- 9 files changed, 484 insertions(+), 29 deletions(-) create mode 100644 packages/web/src/components/JoinPage.tsx diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 8611ff1e..5ad31e77 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -1085,6 +1085,33 @@ export async function spaceRoutes(app: FastifyInstance): Promise { 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 diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 52e4bc61..64435542 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 019ca651..d5a68bce 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -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 ; + const [searchParams] = useSearchParams(); + const redirect = searchParams.get('redirect'); + if (token) { + if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) { + return ; + } + return ; + } return <>{children}; } @@ -46,11 +54,7 @@ export function App() { /> - - - } + element={} /> Promise<{ success: boolean }>; unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>; transferOwnership: (spaceId: string, newOwnerId: string) => Promise; + invitePreview: (code: string) => Promise; }; readonly channels: { @@ -303,6 +305,8 @@ export class BackspaceApiClient { request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`), transferOwnership: (spaceId: string, newOwnerId: string) => request('PATCH', `/spaces/${spaceId}/transfer-ownership`, { newOwnerId }), + invitePreview: (code: string) => + request('GET', `/spaces/invite/${encodeURIComponent(code)}/preview`, undefined, false), }; this.channels = { diff --git a/packages/web/src/components/JoinPage.tsx b/packages/web/src/components/JoinPage.tsx new file mode 100644 index 00000000..1238e368 --- /dev/null +++ b/packages/web/src/components/JoinPage.tsx @@ -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(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 { + 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 ( +
+
+
+ + + + +

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 ? ( + /* Authenticated user */ + + ) : ( + /* Unauthenticated user */ +
+ + Log in to join + + + Create an account + +
+ )} + + {/* Other instance section */} +
+ + I use Backspace on another instance + +
+ +
+ 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" + /> + +
+

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

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

+ Connect to {hostDisplay} to join this space. +

+
+ + 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 + /> +

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

+
+
+ + +
+
+ )} + + {/* 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="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} + /> +
+
+ + 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 + /> +
+
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/packages/web/src/components/auth/LoginPage.tsx b/packages/web/src/components/auth/LoginPage.tsx index c74c3edd..c240e591 100644 --- a/packages/web/src/components/auth/LoginPage.tsx +++ b/packages/web/src/components/auth/LoginPage.tsx @@ -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); - navigate('/channels/@me'); + 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() {

Need an account?{' '} - + Register

diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 5f220586..21ed97e1 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -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() { } } - navigate('/channels/@me'); + 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() {

Already have an account?{' '} - + Log In

diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 075df99c..9d2f29bc 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -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); diff --git a/packages/web/src/components/modals/JoinSpace.tsx b/packages/web/src/components/modals/JoinSpace.tsx index 2d69e8c4..577768d5 100644 --- a/packages/web/src/components/modals/JoinSpace.tsx +++ b/packages/web/src/components/modals/JoinSpace.tsx @@ -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);