import { useEffect, useMemo, useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import { inviteApi, type AccountInviteLookup } from '@/api/invite' import { useAuthStore } from '@/store/authStore' import { useAppConfig } from '@/hooks/useAppConfig' import { BrandLogo } from '@/components/common/BrandLogo' import { PasswordInput } from '@/components/common/PasswordInput' import { PageMeta } from '@/components/common/PageMeta' import { buildOAuthAuthorizeUrl } from './RegisterPage' import { cn } from '@/lib/utils' import { encodeOAuthState } from '@/lib/oauthState' function randomCsrf(): string { const buf = new Uint8Array(16) if (typeof crypto !== 'undefined' && crypto.getRandomValues) { crypto.getRandomValues(buf) } else { for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256) } return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('') } type LookupState = | { status: 'loading' } | { status: 'ok'; data: AccountInviteLookup } | { status: 'invalid' } | { status: 'missing-code' } export function AcceptInvitePage() { const navigate = useNavigate() const location = useLocation() const { register, isLoading, error, clearError } = useAuthStore() const appConfig = useAppConfig() const code = useMemo(() => { const search = new URLSearchParams(location.search) return (search.get('code') || '').trim() }, [location.search]) const [lookup, setLookup] = useState( code ? { status: 'loading' } : { status: 'missing-code' }, ) const [name, setName] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [localError, setLocalError] = useState('') useEffect(() => { if (!code) { // eslint-disable-next-line react-hooks/set-state-in-effect -- route changes without a code should replace stale lookup state setLookup({ status: 'missing-code' }) return } let cancelled = false setLookup({ status: 'loading' }) void (async () => { try { const data = await inviteApi.lookupAccountInvite(code) if (cancelled) return setLookup({ status: 'ok', data }) } catch { if (cancelled) return // Any error — 404, 410, network — collapses to the same "ask the // inviter to resend" UX. Anti-enumeration is enforced server-side. setLookup({ status: 'invalid' }) } })() return () => { cancelled = true } }, [code]) const googleAvailable = appConfig.oauth_providers.includes('google') const microsoftAvailable = appConfig.oauth_providers.includes('microsoft') const handleOAuth = (provider: 'google' | 'microsoft') => { if (lookup.status !== 'ok') return const csrf = randomCsrf() try { sessionStorage.setItem('rf-oauth-state', csrf) } catch { // ignore — non-fatal } const stateValue = encodeOAuthState({ csrf, accountInviteCode: code, invitedEmail: lookup.data.invited_email, }) const url = buildOAuthAuthorizeUrl(provider, stateValue) window.location.href = url } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLocalError('') clearError() if (lookup.status !== 'ok') return if (!name || !password) { setLocalError('Please fill in all fields') return } if (password !== confirmPassword) { setLocalError('Passwords do not match') return } if (password.length < 10) { setLocalError('Password must be at least 10 characters') return } try { await register({ email: lookup.data.invited_email, password, name, account_invite_code: code, }) // Invitees skip the welcome wizard — they're joining an existing shop. // The `?welcome=teammate` marker is decoded by the dashboard in Task 41 // to surface the "Welcome to {account_name}" toast and pre-checked // checklist items. navigate('/?welcome=teammate', { replace: true }) } catch { // Error is set in the store } } return ( <>

ResolutionFlow

{lookup.status === 'loading' && (

Loading invite…

)} {(lookup.status === 'invalid' || lookup.status === 'missing-code') && (

This invite is no longer valid

{lookup.status === 'missing-code' ? 'The invite link is missing its code.' : 'This invite has expired, been used, or been revoked.'}{' '} Ask the person who invited you to resend it.

Email your inviter

Already have an account?{' '} Sign in

)} {lookup.status === 'ok' && ( <>

Join {lookup.data.account_name} on ResolutionFlow

{lookup.data.inviter_name} invited you as {lookup.data.role}.

{(error || localError) && (
{localError || error}
)}

Joining as

{lookup.data.invited_email}

The invite is locked to this email address.

{(googleAvailable || microsoftAvailable) && (
{googleAvailable && ( )} {microsoftAvailable && ( )}
or set a password
)}
setName(e.target.value)} className={cn( 'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20', )} placeholder="Jane Doe" />
setPassword(e.target.value)} className={cn( 'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20', )} placeholder="••••••••••" />

Must be at least 10 characters

setConfirmPassword(e.target.value)} className={cn( 'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20', )} placeholder="••••••••••" />
)}

Already have an account?{' '} Sign in

) } export default AcceptInvitePage