diff --git a/frontend/.env.example b/frontend/.env.example index 62e5edc5..574a1872 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,21 @@ VITE_API_URL=http://localhost:8000 # Sentry error monitoring (optional in dev, required in production) 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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index d539bd53..6bcdc0c8 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -17,10 +17,20 @@ ARG VITE_API_URL ARG VITE_SENTRY_DSN ARG VITE_PUBLIC_POSTHOG_KEY 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_SENTRY_DSN=$VITE_SENTRY_DSN ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY 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 RUN npm run build diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index afc3fec3..7382679d 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,6 +1,13 @@ import apiClient from './client' 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 = { async register(data: UserCreate): Promise { const response = await apiClient.post('/auth/register', data) @@ -71,6 +78,22 @@ export const authApi = { async verifyEmail(token: string): Promise { await apiClient.post('/auth/email/verify', { token }) }, + + async googleCallback(code: string): Promise { + const response = await apiClient.post( + '/auth/google/callback', + { code }, + ) + return response.data + }, + + async microsoftCallback(code: string): Promise { + const response = await apiClient.post( + '/auth/microsoft/callback', + { code }, + ) + return response.data + }, } export default authApi diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts new file mode 100644 index 00000000..c3337f2c --- /dev/null +++ b/frontend/src/api/config.ts @@ -0,0 +1,15 @@ +import apiClient from './client' + +export interface PublicConfig { + self_serve_enabled: boolean + oauth_providers: string[] +} + +export const configApi = { + async getPublic(): Promise { + const response = await apiClient.get('/config/public') + return response.data + }, +} + +export default configApi diff --git a/frontend/src/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts new file mode 100644 index 00000000..49348c73 --- /dev/null +++ b/frontend/src/hooks/useAppConfig.ts @@ -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 | 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 { + 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(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 diff --git a/frontend/src/pages/OAuthCallbackPage.tsx b/frontend/src/pages/OAuthCallbackPage.tsx new file mode 100644 index 00000000..5e0b8a1d --- /dev/null +++ b/frontend/src/pages/OAuthCallbackPage.tsx @@ -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(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 ( + <> + +
+
+
+ +
+ {error ? ( + <> +

+ Sign-in failed +

+

{error}

+ + + ) : ( + <> +

+ Signing you in… +

+

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

+ + )} +
+
+ + ) +} + +export default OAuthCallbackPage diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx index 73cdea2e..791cc5af 100644 --- a/frontend/src/pages/RegisterPage.tsx +++ b/frontend/src/pages/RegisterPage.tsx @@ -1,18 +1,77 @@ -import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' import { useAuthStore } from '@/store/authStore' import { inviteApi } from '@/api/invite' +import { useAppConfig } from '@/hooks/useAppConfig' import { BrandLogo } from '@/components/common/BrandLogo' import { PasswordInput } from '@/components/common/PasswordInput' import { PageMeta } from '@/components/common/PageMeta' 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() { const navigate = useNavigate() + const location = useLocation() const { register, isLoading, error, clearError } = useAuthStore() + const appConfig = useAppConfig() 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 [name, setName] = useState('') const [email, setEmail] = useState('') @@ -20,6 +79,32 @@ export function RegisterPage() { const [confirmPassword, setConfirmPassword] = 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) => { if (!code.trim()) { setInviteCodeStatus('idle') @@ -43,8 +128,8 @@ export function RegisterPage() { setLocalError('') clearError() - // Only validate invite code if one was entered - if (inviteCode.trim() && inviteCodeStatus === 'invalid') { + // Only validate invite code when the field is shown (legacy invite flow). + if (showInviteCode && inviteCode.trim() && inviteCodeStatus === 'invalid') { setLocalError('Please enter a valid invite code') return } @@ -65,12 +150,15 @@ export function RegisterPage() { } try { - // Only include invite_code if provided - const userData = inviteCode.trim() - ? { email, password, name, invite_code: inviteCode.trim() } - : { email, password, name } + const userData = + showInviteCode && inviteCode.trim() + ? { email, password, name, invite_code: inviteCode.trim() } + : { email, password, name } 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 { // Error is set in the store } @@ -78,28 +166,30 @@ export function RegisterPage() { return ( <> - -
- {/* Subtle radial overlay */} -
+ +
+ {/* Subtle radial overlay */} +
-
-
-
- +
+
+
+ +
+

+ ResolutionFlow +

+

+ AI-Powered Troubleshooting for MSPs +

+

+ Create your account +

-

- ResolutionFlow -

-

- AI-Powered Troubleshooting for MSPs -

-

- Create your account -

-
-
{(error || localError) && (
@@ -107,140 +197,217 @@ export function RegisterPage() {
)} -
- - { - 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' + {showOAuthButtons && (googleAvailable || microsoftAvailable) && ( +
+ {googleAvailable && ( + )} - placeholder="ABCD1234" - /> - {inviteCodeStatus === 'checking' && ( -

Validating...

+ {microsoftAvailable && ( + + )} + +
+
+
+
+
+ + or sign up with email + +
+
+
+ )} + + + {showInviteCode && ( +
+ + { + 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' && ( +

+ Validating... +

+ )} + {inviteCodeStatus === 'valid' && ( +

+ {inviteCodeMessage} +

+ )} + {inviteCodeStatus === 'invalid' && ( +

+ {inviteCodeMessage} +

+ )} +
)} - {inviteCodeStatus === 'valid' && ( -

{inviteCodeMessage}

- )} - {inviteCodeStatus === 'invalid' && ( -

{inviteCodeMessage}

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

+ Must be at least 10 characters +

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

- Must be at least 10 characters -

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

@@ -249,9 +416,8 @@ export function RegisterPage() { Sign in

- +
-
) } diff --git a/frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx b/frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx new file mode 100644 index 00000000..392e9403 --- /dev/null +++ b/frontend/src/pages/__tests__/OAuthCallbackPage.test.tsx @@ -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( + + + + } + /> + } + /> + + + , + ) +} + +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() + }) +}) diff --git a/frontend/src/pages/__tests__/RegisterPage.test.tsx b/frontend/src/pages/__tests__/RegisterPage.test.tsx new file mode 100644 index 00000000..dc9393cd --- /dev/null +++ b/frontend/src/pages/__tests__/RegisterPage.test.tsx @@ -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( + + + + + , + ) +} + +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') + }) +}) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 44e4fa30..f0b3e50b 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -25,6 +25,7 @@ const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage')) // Standalone auth pages const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage')) +const OAuthCallbackPage = lazyWithRetry(() => import('@/pages/OAuthCallbackPage')) const ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage')) const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage')) const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage')) @@ -149,6 +150,16 @@ export const router = sentryCreateBrowserRouter([ element: page(VerifyEmailPage), errorElement: , }, + { + path: '/auth/google/callback', + element: page(OAuthCallbackPage), + errorElement: , + }, + { + path: '/auth/microsoft/callback', + element: page(OAuthCallbackPage), + errorElement: , + }, { path: '/survey', element: page(SurveyPage),