Files
resolutionflow/frontend/src/pages/OAuthCallbackPage.tsx
Michael Chihlas 05646465b8
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 5m32s
CI / frontend (pull_request) Failing after 5m34s
CI / backend (pull_request) Successful in 10m19s
feat(routing): serve public landing at / and move authed index to /home
Stripe's compliance crawler fetches the apex URL without executing JS and
declined live-mode review when `https://resolutionflow.com/` returned the
empty SPA shell that redirected to /landing client-side. Restructure the
router so / serves LandingPage directly:

- `/` → new `PublicLanding` wrapper (LandingPage for anon; Navigate to
  /home for authed users so there's no marketing-frame flicker).
- Authed tree converted to a path-less layout route with absolute child
  paths. QuickStartPage moves to `/home`; all other children
  (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) keep their URLs.
- `/landing` kept as a one-release stale-bookmark redirect to /.
- `ProtectedRoute` unauth redirect flipped /landing → /; `state.from`
  preserved for post-login return.

Reference updates:
- Post-login / post-onboarding destinations → /home: OAuthCallbackPage
  (incl. `?welcome=teammate` query), WelcomeStep1/2/3 dismiss-rest,
  AssistantChatPage post-escalate, WelcomeRouter completion/dismiss
  redirects, VerifyEmailPage's three "Go to dashboard" links.
- Authed chrome → /home: TopBar logo, AppLayout mobile nav + drawer
  logo, CommandPalette Dashboard entry.
- Dashboard onboarding → /home: NextStepCard `ran_session.ctaPath`,
  SetupChecklist `ran_session.path`, SessionHistoryPage empty-state CTA.
- Public back-links → /: TermsPage, PrivacyPage, PoliciesPage,
  ContactPage, PromotionsPage, PublicTemplatesPage (header + footer).
  SharedSessionPage's `to="/"` left as-is — now correctly lands anon
  visitors on the public landing.

Crawlability:
- New `frontend/public/robots.txt` allowlisting public pages and
  disallowing the authed app.
- New `frontend/public/sitemap.xml` for /, /pricing, /contact-sales,
  /contact, /templates, /terms, /privacy, /policies, /promotions.
- `PageMeta` gains an `og:url` (defaults to `window.location.href`) and
  flips `twitter:card` to `summary_large_image` when an `ogImage` is
  passed.

Tests:
- `AppLayout.test.tsx` updated to mount at `/home`.
- New `ProtectedRoute.test.tsx` asserts unauthenticated `/home`
  redirects to `/` (not `/landing`) and preserves origin in `state.from`.

If Stripe's crawler still cannot see the site after this (zero-JS
crawler), the documented next escalation is server-side prerendering of
public routes via `vite-plugin-ssg`. Out of scope here.

Plan: docs/plans/2026-05-13-public-landing-routing-refactor.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:58:10 -04:00

199 lines
7.2 KiB
TypeScript

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<string | null>(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 /home.
let dest = '/home'
if (decoded?.accountInviteCode) {
dest = '/home?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<string, unknown>)
) {
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 (
<>
<PageMeta title="Signing you in" description="Completing OAuth sign-in" />
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="relative w-full max-w-md space-y-6 text-center">
<div className="flex justify-center">
<BrandLogo size="lg" />
</div>
{error ? (
<>
<h1 className="text-xl font-semibold text-foreground">
Sign-in failed
</h1>
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110"
>
Back to sign in
</button>
</>
) : (
<>
<h1 className="text-xl font-semibold text-foreground">
Signing you in
</h1>
<p className="text-sm text-muted-foreground">
Finishing up the {provider === 'microsoft' ? 'Microsoft' : 'Google'} sign-in flow.
</p>
</>
)}
</div>
</div>
</>
)
}
export default OAuthCallbackPage