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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
15
frontend/src/api/config.ts
Normal file
15
frontend/src/api/config.ts
Normal 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
|
||||||
99
frontend/src/hooks/useAppConfig.ts
Normal file
99
frontend/src/hooks/useAppConfig.ts
Normal 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
|
||||||
141
frontend/src/pages/OAuthCallbackPage.tsx
Normal file
141
frontend/src/pages/OAuthCallbackPage.tsx
Normal 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
|
||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
81
frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx
Normal file
81
frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
121
frontend/src/pages/__tests__/RegisterPage.test.tsx
Normal file
121
frontend/src/pages/__tests__/RegisterPage.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user