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:
@@ -1085,6 +1085,33 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send(spaceData);
|
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 ───────────────────────────────────────────────────────
|
// ─── Ban Management ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// GET /api/spaces/:id/bans - List bans
|
// GET /api/spaces/:id/bans - List bans
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ export interface Space {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvitePreview {
|
||||||
|
spaceName: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
avatarColor: AvatarColor | null;
|
||||||
|
memberCount: number;
|
||||||
|
instanceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExploreSpace {
|
export interface ExploreSpace {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
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 { LoginPage } from './components/auth/LoginPage';
|
||||||
import { RegisterPage } from './components/auth/RegisterPage';
|
import { RegisterPage } from './components/auth/RegisterPage';
|
||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
|
import { JoinPage } from './components/JoinPage';
|
||||||
import { useAuthStore } from './stores/authStore';
|
import { useAuthStore } from './stores/authStore';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -13,7 +14,14 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function AuthRedirect({ children }: { children: React.ReactNode }) {
|
function AuthRedirect({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
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}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +54,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/join/:inviteCode"
|
path="/join/:inviteCode"
|
||||||
element={
|
element={<JoinPage />}
|
||||||
<ProtectedRoute>
|
|
||||||
<AppLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/explore"
|
path="/explore"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
Role,
|
Role,
|
||||||
SpaceLayoutItem,
|
SpaceLayoutItem,
|
||||||
SpaceFolder,
|
SpaceFolder,
|
||||||
|
InvitePreview,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
|
||||||
export class RateLimitError extends Error {
|
export class RateLimitError extends Error {
|
||||||
@@ -86,6 +87,7 @@ export class BackspaceApiClient {
|
|||||||
ban: (spaceId: string, userId: string, reason?: string) => Promise<{ success: boolean }>;
|
ban: (spaceId: string, userId: string, reason?: string) => Promise<{ success: boolean }>;
|
||||||
unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>;
|
unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>;
|
||||||
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<Space>;
|
transferOwnership: (spaceId: string, newOwnerId: string) => Promise<Space>;
|
||||||
|
invitePreview: (code: string) => Promise<InvitePreview>;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly channels: {
|
readonly channels: {
|
||||||
@@ -303,6 +305,8 @@ export class BackspaceApiClient {
|
|||||||
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`),
|
request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`),
|
||||||
transferOwnership: (spaceId: string, newOwnerId: string) =>
|
transferOwnership: (spaceId: string, newOwnerId: string) =>
|
||||||
request<Space>('PATCH', `/spaces/${spaceId}/transfer-ownership`, { newOwnerId }),
|
request<Space>('PATCH', `/spaces/${spaceId}/transfer-ownership`, { newOwnerId }),
|
||||||
|
invitePreview: (code: string) =>
|
||||||
|
request<InvitePreview>('GET', `/spaces/invite/${encodeURIComponent(code)}/preview`, undefined, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.channels = {
|
this.channels = {
|
||||||
|
|||||||
@@ -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 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 { useAuthStore } from '../../stores/authStore';
|
||||||
import { RateLimitError } from '../../api/client';
|
import { RateLimitError } from '../../api/client';
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ export function LoginPage() {
|
|||||||
const login = useAuthStore((s) => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const redirect = searchParams.get('redirect');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (retryAfter <= 0) return;
|
if (retryAfter <= 0) return;
|
||||||
@@ -41,7 +43,11 @@ export function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(username.trim(), password);
|
await login(username.trim(), password);
|
||||||
navigate('/channels/@me');
|
if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||||
|
navigate(redirect);
|
||||||
|
} else {
|
||||||
|
navigate('/channels/@me');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof RateLimitError) {
|
if (err instanceof RateLimitError) {
|
||||||
setRetryAfter(err.retryAfter);
|
setRetryAfter(err.retryAfter);
|
||||||
@@ -118,7 +124,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
<p className="mt-3 text-sm text-txt-tertiary">
|
<p className="mt-3 text-sm text-txt-tertiary">
|
||||||
Need an account?{' '}
|
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
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
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 { useAuthStore } from '../../stores/authStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { ImageCropModal } from '../ui/ImageCropModal';
|
import { ImageCropModal } from '../ui/ImageCropModal';
|
||||||
@@ -43,6 +43,8 @@ export function RegisterPage() {
|
|||||||
const register = useAuthStore((s) => s.register);
|
const register = useAuthStore((s) => s.register);
|
||||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const redirect = searchParams.get('redirect');
|
||||||
|
|
||||||
// Cleanup blob URL on unmount
|
// Cleanup blob URL on unmount
|
||||||
useEffect(() => {
|
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) {
|
} catch (err) {
|
||||||
if (err instanceof RateLimitError) {
|
if (err instanceof RateLimitError) {
|
||||||
setRetryAfter(err.retryAfter);
|
setRetryAfter(err.retryAfter);
|
||||||
@@ -318,7 +324,7 @@ export function RegisterPage() {
|
|||||||
|
|
||||||
<p className="mt-3 text-sm text-txt-tertiary">
|
<p className="mt-3 text-sm text-txt-tertiary">
|
||||||
Already have an account?{' '}
|
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
|
Log In
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
|
|
||||||
export function AppLayout() {
|
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();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Global interaction handler to resume AudioContext
|
// Global interaction handler to resume AudioContext
|
||||||
@@ -91,7 +91,6 @@ export function AppLayout() {
|
|||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||||
const setShowDms = useUIStore((s) => s.setShowDms);
|
const setShowDms = useUIStore((s) => s.setShowDms);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
|
||||||
|
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||||
const isMobile = useUIStore((s) => s.isMobile);
|
const isMobile = useUIStore((s) => s.isMobile);
|
||||||
@@ -200,12 +199,6 @@ export function AppLayout() {
|
|||||||
}
|
}
|
||||||
}, [spaceId, setCurrentSpace, loadSpaceDetail, setShowDms]);
|
}, [spaceId, setCurrentSpace, loadSpaceDetail, setShowDms]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (inviteCode) {
|
|
||||||
openModal('joinSpace');
|
|
||||||
}
|
|
||||||
}, [inviteCode, openModal]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (channelId) {
|
if (channelId) {
|
||||||
setCurrentChannel(channelId);
|
setCurrentChannel(channelId);
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import { useUIStore } from '../../stores/uiStore';
|
|||||||
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
|
import { useSpaceStore, NotConnectedError } from '../../stores/spaceStore';
|
||||||
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { parseInviteInput } from '../../utils/inviteParser';
|
import { parseInviteInput } from '../../utils/inviteParser';
|
||||||
|
|
||||||
type JoinPhase = 'input' | 'connect' | 'fallback';
|
type JoinPhase = 'input' | 'connect' | 'fallback';
|
||||||
|
|
||||||
export function JoinSpaceModal() {
|
export function JoinSpaceModal() {
|
||||||
const { inviteCode: urlInviteCode } = useParams<{ inviteCode?: string }>();
|
|
||||||
const [inviteCode, setInviteCode] = useState('');
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -31,12 +30,9 @@ export function JoinSpaceModal() {
|
|||||||
|
|
||||||
const isOpen = activeModal === 'joinSpace';
|
const isOpen = activeModal === 'joinSpace';
|
||||||
|
|
||||||
// Pre-fill from URL param and reset state on open/close
|
// Reset state on close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (!isOpen) {
|
||||||
if (urlInviteCode) setInviteCode(urlInviteCode);
|
|
||||||
} else {
|
|
||||||
// Reset all state when modal closes
|
|
||||||
setInviteCode('');
|
setInviteCode('');
|
||||||
setError('');
|
setError('');
|
||||||
setPhase('input');
|
setPhase('input');
|
||||||
@@ -46,7 +42,7 @@ export function JoinSpaceModal() {
|
|||||||
setFallbackUsername('');
|
setFallbackUsername('');
|
||||||
setFallbackPassword('');
|
setFallbackPassword('');
|
||||||
}
|
}
|
||||||
}, [isOpen, urlInviteCode]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const joinAndNavigate = async (code: string, origin?: string) => {
|
const joinAndNavigate = async (code: string, origin?: string) => {
|
||||||
const space = await joinByCode(code, origin || undefined);
|
const space = await joinByCode(code, origin || undefined);
|
||||||
|
|||||||
Reference in New Issue
Block a user