feat(auth): redesign /register with OAuth buttons; hide invite-code under flag

Phase 2 Task 35. Adds OAuth Google/Microsoft sign-in to the register flow,
gated on the public SELF_SERVE_ENABLED flag, and hides the legacy invite-code
field when self-serve is on.

- New `useAppConfig` hook + `configApi`. One-shot module-cached fetch of
  `GET /api/v1/config/public`; falls back to `VITE_SELF_SERVE_ENABLED` env
  var (default false) if the endpoint is unreachable.
- New `OAuthCallbackPage` mounted at `/auth/google/callback` and
  `/auth/microsoft/callback` (public, NOT inside ProtectedRoute). Posts the
  authorization code to the backend, persists tokens, hydrates the auth
  store via fetchUser, and redirects to `/welcome` (new) or `/` (returning).
- `RegisterPage` now renders OAuth buttons + email/password divider when
  `self_serve_enabled` is true and only emits buttons for providers the
  backend reports as configured. Invite-code field hidden in that mode.
  Captures `?plan=pro` into `localStorage.rf-intended-plan` on mount.
- `authApi` gains `googleCallback(code)` / `microsoftCallback(code)`.
- `frontend/.env.example` + `frontend/Dockerfile` document and bake the
  three new VITE_* build-time variables (Lesson 60: Vite needs ARG+ENV).
- Vitest coverage for the three required cases plus the plan-param capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 21:11:09 -04:00
parent ece82225f2
commit 70ab1f34d4
10 changed files with 845 additions and 160 deletions

View File

@@ -3,3 +3,21 @@ VITE_API_URL=http://localhost:8000
# Sentry error monitoring (optional in dev, required in production) # Sentry error monitoring (optional in dev, required in production)
VITE_SENTRY_DSN= VITE_SENTRY_DSN=
# Stripe publishable key (same pk_test_/pk_live_ value as backend STRIPE_PUBLISHABLE_KEY).
# Vite bakes this at build time, so prod requires ARG+ENV in frontend/Dockerfile (Lesson 60).
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_
# OAuth client IDs — must match backend GOOGLE_CLIENT_ID / MS_CLIENT_ID.
# Public values; Vite bakes at build time so prod requires ARG+ENV in frontend/Dockerfile.
VITE_GOOGLE_CLIENT_ID=
VITE_MS_CLIENT_ID=
# Origin used to build OAuth redirect_uri (e.g. http://localhost:5173 or https://app.example.com).
# Must equal backend OAUTH_REDIRECT_BASE so callback paths align. If unset, the
# frontend falls back to window.location.origin at click time.
VITE_OAUTH_REDIRECT_BASE=
# Self-serve signup safety fallback used by useAppConfig when GET /config/public
# is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED.
VITE_SELF_SERVE_ENABLED=false

View File

@@ -17,10 +17,20 @@ ARG VITE_API_URL
ARG VITE_SENTRY_DSN ARG VITE_SENTRY_DSN
ARG VITE_PUBLIC_POSTHOG_KEY ARG VITE_PUBLIC_POSTHOG_KEY
ARG VITE_PUBLIC_POSTHOG_HOST ARG VITE_PUBLIC_POSTHOG_HOST
ARG VITE_STRIPE_PUBLISHABLE_KEY
ARG VITE_GOOGLE_CLIENT_ID
ARG VITE_MS_CLIENT_ID
ARG VITE_OAUTH_REDIRECT_BASE
ARG VITE_SELF_SERVE_ENABLED
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY
ENV VITE_PUBLIC_POSTHOG_HOST=$VITE_PUBLIC_POSTHOG_HOST ENV VITE_PUBLIC_POSTHOG_HOST=$VITE_PUBLIC_POSTHOG_HOST
ENV VITE_STRIPE_PUBLISHABLE_KEY=$VITE_STRIPE_PUBLISHABLE_KEY
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID
ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE
ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED
# Build the application # Build the application
RUN npm run build RUN npm run build

View File

@@ -1,6 +1,13 @@
import apiClient from './client' import apiClient from './client'
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types' import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types'
export interface OAuthCallbackResponse {
access_token: string
refresh_token: string
token_type: string
is_new_user: boolean
}
export const authApi = { export const authApi = {
async register(data: UserCreate): Promise<User> { async register(data: UserCreate): Promise<User> {
const response = await apiClient.post<User>('/auth/register', data) const response = await apiClient.post<User>('/auth/register', data)
@@ -71,6 +78,22 @@ export const authApi = {
async verifyEmail(token: string): Promise<void> { async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token }) await apiClient.post('/auth/email/verify', { token })
}, },
async googleCallback(code: string): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/google/callback',
{ code },
)
return response.data
},
async microsoftCallback(code: string): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/microsoft/callback',
{ code },
)
return response.data
},
} }
export default authApi export default authApi

View File

@@ -0,0 +1,15 @@
import apiClient from './client'
export interface PublicConfig {
self_serve_enabled: boolean
oauth_providers: string[]
}
export const configApi = {
async getPublic(): Promise<PublicConfig> {
const response = await apiClient.get<PublicConfig>('/config/public')
return response.data
},
}
export default configApi

View File

@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import { configApi, type PublicConfig } from '@/api/config'
/**
* Module-scope cache: the public config endpoint is fetched at most once
* per page load. Subsequent hook mounts return the cached value synchronously
* (after the initial state update).
*/
let cached: PublicConfig | null = null
let inFlight: Promise<PublicConfig> | null = null
const subscribers = new Set<(c: PublicConfig) => void>()
function envFallback(): PublicConfig {
// Falls back to build-time flag when the public config endpoint is
// unreachable. Defaults to the legacy invite-only behavior so that
// a backend hiccup never opens public signup.
const selfServe =
String(import.meta.env.VITE_SELF_SERVE_ENABLED ?? '').toLowerCase() === 'true'
return {
self_serve_enabled: selfServe,
oauth_providers: [],
}
}
async function loadConfig(): Promise<PublicConfig> {
if (cached) return cached
if (inFlight) return inFlight
inFlight = configApi
.getPublic()
.then((c) => {
cached = c
subscribers.forEach((cb) => cb(c))
return c
})
.catch(() => {
const fallback = envFallback()
cached = fallback
subscribers.forEach((cb) => cb(fallback))
return fallback
})
.finally(() => {
inFlight = null
})
return inFlight
}
/** Test-only: clear the module-scope cache between tests. */
export function __resetAppConfigCache() {
cached = null
inFlight = null
subscribers.clear()
}
/** Test-only: prime the module-scope cache so hook returns synchronously. */
export function __setAppConfigCache(c: PublicConfig) {
cached = c
}
export interface UseAppConfigResult {
self_serve_enabled: boolean
oauth_providers: string[]
isLoading: boolean
}
export function useAppConfig(): UseAppConfigResult {
const [config, setConfig] = useState<PublicConfig | null>(cached)
useEffect(() => {
if (cached) {
setConfig(cached)
return
}
let active = true
const handler = (c: PublicConfig) => {
if (active) setConfig(c)
}
subscribers.add(handler)
void loadConfig()
return () => {
active = false
subscribers.delete(handler)
}
}, [])
if (config) {
return {
self_serve_enabled: config.self_serve_enabled,
oauth_providers: config.oauth_providers,
isLoading: false,
}
}
return {
self_serve_enabled: false,
oauth_providers: [],
isLoading: true,
}
}
export default useAppConfig

View File

@@ -0,0 +1,141 @@
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'
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
* to /welcome (new user) or / (returning user).
*/
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 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) {
setError(`OAuth error: ${oauthError}`)
return
}
if (!storedState || returnedState !== storedState) {
setError('Invalid OAuth state — possible CSRF. Please try again.')
return
}
if (!code) {
setError('Missing authorization code')
return
}
let cancelled = false
void (async () => {
try {
const result =
provider === 'microsoft'
? await authApi.microsoftCallback(code)
: await authApi.googleCallback(code)
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',
})
// Hydrate user / account / subscription.
await fetchUser()
if (cancelled) return
const dest = result.is_new_user ? '/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
const msg =
(typeof detail === 'string' ? detail : null) ||
(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

View File

@@ -1,18 +1,77 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { inviteApi } from '@/api/invite' import { inviteApi } from '@/api/invite'
import { useAppConfig } from '@/hooks/useAppConfig'
import { BrandLogo } from '@/components/common/BrandLogo' import { BrandLogo } from '@/components/common/BrandLogo'
import { PasswordInput } from '@/components/common/PasswordInput' import { PasswordInput } from '@/components/common/PasswordInput'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
const MICROSOFT_AUTH_URL =
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
function getRedirectBase(): string {
const fromEnv = import.meta.env.VITE_OAUTH_REDIRECT_BASE
if (fromEnv) return fromEnv as string
// Falls back to current origin in dev so feature works without explicit env.
if (typeof window !== 'undefined') return window.location.origin
return ''
}
function randomState(): string {
// Lightweight random state — used only to harden against CSRF on the OAuth
// round-trip. Not a security boundary; backend independently authenticates
// via the authorization code exchange.
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('')
}
/** Build provider authorize URL. Exported for tests. */
export function buildOAuthAuthorizeUrl(
provider: 'google' | 'microsoft',
state: string,
): string {
const redirectUri = `${getRedirectBase()}/auth/${provider}/callback`
if (provider === 'google') {
const params = new URLSearchParams({
client_id: (import.meta.env.VITE_GOOGLE_CLIENT_ID as string) || '',
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile',
access_type: 'offline',
prompt: 'consent',
state,
})
return `${GOOGLE_AUTH_URL}?${params.toString()}`
}
const params = new URLSearchParams({
client_id: (import.meta.env.VITE_MS_CLIENT_ID as string) || '',
redirect_uri: redirectUri,
response_type: 'code',
scope: 'openid email profile offline_access',
response_mode: 'query',
state,
})
return `${MICROSOFT_AUTH_URL}?${params.toString()}`
}
export function RegisterPage() { export function RegisterPage() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { register, isLoading, error, clearError } = useAuthStore() const { register, isLoading, error, clearError } = useAuthStore()
const appConfig = useAppConfig()
const [inviteCode, setInviteCode] = useState('') const [inviteCode, setInviteCode] = useState('')
const [inviteCodeStatus, setInviteCodeStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle') const [inviteCodeStatus, setInviteCodeStatus] = useState<
'idle' | 'checking' | 'valid' | 'invalid'
>('idle')
const [inviteCodeMessage, setInviteCodeMessage] = useState('') const [inviteCodeMessage, setInviteCodeMessage] = useState('')
const [name, setName] = useState('') const [name, setName] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -20,6 +79,32 @@ export function RegisterPage() {
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [localError, setLocalError] = useState('') const [localError, setLocalError] = useState('')
// Capture ?plan=pro into localStorage so the in-app flow / start_trial
// can later read it. One-shot on mount.
useEffect(() => {
const params = new URLSearchParams(location.search)
const plan = params.get('plan')
if (plan) localStorage.setItem('rf-intended-plan', plan)
}, [location.search])
const showOAuthButtons = appConfig.self_serve_enabled
const showInviteCode = !appConfig.self_serve_enabled
const googleAvailable =
showOAuthButtons && appConfig.oauth_providers.includes('google')
const microsoftAvailable =
showOAuthButtons && appConfig.oauth_providers.includes('microsoft')
const handleOAuth = (provider: 'google' | 'microsoft') => {
const state = randomState()
try {
sessionStorage.setItem('rf-oauth-state', state)
} catch {
// ignore — non-fatal
}
const url = buildOAuthAuthorizeUrl(provider, state)
window.location.href = url
}
const validateInviteCode = async (code: string) => { const validateInviteCode = async (code: string) => {
if (!code.trim()) { if (!code.trim()) {
setInviteCodeStatus('idle') setInviteCodeStatus('idle')
@@ -43,8 +128,8 @@ export function RegisterPage() {
setLocalError('') setLocalError('')
clearError() clearError()
// Only validate invite code if one was entered // Only validate invite code when the field is shown (legacy invite flow).
if (inviteCode.trim() && inviteCodeStatus === 'invalid') { if (showInviteCode && inviteCode.trim() && inviteCodeStatus === 'invalid') {
setLocalError('Please enter a valid invite code') setLocalError('Please enter a valid invite code')
return return
} }
@@ -65,12 +150,15 @@ export function RegisterPage() {
} }
try { try {
// Only include invite_code if provided const userData =
const userData = inviteCode.trim() showInviteCode && inviteCode.trim()
? { email, password, name, invite_code: inviteCode.trim() } ? { email, password, name, invite_code: inviteCode.trim() }
: { email, password, name } : { email, password, name }
await register(userData) await register(userData)
navigate('/', { replace: true }) // New users land on the welcome wizard. The /welcome route is
// materialized by Task 38; until that lands, this redirect falls
// through to the catch-all 404 — acceptable per spec.
navigate('/welcome', { replace: true })
} catch { } catch {
// Error is set in the store // Error is set in the store
} }
@@ -78,28 +166,30 @@ export function RegisterPage() {
return ( return (
<> <>
<PageMeta title="Create Account" description="Create your ResolutionFlow account to start building guided troubleshooting flows" /> <PageMeta
<div className="flex min-h-screen items-center justify-center bg-black px-4"> title="Create Account"
{/* Subtle radial overlay */} description="Create your ResolutionFlow account to start building guided troubleshooting flows"
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" /> />
<div className="flex min-h-screen items-center justify-center bg-black px-4">
{/* Subtle radial overlay */}
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
<div className="relative w-full max-w-md space-y-8"> <div className="relative w-full max-w-md space-y-8">
<div className="text-center"> <div className="text-center">
<div className="mb-4 flex justify-center sm:mb-6"> <div className="mb-4 flex justify-center sm:mb-6">
<BrandLogo size="lg" /> <BrandLogo size="lg" />
</div>
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
ResolutionFlow
</h1>
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
AI-Powered Troubleshooting for MSPs
</p>
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
Create your account
</p>
</div> </div>
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
ResolutionFlow
</h1>
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
AI-Powered Troubleshooting for MSPs
</p>
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
Create your account
</p>
</div>
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="bg-card border border-border rounded-xl p-6 space-y-4"> <div className="bg-card border border-border rounded-xl p-6 space-y-4">
{(error || localError) && ( {(error || localError) && (
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400"> <div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
@@ -107,140 +197,217 @@ export function RegisterPage() {
</div> </div>
)} )}
<div> {showOAuthButtons && (googleAvailable || microsoftAvailable) && (
<label htmlFor="inviteCode" className="block text-sm font-medium text-foreground"> <div className="space-y-3">
Invite code {googleAvailable && (
</label> <button
<input type="button"
id="inviteCode" onClick={() => handleOAuth('google')}
name="inviteCode" data-testid="oauth-google"
type="text" className={cn(
value={inviteCode} 'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
onChange={(e) => { // TODO(brand): swap to white-on-black with Google "G" mark
setInviteCode(e.target.value.toUpperCase()) // when brand assets are imported. Neutral fallback for now.
setInviteCodeStatus('idle') 'bg-card border border-border text-foreground hover:bg-foreground/5',
}} 'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
onBlur={(e) => validateInviteCode(e.target.value)} 'transition-all',
className={cn( )}
'mt-1 block w-full rounded-xl border bg-card px-3 py-2 font-mono tracking-wider', >
'text-foreground placeholder:text-muted-foreground', Continue with Google
'focus:outline-hidden focus:ring-1', </button>
inviteCodeStatus === 'valid' && 'border-emerald-400/50 focus:border-emerald-400 focus:ring-emerald-400/30',
inviteCodeStatus === 'invalid' && 'border-red-400/50 focus:border-red-400 focus:ring-red-400/30',
inviteCodeStatus === 'idle' && 'border-border focus:border-primary focus:ring-primary/20',
inviteCodeStatus === 'checking' && 'border-border focus:border-primary focus:ring-primary/20'
)} )}
placeholder="ABCD1234" {microsoftAvailable && (
/> <button
{inviteCodeStatus === 'checking' && ( type="button"
<p className="mt-1 text-xs text-muted-foreground">Validating...</p> onClick={() => handleOAuth('microsoft')}
data-testid="oauth-microsoft"
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'transition-all',
)}
>
Continue with Microsoft
</button>
)}
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase tracking-wider">
<span className="bg-card px-2 text-muted-foreground">
or sign up with email
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{showInviteCode && (
<div>
<label
htmlFor="inviteCode"
className="block text-sm font-medium text-foreground"
>
Invite code
</label>
<input
id="inviteCode"
name="inviteCode"
type="text"
value={inviteCode}
onChange={(e) => {
setInviteCode(e.target.value.toUpperCase())
setInviteCodeStatus('idle')
}}
onBlur={(e) => validateInviteCode(e.target.value)}
className={cn(
'mt-1 block w-full rounded-xl border bg-card px-3 py-2 font-mono tracking-wider',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-hidden focus:ring-1',
inviteCodeStatus === 'valid' &&
'border-emerald-400/50 focus:border-emerald-400 focus:ring-emerald-400/30',
inviteCodeStatus === 'invalid' &&
'border-red-400/50 focus:border-red-400 focus:ring-red-400/30',
inviteCodeStatus === 'idle' &&
'border-border focus:border-primary focus:ring-primary/20',
inviteCodeStatus === 'checking' &&
'border-border focus:border-primary focus:ring-primary/20',
)}
placeholder="ABCD1234"
/>
{inviteCodeStatus === 'checking' && (
<p className="mt-1 text-xs text-muted-foreground">
Validating...
</p>
)}
{inviteCodeStatus === 'valid' && (
<p className="mt-1 text-xs text-emerald-400">
{inviteCodeMessage}
</p>
)}
{inviteCodeStatus === 'invalid' && (
<p className="mt-1 text-xs text-red-400">
{inviteCodeMessage}
</p>
)}
</div>
)} )}
{inviteCodeStatus === 'valid' && (
<p className="mt-1 text-xs text-emerald-400">{inviteCodeMessage}</p>
)}
{inviteCodeStatus === 'invalid' && (
<p className="mt-1 text-xs text-red-400">{inviteCodeMessage}</p>
)}
</div>
<div> <div>
<label htmlFor="name" className="block text-sm font-medium text-foreground"> <label
Full name htmlFor="name"
</label> className="block text-sm font-medium text-foreground"
<input >
id="name" Full name
name="name" </label>
type="text" <input
autoComplete="name" id="name"
required name="name"
value={name} type="text"
onChange={(e) => setName(e.target.value)} autoComplete="name"
required
value={name}
onChange={(e) => 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="John Smith"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-foreground"
>
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(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="you@example.com"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-foreground"
>
Password
</label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => 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="••••••••••"
/>
<p className="mt-1 text-xs text-muted-foreground">
Must be at least 10 characters
</p>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-foreground"
>
Confirm password
</label>
<PasswordInput
id="confirmPassword"
name="confirmPassword"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => 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="••••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn( className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2', 'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'text-foreground placeholder:text-muted-foreground', 'bg-primary text-white hover:brightness-110',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' 'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all',
)} )}
placeholder="John Smith" >
/> {isLoading ? 'Creating account...' : 'Create account'}
</div> </button>
</form>
<div>
<label htmlFor="email" className="block text-sm font-medium text-foreground">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(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="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground">
Password
</label>
<PasswordInput
id="password"
name="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => 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="••••••••••"
/>
<p className="mt-1 text-xs text-muted-foreground">
Must be at least 10 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground">
Confirm password
</label>
<PasswordInput
id="confirmPassword"
name="confirmPassword"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => 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="••••••••••"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-white hover:brightness-110',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-all'
)}
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</div> </div>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
@@ -249,9 +416,8 @@ export function RegisterPage() {
Sign in Sign in
</Link> </Link>
</p> </p>
</form> </div>
</div> </div>
</div>
</> </>
) )
} }

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { OAuthCallbackPage } from '../OAuthCallbackPage'
import { authApi } from '@/api/auth'
vi.mock('@/api/auth', () => ({
authApi: {
googleCallback: vi.fn(),
microsoftCallback: vi.fn(),
},
}))
vi.mock('@/store/authStore', () => ({
useAuthStore: () => ({
setTokens: vi.fn(),
fetchUser: vi.fn().mockResolvedValue(undefined),
}),
}))
function renderAt(path: string) {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/auth/google/callback"
element={<OAuthCallbackPage />}
/>
<Route
path="/auth/microsoft/callback"
element={<OAuthCallbackPage />}
/>
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
}
describe('OAuthCallbackPage CSRF state validation', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})
afterEach(() => {
sessionStorage.clear()
})
it('shows error and does NOT call googleCallback when state in URL does not match sessionStorage', async () => {
sessionStorage.setItem('rf-oauth-state', 'expected-state-value')
renderAt('/auth/google/callback?code=auth-code-123&state=attacker-state')
await waitFor(() => {
expect(
screen.getByText(/Invalid OAuth state/i),
).toBeInTheDocument()
})
expect(authApi.googleCallback).not.toHaveBeenCalled()
expect(authApi.microsoftCallback).not.toHaveBeenCalled()
// Stored value must be cleared regardless of outcome.
expect(sessionStorage.getItem('rf-oauth-state')).toBeNull()
})
it('shows error and does NOT call googleCallback when stored state is missing', async () => {
// No sessionStorage entry set.
renderAt('/auth/google/callback?code=auth-code-123&state=any-state')
await waitFor(() => {
expect(
screen.getByText(/Invalid OAuth state/i),
).toBeInTheDocument()
})
expect(authApi.googleCallback).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { RegisterPage } from '../RegisterPage'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
function renderPage(initialPath = '/register') {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={[initialPath]}>
<RegisterPage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('RegisterPage', () => {
beforeEach(() => {
__resetAppConfigCache()
// Provide mock env values so authorize URL build is deterministic.
vi.stubEnv('VITE_GOOGLE_CLIENT_ID', 'test-google-client')
vi.stubEnv('VITE_MS_CLIENT_ID', 'test-ms-client')
vi.stubEnv('VITE_OAUTH_REDIRECT_BASE', 'http://localhost:5173')
})
it('hides OAuth + shows invite-code field when self_serve_enabled is false', () => {
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: ['google', 'microsoft'],
})
renderPage()
expect(screen.getByLabelText(/invite code/i)).toBeInTheDocument()
expect(screen.queryByTestId('oauth-google')).not.toBeInTheDocument()
expect(screen.queryByTestId('oauth-microsoft')).not.toBeInTheDocument()
expect(
screen.queryByText(/or sign up with email/i),
).not.toBeInTheDocument()
})
it('hides invite-code + shows OAuth buttons when self_serve_enabled is true', () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: ['google', 'microsoft'],
})
renderPage()
expect(screen.queryByLabelText(/invite code/i)).not.toBeInTheDocument()
expect(screen.getByTestId('oauth-google')).toBeInTheDocument()
expect(screen.getByTestId('oauth-microsoft')).toBeInTheDocument()
expect(screen.getByText(/or sign up with email/i)).toBeInTheDocument()
})
it('clicking Continue with Google opens OAuth flow with correct URL', () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: ['google'],
})
// Stub window.location.href assignment.
const originalLocation = window.location
const hrefSetter = vi.fn()
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
origin: 'http://localhost:5173',
set href(value: string) {
hrefSetter(value)
},
get href() {
return originalLocation.href
},
},
})
try {
renderPage()
const button = screen.getByTestId('oauth-google')
fireEvent.click(button)
expect(hrefSetter).toHaveBeenCalledTimes(1)
const url = hrefSetter.mock.calls[0][0] as string
expect(url).toMatch(
/^https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?/,
)
const search = new URL(url).searchParams
expect(search.get('client_id')).toBe('test-google-client')
expect(search.get('redirect_uri')).toBe(
'http://localhost:5173/auth/google/callback',
)
expect(search.get('response_type')).toBe('code')
expect(search.get('scope')).toContain('openid')
expect(search.get('state')).toBeTruthy()
} finally {
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
}
})
it('captures ?plan=pro into localStorage on mount', () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
localStorage.removeItem('rf-intended-plan')
renderPage('/register?plan=pro')
expect(localStorage.getItem('rf-intended-plan')).toBe('pro')
})
})

View File

@@ -25,6 +25,7 @@ const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
// Standalone auth pages // Standalone auth pages
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage')) const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
const OAuthCallbackPage = lazyWithRetry(() => import('@/pages/OAuthCallbackPage'))
const ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage')) const ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage')) const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage')) const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage'))
@@ -149,6 +150,16 @@ export const router = sentryCreateBrowserRouter([
element: page(VerifyEmailPage), element: page(VerifyEmailPage),
errorElement: <RouteError />, errorElement: <RouteError />,
}, },
{
path: '/auth/google/callback',
element: page(OAuthCallbackPage),
errorElement: <RouteError />,
},
{
path: '/auth/microsoft/callback',
element: page(OAuthCallbackPage),
errorElement: <RouteError />,
},
{ {
path: '/survey', path: '/survey',
element: page(SurveyPage), element: page(SurveyPage),