import React, { useState, useRef, useEffect } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuthStore } from '../../stores/authStore'; import { Avatar } from '../ui/Avatar'; import { ImageCropModal } from '../ui/ImageCropModal'; import { AVATAR_GRADIENT_MAP } from '../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@backspace/shared'; import { api, RateLimitError } from '../../api/client'; import { useTransferStore } from '../../stores/transferStore'; import { waitForTransferAttachment } from '../../utils/waitForTransfer'; import { SourceCodeLink } from '../ui/SourceCodeLink'; // Single-source regex for extracting a bare invite token from a pasted full URL. // Token format: 22 chars base64url ([A-Za-z0-9_-]). const INVITE_URL_REGEX = /[?&]invite=([A-Za-z0-9_-]{22})/; type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid'; export function RegisterPage() { // Step state const [step, setStep] = useState<1 | 2>(1); const [direction, setDirection] = useState<'forward' | 'back'>('forward'); // Step 1 fields const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); // Username availability check const [usernameStatus, setUsernameStatus] = useState('idle'); const [usernameStatusMessage, setUsernameStatusMessage] = useState(''); const usernameCheckTimerRef = useRef | null>(null); const usernameCheckAbortRef = useRef(null); // Instance info (for registration policy) const [instanceInfo, setInstanceInfo] = useState(null); // Invite token state const [manualInviteToken, setManualInviteToken] = useState(''); const [inviteCheck, setInviteCheck] = useState(null); const [inviteChecking, setInviteChecking] = useState(false); const inviteCheckTimerRef = useRef | null>(null); // Ref tracking whether the URL token has been confirmed invalid by the server. // Used as the gate for the manual-entry debounce so we don't need inviteCheck?.valid // in the manual-effect dep array (which would cause a dep loop via setInviteCheck). const urlTokenInvalidRef = useRef(false); // Step 2 fields const [displayName, setDisplayName] = useState(''); const [avatarColor, setAvatarColor] = useState( () => AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] ?? 'mint' ); const [avatarPreview, setAvatarPreview] = useState(null); const [avatarFile, setAvatarFile] = useState(null); const [avatarCropSrc, setAvatarCropSrc] = useState(null); const avatarInputRef = useRef(null); const [error, setError] = useState(''); const [isRegistering, setIsRegistering] = useState(false); const [retryAfter, setRetryAfter] = useState(0); const initSession = useAuthStore((s) => s.initSession); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const redirect = searchParams.get('redirect'); const urlInviteToken = searchParams.get('invite'); // Cleanup blob URL on unmount useEffect(() => { return () => { if (avatarPreview) URL.revokeObjectURL(avatarPreview); }; }, [avatarPreview]); // Fetch instance info to determine registration policy useEffect(() => { let cancelled = false; api.instance.info() .then((info) => { if (!cancelled) setInstanceInfo(info); }) .catch(() => { // Leave null — treat as open by default to avoid soft-locking the page. // Server-side validation (Task 11) is the real gate. }); return () => { cancelled = true; }; }, []); // Validate URL-supplied invite token — only fires when: // (a) a URL token is present, AND // (b) instanceInfo has loaded AND indicates registration is closed. // Per spec §4.4: when registration is open, the URL invite param is silently ignored // (no token consumption, no validation, no rate-limit slot burned). // When the result is invalid, urlTokenInvalidRef is set so the manual-entry // debounce effect can use it as a stable gate without introducing a dep loop. useEffect(() => { if (!urlInviteToken) return; if (!instanceInfo || instanceInfo.registrationOpen) return; let cancelled = false; urlTokenInvalidRef.current = false; setInviteChecking(true); api.auth.checkInvite(urlInviteToken) .then((res) => { if (!cancelled) { if (!res.valid) urlTokenInvalidRef.current = true; setInviteCheck(res); } }) .catch(() => { if (!cancelled) { urlTokenInvalidRef.current = true; setInviteCheck({ valid: false, reason: 'invalid' }); } }) .finally(() => { if (!cancelled) setInviteChecking(false); }); return () => { cancelled = true; }; }, [urlInviteToken, instanceInfo]); // Debounced manual-entry invite validation. // The URL token takes precedence while it is still in-flight or has been confirmed valid. // Once the URL token is confirmed invalid (urlTokenInvalidRef.current === true), this // effect fires on manual input changes. // // We intentionally do NOT include inviteCheck?.valid in the dep array — the URL-token // validation effect sets urlTokenInvalidRef synchronously when the server response arrives, // and the user's next keystroke in the manual field re-triggers this effect. This avoids // a dep-loop where setInviteCheck() inside this effect would mutate a dep and cause // infinite re-runs. useEffect(() => { if (urlInviteToken && !urlTokenInvalidRef.current) return; // URL token takes precedence while in flight or valid const trimmed = manualInviteToken.trim(); if (!trimmed) { setInviteCheck(null); setInviteChecking(false); return; } // Extract bare token if user pasted a full URL let token = trimmed; const urlMatch = trimmed.match(INVITE_URL_REGEX); if (urlMatch) token = urlMatch[1]!; let cancelled = false; if (inviteCheckTimerRef.current) clearTimeout(inviteCheckTimerRef.current); // Set checking=true immediately so the manual-entry row shows "Checking..." rather // than the stale URL-token failure state while the user is actively typing. setInviteChecking(true); inviteCheckTimerRef.current = setTimeout(async () => { if (cancelled) return; // Clear stale check result (e.g., prior URL-token invalid result) before the // fresh API response arrives so stale text never briefly flashes on completion. setInviteCheck(null); try { const res = await api.auth.checkInvite(token); if (cancelled) return; setInviteCheck(res); } catch { if (cancelled) return; setInviteCheck({ valid: false, reason: 'invalid' }); } finally { if (!cancelled) setInviteChecking(false); } }, 500); return () => { cancelled = true; if (inviteCheckTimerRef.current) clearTimeout(inviteCheckTimerRef.current); }; }, [manualInviteToken, urlInviteToken]); // Debounced username availability check useEffect(() => { // Clear previous timer and abort if (usernameCheckTimerRef.current) clearTimeout(usernameCheckTimerRef.current); if (usernameCheckAbortRef.current) usernameCheckAbortRef.current.abort(); const trimmed = username.trim(); if (trimmed.length === 0) { setUsernameStatus('idle'); setUsernameStatusMessage(''); return; } if (trimmed.length < 3 || trimmed.length > 32) { setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle'); setUsernameStatusMessage(trimmed.length > 0 ? 'Username must be between 3 and 32 characters' : ''); return; } if (!/^[a-z0-9_]+$/.test(trimmed)) { setUsernameStatus('invalid'); setUsernameStatusMessage('Username can only contain lowercase letters, numbers, and underscores'); return; } setUsernameStatus('checking'); setUsernameStatusMessage('Checking availability...'); usernameCheckTimerRef.current = setTimeout(async () => { const controller = new AbortController(); usernameCheckAbortRef.current = controller; try { const result = await api.auth.checkUsername(trimmed); if (controller.signal.aborted) return; if (result.reason) { setUsernameStatus('invalid'); setUsernameStatusMessage(result.reason); } else if (result.available) { setUsernameStatus('available'); setUsernameStatusMessage('Username is available'); } else { setUsernameStatus('taken'); setUsernameStatusMessage('Username is already taken'); } } catch { if (controller.signal.aborted) return; // Network error or rate limit — fall back to idle silently setUsernameStatus('idle'); setUsernameStatusMessage(''); } }, 500); return () => { if (usernameCheckTimerRef.current) clearTimeout(usernameCheckTimerRef.current); if (usernameCheckAbortRef.current) usernameCheckAbortRef.current.abort(); }; }, [username]); // Countdown timer useEffect(() => { if (retryAfter <= 0) return; const timer = setInterval(() => { setRetryAfter((prev) => { if (prev <= 1) { clearInterval(timer); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(timer); }, [retryAfter]); // ── Invite requirements ── const inviteRequired = instanceInfo !== null && !instanceInfo.registrationOpen; const inviteValid = inviteRequired ? inviteCheck?.valid === true : true; // Show the manual-entry container when: // - Registration is closed AND // - There is no URL token, OR the URL token has already been validated as invalid const showManualEntry = instanceInfo !== null && !instanceInfo.registrationOpen && (!urlInviteToken || inviteCheck?.valid === false); // ── Step 1 validation ── const handleContinue = (e: React.FormEvent) => { e.preventDefault(); setError(''); const trimmed = username.trim(); if (!trimmed) { setError('Username is required'); return; } if (trimmed.length < 3 || trimmed.length > 32) { setError('Username must be between 3 and 32 characters'); return; } if (!/^[a-z0-9_]+$/.test(trimmed)) { setError('Username can only contain lowercase letters, numbers, and underscores'); return; } if (usernameStatus === 'taken' || usernameStatus === 'invalid') { return; } if (!password) { setError('Password is required'); return; } if (password.length < 6) { setError('Password must be at least 6 characters'); return; } if (password !== confirmPassword) { setError('Passwords do not match'); return; } setDirection('forward'); setStep(2); }; // ── Avatar file selection ── const handleAvatarSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => setAvatarCropSrc(reader.result as string); reader.readAsDataURL(file); if (avatarInputRef.current) avatarInputRef.current.value = ''; }; const handleAvatarCropComplete = (blob: Blob) => { if (avatarPreview) URL.revokeObjectURL(avatarPreview); const previewUrl = URL.createObjectURL(blob); setAvatarPreview(previewUrl); setAvatarFile(new File([blob], 'avatar.png', { type: 'image/png' })); setAvatarCropSrc(null); }; // ── Registration ── const handleRegister = async (skip: boolean) => { setError(''); setIsRegistering(true); try { const dn = skip ? undefined : displayName.trim() || undefined; const ac = skip ? undefined : avatarColor; // Resolve the token to send. // Per spec §4.4 / §3: open-registration instances silently ignore invite tokens — // sending one would be harmless (server ignores it) but it's cleaner to omit it // client-side so nothing unexpected is on the wire. const tokenForRegister = (() => { if (!instanceInfo || instanceInfo.registrationOpen) { return undefined; } // URL token takes precedence ONLY while it hasn't been confirmed invalid. const urlInvalid = !!urlInviteToken && inviteCheck?.valid === false; if (urlInviteToken && !urlInvalid) return urlInviteToken; const trimmed = manualInviteToken.trim(); if (trimmed) { const urlMatch = trimmed.match(INVITE_URL_REGEX); return urlMatch ? urlMatch[1] : trimmed; } // Manual empty AND URL token was confirmed invalid — send URL token so the // server returns the authoritative error message rather than "invite required". return urlInviteToken ?? undefined; })(); // Step 1: Register via API — store token in localStorage for API auth, // but NOT in Zustand yet so AuthRedirect doesn't fire prematurely const response = await api.auth.register({ username: username.trim(), password, displayName: dn, avatarColor: ac, ...(tokenForRegister ? { inviteToken: tokenForRegister } : {}), }); localStorage.setItem('backspace_token', response.token); // Step 2: Upload avatar while still on the register page let finalUser = response.user; if (!skip && avatarFile) { try { const tid = await useTransferStore.getState().startUpload(avatarFile, { tray: false }); const { filename } = await waitForTransferAttachment(tid); finalUser = await api.users.update({ avatar: filename }); } catch { // Avatar upload failed — user can set it later in settings } } // Step 3: Activate session — sets Zustand token, triggers AuthRedirect initSession(response.token, finalUser); if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) { navigate(redirect); } else { navigate('/channels/@me'); } } catch (err) { if (err instanceof RateLimitError) { setRetryAfter(err.retryAfter); setError(''); } else { setError(err instanceof Error ? err.message : 'Registration failed'); } setIsRegistering(false); } }; const effectiveDisplayName = displayName.trim() || username.trim(); const initial = effectiveDisplayName.charAt(0).toUpperCase(); const gradient = AVATAR_GRADIENT_MAP[avatarColor]; const isDisabled = isRegistering || retryAfter > 0; // Continue button is blocked while username is invalid/taken OR when an invite is required // but not yet validated as valid const continueDisabled = usernameStatus === 'taken' || usernameStatus === 'invalid' || (inviteRequired && !inviteValid); return ( // Outer scroll container — root is `h-full overflow-hidden`, so this page must own // its own scroll. Without it, mobile users with the keyboard up cannot reach the // submit button on Step 2 (avatar + color picker + display name + buttons exceeds // the visible viewport once the iOS keyboard claims ~300 px). `h-full` (rather than // `min-h-full`) makes this element exactly viewport-height; the inner flex wrapper // uses `min-h-full` so short content still centers vertically.
{/* Progress dots */}
{step === 1 ? (

Create an account

{error && (
{error}
)} {/* Closed-registration invite entry — shown when registration is closed and: (a) no URL token is present, or (b) the URL token has already failed validation */} {showManualEntry && (
Registration is invite-only on this instance. Paste your invite link or enter the code below.
setManualInviteToken(e.target.value)} placeholder="Invite code or link" // text-base on mobile prevents iOS Safari from auto-zooming // when the field is focused (any with font-size <16px triggers zoom). className="input-standard w-full px-3 py-2 text-base md:text-sm" aria-label="Invite code or link" autoComplete="off" /> {inviteChecking && (
Checking...
)} {!inviteChecking && inviteCheck?.valid === true && (
Valid invite: {inviteCheck.name}
)} {!inviteChecking && inviteCheck?.valid === false && (
{inviteCheck.reason === 'expired' && 'This invite link has expired. Ask the admin for a new one.'} {inviteCheck.reason === 'exhausted' && 'This invite has reached its usage limit. Ask the admin to extend it.'} {inviteCheck.reason === 'invalid' && 'Invalid invite code.'}
)}
)} {/* URL-token chip — shown only when registration is closed AND a URL token was provided. Per spec §4.4: open-registration instances silently ignore the ?invite= param. Layout: inline pill on desktop, full-width banner on mobile so the longer error copy ("Invalid invite link — please request a new one") wraps cleanly inside a 360 px viewport instead of forcing a single-line pill that overflows. */} {urlInviteToken && instanceInfo && !instanceInfo.registrationOpen && (
{inviteChecking ? ( <> Validating invite... ) : inviteCheck?.valid === true ? ( <> Using invite: {inviteCheck.name} ) : inviteCheck?.valid === false ? ( <> Invalid invite link — please request a new one ) : ( <>Validating invite... )}
)}
setUsername(e.target.value.toLowerCase())} // text-base on mobile prevents iOS Safari zoom-on-focus (<16px triggers it). className="input-standard w-full py-2.5 text-base md:text-sm" autoFocus autoComplete="username" /> {usernameStatus !== 'idle' && (
{usernameStatus === 'checking' && ( )} {usernameStatus === 'available' && ( )} {(usernameStatus === 'taken' || usernameStatus === 'invalid') && ( )} {usernameStatusMessage}
)}
setPassword(e.target.value)} className="input-standard w-full py-2.5 text-base md:text-sm" autoComplete="new-password" />
setConfirmPassword(e.target.value)} className="input-standard w-full py-2.5 text-base md:text-sm" autoComplete="new-password" />
{/* Helper text when invite is required but not yet entered */} {inviteRequired && !manualInviteToken.trim() && !urlInviteToken && (
An invite is required to register on this instance.
)}

Already have an account?{' '} Log In

) : (

Make it yours

Personalize your profile, or skip for now

{retryAfter > 0 && (

Too many attempts

Try again in {retryAfter}s

)} {error && (
{error}
)} {/* Avatar preview */}
{/* Display Name */}
setDisplayName(e.target.value)} placeholder={username.trim() || 'Display name'} className="input-standard w-full py-2.5 text-base md:text-sm" autoComplete="name" />
{/* Avatar Color Picker */}
{/* Color swatch row: gap tightens on narrow viewports so the 7 swatches fit inside a 360 px viewport (p-6 inner content area is ~280 px; 7×32 + 6×10 = 284 px would overflow with gap-2.5). */}
{AVATAR_COLORS.map((key) => { const entry = AVATAR_GRADIENT_MAP[key]; return (
{/* Actions */}
)} {/* AGPL § 13: source offer for anonymous visitors, shown on both steps. */} {instanceInfo && (
)}
{/* Image Crop Modal */} {avatarCropSrc && ( setAvatarCropSrc(null)} imageSrc={avatarCropSrc} onCropComplete={handleAvatarCropComplete} title="Crop Avatar" aspectRatio={1} cropShape="round" maxOutputDimension={256} /> )}
); }