From 0dae2177878c4bbd38e3dc79ca5584d31d3d49e0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:38:52 +0200 Subject: [PATCH] feat(web): RegisterPage invite-token URL pickup + manual entry + closed-state UX --- .../web/src/components/auth/RegisterPage.tsx | 186 +++++++++++++++++- 1 file changed, 183 insertions(+), 3 deletions(-) diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 9b0fc9ce..074457b0 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -5,7 +5,7 @@ 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 } from '@backspace/shared'; +import type { AvatarColor, CheckInviteResponse, InstanceInfoResponse } from '@backspace/shared'; import { api, RateLimitError } from '../../api/client'; type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid'; @@ -26,6 +26,15 @@ export function RegisterPage() { 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); + // Step 2 fields const [displayName, setDisplayName] = useState(''); const [avatarColor, setAvatarColor] = useState( @@ -44,6 +53,7 @@ export function RegisterPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const redirect = searchParams.get('redirect'); + const urlInviteToken = searchParams.get('invite'); // Cleanup blob URL on unmount useEffect(() => { @@ -52,6 +62,70 @@ export function RegisterPage() { }; }, [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 on mount + useEffect(() => { + if (!urlInviteToken) return; + let cancelled = false; + setInviteChecking(true); + api.auth.checkInvite(urlInviteToken) + .then((res) => { if (!cancelled) setInviteCheck(res); }) + .catch(() => { if (!cancelled) setInviteCheck({ valid: false, reason: 'invalid' }); }) + .finally(() => { if (!cancelled) setInviteChecking(false); }); + return () => { cancelled = true; }; + }, [urlInviteToken]); + + // Debounced manual-entry invite validation + useEffect(() => { + if (urlInviteToken) return; // URL token takes precedence while it is present + + 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=([A-Za-z0-9_-]{22})/); + if (urlMatch) token = urlMatch[1]!; + + let cancelled = false; + + if (inviteCheckTimerRef.current) clearTimeout(inviteCheckTimerRef.current); + + inviteCheckTimerRef.current = setTimeout(async () => { + setInviteChecking(true); + 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 @@ -128,6 +202,17 @@ export function RegisterPage() { 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(); @@ -192,10 +277,23 @@ export function RegisterPage() { const dn = skip ? undefined : displayName.trim() || undefined; const ac = skip ? undefined : avatarColor; + // Resolve the token to send: prefer URL token, fall back to manual entry + const tokenForRegister = (() => { + if (urlInviteToken) return urlInviteToken; + const trimmed = manualInviteToken.trim(); + if (!trimmed) return undefined; + const urlMatch = trimmed.match(/[?&]invite=([A-Za-z0-9_-]{22})/); + return urlMatch ? urlMatch[1] : trimmed; + })(); + // 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, + username: username.trim(), + password, + displayName: dn, + avatarColor: ac, + ...(tokenForRegister ? { inviteToken: tokenForRegister } : {}), }); localStorage.setItem('backspace_token', response.token); @@ -235,6 +333,13 @@ export function RegisterPage() { 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 (
@@ -258,6 +363,74 @@ export function RegisterPage() {
)} + {/* 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" + className="input-standard w-full px-3 py-2 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 whenever a token was provided in the URL */} + {urlInviteToken && ( +
+ {inviteChecking ? ( + <> + + + + + Validating invite... + + ) : inviteCheck?.valid === true ? ( + <> + + + + Using invite: {inviteCheck.name} + + ) : inviteCheck?.valid === false ? ( + <> + + + + Invalid invite link — please request a new one + + ) : ( + <>Validating invite... + )} +
+ )} +