feat: self-serve signup Phase 2 (frontend cutover) #162
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<User> {
|
||||
const response = await apiClient.post<User>('/auth/register', data)
|
||||
@@ -71,6 +78,22 @@ export const authApi = {
|
||||
async verifyEmail(token: string): Promise<void> {
|
||||
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
|
||||
|
||||
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 { 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 (
|
||||
<>
|
||||
<PageMeta title="Create Account" description="Create your ResolutionFlow account to start building guided troubleshooting flows" />
|
||||
<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%)]" />
|
||||
<PageMeta
|
||||
title="Create Account"
|
||||
description="Create your ResolutionFlow account to start building guided troubleshooting flows"
|
||||
/>
|
||||
<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="text-center">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<BrandLogo size="lg" />
|
||||
<div className="relative w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<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>
|
||||
<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">
|
||||
{(error || localError) && (
|
||||
<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>
|
||||
<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'
|
||||
{showOAuthButtons && (googleAvailable || microsoftAvailable) && (
|
||||
<div className="space-y-3">
|
||||
{googleAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuth('google')}
|
||||
data-testid="oauth-google"
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
// TODO(brand): swap to white-on-black with Google "G" mark
|
||||
// when brand assets are imported. Neutral fallback for now.
|
||||
'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 Google
|
||||
</button>
|
||||
)}
|
||||
placeholder="ABCD1234"
|
||||
/>
|
||||
{inviteCodeStatus === 'checking' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Validating...</p>
|
||||
{microsoftAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-foreground">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
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(
|
||||
'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'
|
||||
'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',
|
||||
)}
|
||||
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(
|
||||
'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>
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
@@ -249,9 +416,8 @@ export function RegisterPage() {
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</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
|
||||
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: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/auth/google/callback',
|
||||
element: page(OAuthCallbackPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/auth/microsoft/callback',
|
||||
element: page(OAuthCallbackPage),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/survey',
|
||||
element: page(SurveyPage),
|
||||
|
||||
Reference in New Issue
Block a user