import { useEffect, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { authApi } from '@/api/auth' import { useAuthStore } from '@/store/authStore' import { BrandLogo } from '@/components/common/BrandLogo' import { PageMeta } from '@/components/common/PageMeta' import { decodeOAuthState } from '@/lib/oauthState' type Provider = 'google' | 'microsoft' /** * Handles the OAuth redirect leg of the full-page Google / Microsoft sign-in * flow. Mounted at /auth/google/callback and /auth/microsoft/callback as * public routes (NOT inside ProtectedRoute). * * Reads `?code=...` from the URL, POSTs it to the backend, stores the * returned tokens, hydrates the auth store via fetchUser(), and redirects. * * Two state forms are supported: * - Legacy: `state` is a raw random hex string. CSRF check against * sessionStorage('rf-oauth-state'). * - /accept-invite: `state` is base64url(JSON({csrf, accountInviteCode, * invitedEmail})). The CSRF value is compared against * sessionStorage('rf-oauth-state'); the invite fields are forwarded to * the backend so the new user joins the invited account instead of * getting a personal one. */ export function OAuthCallbackPage() { const navigate = useNavigate() const location = useLocation() const { setTokens, fetchUser } = useAuthStore() const [error, setError] = useState(null) // Derive provider purely from URL pathname — routes are static // (/auth/google/callback and /auth/microsoft/callback), so there is // no `:provider` route param to read. const provider: Provider = location.pathname.includes('/microsoft/') ? 'microsoft' : 'google' useEffect(() => { const search = new URLSearchParams(location.search) const code = search.get('code') const oauthError = search.get('error') const returnedState = search.get('state') // CSRF: validate state round-trip against the value RegisterPage / // AcceptInvitePage stashed in sessionStorage before redirecting to the // provider. Always clear the stored value so a stale entry can't be // re-used by a later attempt. let storedState: string | null = null try { storedState = sessionStorage.getItem('rf-oauth-state') sessionStorage.removeItem('rf-oauth-state') } catch { // sessionStorage may be unavailable (private mode, etc.) — treat as missing. storedState = null } if (oauthError) { // eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state setError(`OAuth error: ${oauthError}`) return } if (!storedState || !returnedState) { setError('Invalid OAuth state — possible CSRF. Please try again.') return } // The decoded form encodes the original CSRF value; compare that. const decoded = decodeOAuthState(returnedState) const matchesCsrf = decoded ? decoded.csrf === storedState : returnedState === storedState if (!matchesCsrf) { setError('Invalid OAuth state — possible CSRF. Please try again.') return } if (!code) { setError('Missing authorization code') return } let cancelled = false void (async () => { try { const inviteOptions = decoded ? { accountInviteCode: decoded.accountInviteCode, invitedEmail: decoded.invitedEmail, } : undefined const result = provider === 'microsoft' ? await authApi.microsoftCallback(code, inviteOptions) : await authApi.googleCallback(code, inviteOptions) if (cancelled) return // Persist tokens for apiClient interceptor + zustand store. localStorage.setItem('access_token', result.access_token) localStorage.setItem('refresh_token', result.refresh_token) setTokens({ access_token: result.access_token, refresh_token: result.refresh_token, token_type: result.token_type || 'bearer', idle_expires_at: result.idle_expires_at, absolute_expires_at: result.absolute_expires_at, }) // Hydrate user / account / subscription. await fetchUser() if (cancelled) return // Invitee path lands on the dashboard with the teammate-welcome // marker; new self-serve owners go to the welcome wizard; returning // users to /. let dest = '/' if (decoded?.accountInviteCode) { dest = '/?welcome=teammate' } else if (result.is_new_user) { dest = '/welcome' } navigate(dest, { replace: true }) } catch (err: unknown) { if (cancelled) return const axiosErr = err as { response?: { data?: { detail?: unknown } } } const detail = axiosErr.response?.data?.detail // Backend returns { error: "invite_email_mismatch" } etc. let msg: string | null = null if (typeof detail === 'string') { msg = detail } else if ( detail && typeof detail === 'object' && 'error' in (detail as Record) ) { const code = (detail as { error: string }).error if (code === 'invite_email_mismatch') { msg = 'The email on your provider account does not match the invited email. ' + 'Sign in with the matching account, or ask your inviter to resend.' } else if (code === 'invite_invalid_or_expired_or_revoked') { msg = 'This invite is no longer valid. Ask your inviter to resend.' } else { msg = code } } msg = msg || (err instanceof Error ? err.message : 'Sign-in failed') setError(msg) } })() return () => { cancelled = true } }, [location.search, provider, setTokens, fetchUser, navigate]) return ( <>
{error ? ( <>

Sign-in failed

{error}

) : ( <>

Signing you in…

Finishing up the {provider === 'microsoft' ? 'Microsoft' : 'Google'} sign-in flow.

)}
) } export default OAuthCallbackPage