feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled

Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
2026-05-07 18:42:20 +00:00
committed by chihlasm
parent f918b766b0
commit f1be3abcc5
123 changed files with 11563 additions and 559 deletions

View File

@@ -3,3 +3,26 @@ 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
# Calendly link surfaced on the /contact-sales confirmation screen. When unset,
# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod
# requires ARG+ENV in frontend/Dockerfile.
VITE_CALENDLY_URL=

View File

@@ -17,10 +17,22 @@ 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
ARG VITE_CALENDLY_URL
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
ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL
# Build the application
RUN npm run build

View File

@@ -1,6 +1,22 @@
import apiClient from './client'
import type { Account, SubscriptionDetails, AccountMember, AccountInvite } from '@/types'
export interface BulkInviteRow {
email: string
role: 'engineer' | 'viewer'
expires_in_days?: number
}
export interface BulkInviteFailure {
email: string
error: string
}
export interface BulkInviteResponse {
created: AccountInvite[]
failed: BulkInviteFailure[]
}
export const accountsApi = {
async getMyAccount(): Promise<Account> {
const response = await apiClient.get<Account>('/accounts/me')
@@ -39,6 +55,18 @@ export const accountsApi = {
return response.data
},
/**
* Create multiple invites in one call (used by the welcome wizard step 3).
* Per-row failures land in `failed[]`; successes in `created[]`.
*/
async bulkInvite(invites: BulkInviteRow[]): Promise<BulkInviteResponse> {
const response = await apiClient.post<BulkInviteResponse>(
'/accounts/me/invites/bulk',
{ invites },
)
return response.data
},
async getInvites(): Promise<AccountInvite[]> {
const response = await apiClient.get<AccountInvite[]>('/accounts/me/invites')
return response.data

View File

@@ -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,36 @@ export const authApi = {
async verifyEmail(token: string): Promise<void> {
await apiClient.post('/auth/email/verify', { token })
},
async googleCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/google/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
async microsoftCallback(
code: string,
options?: { accountInviteCode?: string; invitedEmail?: string },
): Promise<OAuthCallbackResponse> {
const response = await apiClient.post<OAuthCallbackResponse>(
'/auth/microsoft/callback',
{
code,
account_invite_code: options?.accountInviteCode,
invited_email: options?.invitedEmail,
},
)
return response.data
},
}
export default authApi

View File

@@ -0,0 +1,79 @@
import { AxiosError } from 'axios'
import apiClient from './client'
import {
BillingPortalError,
type BillingPortalErrorCode,
type BillingPortalSessionResponse,
type BillingStateApiResponse,
type BillingStatePayload,
type CheckoutSessionRequest,
type CheckoutSessionResponse,
} from '@/types/billing'
/**
* Single boundary where the snake_case backend payload is transformed
* into the camelCase shape used by the rest of the frontend.
*
* Keeping the transform here means the store, hooks, and components
* never see snake_case keys.
*/
function transformBillingState(raw: BillingStateApiResponse): BillingStatePayload {
return {
subscription: raw.subscription ?? null,
planBilling: raw.plan_billing ?? null,
planLimits: raw.plan_limits ?? {},
enabledFeatures: raw.enabled_features ?? {},
}
}
export const billingApi = {
async getState(): Promise<BillingStatePayload> {
const response = await apiClient.get<BillingStateApiResponse>('/billing/state')
return transformBillingState(response.data)
},
/**
* Request a Stripe Customer Portal session URL for the active account.
*
* Throws a typed `BillingPortalError` when:
* - HTTP 503 → `stripe_not_configured` (server-side Stripe is disabled)
* - HTTP 400 + `error: 'no_stripe_customer'` → account hasn't been billed yet
*
* Other errors (5xx, network) propagate as the underlying AxiosError.
*/
async getPortalSession(): Promise<BillingPortalSessionResponse> {
try {
const response = await apiClient.get<BillingPortalSessionResponse>(
'/billing/portal-session',
)
return response.data
} catch (err) {
if (err instanceof AxiosError && err.response) {
const { status, data } = err.response
const code: BillingPortalErrorCode | null =
status === 503
? 'stripe_not_configured'
: status === 400 && data?.detail?.error === 'no_stripe_customer'
? 'no_stripe_customer'
: null
if (code) {
throw new BillingPortalError(code)
}
}
throw err
}
},
async createCheckoutSession(
payload: CheckoutSessionRequest,
): Promise<CheckoutSessionResponse> {
const response = await apiClient.post<CheckoutSessionResponse>(
'/billing/checkout-session',
payload,
)
return response.data
},
}
export default billingApi

View File

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

View File

@@ -9,6 +9,16 @@ export { default as foldersApi } from './folders'
export { default as stepsApi } from './steps'
export { default as stepCategoriesApi } from './stepCategories'
export { default as accountsApi } from './accounts'
export { default as billingApi } from './billing'
export { default as plansApi } from './plans'
export type { PublicPlanResponse } from './plans'
export { default as salesApi } from './sales'
export type {
SalesLeadCreatePayload,
SalesLeadCreateResponse,
SalesLeadSource,
} from './sales'
export { default as usageApi } from './usage'
export { default as adminApi } from './admin'
export { treeMarkdownApi } from './treeMarkdown'
export { default as analyticsApi } from './analytics'

View File

@@ -1,11 +1,30 @@
import apiClient from './client'
import type { InviteCodeValidation } from '@/types'
/** Public response from GET /accounts/invites/{code}/lookup. */
export interface AccountInviteLookup {
account_name: string
inviter_name: string
invited_email: string
role: string
}
export const inviteApi = {
async validateCode(code: string): Promise<InviteCodeValidation> {
const response = await apiClient.get<InviteCodeValidation>(`/invites/validate/${code}`)
return response.data
},
/** Public lookup of an account invite code — no auth required. Used by
* /accept-invite to render the "Join {account} on ResolutionFlow" card.
* Resolves to 404 with `invite_invalid_or_expired_or_revoked` for any
* invalid state. */
async lookupAccountInvite(code: string): Promise<AccountInviteLookup> {
const response = await apiClient.get<AccountInviteLookup>(
`/accounts/invites/${encodeURIComponent(code)}/lookup`,
)
return response.data
},
}
export default inviteApi

View File

@@ -4,11 +4,15 @@ export interface OnboardingStatus {
created_flow: boolean
ran_session: boolean
exported_session: boolean
/** @deprecated Phase 2 — kept for backward-compat. New UI no longer branches on this. */
tried_ai_assistant: boolean
invited_teammate: boolean
connected_psa: boolean
is_team_user: boolean
dismissed: boolean
// Phase 2 (Task 41) — drive the unified next-step card + checklist.
email_verified: boolean
shop_setup_done: boolean
}
export async function getOnboardingStatus(): Promise<OnboardingStatus> {
@@ -19,3 +23,51 @@ export async function getOnboardingStatus(): Promise<OnboardingStatus> {
export async function dismissOnboarding(): Promise<void> {
await apiClient.post('/users/onboarding-status/dismiss')
}
// --- Welcome wizard (Phase 2) ---------------------------------------------
export type WizardStep = 1 | 2 | 3
export type WizardAction = 'complete' | 'skip'
export type TeamSizeBucket = '1-2' | '3-5' | '6-10' | '11-25' | '26+'
export type RoleAtSignup = 'owner' | 'lead_tech' | 'tech' | 'other'
export type PrimaryPsa = 'connectwise' | 'autotask' | 'halopsa' | 'none'
export interface OnboardingStepData {
// Step 1
company_name?: string
team_size_bucket?: TeamSizeBucket
role_at_signup?: RoleAtSignup
// Step 2
primary_psa?: PrimaryPsa
}
export interface OnboardingStepRequest {
step: WizardStep
action: WizardAction
data?: OnboardingStepData
}
export interface OnboardingStepResponse {
onboarding_step_completed: number | null
onboarding_dismissed: boolean
}
export const onboardingApi = {
getStatus: getOnboardingStatus,
dismiss: dismissOnboarding,
/** Persist welcome-wizard progress for the current user. */
async updateStep(payload: OnboardingStepRequest): Promise<OnboardingStepResponse> {
const response = await apiClient.patch<OnboardingStepResponse>(
'/users/me/onboarding-step',
payload,
)
return response.data
},
/** Skip the rest of the welcome wizard — sets users.onboarding_dismissed=TRUE. */
async dismissRest(): Promise<OnboardingStepResponse> {
const response = await apiClient.post<OnboardingStepResponse>(
'/users/me/onboarding-dismiss-rest',
)
return response.data
},
}

22
frontend/src/api/plans.ts Normal file
View File

@@ -0,0 +1,22 @@
import apiClient from './client'
export interface PublicPlanResponse {
plan: string
display_name: string
description: string | null
monthly_price_cents: number | null
annual_price_cents: number | null
max_seats: number | null
sort_order: number
is_public: boolean
}
export const plansApi = {
/** Public plan catalog for the marketing /pricing page. No auth. */
async getPublic(): Promise<PublicPlanResponse[]> {
const response = await apiClient.get<PublicPlanResponse[]>('/plans/public')
return response.data
},
}
export default plansApi

32
frontend/src/api/sales.ts Normal file
View File

@@ -0,0 +1,32 @@
import apiClient from './client'
export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page'
export interface SalesLeadCreatePayload {
email: string
name: string
company: string
team_size?: string
message?: string
source: SalesLeadSource
posthog_distinct_id?: string
}
export interface SalesLeadCreateResponse {
id: string
status: 'received'
}
export const salesApi = {
/**
* Public Talk-to-Sales submission. No auth required. Rate-limited per IP
* server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted`
* — frontend should NOT also fire this event.
*/
async createLead(payload: SalesLeadCreatePayload): Promise<SalesLeadCreateResponse> {
const response = await apiClient.post<SalesLeadCreateResponse>('/sales-leads', payload)
return response.data
},
}
export default salesApi

23
frontend/src/api/usage.ts Normal file
View File

@@ -0,0 +1,23 @@
import apiClient from './client'
/**
* Usage counters API.
*
* TODO: backend `/usage/{field}` endpoint not yet implemented (planned).
* Tracked under self-serve signup Phase 2 — Task 33 calls this lazily; today
* it 404s and the consuming hook (`useFeatureLimit`) cleanly degrades to
* `used = 0`.
*/
export const usageApi = {
/**
* Fetch the current count for a usage field (e.g. `active_users`,
* `flowpilot_sessions_this_month`). The field name is the same key used in
* `BillingState.planLimits`.
*/
async getCount(field: string): Promise<{ used: number }> {
const response = await apiClient.get<{ used: number }>(`/usage/${field}`)
return response.data
},
}
export default usageApi

View File

@@ -0,0 +1,56 @@
import type { ReactNode } from 'react'
import { useAuthStore } from '@/store/authStore'
import { EmailVerificationWall } from './EmailVerificationWall'
interface EmailVerificationGateProps {
children: ReactNode
/**
* Override the grace period (in days). Day `gracePeriodDays + 1` and beyond
* trigger the wall. Defaults to 6 — the spec says Day 16 unverified renders
* children and Day 7+ renders the wall.
*/
gracePeriodDays?: number
}
const MS_PER_DAY = 24 * 60 * 60 * 1000
/** Whole days elapsed between two ISO timestamps (floored). */
function daysSince(iso: string, now: number = Date.now()): number {
const created = Date.parse(iso)
if (Number.isNaN(created)) {
// Defensive: bad timestamp — treat as just-signed-up so we don't
// accidentally lock anyone out.
return 0
}
return Math.floor((now - created) / MS_PER_DAY)
}
/**
* Wraps protected content. While the current user is past the grace period
* without having verified their email, renders `<EmailVerificationWall />`
* instead of children.
*
* Behavior:
* - No user (signed out): renders children (let route guards handle auth).
* - User has `email_verified_at`: renders children.
* - Day 16 unverified: renders children (banner is shown elsewhere).
* - Day 7+ unverified: renders the wall.
*/
export function EmailVerificationGate({
children,
gracePeriodDays = 6,
}: EmailVerificationGateProps) {
const user = useAuthStore((s) => s.user)
if (!user) return <>{children}</>
if (user.email_verified_at) return <>{children}</>
const elapsed = daysSince(user.created_at)
if (elapsed > gracePeriodDays) {
return <EmailVerificationWall />
}
return <>{children}</>
}
export default EmailVerificationGate

View File

@@ -0,0 +1,90 @@
import { useState } from 'react'
import { Loader2, MailCheck } from 'lucide-react'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
interface EmailVerificationWallProps {
className?: string
}
/**
* Hard wall shown after the email-verification grace period expires.
*
* Minimal v1 — Task 37 will refine copy, layout, and add the
* `/verify-email?token=...` route handling. Until then this gives
* Day 7+ unverified users a way to re-send the verification email
* or sign out.
*/
export function EmailVerificationWall({ className }: EmailVerificationWallProps) {
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const [isSending, setIsSending] = useState(false)
const handleResend = async () => {
setIsSending(true)
try {
await authApi.sendVerificationEmail()
toast.success('Verification email sent')
} catch {
toast.error('Failed to send verification email')
} finally {
setIsSending(false)
}
}
const handleLogout = async () => {
try {
await logout()
} catch {
// logout swallows API errors internally
}
}
return (
<div
className={cn(
'flex min-h-[60vh] items-center justify-center px-4 py-12',
className,
)}
data-testid="email-verification-wall"
>
<div className="w-full max-w-md rounded-lg border border-default bg-card p-6 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-default bg-elevated text-muted-foreground">
<MailCheck className="h-5 w-5" aria-hidden="true" />
</div>
<h2 className="text-lg font-semibold text-heading">
Verify your email to continue
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{user?.email
? `We sent a verification link to ${user.email}. Click it to unlock your account.`
: 'Check your inbox for the verification link we sent when you signed up.'}
</p>
<div className="mt-6 flex flex-col gap-2">
<button
type="button"
onClick={handleResend}
disabled={isSending}
data-testid="resend-button"
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{isSending && <Loader2 className="h-4 w-4 animate-spin" />}
Resend verification email
</button>
<button
type="button"
onClick={handleLogout}
data-testid="sign-out-button"
className="rounded-md border border-default bg-elevated px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-white/[0.06]"
>
Sign out
</button>
</div>
</div>
</div>
)
}
export default EmailVerificationWall

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from 'react'
import { useFeature } from '@/hooks/useFeature'
import { UpgradePrompt } from './UpgradePrompt'
interface FeatureGateProps {
/** Feature flag key (e.g. `psa_integration`). Must match a backend `feature_flags.flag_key`. */
feature: string
/**
* Rendered when the feature is enabled for the current account.
*/
children: ReactNode
/**
* Rendered when the feature is disabled. Defaults to `<UpgradePrompt feature={feature} />`.
* Pass `null` to render nothing.
*/
fallback?: ReactNode
}
/**
* Conditionally renders `children` based on whether `feature` is enabled
* for the current account.
*
* This is a UX affordance — the security boundary is the backend
* `require_feature` dependency. Never trust this gate for authorization.
*/
export function FeatureGate({ feature, children, fallback }: FeatureGateProps) {
const enabled = useFeature(feature)
if (enabled) {
return <>{children}</>
}
// Use explicit fallback when provided, otherwise render the standard prompt.
// `null` is a valid fallback (renders nothing).
if (fallback !== undefined) {
return <>{fallback}</>
}
return <UpgradePrompt feature={feature} />
}
export default FeatureGate

View File

@@ -0,0 +1,111 @@
import { Lock, Sparkles } from 'lucide-react'
import { Link } from 'react-router-dom'
import { cn } from '@/lib/utils'
interface UpgradePromptProps {
feature: string
className?: string
}
interface FeatureMeta {
/** Display name shown in the prompt heading. */
displayName: string
/** Plan that unlocks this feature. */
requiredPlan: string
/** Optional one-line value pitch. */
description?: string
}
/**
* Mapping from feature flag key to display metadata.
*
* v1: small inline table maintained here. If this grows, lift to
* `frontend/src/lib/featureCatalog.ts` and source from a backend endpoint.
*
* Keys must match `feature_flags.flag_key` on the backend.
*/
const FEATURE_CATALOG: Record<string, FeatureMeta> = {
psa_integration: {
displayName: 'PSA Integration',
requiredPlan: 'Pro',
description: 'Sync tickets and assets with your PSA in real time.',
},
kb_accelerator: {
displayName: 'Knowledge Base Accelerator',
requiredPlan: 'Pro',
description: 'Auto-generate troubleshooting flows from your existing KB.',
},
ai_builder: {
displayName: 'AI Builder',
requiredPlan: 'Pro',
description: 'Generate decision trees from natural-language prompts.',
},
branching_logic: {
displayName: 'Branching Logic',
requiredPlan: 'Pro',
},
custom_branding: {
displayName: 'Custom Branding',
requiredPlan: 'Pro',
},
api_access: {
displayName: 'API Access',
requiredPlan: 'Pro',
},
sso: {
displayName: 'Single Sign-On',
requiredPlan: 'Enterprise',
},
}
/** Humanize an unknown feature key for the fallback display name. */
function humanizeFeatureKey(key: string): string {
return key
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
/**
* Standardized "this feature is on Pro" affordance.
*
* Renders a locked panel with a CTA that routes to the plan-selection page.
* The actual gating is enforced server-side via `require_feature` — this is UX.
*/
export function UpgradePrompt({ feature, className }: UpgradePromptProps) {
const meta = FEATURE_CATALOG[feature]
const displayName = meta?.displayName ?? humanizeFeatureKey(feature)
const requiredPlan = meta?.requiredPlan ?? 'Pro'
const description = meta?.description
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-lg border border-default bg-white/[0.04] px-6 py-10 text-center',
className,
)}
data-testid="upgrade-prompt"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-default bg-elevated text-muted-foreground">
<Lock className="h-4 w-4" aria-hidden="true" />
</div>
<div className="space-y-1">
<h3 className="text-base font-semibold text-heading">
{displayName} is available on {requiredPlan}
</h3>
{description && (
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
)}
</div>
<Link
to="/account/billing/select-plan"
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<Sparkles className="h-4 w-4" aria-hidden="true" />
Upgrade to {requiredPlan}
</Link>
</div>
)
}
export default UpgradePrompt

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { EmailVerificationGate } from '../EmailVerificationGate'
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'engineer',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
...overrides,
}
}
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>)
}
describe('EmailVerificationGate', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(FROZEN_NOW)
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
})
afterEach(() => {
vi.useRealTimers()
})
it('renders children when no user is signed in', () => {
renderWithRouter(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
expect(screen.getByText('protected')).toBeInTheDocument()
})
it('renders children when user has verified email', () => {
useAuthStore.setState({
user: makeUser({ email_verified_at: '2026-04-01T00:00:00Z' }),
})
renderWithRouter(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
expect(screen.getByText('protected')).toBeInTheDocument()
})
it('renders children on day 1 unverified (within grace)', () => {
// created 1 day before frozen now.
useAuthStore.setState({
user: makeUser({ created_at: '2026-05-05T00:00:00Z' }),
})
renderWithRouter(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
expect(screen.getByText('protected')).toBeInTheDocument()
})
it('renders children on day 6 unverified (last day of grace)', () => {
// created 6 days before frozen now.
useAuthStore.setState({
user: makeUser({ created_at: '2026-04-30T00:00:00Z' }),
})
renderWithRouter(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
expect(screen.getByText('protected')).toBeInTheDocument()
})
it('renders wall on day 7 unverified user', () => {
// created 7 days before frozen now -> elapsed=7, > grace=6 -> wall.
useAuthStore.setState({
user: makeUser({ created_at: '2026-04-29T00:00:00Z' }),
})
renderWithRouter(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
expect(screen.queryByText('protected')).not.toBeInTheDocument()
expect(screen.getByTestId('email-verification-wall')).toBeInTheDocument()
expect(screen.getByText(/Verify your email to continue/i)).toBeInTheDocument()
})
it('renders wall on day 8 unverified user', () => {
// created 8 days before frozen now.
useAuthStore.setState({
user: makeUser({ created_at: '2026-04-28T00:00:00Z' }),
})
renderWithRouter(
<EmailVerificationGate>
<div>protected</div>
</EmailVerificationGate>,
)
expect(screen.queryByText('protected')).not.toBeInTheDocument()
expect(screen.getByTestId('email-verification-wall')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { FeatureGate } from '../FeatureGate'
import { useBillingStore } from '@/store/billingStore'
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>)
}
describe('FeatureGate', () => {
beforeEach(() => {
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('renders children when flag enabled, fallback when disabled', () => {
// Disabled by default — renders default UpgradePrompt fallback.
const { unmount } = renderWithRouter(
<FeatureGate feature="psa_integration">
<div>protected content</div>
</FeatureGate>,
)
expect(screen.queryByText('protected content')).not.toBeInTheDocument()
expect(screen.getByTestId('upgrade-prompt')).toBeInTheDocument()
unmount()
// Enabled — renders children.
useBillingStore.setState({ enabledFeatures: { psa_integration: true } })
renderWithRouter(
<FeatureGate feature="psa_integration">
<div>protected content</div>
</FeatureGate>,
)
expect(screen.getByText('protected content')).toBeInTheDocument()
expect(screen.queryByTestId('upgrade-prompt')).not.toBeInTheDocument()
})
it('renders custom fallback when disabled', () => {
renderWithRouter(
<FeatureGate
feature="psa_integration"
fallback={<div>custom fallback</div>}
>
<div>protected</div>
</FeatureGate>,
)
expect(screen.getByText('custom fallback')).toBeInTheDocument()
expect(screen.queryByText('protected')).not.toBeInTheDocument()
})
it('renders nothing when fallback is null and feature disabled', () => {
const { container } = renderWithRouter(
<FeatureGate feature="psa_integration" fallback={null}>
<div>protected</div>
</FeatureGate>,
)
expect(screen.queryByText('protected')).not.toBeInTheDocument()
expect(container.textContent).toBe('')
})
})

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { UpgradePrompt } from '../UpgradePrompt'
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>)
}
describe('UpgradePrompt', () => {
it('renders display name and required plan from catalog', () => {
renderWithRouter(<UpgradePrompt feature="psa_integration" />)
expect(
screen.getByText(/PSA Integration is available on Pro/i),
).toBeInTheDocument()
})
it('CTA navigates to /account/billing/select-plan', () => {
renderWithRouter(<UpgradePrompt feature="psa_integration" />)
const cta = screen.getByRole('link', { name: /Upgrade to Pro/i })
expect(cta).toHaveAttribute('href', '/account/billing/select-plan')
})
it('humanizes unknown feature keys and falls back to Pro', () => {
renderWithRouter(<UpgradePrompt feature="some_new_feature" />)
expect(
screen.getByText(/Some New Feature is available on Pro/i),
).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,170 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ArrowRight, X } from 'lucide-react'
import { dismissOnboarding } from '@/api/onboarding'
import type { OnboardingStatus } from '@/api/onboarding'
import { useTrialBanner } from '@/hooks/useTrialBanner'
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
/**
* Next-step card — surfaces the single highest-priority incomplete onboarding
* item with a primary CTA. Replaces the old multi-item `OnboardingChecklist`
* widget at the top of the dashboard.
*
* `useOnboardingStatusQuery` is exported as a tiny shared hook so the parent
* page can decide whether to render the surrounding "Show all setup steps"
* toggle without duplicating the fetch.
*
* Returns `null` when:
* - status hasn't loaded yet
* - `status.dismissed` is true
* - all items are complete
*
* Priority order (first incomplete wins):
* 1. Verify your email
* 2. Set up your shop
* 3. Run your first FlowPilot session
* 4. Connect your PSA
* 5. Invite a teammate
* 6. Pick a plan (only when trial stage is warning / urgent / expired)
*/
export interface NextStepItem {
/** Stable id used in tests + analytics. */
key: string
title: string
description: string
ctaLabel: string
ctaPath: string
}
const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
'warning',
'urgent',
'expired',
]
/**
* Pure helper — picks the highest-priority incomplete item, or `null` when
* all relevant items are done. Exported for direct unit testing.
*/
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
export function pickNextStep(
status: OnboardingStatus,
trialStage: TrialBannerStage | null,
): NextStepItem | null {
if (!status.email_verified) {
return {
key: 'verify_email',
title: 'Verify your email',
description: 'Confirm your address to keep your account active after the grace period.',
ctaLabel: 'Verify email',
ctaPath: '/verify-email',
}
}
if (!status.shop_setup_done) {
return {
key: 'shop_setup',
title: 'Set up your shop',
description: 'Tell us a bit about your team so ResolutionFlow can tailor itself.',
ctaLabel: 'Set up shop',
ctaPath: '/welcome/step-1',
}
}
if (!status.ran_session) {
return {
key: 'ran_session',
title: 'Run your first FlowPilot session',
description: 'Paste a ticket or pick a flow to see ResolutionFlow in action.',
ctaLabel: 'Start a session',
ctaPath: '/',
}
}
if (!status.connected_psa) {
return {
key: 'connected_psa',
title: 'Connect your PSA',
description: 'Sync tickets from ConnectWise, Autotask, or HaloPSA.',
ctaLabel: 'Connect PSA',
ctaPath: '/account/integrations',
}
}
if (!status.invited_teammate) {
return {
key: 'invited_teammate',
title: 'Invite a teammate',
description: 'ResolutionFlow gets stronger when your whole team is on it.',
ctaLabel: 'Invite teammate',
ctaPath: '/account',
}
}
if (trialStage && PLAN_GATE_STAGES.includes(trialStage)) {
return {
key: 'pick_plan',
title: 'Pick a plan',
description: 'Your trial is wrapping up — pick a plan to keep using ResolutionFlow.',
ctaLabel: 'Pick a plan',
ctaPath: '/account/billing/select-plan',
}
}
return null
}
export function NextStepCard() {
const status = useOnboardingStatus()
const [locallyDismissed, setLocallyDismissed] = useState(false)
const { stage } = useTrialBanner()
if (!status || status.dismissed || locallyDismissed) return null
const next = pickNextStep(status, stage)
if (!next) return null
const handleDismiss = async () => {
setLocallyDismissed(true)
try {
await dismissOnboarding()
} catch {
// Already hidden locally — best-effort persist.
}
}
return (
<div
className="card-interactive overflow-hidden p-4 fade-in"
data-testid="next-step-card"
style={{ animationDelay: '150ms' }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Next step
</p>
<h3 className="mt-1 text-base font-semibold text-foreground">{next.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{next.description}</p>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss setup prompts"
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors shrink-0"
>
<X size={16} />
</button>
</div>
<div className="mt-3">
<Link
to={next.ctaPath}
data-testid="next-step-cta"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{next.ctaLabel}
<ArrowRight size={14} />
</Link>
</div>
</div>
)
}
export default NextStepCard

View File

@@ -1,160 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, X, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getOnboardingStatus, dismissOnboarding } from '@/api/onboarding'
import type { OnboardingStatus } from '@/api/onboarding'
interface ChecklistItem {
key: keyof OnboardingStatus
label: string
path: string
}
const SOLO_ITEMS: ChecklistItem[] = [
{ key: 'ran_session', label: 'Try troubleshooting a ticket', path: '/' },
{ key: 'exported_session', label: 'Review your session notes', path: '/sessions' },
{ key: 'created_flow', label: 'Explore guided flows', path: '/trees' },
{ key: 'tried_ai_assistant', label: 'Check out the Script Builder', path: '/script-builder' },
]
const TEAM_ITEMS: ChecklistItem[] = [
{ key: 'ran_session', label: 'Try troubleshooting a ticket', path: '/' },
{ key: 'exported_session', label: 'Review your session notes', path: '/sessions' },
{ key: 'invited_teammate', label: 'Invite a team member', path: '/account' },
{ key: 'created_flow', label: 'Explore guided flows', path: '/trees' },
{ key: 'connected_psa', label: 'Connect your PSA', path: '/account/integrations' },
]
export function OnboardingChecklist() {
const navigate = useNavigate()
const [status, setStatus] = useState<OnboardingStatus | null>(null)
const [dismissed, setDismissed] = useState(false)
const [allComplete, setAllComplete] = useState(false)
useEffect(() => {
getOnboardingStatus()
.then(setStatus)
.catch(() => {
// Silently fail — don't show checklist if endpoint unavailable
})
}, [])
const items = status?.is_team_user ? TEAM_ITEMS : SOLO_ITEMS
const completedCount = status
? items.filter((item) => status[item.key]).length
: 0
const totalCount = items.length
const isAllDone = completedCount === totalCount && status !== null
useEffect(() => {
if (isAllDone) {
const timer = setTimeout(() => setAllComplete(true), 2000)
return () => clearTimeout(timer)
}
}, [isAllDone])
// Don't render if dismissed, fully complete, or not loaded yet
if (!status || status.dismissed || dismissed || allComplete) return null
const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
const handleDismiss = async () => {
setDismissed(true)
try {
await dismissOnboarding()
} catch {
// Already hidden locally
}
}
return (
<div className="card-interactive overflow-hidden fade-in" style={{ animationDelay: '150ms' }}>
{/* Progress bar */}
<div className="h-1 w-full bg-[rgba(255,255,255,0.04)]">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="p-4">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Getting Started
</p>
<p className="text-sm text-foreground mt-0.5">
{isAllDone ? (
<span className="text-accent-text font-semibold">You're all set!</span>
) : (
<span>
<span className="text-accent-text font-semibold">{completedCount}</span>
{' '}of {totalCount} complete
</span>
)}
</p>
</div>
<button
onClick={handleDismiss}
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
aria-label="Dismiss onboarding checklist"
>
<X size={16} />
</button>
</div>
{/* Checklist items */}
<ul className="space-y-1">
{items.map((item) => {
const done = status[item.key]
return (
<li key={item.key}>
<button
onClick={() => !done && navigate(item.path)}
disabled={done}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
done
? 'cursor-default'
: 'hover:bg-[rgba(255,255,255,0.04)]'
)}
>
{/* Checkbox */}
<span
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
done
? 'bg-primary border-transparent'
: 'border-border'
)}
>
{done && <Check size={12} className="text-white" />}
</span>
{/* Label */}
<span
className={cn(
'flex-1',
done
? 'text-muted-foreground line-through'
: 'text-foreground'
)}
>
{item.label}
</span>
{/* Arrow for incomplete items */}
{!done && (
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
)}
</button>
</li>
)
})}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
import { Link } from 'react-router-dom'
import { Check, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { OnboardingStatus } from '@/api/onboarding'
import { useTrialBanner } from '@/hooks/useTrialBanner'
import type { TrialBannerStage } from '@/hooks/useTrialBanner'
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
/**
* Unified setup checklist — single list (no SOLO/TEAM bifurcation).
*
* Replaces the old `OnboardingChecklist` widget. Items match `NextStepCard`'s
* priority order. The "Pick a plan" item is gated on the trial stage.
*
* Surfaced behind a "Show all setup steps" toggle on the dashboard so the
* always-visible surface is just the single next-step card.
*/
interface ChecklistItem {
key: string
label: string
path: string
done: boolean
}
const PLAN_GATE_STAGES: ReadonlyArray<TrialBannerStage> = [
'warning',
'urgent',
'expired',
]
// eslint-disable-next-line react-refresh/only-export-components -- pure helper exported for focused unit tests
export function buildChecklistItems(
status: OnboardingStatus,
trialStage: TrialBannerStage | null,
): ChecklistItem[] {
const items: ChecklistItem[] = [
{
key: 'verify_email',
label: 'Verify your email',
path: '/verify-email',
done: status.email_verified,
},
{
key: 'shop_setup',
label: 'Set up your shop',
path: '/welcome/step-1',
done: status.shop_setup_done,
},
{
key: 'ran_session',
label: 'Run your first FlowPilot session',
path: '/',
done: status.ran_session,
},
{
key: 'connected_psa',
label: 'Connect your PSA',
path: '/account/integrations',
done: status.connected_psa,
},
{
key: 'invited_teammate',
label: 'Invite a teammate',
path: '/account',
done: status.invited_teammate,
},
]
if (trialStage && PLAN_GATE_STAGES.includes(trialStage)) {
items.push({
key: 'pick_plan',
label: 'Pick a plan',
path: '/account/billing/select-plan',
done: false,
})
}
return items
}
export function SetupChecklist() {
const status = useOnboardingStatus()
const { stage } = useTrialBanner()
if (!status || status.dismissed) return null
const items = buildChecklistItems(status, stage)
const completedCount = items.filter((i) => i.done).length
const totalCount = items.length
return (
<div className="card-interactive overflow-hidden" data-testid="setup-checklist">
<div className="px-4 pt-3 pb-2">
<p className="font-sans text-[0.625rem] uppercase tracking-[0.12em] font-semibold text-muted-foreground">
Setup steps · {completedCount} of {totalCount}
</p>
</div>
<ul className="px-2 pb-2 space-y-1">
{items.map((item) => (
<li key={item.key}>
{item.done ? (
<div
className="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm cursor-default"
data-testid={`checklist-item-${item.key}`}
data-done="true"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-transparent bg-primary">
<Check size={12} className="text-white" />
</span>
<span className="flex-1 text-muted-foreground line-through">
{item.label}
</span>
</div>
) : (
<Link
to={item.path}
className={cn(
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
'hover:bg-[rgba(255,255,255,0.04)]',
)}
data-testid={`checklist-item-${item.key}`}
data-done="false"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-border" />
<span className="flex-1 text-foreground">{item.label}</span>
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
</Link>
)}
</li>
))}
</ul>
</div>
)
}
export default SetupChecklist

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { NextStepCard, pickNextStep } from '../NextStepCard'
import { useBillingStore } from '@/store/billingStore'
import type { OnboardingStatus } from '@/api/onboarding'
vi.mock('@/api/onboarding', () => {
const mockGet = vi.fn()
const mockDismiss = vi.fn()
return {
getOnboardingStatus: mockGet,
dismissOnboarding: mockDismiss,
}
})
import {
getOnboardingStatus as _getOnboardingStatus,
} from '@/api/onboarding'
const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): OnboardingStatus {
return {
created_flow: false,
ran_session: false,
exported_session: false,
tried_ai_assistant: false,
invited_teammate: false,
connected_psa: false,
is_team_user: false,
dismissed: false,
email_verified: false,
shop_setup_done: false,
...overrides,
}
}
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>)
}
function setBillingComplimentary() {
// 'complimentary' status -> stage 'complimentary' (not in plan-gate set), so the
// "Pick a plan" item stays hidden — perfect default for unrelated tests.
useBillingStore.setState({
subscription: {
status: 'complimentary',
plan: 'pro',
current_period_start: '2026-05-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: true,
is_paid: true,
},
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
}
describe('NextStepCard', () => {
beforeEach(() => {
getOnboardingStatus.mockReset()
setBillingComplimentary()
})
it('renders Verify your email when email unverified', async () => {
getOnboardingStatus.mockResolvedValue(makeStatus({ email_verified: false }))
renderWithRouter(<NextStepCard />)
await waitFor(() => {
expect(screen.getByTestId('next-step-card')).toBeInTheDocument()
})
expect(screen.getByRole('heading', { name: /Verify your email/i })).toBeInTheDocument()
})
it('renders Set up your shop after email verified', async () => {
getOnboardingStatus.mockResolvedValue(
makeStatus({ email_verified: true, shop_setup_done: false }),
)
renderWithRouter(<NextStepCard />)
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Set up your shop/i })).toBeInTheDocument()
})
})
it('renders Run your first FlowPilot session after shop setup', async () => {
getOnboardingStatus.mockResolvedValue(
makeStatus({
email_verified: true,
shop_setup_done: true,
ran_session: false,
}),
)
renderWithRouter(<NextStepCard />)
await waitFor(() => {
expect(
screen.getByRole('heading', { name: /Run your first FlowPilot session/i }),
).toBeInTheDocument()
})
})
it('hidden when all items done', async () => {
getOnboardingStatus.mockResolvedValue(
makeStatus({
email_verified: true,
shop_setup_done: true,
ran_session: true,
connected_psa: true,
invited_teammate: true,
}),
)
const { container } = renderWithRouter(<NextStepCard />)
// Resolve the awaited promise.
await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled())
expect(container.querySelector('[data-testid="next-step-card"]')).toBeNull()
})
it('hidden when onboarding_dismissed', async () => {
getOnboardingStatus.mockResolvedValue(makeStatus({ dismissed: true }))
const { container } = renderWithRouter(<NextStepCard />)
await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled())
expect(container.querySelector('[data-testid="next-step-card"]')).toBeNull()
})
it('Pick a plan item appears when trial stage is warning or later', () => {
// Direct unit-test on the pure picker — easier than coordinating both the
// billing store + the network mock + a fake clock for stage='warning'.
const allDoneExceptPlan = makeStatus({
email_verified: true,
shop_setup_done: true,
ran_session: true,
connected_psa: true,
invited_teammate: true,
})
expect(pickNextStep(allDoneExceptPlan, 'pristine')).toBeNull()
expect(pickNextStep(allDoneExceptPlan, 'paid')).toBeNull()
expect(pickNextStep(allDoneExceptPlan, 'complimentary')).toBeNull()
expect(pickNextStep(allDoneExceptPlan, 'warning')?.key).toBe('pick_plan')
expect(pickNextStep(allDoneExceptPlan, 'urgent')?.key).toBe('pick_plan')
expect(pickNextStep(allDoneExceptPlan, 'expired')?.key).toBe('pick_plan')
})
})

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { SetupChecklist, buildChecklistItems } from '../SetupChecklist'
import { useBillingStore } from '@/store/billingStore'
import type { OnboardingStatus } from '@/api/onboarding'
vi.mock('@/api/onboarding', () => {
const mockGet = vi.fn()
return {
getOnboardingStatus: mockGet,
dismissOnboarding: vi.fn(),
}
})
import { getOnboardingStatus as _getOnboardingStatus } from '@/api/onboarding'
const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): OnboardingStatus {
return {
created_flow: false,
ran_session: false,
exported_session: false,
tried_ai_assistant: false,
invited_teammate: false,
connected_psa: false,
is_team_user: false,
dismissed: false,
email_verified: false,
shop_setup_done: false,
...overrides,
}
}
function renderWithRouter(ui: React.ReactElement) {
return render(<BrowserRouter>{ui}</BrowserRouter>)
}
function setBillingComplimentary() {
useBillingStore.setState({
subscription: {
status: 'complimentary',
plan: 'pro',
current_period_start: '2026-05-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: true,
is_paid: true,
},
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
}
describe('SetupChecklist', () => {
beforeEach(() => {
getOnboardingStatus.mockReset()
setBillingComplimentary()
})
it('renders unified list with no SOLO/TEAM headers', async () => {
getOnboardingStatus.mockResolvedValue(makeStatus())
renderWithRouter(<SetupChecklist />)
await waitFor(() => {
expect(screen.getByTestId('setup-checklist')).toBeInTheDocument()
})
// Single unified list — no team/solo section dividers (the old component had
// separate SOLO_ITEMS / TEAM_ITEMS branches; the new one is one flat list).
expect(screen.queryByText(/^SOLO$/)).toBeNull()
expect(screen.queryByText(/^TEAM$/)).toBeNull()
expect(screen.queryByText(/Solo users/i)).toBeNull()
expect(screen.queryByText(/Team users/i)).toBeNull()
// Core items present.
expect(screen.getByText(/Verify your email/i)).toBeInTheDocument()
expect(screen.getByText(/Set up your shop/i)).toBeInTheDocument()
expect(screen.getByText(/Run your first FlowPilot session/i)).toBeInTheDocument()
expect(screen.getByText(/Connect your PSA/i)).toBeInTheDocument()
expect(screen.getByText(/Invite a teammate/i)).toBeInTheDocument()
})
it('does NOT include the stale tried_ai_assistant / Script Builder item', async () => {
getOnboardingStatus.mockResolvedValue(makeStatus())
renderWithRouter(<SetupChecklist />)
await waitFor(() => {
expect(screen.getByTestId('setup-checklist')).toBeInTheDocument()
})
expect(screen.queryByText(/Script Builder/i)).toBeNull()
expect(screen.queryByText(/AI Assistant/i)).toBeNull()
})
it('hidden when onboarding_dismissed', async () => {
getOnboardingStatus.mockResolvedValue(makeStatus({ dismissed: true }))
const { container } = renderWithRouter(<SetupChecklist />)
await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled())
expect(container.querySelector('[data-testid="setup-checklist"]')).toBeNull()
})
describe('buildChecklistItems', () => {
it('does not include "Pick a plan" when stage is pristine', () => {
const items = buildChecklistItems(makeStatus(), 'pristine')
expect(items.find((i) => i.key === 'pick_plan')).toBeUndefined()
})
it('includes "Pick a plan" when stage is warning', () => {
const items = buildChecklistItems(makeStatus(), 'warning')
expect(items.find((i) => i.key === 'pick_plan')).toBeDefined()
})
it('includes "Pick a plan" when stage is urgent or expired', () => {
expect(
buildChecklistItems(makeStatus(), 'urgent').find((i) => i.key === 'pick_plan'),
).toBeDefined()
expect(
buildChecklistItems(makeStatus(), 'expired').find((i) => i.key === 'pick_plan'),
).toBeDefined()
})
})
})

View File

@@ -4,15 +4,20 @@ import { Menu, X, LayoutGrid, Clock, AlertTriangle, GitBranch, Wand2, BarChart3,
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { useBillingPoll } from '@/hooks/useBillingPoll'
import { BrandLogo } from '@/components/common/BrandLogo'
import { TopBar } from './TopBar'
import { Sidebar } from './Sidebar'
import { EmailVerificationBanner } from './EmailVerificationBanner'
import { EmailVerificationGate } from '@/components/common/EmailVerificationGate'
import { ViewTransitionOutlet } from './ViewTransitionOutlet'
import { FeedbackWidget } from '@/components/common/FeedbackWidget'
import { cn } from '@/lib/utils'
export function AppLayout() {
// Poll /billing/state every 60s while authenticated. Hook no-ops when logged out.
useBillingPoll()
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
@@ -169,7 +174,9 @@ export function AppLayout() {
{/* Main Content */}
<main className="main-content flex flex-col overflow-hidden min-h-0">
<EmailVerificationBanner />
<ViewTransitionOutlet />
<EmailVerificationGate>
<ViewTransitionOutlet />
</EmailVerificationGate>
</main>
</div>

View File

@@ -5,7 +5,39 @@ import { useAuthStore } from '@/store/authStore'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
export function EmailVerificationBanner() {
const MS_PER_DAY = 24 * 60 * 60 * 1000
/**
* Whole days elapsed between an ISO timestamp and now (floored).
*
* Mirrors the helper in `EmailVerificationGate` — keep the two in sync so the
* banner hides on the same day the wall appears (Day 7+ unverified). Defensive
* on bad timestamps: treats unparseable input as "just signed up" so we never
* accidentally hide the banner on a real unverified user.
*/
function daysSince(iso: string, now: number = Date.now()): number {
const created = Date.parse(iso)
if (Number.isNaN(created)) return 0
return Math.floor((now - created) / MS_PER_DAY)
}
interface EmailVerificationBannerProps {
/**
* Override the grace period (in days). Day `gracePeriodDays + 1` and beyond
* suppress the banner — `EmailVerificationGate` shows the wall instead.
* Defaults to 6 (matches the gate).
*/
gracePeriodDays?: number
}
/**
* Top-of-dashboard bar shown to users who signed up but haven't verified their
* email yet. Hides itself once the grace period expires (the wall takes over)
* and once the user dismisses it for the session.
*/
export function EmailVerificationBanner({
gracePeriodDays = 6,
}: EmailVerificationBannerProps = {}) {
const user = useAuthStore((s) => s.user)
const [dismissed, setDismissed] = useState(false)
const [isSending, setIsSending] = useState(false)
@@ -19,6 +51,11 @@ export function EmailVerificationBanner() {
if (!user || user.email_verified_at || dismissed || !verificationEnabled) return null
// Past grace period: the wall takes over inside <EmailVerificationGate>.
// Keep the banner out of the way so we don't double-show messaging.
const elapsed = daysSince(user.created_at)
if (elapsed > gracePeriodDays) return null
const handleResend = async () => {
setIsSending(true)
try {
@@ -32,22 +69,29 @@ export function EmailVerificationBanner() {
}
return (
<div className="flex items-center gap-3 border-b border-amber-400/20 bg-amber-400/5 px-4 py-2 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-400" />
<span className="text-amber-200">
<div
data-testid="email-verification-banner"
className="flex items-center gap-3 border-b border-warning/20 bg-warning-dim px-4 py-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span className="text-foreground">
Your email is not verified.
</span>
<button
type="button"
onClick={handleResend}
disabled={isSending}
data-testid="banner-resend-button"
className={cn(
'text-amber-400 underline hover:text-amber-300 disabled:opacity-50'
'text-warning underline hover:opacity-80 disabled:opacity-50',
)}
>
{isSending ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Resend verification email'}
</button>
<button
type="button"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
className="ml-auto text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />

View File

@@ -6,6 +6,7 @@ import { usePermissions } from '@/hooks/usePermissions'
import { BrandLogo } from '@/components/common/BrandLogo'
import { CommandPalette } from './CommandPalette'
import { NotificationsPanel } from './NotificationsPanel'
import { TrialPill } from './TrialPill'
import { cn } from '@/lib/utils'
export function TopBar() {
@@ -110,6 +111,9 @@ export function TopBar() {
{/* Spacer - push actions to right */}
<div className="flex-1" />
{/* Billing-state pill (trial countdown / paid tier / past_due / etc.) */}
<TrialPill />
{/* Action buttons */}
<div className="flex items-center gap-1">
<Link

View File

@@ -0,0 +1,147 @@
import { Link } from 'react-router-dom'
import { Clock } from 'lucide-react'
import { useTrialBanner } from '@/hooks/useTrialBanner'
import { useBillingStore } from '@/store/billingStore'
import { cn } from '@/lib/utils'
/**
* Topbar billing-state pill.
*
* Reads `useTrialBanner()` to map subscription state → label + tone.
* Returns `null` when there is nothing to display (e.g. subscription not yet
* loaded). Clickable variants (expired / past_due / canceled) render as
* keyboard-focusable `<Link>`s; static variants render as `<span>`.
*
* Mobile: when the topbar is too narrow, the label collapses to a clock icon
* with a `title` tooltip carrying the full text.
*/
interface PillContent {
/** Full label shown on >= sm. */
label: string
/** Short label for mobile (sm:hidden); typically a single token / icon. */
shortLabel?: string
/** Tailwind classes applied to the pill (color tokens). */
toneClass: string
/** When set, render as a clickable Link to this route. */
href?: string
/** Extra emphasis (used by `urgent` to differentiate from `warning`). */
emphasized?: boolean
}
const BASE_CLASS =
'trial-pill inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors whitespace-nowrap'
export function TrialPill() {
const { stage, daysRemaining } = useTrialBanner()
const planBilling = useBillingStore((s) => s.planBilling)
const content = resolveContent(stage, daysRemaining, planBilling?.display_name ?? null)
if (!content) return null
const className = cn(
BASE_CLASS,
content.toneClass,
content.emphasized && 'font-semibold',
content.href &&
'cursor-pointer hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-bg-sidebar',
)
const inner = (
<>
<span className="hidden sm:inline">{content.label}</span>
<span className="sm:hidden inline-flex items-center" aria-hidden="true">
<Clock size={14} />
</span>
</>
)
if (content.href) {
return (
<Link
to={content.href}
className={className}
title={content.label}
data-testid="trial-pill"
>
{inner}
</Link>
)
}
return (
<span
className={className}
title={content.label}
data-testid="trial-pill"
>
{inner}
</span>
)
}
function resolveContent(
stage: ReturnType<typeof useTrialBanner>['stage'],
daysRemaining: number | null,
paidDisplayName: string | null,
): PillContent | null {
switch (stage) {
case null:
return null
case 'pristine': {
const days = daysRemaining ?? 0
return {
label: `Pro trial · ${days}d`,
toneClass: 'text-info bg-info-dim',
}
}
case 'warning': {
const days = daysRemaining ?? 0
return {
label: `Pro trial · ${days}d`,
toneClass: 'text-warning bg-warning-dim',
}
}
case 'urgent':
return {
label: 'Pro trial · today',
toneClass: 'text-warning bg-warning-dim',
emphasized: true,
}
case 'expired':
return {
label: 'Trial expired — pick a plan',
toneClass: 'text-danger bg-danger-dim',
href: '/account/billing/select-plan',
}
case 'paid':
return {
label: paidDisplayName ?? 'Pro',
toneClass: 'text-muted-foreground bg-elevated',
}
case 'complimentary':
return {
label: 'Complimentary Pro',
toneClass: 'text-accent bg-accent-dim',
}
case 'past_due':
return {
label: 'Payment failed — update card',
toneClass: 'text-warning bg-warning-dim',
href: '/account/billing',
}
case 'canceled':
return {
label: 'Reactivate',
toneClass: 'text-warning bg-warning-dim',
href: '/account/billing/select-plan',
}
default: {
const _exhaustive: never = stage
void _exhaustive
return null
}
}
}
export default TrialPill

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { AppLayout } from '../AppLayout'
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
// Mock heavy/external pieces so this stays a focused integration test for the
// gate placement. We don't care that TopBar/Sidebar render real content here —
// only that the EmailVerificationGate is in the tree and gates the outlet.
vi.mock('@/hooks/useBillingPoll', () => ({
useBillingPoll: () => undefined,
}))
vi.mock('@/hooks/usePermissions', () => ({
usePermissions: () => ({ effectiveRole: 'engineer' }),
}))
vi.mock('../TopBar', () => ({
TopBar: () => <div data-testid="top-bar" />,
}))
vi.mock('../Sidebar', () => ({
Sidebar: () => <div data-testid="sidebar" />,
}))
vi.mock('../EmailVerificationBanner', () => ({
EmailVerificationBanner: () => <div data-testid="email-verification-banner-mock" />,
}))
vi.mock('@/components/common/FeedbackWidget', () => ({
FeedbackWidget: () => null,
}))
vi.mock('@/api/auth', () => ({
authApi: {
getVerificationStatus: vi.fn().mockResolvedValue({ enabled: true }),
sendVerificationEmail: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('@/lib/toast', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'engineer',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
...overrides,
}
}
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
function renderAppLayout() {
return render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route element={<AppLayout />}>
<Route
index
element={<div data-testid="child-route-content">child route</div>}
/>
</Route>
</Routes>
</MemoryRouter>,
)
}
describe('AppLayout — EmailVerificationGate wiring', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(FROZEN_NOW)
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
})
afterEach(() => {
vi.useRealTimers()
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
})
it('renders the wall and hides the child route on day 8 unverified', () => {
// created 8 days before frozen now -> elapsed=8, > grace=6 -> wall.
useAuthStore.setState({
user: makeUser({ created_at: '2026-04-28T00:00:00Z' }),
})
renderAppLayout()
expect(screen.getByTestId('email-verification-wall')).toBeInTheDocument()
expect(screen.queryByTestId('child-route-content')).not.toBeInTheDocument()
})
it('renders the child route within the grace period (day 1 unverified)', () => {
useAuthStore.setState({
user: makeUser({ created_at: '2026-05-05T00:00:00Z' }),
})
renderAppLayout()
expect(screen.getByTestId('child-route-content')).toBeInTheDocument()
expect(
screen.queryByTestId('email-verification-wall'),
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { EmailVerificationBanner } from '../EmailVerificationBanner'
import { useAuthStore } from '@/store/authStore'
import { authApi } from '@/api/auth'
import type { User } from '@/types'
vi.mock('@/api/auth', () => ({
authApi: {
getVerificationStatus: vi.fn(),
sendVerificationEmail: vi.fn(),
},
}))
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'engineer',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
...overrides,
}
}
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
describe('EmailVerificationBanner', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(FROZEN_NOW)
useAuthStore.setState({ user: null, token: null, isAuthenticated: false })
vi.mocked(authApi.getVerificationStatus).mockResolvedValue({
enabled: true,
})
vi.mocked(authApi.sendVerificationEmail).mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('hides past grace day-7+', async () => {
// Created 8 days before frozen now -> elapsed=8, > grace=6.
useAuthStore.setState({
user: makeUser({ created_at: '2026-04-28T00:00:00Z' }),
})
const { container } = render(<EmailVerificationBanner />)
// Wait long enough for any pending verification-status fetch to resolve.
await waitFor(() => {
expect(authApi.getVerificationStatus).toHaveBeenCalled()
})
expect(
screen.queryByTestId('email-verification-banner'),
).not.toBeInTheDocument()
expect(container.firstChild).toBeNull()
})
it('renders within the grace window', async () => {
// Created 1 day before frozen now -> elapsed=1, within grace.
useAuthStore.setState({
user: makeUser({ created_at: '2026-05-05T00:00:00Z' }),
})
render(<EmailVerificationBanner />)
await waitFor(() => {
expect(
screen.getByTestId('email-verification-banner'),
).toBeInTheDocument()
})
})
it('resend triggers API call', async () => {
useAuthStore.setState({
user: makeUser({ created_at: '2026-05-05T00:00:00Z' }),
})
render(<EmailVerificationBanner />)
await waitFor(() => {
expect(
screen.getByTestId('email-verification-banner'),
).toBeInTheDocument()
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
await user.click(screen.getByTestId('banner-resend-button'))
await waitFor(() => {
expect(authApi.sendVerificationEmail).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { TrialPill } from '../TrialPill'
import { useBillingStore } from '@/store/billingStore'
import type { SubscriptionState, PlanBillingState } from '@/types/billing'
const FROZEN_NOW = new Date('2026-05-06T12:00:00Z')
function renderPill() {
return render(
<MemoryRouter>
<TrialPill />
</MemoryRouter>,
)
}
function setBilling(opts: {
subscription: SubscriptionState | null
planBilling?: PlanBillingState | null
}) {
useBillingStore.setState({
subscription: opts.subscription,
planBilling: opts.planBilling ?? null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
}
function isoDaysFromNow(days: number): string {
const d = new Date(FROZEN_NOW.getTime() + days * 24 * 60 * 60 * 1000)
return d.toISOString()
}
describe('TrialPill', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(FROZEN_NOW)
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('renders Pro trial · Nd for pristine stage', () => {
setBilling({
subscription: {
status: 'trialing',
plan: 'pro',
current_period_start: FROZEN_NOW.toISOString(),
current_period_end: isoDaysFromNow(12),
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: false,
},
})
renderPill()
const pill = screen.getByTestId('trial-pill')
expect(pill).toHaveTextContent(/Pro trial · 12d/)
// Pristine uses info tone tokens.
expect(pill.className).toContain('text-info')
expect(pill.className).toContain('bg-info-dim')
})
it('renders Trial expired CTA for expired stage', () => {
setBilling({
subscription: {
status: 'trialing',
plan: 'pro',
current_period_start: isoDaysFromNow(-14),
current_period_end: isoDaysFromNow(-1), // already past
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: false,
is_paid: false,
},
})
renderPill()
const pill = screen.getByTestId('trial-pill')
expect(pill).toHaveTextContent(/Trial expired — pick a plan/)
// Clickable: rendered as anchor/link.
expect(pill.tagName).toBe('A')
expect(pill.getAttribute('href')).toBe('/account/billing/select-plan')
})
it('renders Complimentary Pro tag for complimentary subscription', () => {
setBilling({
subscription: {
status: 'complimentary',
plan: 'pro',
current_period_start: null,
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: true,
is_paid: true,
},
})
renderPill()
const pill = screen.getByTestId('trial-pill')
expect(pill).toHaveTextContent(/Complimentary Pro/)
// Friendly tag, not clickable.
expect(pill.tagName).toBe('SPAN')
expect(pill.className).toContain('text-accent')
})
it('is hidden when subscription is null', () => {
setBilling({ subscription: null })
const { container } = renderPill()
expect(screen.queryByTestId('trial-pill')).not.toBeInTheDocument()
expect(container.firstChild).toBeNull()
})
it('past_due variant is clickable and links to /account/billing', () => {
setBilling({
subscription: {
status: 'past_due',
plan: 'pro',
current_period_start: isoDaysFromNow(-30),
current_period_end: isoDaysFromNow(-2),
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: false,
is_paid: true,
},
})
renderPill()
const pill = screen.getByTestId('trial-pill')
expect(pill).toHaveTextContent(/Payment failed — update card/)
expect(pill.tagName).toBe('A')
expect(pill.getAttribute('href')).toBe('/account/billing')
})
})

View File

@@ -0,0 +1,96 @@
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) return
let active = true
const handler = (c: PublicConfig) => {
if (active) setConfig(c)
}
subscribers.add(handler)
void loadConfig()
return () => {
active = false
subscribers.delete(handler)
}
}, [])
if (config) {
return {
self_serve_enabled: config.self_serve_enabled,
oauth_providers: config.oauth_providers,
isLoading: false,
}
}
return {
self_serve_enabled: false,
oauth_providers: [],
isLoading: true,
}
}
export default useAppConfig

View File

@@ -0,0 +1,32 @@
import { useEffect } from 'react'
import { useAuthStore } from '@/store/authStore'
import { useBillingStore } from '@/store/billingStore'
const POLL_INTERVAL_MS = 60_000
/**
* Re-fetches billing state every 60s while a user is logged in.
*
* Mount once at the top of the authenticated dashboard tree. Polling
* automatically pauses when the auth store reports no logged-in user.
*
* Note: this is a v1 simple-interval implementation; a later task may
* swap to SSE / visibility-aware polling.
*/
export function useBillingPoll(): void {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
useEffect(() => {
if (!isAuthenticated) return
const id = window.setInterval(() => {
void useBillingStore.getState().refetch()
}, POLL_INTERVAL_MS)
return () => {
window.clearInterval(id)
}
}, [isAuthenticated])
}
export default useBillingPoll

View File

@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useFeature } from './useFeature'
import { useBillingStore } from '@/store/billingStore'
describe('useFeature', () => {
beforeEach(() => {
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('returns false when flag absent', () => {
const { result } = renderHook(() => useFeature('does_not_exist'))
expect(result.current).toBe(false)
})
it('returns true when flag is enabled', () => {
useBillingStore.setState({ enabledFeatures: { ai_builder: true } })
const { result } = renderHook(() => useFeature('ai_builder'))
expect(result.current).toBe(true)
})
it('returns false when flag is explicitly disabled', () => {
useBillingStore.setState({ enabledFeatures: { ai_builder: false } })
const { result } = renderHook(() => useFeature('ai_builder'))
expect(result.current).toBe(false)
})
it('updates when store changes (subscribes to store)', () => {
const { result } = renderHook(() => useFeature('foo'))
expect(result.current).toBe(false)
act(() => {
useBillingStore.setState({ enabledFeatures: { foo: true } })
})
expect(result.current).toBe(true)
})
})

View File

@@ -0,0 +1,16 @@
import { useBillingStore } from '@/store/billingStore'
/**
* Returns whether a feature flag is enabled for the current account.
*
* Reads from `useBillingStore.enabledFeatures`, which is populated by
* `GET /billing/state`. Returns `false` when the flag is absent (closed-by-default).
*
* The hook subscribes to the store so updates from `refetch()` propagate
* without manual refetch in the component.
*/
export function useFeature(flagKey: string): boolean {
return useBillingStore((state) => Boolean(state.enabledFeatures[flagKey]))
}
export default useFeature

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useFeatureLimit, clearUsageCache } from './useFeatureLimit'
import { useBillingStore } from '@/store/billingStore'
vi.mock('@/api/usage', () => ({
usageApi: {
getCount: vi.fn(),
},
}))
import { usageApi } from '@/api/usage'
const mockedGetCount = vi.mocked(usageApi.getCount)
describe('useFeatureLimit', () => {
beforeEach(() => {
clearUsageCache()
mockedGetCount.mockReset()
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('transitions isLoading -> loaded', async () => {
useBillingStore.setState({ planLimits: { active_users: 10 } })
mockedGetCount.mockResolvedValueOnce({ used: 4 })
const { result } = renderHook(() => useFeatureLimit('active_users'))
// Non-blocking initial state.
expect(result.current.isLoading).toBe(true)
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(10)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.used).toBe(4)
expect(result.current.limit).toBe(10)
expect(result.current.percentage).toBe(40)
expect(result.current.isAtLimit).toBe(false)
})
it('flags isAtLimit when used >= limit', async () => {
useBillingStore.setState({ planLimits: { seats: 3 } })
mockedGetCount.mockResolvedValueOnce({ used: 3 })
const { result } = renderHook(() => useFeatureLimit('seats'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.isAtLimit).toBe(true)
expect(result.current.percentage).toBe(100)
})
it('returns null percentage when limit is null (unlimited)', async () => {
useBillingStore.setState({ planLimits: { sessions: null } })
mockedGetCount.mockResolvedValueOnce({ used: 7 })
const { result } = renderHook(() => useFeatureLimit('sessions'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.limit).toBe(null)
expect(result.current.percentage).toBe(null)
expect(result.current.isAtLimit).toBe(false)
})
it('resets isLoading=true synchronously when `field` prop changes', async () => {
useBillingStore.setState({ planLimits: { max_trees: 5, max_users: 10 } })
mockedGetCount.mockResolvedValueOnce({ used: 2 }) // for max_trees
mockedGetCount.mockResolvedValueOnce({ used: 3 }) // for max_users (slow)
const { result, rerender } = renderHook(
({ field }: { field: string }) => useFeatureLimit(field),
{ initialProps: { field: 'max_trees' } },
)
// First field resolves.
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(2)
expect(result.current.limit).toBe(5)
// Switch field. Next render must report isLoading=true (no stale data
// bleed-through) before the new fetch resolves.
rerender({ field: 'max_users' })
expect(result.current.isLoading).toBe(true)
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(10)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(3)
expect(result.current.limit).toBe(10)
})
it('degrades to used=0 on fetch error (404 from missing endpoint)', async () => {
useBillingStore.setState({ planLimits: { active_users: 5 } })
mockedGetCount.mockRejectedValueOnce(new Error('Request failed with status 404'))
const { result } = renderHook(() => useFeatureLimit('active_users'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.used).toBe(0)
expect(result.current.limit).toBe(5)
expect(result.current.percentage).toBe(0)
})
})

View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
import { useBillingStore } from '@/store/billingStore'
import { usageApi } from '@/api/usage'
const CACHE_TTL_MS = 60 * 1000
interface CacheEntry {
used: number
timestamp: number
}
const cache = new Map<string, CacheEntry>()
/** Clear the usage cache (call on logout to prevent stale data across users). */
export function clearUsageCache() {
cache.clear()
}
export interface FeatureLimitResult {
used: number
limit: number | null
/** null when limit is null (unlimited) or unknown */
percentage: number | null
isAtLimit: boolean
isLoading: boolean
}
function coerceLimit(raw: unknown): number | null {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
if (raw === null || raw === undefined) return null
// The store types planLimits as Record<string, unknown>; the backend
// currently returns numbers, but defensively handle string ints too.
if (typeof raw === 'string') {
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
return null
}
/**
* Returns progress against a quantitative plan limit.
*
* `limit` comes from `useBillingStore.planLimits[field]`, which is read
* synchronously from the store. `used` is fetched lazily from
* `GET /api/v1/usage/{field}` on mount and cached for 60s in a module-level
* map keyed by field.
*
* Render is non-blocking: the hook returns `isLoading=true` (with `used=0`)
* until the usage fetch resolves. On 404 or any error the hook degrades to
* `used=0` with `isLoading=false` rather than surfacing the error — the
* `/usage/{field}` endpoint is not yet implemented on the backend (planned).
*/
export function useFeatureLimit(field: string): FeatureLimitResult {
const limit = useBillingStore((state) => coerceLimit(state.planLimits[field]))
const [state, setState] = useState(() => {
const existing = cache.get(field)
const fresh = existing && Date.now() - existing.timestamp < CACHE_TTL_MS
return {
field,
used: fresh ? existing.used : 0,
isLoading: !fresh,
}
})
useEffect(() => {
const existing = cache.get(field)
if (existing && Date.now() - existing.timestamp < CACHE_TTL_MS) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync local hook state with fresh module cache on field change
setState({ field, used: existing.used, isLoading: false })
return
}
let cancelled = false
setState({ field, used: 0, isLoading: true })
usageApi
.getCount(field)
.then((result) => {
if (cancelled) return
cache.set(field, { used: result.used, timestamp: Date.now() })
setState({ field, used: result.used, isLoading: false })
})
.catch(() => {
// TODO: backend /usage/{field} endpoint not yet implemented (planned).
// 404s and other errors degrade to used=0 silently — no toast.
if (cancelled) return
setState({ field, used: 0, isLoading: false })
})
return () => {
cancelled = true
}
}, [field])
const used = state.field === field ? state.used : 0
const isLoading = state.field === field ? state.isLoading : true
const percentage =
limit === null || limit <= 0 ? null : Math.min(100, Math.round((used / limit) * 100))
const isAtLimit = limit !== null && used >= limit
return { used, limit, percentage, isAtLimit, isLoading }
}
export default useFeatureLimit

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import { getOnboardingStatus } from '@/api/onboarding'
import type { OnboardingStatus } from '@/api/onboarding'
/**
* Tiny shared hook that fetches `/users/onboarding-status` once on mount.
*
* Used by `NextStepCard`, `SetupChecklist`, and `QuickStartPage` so the toggle
* row can disappear when there's nothing to show. Each consumer has its own
* state — fetches are not deduplicated. That's fine for now; if it becomes a
* problem we can lift this into a Zustand store or react-query.
*/
export function useOnboardingStatus(): OnboardingStatus | null {
const [status, setStatus] = useState<OnboardingStatus | null>(null)
useEffect(() => {
getOnboardingStatus()
.then(setStatus)
.catch(() => {
// Silently fail — never block the dashboard if the endpoint is down.
})
}, [])
return status
}
export default useOnboardingStatus

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useTrialBanner } from './useTrialBanner'
import { useBillingStore } from '@/store/billingStore'
import type { SubscriptionState } from '@/types/billing'
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
function makeSub(overrides: Partial<SubscriptionState>): SubscriptionState {
return {
status: 'trialing',
plan: 'starter',
current_period_start: '2026-05-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: false,
is_paid: false,
...overrides,
}
}
function setSubscription(overrides: Partial<SubscriptionState>) {
useBillingStore.setState({ subscription: makeSub(overrides) })
}
describe('useTrialBanner', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(FROZEN_NOW)
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
afterEach(() => {
vi.useRealTimers()
})
describe('stage matches subscription state matrix', () => {
it('returns null when subscription is null (no flicker on initial load)', () => {
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe(null)
expect(result.current.daysRemaining).toBe(null)
})
it('complimentary status -> complimentary stage', () => {
setSubscription({ status: 'complimentary' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('complimentary')
})
it('active status -> paid stage', () => {
setSubscription({ status: 'active' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('paid')
})
it('past_due status -> past_due stage', () => {
setSubscription({ status: 'past_due' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('past_due')
})
it('canceled status -> canceled stage', () => {
setSubscription({ status: 'canceled' })
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('canceled')
})
it('trialing >3 days remaining -> pristine', () => {
// 7 days from frozen now.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-13T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('pristine')
expect(result.current.daysRemaining).toBe(7)
})
it('trialing 1-3 days remaining -> warning', () => {
// 2 days from frozen now.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-08T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('warning')
expect(result.current.daysRemaining).toBe(2)
})
it('trialing exactly 24 hours remaining -> warning (boundary, not urgent)', () => {
// Exactly 1.0 fractional day from frozen now — must sit on the warning
// side per spec (13 days inclusive of 1).
setSubscription({
status: 'trialing',
current_period_end: '2026-05-07T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('warning')
expect(result.current.daysRemaining).toBe(1)
})
it('trialing <1 day remaining -> urgent', () => {
// 12 hours from frozen now -> Math.ceil(0.5) = 1 day.
setSubscription({
status: 'trialing',
current_period_end: '2026-05-06T12:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('urgent')
expect(result.current.daysRemaining).toBe(1)
})
it('trialing past period_end -> expired', () => {
setSubscription({
status: 'trialing',
current_period_end: '2026-05-01T00:00:00Z',
})
const { result } = renderHook(() => useTrialBanner())
expect(result.current.stage).toBe('expired')
expect(result.current.daysRemaining).toBe(0)
})
})
})

View File

@@ -0,0 +1,87 @@
import { useState } from 'react'
import { useBillingStore } from '@/store/billingStore'
export type TrialBannerStage =
| 'pristine'
| 'warning'
| 'urgent'
| 'expired'
| 'complimentary'
| 'paid'
| 'past_due'
| 'canceled'
export interface TrialBannerResult {
stage: TrialBannerStage | null
daysRemaining: number | null
}
const MS_PER_DAY = 24 * 60 * 60 * 1000
/**
* Derives the trial-banner display stage from the current subscription.
*
* Returns `{ stage: null, daysRemaining: null }` when subscription data is
* not yet loaded — this prevents the banner flickering on initial render.
*
* Subscribes to `useBillingStore` so updates from `refetch()` after a Stripe
* checkout propagate automatically.
*/
export function useTrialBanner(): TrialBannerResult {
const subscription = useBillingStore((state) => state.subscription)
const [now] = useState(() => Date.now())
if (!subscription) {
return { stage: null, daysRemaining: null }
}
switch (subscription.status) {
case 'complimentary':
return { stage: 'complimentary', daysRemaining: null }
case 'active':
return { stage: 'paid', daysRemaining: null }
case 'past_due':
return { stage: 'past_due', daysRemaining: null }
case 'canceled':
return { stage: 'canceled', daysRemaining: null }
case 'trialing': {
const end = subscription.current_period_end
? new Date(subscription.current_period_end).getTime()
: null
if (end === null || Number.isNaN(end)) {
// Trialing without a period end is malformed; treat as expired so the
// upgrade prompt still surfaces rather than silently swallowing it.
return { stage: 'expired', daysRemaining: null }
}
if (end <= now) {
return { stage: 'expired', daysRemaining: 0 }
}
const msRemaining = end - now
// Use fractional days for stage thresholds so exactly 24h remaining
// sits on the warning side (1.0), not urgent. The displayed integer
// countdown still uses Math.ceil so "0.5 days" renders as "1 day".
const fractionalDays = msRemaining / MS_PER_DAY
const daysRemaining = Math.ceil(fractionalDays)
// Spec thresholds:
// >3 days remaining → pristine
// 13 days → warning (inclusive of exactly 1)
// <1 day → urgent
let stage: TrialBannerStage = 'pristine'
if (fractionalDays < 1) stage = 'urgent'
else if (fractionalDays <= 3) stage = 'warning'
return { stage, daysRemaining }
}
case 'incomplete':
// Not in the spec's matrix; surface as null so the banner stays hidden
// until checkout actually resolves.
return { stage: null, daysRemaining: null }
default: {
// Defensive fallthrough for unknown statuses — keep the banner hidden.
const _exhaustive: never = subscription.status as never
void _exhaustive
return { stage: null, daysRemaining: null }
}
}
}
export default useTrialBanner

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { encodeOAuthState, decodeOAuthState } from './oauthState'
describe('oauthState', () => {
it('round-trips ASCII payloads', () => {
const encoded = encodeOAuthState({
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@example.com',
})
expect(encoded).not.toContain('+')
expect(encoded).not.toContain('/')
expect(encoded).not.toContain('=')
expect(decodeOAuthState(encoded)).toEqual({
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@example.com',
})
})
it('round-trips non-Latin-1 email characters without throwing', () => {
// Pre-fix: btoa(json) throws DOMException on code points > 255.
const payload = {
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@münchen.de',
}
const encoded = encodeOAuthState(payload)
expect(decodeOAuthState(encoded)).toEqual(payload)
})
it('round-trips emoji and CJK characters', () => {
const payload = {
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: '日本語+🎉@例え.jp',
}
expect(decodeOAuthState(encodeOAuthState(payload))).toEqual(payload)
})
it('returns null for legacy raw-hex CSRF state (not JSON)', () => {
expect(decodeOAuthState('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull()
})
it('returns null for null / empty input', () => {
expect(decodeOAuthState(null)).toBeNull()
expect(decodeOAuthState('')).toBeNull()
})
it('returns null for malformed base64', () => {
expect(decodeOAuthState('!!!not-base64!!!')).toBeNull()
})
})

View File

@@ -0,0 +1,61 @@
/**
* UTF-8-safe base64url encoding for OAuth `state` payloads.
*
* The /accept-invite flow round-trips an invite code + invited email through
* the OAuth provider's `state` parameter. Internationalized email addresses
* (e.g., `user@münchen.de`) contain code points > 255, which raw `btoa` /
* `atob` cannot represent — they throw `DOMException: The string to be
* encoded contains characters outside of the Latin1 range`.
*
* The classic `unescape(encodeURIComponent(...))` trick maps a UTF-16 string
* through its UTF-8 byte representation into a Latin-1 string that `btoa`
* accepts. The decode side reverses the transformation.
*/
export interface OAuthStatePayload {
csrf: string
accountInviteCode: string
invitedEmail: string
}
export interface DecodedOAuthState {
csrf: string
accountInviteCode?: string
invitedEmail?: string
}
/** Encode an OAuth state payload as URL-safe base64. UTF-8 safe. */
export function encodeOAuthState(payload: OAuthStatePayload): string {
const json = JSON.stringify(payload)
// unescape(encodeURIComponent(...)) converts UTF-16 -> UTF-8 -> Latin-1
// string so btoa can encode it without throwing on non-Latin-1 chars.
const b64 = btoa(unescape(encodeURIComponent(json)))
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/** Best-effort base64url-decode. Returns null on legacy random-hex states or
* malformed input so the caller can fall back to a simple equality check. */
export function decodeOAuthState(raw: string | null): DecodedOAuthState | null {
if (!raw) return null
try {
const padded = raw.replace(/-/g, '+').replace(/_/g, '/')
const b64 = padded + '='.repeat((4 - (padded.length % 4)) % 4)
// decodeURIComponent(escape(...)) reverses the encode-side transform.
const json = decodeURIComponent(escape(atob(b64)))
const parsed = JSON.parse(json) as Partial<DecodedOAuthState>
if (typeof parsed?.csrf === 'string') {
return {
csrf: parsed.csrf,
accountInviteCode:
typeof parsed.accountInviteCode === 'string'
? parsed.accountInviteCode
: undefined,
invitedEmail:
typeof parsed.invitedEmail === 'string' ? parsed.invitedEmail : undefined,
}
}
return null
} catch {
return null
}
}

View File

@@ -0,0 +1,372 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { inviteApi, type AccountInviteLookup } from '@/api/invite'
import { useAuthStore } from '@/store/authStore'
import { useAppConfig } from '@/hooks/useAppConfig'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PasswordInput } from '@/components/common/PasswordInput'
import { PageMeta } from '@/components/common/PageMeta'
import { buildOAuthAuthorizeUrl } from './RegisterPage'
import { cn } from '@/lib/utils'
import { encodeOAuthState } from '@/lib/oauthState'
function randomCsrf(): string {
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('')
}
type LookupState =
| { status: 'loading' }
| { status: 'ok'; data: AccountInviteLookup }
| { status: 'invalid' }
| { status: 'missing-code' }
export function AcceptInvitePage() {
const navigate = useNavigate()
const location = useLocation()
const { register, isLoading, error, clearError } = useAuthStore()
const appConfig = useAppConfig()
const code = useMemo(() => {
const search = new URLSearchParams(location.search)
return (search.get('code') || '').trim()
}, [location.search])
const [lookup, setLookup] = useState<LookupState>(
code ? { status: 'loading' } : { status: 'missing-code' },
)
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [localError, setLocalError] = useState('')
useEffect(() => {
if (!code) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- route changes without a code should replace stale lookup state
setLookup({ status: 'missing-code' })
return
}
let cancelled = false
setLookup({ status: 'loading' })
void (async () => {
try {
const data = await inviteApi.lookupAccountInvite(code)
if (cancelled) return
setLookup({ status: 'ok', data })
} catch {
if (cancelled) return
// Any error — 404, 410, network — collapses to the same "ask the
// inviter to resend" UX. Anti-enumeration is enforced server-side.
setLookup({ status: 'invalid' })
}
})()
return () => {
cancelled = true
}
}, [code])
const googleAvailable = appConfig.oauth_providers.includes('google')
const microsoftAvailable = appConfig.oauth_providers.includes('microsoft')
const handleOAuth = (provider: 'google' | 'microsoft') => {
if (lookup.status !== 'ok') return
const csrf = randomCsrf()
try {
sessionStorage.setItem('rf-oauth-state', csrf)
} catch {
// ignore — non-fatal
}
const stateValue = encodeOAuthState({
csrf,
accountInviteCode: code,
invitedEmail: lookup.data.invited_email,
})
const url = buildOAuthAuthorizeUrl(provider, stateValue)
window.location.href = url
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLocalError('')
clearError()
if (lookup.status !== 'ok') return
if (!name || !password) {
setLocalError('Please fill in all fields')
return
}
if (password !== confirmPassword) {
setLocalError('Passwords do not match')
return
}
if (password.length < 10) {
setLocalError('Password must be at least 10 characters')
return
}
try {
await register({
email: lookup.data.invited_email,
password,
name,
account_invite_code: code,
})
// Invitees skip the welcome wizard — they're joining an existing shop.
// The `?welcome=teammate` marker is decoded by the dashboard in Task 41
// to surface the "Welcome to {account_name}" toast and pre-checked
// checklist items.
navigate('/?welcome=teammate', { replace: true })
} catch {
// Error is set in the store
}
}
return (
<>
<PageMeta
title="Join your team on ResolutionFlow"
description="Accept an invite to join an existing ResolutionFlow account"
/>
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<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-6">
<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>
</div>
{lookup.status === 'loading' && (
<div className="bg-card border border-border rounded-xl p-6 text-center">
<p className="text-sm text-muted-foreground">Loading invite</p>
</div>
)}
{(lookup.status === 'invalid' || lookup.status === 'missing-code') && (
<div className="bg-card border border-border rounded-xl p-6 space-y-3">
<h2 className="text-lg font-semibold text-foreground">
This invite is no longer valid
</h2>
<p className="text-sm text-muted-foreground">
{lookup.status === 'missing-code'
? 'The invite link is missing its code.'
: 'This invite has expired, been used, or been revoked.'}{' '}
Ask the person who invited you to resend it.
</p>
<a
href="mailto:?subject=Please%20resend%20my%20ResolutionFlow%20invite&body=Hi%2C%20could%20you%20resend%20my%20ResolutionFlow%20invite%3F%20The%20link%20I%20got%20is%20no%20longer%20valid.%20Thanks!"
className={cn(
'inline-block rounded-xl px-4 py-2 text-sm font-semibold btn-press',
'bg-primary text-white hover:brightness-110',
)}
>
Email your inviter
</a>
<p className="text-xs text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="font-medium text-foreground hover:underline">
Sign in
</Link>
</p>
</div>
)}
{lookup.status === 'ok' && (
<>
<div className="text-center">
<p className="text-base font-medium text-foreground">
Join <span className="font-semibold">{lookup.data.account_name}</span> on
ResolutionFlow
</p>
<p className="mt-1 text-sm text-muted-foreground">
{lookup.data.inviter_name} invited you as {lookup.data.role}.
</p>
</div>
<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">
{localError || error}
</div>
)}
<div>
<p className="block text-sm font-medium text-foreground">
Joining as
</p>
<p
className={cn(
'mt-1 block w-full rounded-xl border border-border bg-card px-3 py-2',
'text-foreground',
)}
data-testid="invited-email"
>
{lookup.data.invited_email}
</p>
<p className="mt-1 text-xs text-muted-foreground">
The invite is locked to this email address.
</p>
</div>
{(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',
'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>
)}
{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 set a password
</span>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<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="Jane Doe"
/>
</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"
data-testid="accept-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 ? 'Joining…' : `Join ${lookup.data.account_name}`}
</button>
</form>
</div>
</>
)}
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link to="/login" className="font-medium text-foreground hover:underline">
Sign in
</Link>
</p>
</div>
</div>
</>
)
}
export default AcceptInvitePage

View File

@@ -6,6 +6,7 @@ import {
Check,
Clock,
Copy,
CreditCard,
Crown,
FolderTree,
Loader2,
@@ -598,6 +599,12 @@ export function AccountSettingsPage() {
title="Profile"
description="Your name, email, and personal preferences"
/>
<SettingsRow
to="/account/billing"
icon={<CreditCard className="h-4 w-4" />}
title="Billing"
description="Subscription, payment method, and invoices"
/>
</div>
{isAccountOwner && (

View File

@@ -0,0 +1,396 @@
import { useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { salesApi, type SalesLeadSource } from '@/api/sales'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
/* ---------------------------------------------------------------------------
* Source detection
*
* The backend `/sales-leads` endpoint requires a `source` enum. We classify
* by document.referrer: visitors landing on /contact-sales after viewing the
* pricing page get tagged `pricing_page`; everything else is `landing_page`.
* `register_footer` is reserved for the (future) sign-up footer CTA.
*
* Acceptable for v1 — server-side PostHog event uses this same `source` value.
* ------------------------------------------------------------------------- */
function detectSource(): SalesLeadSource {
if (typeof document === 'undefined') return 'landing_page'
const ref = document.referrer || ''
if (ref.includes('/pricing')) return 'pricing_page'
return 'landing_page'
}
const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '', label: 'Select team size' },
{ value: '1-2', label: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ value: '26+', label: 'More than 26' },
]
interface FormState {
name: string
email: string
company: string
team_size: string
message: string
}
const INITIAL: FormState = {
name: '',
email: '',
company: '',
team_size: '',
message: '',
}
function ContactSalesNotFound() {
return (
<div
data-testid="contact-sales-not-found"
style={{
minHeight: '60vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '2rem',
}}
>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
<p style={{ color: '#9198a8' }}>This page is not available.</p>
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
Go to login
</Link>
</div>
)
}
export function ContactSalesPage() {
const appConfig = useAppConfig()
const [form, setForm] = useState<FormState>(INITIAL)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const calendlyUrl = useMemo(() => {
const raw = import.meta.env.VITE_CALENDLY_URL
return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : ''
}, [])
// Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.)
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
return (
<>
<PageMeta title="Page not found" />
<ContactSalesNotFound />
</>
)
}
const handleChange =
(field: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }))
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (submitting) return
const name = form.name.trim()
const email = form.email.trim()
const company = form.company.trim()
if (!name || !email || !company) {
setError('Please fill in name, work email, and company.')
return
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError('Enter a valid work email address.')
return
}
setSubmitting(true)
setError(null)
try {
await salesApi.createLead({
name,
email,
company,
team_size: form.team_size || undefined,
message: form.message.trim() || undefined,
source: detectSource(),
})
setSubmitted(true)
} catch {
// The backend may rate-limit (429) or reject for validation; surface a
// generic message and allow retry. Don't leak internal errors.
setError('Something went wrong. Please try again or email hello@resolutionflow.com.')
} finally {
setSubmitting(false)
}
}
return (
<div className="landing-page">
<PageMeta
title="Talk to Sales"
description="Get in touch with the ResolutionFlow team about Enterprise plans, custom seats, SSO, and onboarding for your MSP."
/>
<main
className="landing-main"
style={{ paddingTop: '4rem', paddingBottom: '4rem' }}
>
<section
style={{
maxWidth: '640px',
margin: '0 auto',
padding: '3rem 1.5rem 1.5rem',
textAlign: 'center',
}}
>
<h1
style={{
color: 'var(--lp-text-heading)',
fontSize: 'clamp(1.75rem, 3.5vw, 2.5rem)',
fontWeight: 700,
lineHeight: 1.15,
margin: '0 0 0.75rem',
}}
>
Talk to Sales
</h1>
<p
style={{
color: 'var(--lp-text-body)',
fontSize: '1.05rem',
margin: 0,
}}
>
Tell us about your MSP. We&rsquo;ll reach out within 1 business day.
</p>
</section>
<section
style={{
maxWidth: '560px',
margin: '0 auto',
padding: '1rem 1.5rem 3rem',
}}
>
{submitted ? (
<div
data-testid="contact-sales-confirmation"
style={{
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '2rem 1.75rem',
textAlign: 'center',
}}
>
<h2
style={{
color: 'var(--lp-text-heading)',
fontSize: '1.25rem',
fontWeight: 600,
margin: '0 0 0.75rem',
}}
>
Thanks &mdash; we&rsquo;ll reach out within 1 business day.
</h2>
{calendlyUrl && (
<div data-testid="calendly-block">
<p style={{ color: 'var(--lp-text-body)', margin: '0 0 1rem' }}>
Want to skip ahead?
</p>
<a
data-testid="calendly-link"
href={calendlyUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: '0.65rem 1.25rem',
background: 'var(--lp-accent)',
color: '#0d0f15',
borderRadius: '8px',
fontWeight: 600,
textDecoration: 'none',
}}
>
Book a time
</a>
</div>
)}
</div>
) : (
<form
data-testid="contact-sales-form"
onSubmit={handleSubmit}
noValidate
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '1.75rem',
}}
>
<Field label="Name" htmlFor="cs-name" required>
<input
id="cs-name"
data-testid="cs-name"
type="text"
required
value={form.name}
onChange={handleChange('name')}
autoComplete="name"
style={inputStyle}
/>
</Field>
<Field label="Work email" htmlFor="cs-email" required>
<input
id="cs-email"
data-testid="cs-email"
type="email"
required
value={form.email}
onChange={handleChange('email')}
autoComplete="email"
style={inputStyle}
/>
</Field>
<Field label="Company" htmlFor="cs-company" required>
<input
id="cs-company"
data-testid="cs-company"
type="text"
required
value={form.company}
onChange={handleChange('company')}
autoComplete="organization"
style={inputStyle}
/>
</Field>
<Field label="Team size" htmlFor="cs-team-size">
<select
id="cs-team-size"
data-testid="cs-team-size"
value={form.team_size}
onChange={handleChange('team_size')}
style={inputStyle}
>
{TEAM_SIZE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
<Field label="What brought you here?" htmlFor="cs-message">
<textarea
id="cs-message"
data-testid="cs-message"
rows={4}
value={form.message}
onChange={handleChange('message')}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</Field>
{error && (
<div
role="alert"
data-testid="cs-error"
style={{ color: '#f87171', fontSize: '0.9rem' }}
>
{error}
</div>
)}
<button
type="submit"
data-testid="cs-submit"
disabled={submitting}
style={{
padding: '0.75rem 1.25rem',
background: 'var(--lp-accent)',
color: '#0d0f15',
border: 'none',
borderRadius: '8px',
fontWeight: 600,
cursor: submitting ? 'not-allowed' : 'pointer',
opacity: submitting ? 0.7 : 1,
}}
>
{submitting ? 'Sending…' : 'Submit'}
</button>
</form>
)}
</section>
</main>
</div>
)
}
function Field({
label,
htmlFor,
required,
children,
}: {
label: string
htmlFor: string
required?: boolean
children: React.ReactNode
}) {
return (
<label
htmlFor={htmlFor}
style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}
>
<span
style={{
color: 'var(--lp-text-heading)',
fontSize: '0.9rem',
fontWeight: 500,
}}
>
{label}
{required && (
<span aria-hidden="true" style={{ color: 'var(--lp-accent)', marginLeft: '0.25rem' }}>
*
</span>
)}
</span>
{children}
</label>
)
}
const inputStyle: React.CSSProperties = {
padding: '0.6rem 0.75rem',
background: 'var(--lp-bg-alt, #1a1d26)',
border: '1px solid var(--lp-border)',
borderRadius: '8px',
color: 'var(--lp-text-heading)',
fontSize: '0.95rem',
fontFamily: 'inherit',
outline: 'none',
}
export default ContactSalesPage

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const FAQ_ITEMS = [
{
q: 'How is this different from just using ChatGPT?',
@@ -29,11 +28,9 @@ const FAQ_ITEMS = [
]
export default function LandingPage() {
const appConfig = useAppConfig()
const [navScrolled, setNavScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [betaEmail, setBetaEmail] = useState('')
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [betaError, setBetaError] = useState('')
const [openFaq, setOpenFaq] = useState<number | null>(null)
const mobileMenuRef = useRef<HTMLDivElement>(null)
@@ -71,32 +68,6 @@ export default function LandingPage() {
return () => observer.disconnect()
}, [])
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
const trimmed = betaEmail.trim()
if (!trimmed || betaStatus === 'sending') return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
setBetaStatus('error')
setBetaError('Enter a valid email address.')
return
}
setBetaStatus('sending')
setBetaError('')
try {
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: trimmed }),
})
if (!resp.ok) throw new Error('Signup failed')
setBetaStatus('sent')
setBetaEmail('')
} catch {
setBetaStatus('error')
setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
}
}, [betaEmail, betaStatus])
const toggleFaq = (index: number) => {
setOpenFaq(prev => prev === index ? null : index)
}
@@ -174,6 +145,15 @@ export default function LandingPage() {
</p>
<div className="landing-hero-actions">
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
{appConfig.self_serve_enabled && (
<Link
to="/pricing"
className="landing-btn-hero-secondary"
data-testid="landing-see-pricing"
>
See pricing
</Link>
)}
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
</div>
<p className="landing-hero-credibility">
@@ -422,34 +402,10 @@ export default function LandingPage() {
<section className="landing-cta-section landing-reveal">
<div className="landing-cta-inner">
<h2>Ready to stop writing ticket notes?</h2>
<p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
<div className="landing-cta-input-wrap">
<input
type="email"
className="landing-cta-email-input"
placeholder="you@yourmsp.com"
value={betaEmail}
onChange={e => {
setBetaEmail(e.target.value)
if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
}}
required
aria-describedby="beta-status"
/>
<button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
{betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
</button>
</div>
<div id="beta-status" aria-live="polite" className="landing-cta-status">
{betaStatus === 'sent' && (
<p className="landing-cta-success">You&apos;re in. We&apos;ll send beta access details soon.</p>
)}
{betaStatus === 'error' && betaError && (
<p className="landing-cta-error">{betaError}</p>
)}
</div>
</form>
<p>Get early access. Troubleshoot your next ticket with FlowPilot.</p>
<div className="landing-cta-actions">
<Link to="/register?from=beta" className="landing-btn-hero-primary">Get started</Link>
</div>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
</div>
</section>

View File

@@ -0,0 +1,196 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PageMeta } from '@/components/common/PageMeta'
import { decodeOAuthState } from '@/lib/oauthState'
type Provider = 'google' | 'microsoft'
/**
* Handles the OAuth redirect leg of the full-page Google / Microsoft sign-in
* flow. Mounted at /auth/google/callback and /auth/microsoft/callback as
* public routes (NOT inside ProtectedRoute).
*
* Reads `?code=...` from the URL, POSTs it to the backend, stores the
* returned tokens, hydrates the auth store via fetchUser(), and redirects.
*
* Two state forms are supported:
* - Legacy: `state` is a raw random hex string. CSRF check against
* sessionStorage('rf-oauth-state').
* - /accept-invite: `state` is base64url(JSON({csrf, accountInviteCode,
* invitedEmail})). The CSRF value is compared against
* sessionStorage('rf-oauth-state'); the invite fields are forwarded to
* the backend so the new user joins the invited account instead of
* getting a personal one.
*/
export function OAuthCallbackPage() {
const navigate = useNavigate()
const location = useLocation()
const { setTokens, fetchUser } = useAuthStore()
const [error, setError] = useState<string | null>(null)
// Derive provider purely from URL pathname — routes are static
// (/auth/google/callback and /auth/microsoft/callback), so there is
// no `:provider` route param to read.
const provider: Provider = location.pathname.includes('/microsoft/')
? 'microsoft'
: 'google'
useEffect(() => {
const search = new URLSearchParams(location.search)
const code = search.get('code')
const oauthError = search.get('error')
const returnedState = search.get('state')
// CSRF: validate state round-trip against the value RegisterPage /
// AcceptInvitePage stashed in sessionStorage before redirecting to the
// provider. Always clear the stored value so a stale entry can't be
// re-used by a later attempt.
let storedState: string | null = null
try {
storedState = sessionStorage.getItem('rf-oauth-state')
sessionStorage.removeItem('rf-oauth-state')
} catch {
// sessionStorage may be unavailable (private mode, etc.) — treat as missing.
storedState = null
}
if (oauthError) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state
setError(`OAuth error: ${oauthError}`)
return
}
if (!storedState || !returnedState) {
setError('Invalid OAuth state — possible CSRF. Please try again.')
return
}
// The decoded form encodes the original CSRF value; compare that.
const decoded = decodeOAuthState(returnedState)
const matchesCsrf = decoded
? decoded.csrf === storedState
: returnedState === storedState
if (!matchesCsrf) {
setError('Invalid OAuth state — possible CSRF. Please try again.')
return
}
if (!code) {
setError('Missing authorization code')
return
}
let cancelled = false
void (async () => {
try {
const inviteOptions = decoded
? {
accountInviteCode: decoded.accountInviteCode,
invitedEmail: decoded.invitedEmail,
}
: undefined
const result =
provider === 'microsoft'
? await authApi.microsoftCallback(code, inviteOptions)
: await authApi.googleCallback(code, inviteOptions)
if (cancelled) return
// Persist tokens for apiClient interceptor + zustand store.
localStorage.setItem('access_token', result.access_token)
localStorage.setItem('refresh_token', result.refresh_token)
setTokens({
access_token: result.access_token,
refresh_token: result.refresh_token,
token_type: result.token_type || 'bearer',
})
// Hydrate user / account / subscription.
await fetchUser()
if (cancelled) return
// Invitee path lands on the dashboard with the teammate-welcome
// marker; new self-serve owners go to the welcome wizard; returning
// users to /.
let dest = '/'
if (decoded?.accountInviteCode) {
dest = '/?welcome=teammate'
} else if (result.is_new_user) {
dest = '/welcome'
}
navigate(dest, { replace: true })
} catch (err: unknown) {
if (cancelled) return
const axiosErr = err as {
response?: { data?: { detail?: unknown } }
}
const detail = axiosErr.response?.data?.detail
// Backend returns { error: "invite_email_mismatch" } etc.
let msg: string | null = null
if (typeof detail === 'string') {
msg = detail
} else if (
detail &&
typeof detail === 'object' &&
'error' in (detail as Record<string, unknown>)
) {
const code = (detail as { error: string }).error
if (code === 'invite_email_mismatch') {
msg =
'The email on your provider account does not match the invited email. ' +
'Sign in with the matching account, or ask your inviter to resend.'
} else if (code === 'invite_invalid_or_expired_or_revoked') {
msg = 'This invite is no longer valid. Ask your inviter to resend.'
} else {
msg = code
}
}
msg =
msg ||
(err instanceof Error ? err.message : 'Sign-in failed')
setError(msg)
}
})()
return () => {
cancelled = true
}
}, [location.search, provider, setTokens, fetchUser, navigate])
return (
<>
<PageMeta title="Signing you in" description="Completing OAuth sign-in" />
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="relative w-full max-w-md space-y-6 text-center">
<div className="flex justify-center">
<BrandLogo size="lg" />
</div>
{error ? (
<>
<h1 className="text-xl font-semibold text-foreground">
Sign-in failed
</h1>
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110"
>
Back to sign in
</button>
</>
) : (
<>
<h1 className="text-xl font-semibold text-foreground">
Signing you in
</h1>
<p className="text-sm text-muted-foreground">
Finishing up the {provider === 'microsoft' ? 'Microsoft' : 'Google'} sign-in flow.
</p>
</>
)}
</div>
</div>
</>
)
}
export default OAuthCallbackPage

View File

@@ -0,0 +1,439 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { plansApi, type PublicPlanResponse } from '@/api/plans'
import { PageMeta } from '@/components/common/PageMeta'
import { useAppConfig } from '@/hooks/useAppConfig'
import '@/styles/landing.css'
/* ---------------------------------------------------------------------------
* v1 hardcoded comparison table
*
* The marketing /pricing page surfaces a small "what's in each plan" table.
* Long-term, the source of truth for "plan X has feature Y" should be a
* server-side feature-flag mapping (likely keyed off feature_flag.display_name
* + plan_features). For v1 we hardcode the well-known features so we can ship
* the page without a backend dependency. Replace this block when a server-side
* feature mapping endpoint exists.
* ------------------------------------------------------------------------- */
type PlanColumn = 'starter' | 'pro' | 'enterprise'
const COMPARISON_ROWS: Array<{
feature: string
values: Record<PlanColumn, boolean>
}> = [
{ feature: 'PSA Integration', values: { starter: false, pro: true, enterprise: true } },
{ feature: 'KB Accelerator', values: { starter: false, pro: true, enterprise: true } },
{ feature: 'AI Builder', values: { starter: true, pro: true, enterprise: true } },
{ feature: 'Custom Branding', values: { starter: false, pro: false, enterprise: true } },
{ feature: 'Priority Support', values: { starter: false, pro: true, enterprise: true } },
]
function formatPrice(cents: number | null | undefined): string {
if (cents == null) return ''
const dollars = cents / 100
// Whole dollars (no decimals) for marketing display.
return `$${Math.round(dollars).toLocaleString()}`
}
function PricingNotFound() {
return (
<div
data-testid="pricing-not-found"
style={{
minHeight: '60vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '2rem',
}}
>
<h1 style={{ fontSize: '1.5rem', marginBottom: '0.5rem' }}>Page not found</h1>
<p style={{ color: '#9198a8' }}>This page is not available.</p>
<Link to="/login" style={{ color: '#60a5fa', marginTop: '1rem' }}>
Go to login
</Link>
</div>
)
}
interface PlanCardProps {
plan: PublicPlanResponse | null
fallback: {
plan: string
display_name: string
description: string
}
recommended?: boolean
hidePrice?: boolean
ctaLabel: string
ctaHref: string
ctaTestId: string
}
function PlanCard({ plan, fallback, recommended, hidePrice, ctaLabel, ctaHref, ctaTestId }: PlanCardProps) {
const displayName = plan?.display_name ?? fallback.display_name
const description = plan?.description ?? fallback.description
const monthlyCents = plan?.monthly_price_cents ?? null
return (
<div
data-testid={`plan-card-${fallback.plan}`}
style={{
position: 'relative',
background: 'var(--lp-card)',
border: recommended
? '2px solid var(--lp-accent)'
: '1px solid var(--lp-border)',
borderRadius: '12px',
padding: '2rem 1.75rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
{recommended && (
<span
data-testid="recommended-badge"
style={{
position: 'absolute',
top: '-12px',
left: '50%',
transform: 'translateX(-50%)',
padding: '4px 12px',
background: 'var(--lp-accent)',
color: '#0d0f15',
borderRadius: '999px',
fontSize: '0.75rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Recommended
</span>
)}
<div>
<h3
style={{
color: 'var(--lp-text-heading)',
fontSize: '1.25rem',
fontWeight: 600,
margin: 0,
}}
>
{displayName}
</h3>
<p style={{ margin: '0.5rem 0 0', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
{description}
</p>
</div>
<div style={{ minHeight: '3rem' }}>
{hidePrice ? (
<div style={{ color: 'var(--lp-text-heading)', fontSize: '1.25rem', fontWeight: 600 }}>
Custom pricing
</div>
) : monthlyCents != null ? (
<div>
<span style={{ color: 'var(--lp-text-heading)', fontSize: '2.25rem', fontWeight: 700 }}>
{formatPrice(monthlyCents)}
</span>
<span style={{ color: 'var(--lp-text-secondary)', marginLeft: '0.35rem' }}>/ month</span>
</div>
) : (
<div style={{ color: 'var(--lp-text-secondary)' }}>Contact us</div>
)}
</div>
<Link
to={ctaHref}
data-testid={ctaTestId}
style={{
display: 'inline-block',
textAlign: 'center',
padding: '0.75rem 1.25rem',
background: recommended ? 'var(--lp-accent)' : 'transparent',
color: recommended ? '#0d0f15' : 'var(--lp-text-heading)',
border: recommended ? 'none' : '1px solid var(--lp-border-hover)',
borderRadius: '8px',
fontWeight: 600,
textDecoration: 'none',
}}
>
{ctaLabel}
</Link>
</div>
)
}
export function PricingPage() {
const appConfig = useAppConfig()
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch plans on mount when self-serve is enabled.
useEffect(() => {
if (appConfig.isLoading) return
if (!appConfig.self_serve_enabled) return
let cancelled = false
plansApi
.getPublic()
.then((data) => {
if (cancelled) return
setPlans(data)
setError(null)
})
.catch(() => {
if (cancelled) return
// Non-fatal: page still renders with fallback descriptions and no
// server-driven prices. The CTA still works via /register?plan=...
setError('Unable to load live pricing.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [appConfig.isLoading, appConfig.self_serve_enabled])
// Self-serve disabled: render a 404-style fallback. Done after hooks so
// the React rules-of-hooks invariant holds.
if (!appConfig.isLoading && !appConfig.self_serve_enabled) {
return (
<>
<PageMeta title="Page not found" />
<PricingNotFound />
</>
)
}
const planByName = (name: string) =>
plans?.find((p) => p.plan.toLowerCase() === name) ?? null
return (
<div className="landing-page">
<PageMeta
title="Pricing"
description="ResolutionFlow plans for MSPs — Starter, Pro, and Enterprise. Try Pro free for 14 days, no credit card required."
/>
<main className="landing-main" style={{ paddingTop: '4rem', paddingBottom: '4rem' }}>
{/* ---- HERO ---- */}
<section
style={{
maxWidth: '720px',
margin: '0 auto',
padding: '4rem 1.5rem 2rem',
textAlign: 'center',
}}
>
<h1
style={{
color: 'var(--lp-text-heading)',
fontSize: 'clamp(2rem, 4vw, 2.75rem)',
fontWeight: 700,
lineHeight: 1.15,
margin: '0 0 1rem',
}}
>
Simple pricing for MSPs of every size
</h1>
<p
data-testid="hero-trial-line"
style={{
color: 'var(--lp-text-body)',
fontSize: '1.125rem',
margin: 0,
}}
>
Try Pro free for 14 days. No credit card required.
</p>
</section>
{/* ---- PLAN CARDS ---- */}
<section
aria-label="Plans"
style={{
maxWidth: '1100px',
margin: '0 auto',
padding: '2rem 1.5rem',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: '1.5rem',
}}
>
<PlanCard
plan={planByName('starter')}
fallback={{
plan: 'starter',
display_name: 'Starter',
description: 'For solo techs getting structured.',
}}
ctaLabel="Start free trial"
ctaHref="/register?plan=starter"
ctaTestId="cta-starter"
/>
<PlanCard
plan={planByName('pro')}
recommended
fallback={{
plan: 'pro',
display_name: 'Pro',
description: 'For growing MSP teams. PSA integration + KB Accelerator.',
}}
ctaLabel="Start free trial"
ctaHref="/register?plan=pro"
ctaTestId="cta-pro"
/>
<PlanCard
plan={planByName('enterprise')}
hidePrice
fallback={{
plan: 'enterprise',
display_name: 'Enterprise',
description: 'Custom branding, custom seats, and a dedicated success contact.',
}}
ctaLabel="Talk to sales"
ctaHref="/contact-sales"
ctaTestId="cta-enterprise"
/>
</section>
{loading && (
<div
aria-live="polite"
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)' }}
>
Loading pricing
</div>
)}
{error && (
<div
role="status"
style={{ textAlign: 'center', color: 'var(--lp-text-secondary)', marginTop: '0.5rem' }}
>
{error}
</div>
)}
{/* ---- COMPARISON TABLE ---- */}
<section
aria-label="Plan comparison"
style={{
maxWidth: '1000px',
margin: '3rem auto 2rem',
padding: '0 1.5rem',
}}
>
<h2
style={{
color: 'var(--lp-text-heading)',
fontSize: '1.5rem',
fontWeight: 600,
margin: '0 0 1rem',
textAlign: 'center',
}}
>
Compare plans
</h2>
<div
style={{
overflowX: 'auto',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
}}
>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
color: 'var(--lp-text-body)',
}}
>
<thead>
<tr style={{ background: 'var(--lp-bg-alt)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontWeight: 600 }}>
Feature
</th>
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Starter</th>
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Pro</th>
<th style={{ padding: '0.75rem 1rem', fontWeight: 600 }}>Enterprise</th>
</tr>
</thead>
<tbody>
{COMPARISON_ROWS.map((row) => (
<tr key={row.feature} style={{ borderTop: '1px solid var(--lp-border)' }}>
<td style={{ padding: '0.75rem 1rem' }}>{row.feature}</td>
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
{row.values.starter ? '✓' : '—'}
</td>
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
{row.values.pro ? '✓' : '—'}
</td>
<td style={{ textAlign: 'center', padding: '0.75rem 1rem' }}>
{row.values.enterprise ? '✓' : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* ---- TESTIMONIAL SLOT (placeholder) ---- */}
<section
aria-label="Customer testimonial"
style={{
maxWidth: '720px',
margin: '3rem auto 2rem',
padding: '2rem 1.5rem',
background: 'var(--lp-card)',
border: '1px solid var(--lp-border)',
borderRadius: '12px',
textAlign: 'center',
}}
data-testid="testimonial-slot"
>
<blockquote
style={{
fontStyle: 'italic',
color: 'var(--lp-text-body)',
fontSize: '1.05rem',
margin: 0,
}}
>
"Pilot testimonials coming soon."
</blockquote>
<div style={{ marginTop: '0.75rem', color: 'var(--lp-text-secondary)', fontSize: '0.9rem' }}>
ResolutionFlow pilot, 2026
</div>
</section>
{/* ---- TRUST STRIP ---- */}
<section
aria-label="Trust"
data-testid="trust-strip"
style={{
maxWidth: '900px',
margin: '2rem auto 0',
padding: '1rem 1.5rem',
color: 'var(--lp-text-secondary)',
fontSize: '0.9rem',
textAlign: 'center',
}}
>
Built on Stripe + AWS · Encrypted in transit and at rest
</section>
</main>
</div>
)
}
export default PricingPage

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import { PageMeta } from '@/components/common/PageMeta'
import { useAuthStore } from '@/store/authStore'
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
@@ -7,6 +8,10 @@ import { TicketQueue } from '@/components/dashboard/TicketQueue'
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
import { TeamSummary } from '@/components/dashboard/TeamSummary'
import { NextStepCard, pickNextStep } from '@/components/dashboard/NextStepCard'
import { SetupChecklist } from '@/components/dashboard/SetupChecklist'
import { useOnboardingStatus } from '@/hooks/useOnboardingStatus'
import { useTrialBanner } from '@/hooks/useTrialBanner'
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
return (
@@ -22,6 +27,17 @@ function SectionLabel({ children, action }: { children: React.ReactNode; action?
export function QuickStartPage() {
const user = useAuthStore((s) => s.user)
const [showAllSetupSteps, setShowAllSetupSteps] = useState(false)
const onboardingStatus = useOnboardingStatus()
const { stage: trialStage } = useTrialBanner()
// Onboarding section is visible when there's still something to nudge on.
// We check the same priority list NextStepCard uses so the toggle row
// disappears cleanly once everything is done OR the user dismissed.
const onboardingVisible =
onboardingStatus !== null &&
!onboardingStatus.dismissed &&
pickNextStep(onboardingStatus, trialStage) !== null
const now = new Date()
const greeting = now.getHours() < 12
@@ -47,6 +63,29 @@ export function QuickStartPage() {
</h1>
</div>
{/* Next-step card — surfaces a single onboarding nudge below the hero. */}
{onboardingVisible && (
<div className="mb-6">
<NextStepCard />
<div className="mt-2">
<button
type="button"
onClick={() => setShowAllSetupSteps((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
data-testid="toggle-setup-checklist"
aria-expanded={showAllSetupSteps}
>
{showAllSetupSteps ? 'Hide setup steps' : 'Show all setup steps'}
</button>
</div>
{showAllSetupSteps && (
<div className="mt-3">
<SetupChecklist />
</div>
)}
</div>
)}
{/* Chat-style input */}
<StartSessionInput />

View File

@@ -1,18 +1,78 @@
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 and invite OAuth handoff. */
// eslint-disable-next-line react-refresh/only-export-components -- pure helper shared with AcceptInvitePage and unit 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 +80,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 +129,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 +151,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 +167,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 +198,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 +417,8 @@ export function RegisterPage() {
Sign in
</Link>
</p>
</form>
</div>
</div>
</div>
</>
)
}

View File

@@ -1,73 +1,221 @@
import { useEffect, useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { CheckCircle2, XCircle, Loader2, MailCheck } from 'lucide-react'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { PageMeta } from '@/components/common/PageMeta'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
type Status = 'loading' | 'success' | 'error' | 'already-verified' | 'no-token'
const SUCCESS_REDIRECT_MS = 1200
/**
* Standalone landing page for the email-verification link
* (`/verify-email?token=...`).
*
* Behavior:
* - If the user is already verified, short-circuit to a friendly
* "Already verified" state. No API call.
* - Else fire `POST /auth/email/verify` exactly once (a `useRef` guard keeps
* React 19 strict-mode double-invoke from double-firing the call). On
* success, refresh the auth store and bounce to `/?verified=1` so the
* dashboard surfaces a toast.
* - On error, show "Invalid or expired token" + a "Resend" CTA that calls
* `POST /auth/email/send-verification`.
*/
export function VerifyEmailPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(token ? 'loading' : 'error')
const [errorMessage, setErrorMessage] = useState(token ? '' : 'No verification token provided')
const alreadyVerified = useAuthStore(
(s) => Boolean(s.user?.email_verified_at),
)
const initialStatus: Status = alreadyVerified
? 'already-verified'
: token
? 'loading'
: 'no-token'
const [status, setStatus] = useState<Status>(initialStatus)
const [errorMessage, setErrorMessage] = useState<string>('')
const [isResending, setIsResending] = useState(false)
// Single-fire guard: React 19 strict mode runs effects twice on mount.
// Without this, the verify endpoint would burn the token on the first call
// and then 400 on the second, flashing an error past the success state.
const hasFiredRef = useRef(false)
useEffect(() => {
if (status !== 'loading') return
if (!token) return
if (hasFiredRef.current) return
hasFiredRef.current = true
authApi.verifyEmail(token)
.then(() => setStatus('success'))
.catch((err) => {
setStatus('error')
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
setErrorMessage(detail ?? 'Verification failed')
let cancelled = false
authApi
.verifyEmail(token)
.then(async () => {
// Refresh user so `email_verified_at` is populated everywhere.
try {
await useAuthStore.getState().fetchUser()
} catch {
// Non-fatal: server confirmed verification, the local user object
// will refresh on next page load.
}
if (cancelled) return
setStatus('success')
toast.success('Email verified')
// Brief success state, then redirect with a query flag so the
// dashboard can re-surface confirmation if it wants to.
window.setTimeout(() => {
navigate('/?verified=1', { replace: true })
}, SUCCESS_REDIRECT_MS)
})
}, [token])
.catch((err) => {
if (cancelled) return
const detail = (err as { response?: { data?: { detail?: string } } })
.response?.data?.detail
setErrorMessage(detail ?? 'Invalid or expired verification link')
setStatus('error')
})
return () => {
cancelled = true
}
}, [status, token, navigate])
const handleResend = async () => {
setIsResending(true)
try {
await authApi.sendVerificationEmail()
toast.success('Verification email sent — check your inbox')
} catch {
toast.error('Failed to send verification email')
} finally {
setIsResending(false)
}
}
return (
<>
<PageMeta title="Verify Email" description="Verify your ResolutionFlow email address" />
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="card-flat w-full max-w-md p-8 text-center">
{status === 'loading' && (
<>
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-foreground">Verifying your email...</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-400" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Email Verified</h1>
<p className="mt-2 text-muted-foreground">Your email has been successfully verified.</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-white',
'hover:brightness-110'
)}
>
Go to Dashboard
</Link>
</>
)}
{status === 'error' && (
<>
<XCircle className="mx-auto h-12 w-12 text-rose-500" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Verification Failed</h1>
<p className="mt-2 text-muted-foreground">{errorMessage}</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-input border border-border px-6 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover'
)}
>
Go to Dashboard
</Link>
</>
)}
<PageMeta
title="Verify Email"
description="Verify your ResolutionFlow email address"
/>
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="card-flat w-full max-w-md p-8 text-center">
{status === 'loading' && (
<>
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-foreground">Verifying your email</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle2 className="mx-auto h-12 w-12 text-success" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
Email verified
</h1>
<p className="mt-2 text-muted-foreground">
Redirecting you to the dashboard
</p>
<Link
to="/?verified=1"
replace
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
'hover:brightness-110',
)}
>
Go to dashboard
</Link>
</>
)}
{status === 'already-verified' && (
<>
<MailCheck className="mx-auto h-12 w-12 text-success" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
You&apos;re already verified
</h1>
<p className="mt-2 text-muted-foreground">
This account&apos;s email is already confirmed. No further
action needed.
</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
'hover:brightness-110',
)}
>
Go to dashboard
</Link>
</>
)}
{status === 'error' && (
<>
<XCircle className="mx-auto h-12 w-12 text-danger" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
Verification failed
</h1>
<p className="mt-2 text-muted-foreground">
{errorMessage || 'Invalid or expired verification link'}
</p>
<div className="mt-6 flex flex-col gap-2">
<button
type="button"
onClick={handleResend}
disabled={isResending}
data-testid="resend-button"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground hover:brightness-110 disabled:opacity-50"
>
{isResending && <Loader2 className="h-4 w-4 animate-spin" />}
Resend verification email
</button>
<Link
to="/"
className={cn(
'inline-flex items-center justify-center rounded-lg border border-default bg-input px-6 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover',
)}
>
Go to dashboard
</Link>
</div>
</>
)}
{status === 'no-token' && (
<>
<XCircle className="mx-auto h-12 w-12 text-danger" />
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">
Missing verification token
</h1>
<p className="mt-2 text-muted-foreground">
The link you used doesn&apos;t include a verification token.
Try the link in your verification email again.
</p>
<Link
to="/"
className={cn(
'mt-6 inline-flex items-center rounded-lg bg-primary px-6 py-2 text-sm font-semibold text-primary-foreground',
'hover:brightness-110',
)}
>
Go to dashboard
</Link>
</>
)}
</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { AcceptInvitePage } from '../AcceptInvitePage'
import { inviteApi } from '@/api/invite'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
vi.mock('@/api/invite', () => ({
inviteApi: {
lookupAccountInvite: vi.fn(),
validateCode: vi.fn(),
},
}))
vi.mock('@/store/authStore', () => ({
useAuthStore: () => ({
register: vi.fn().mockResolvedValue(undefined),
isLoading: false,
error: null,
clearError: vi.fn(),
}),
}))
function renderPage(initialPath: string) {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={[initialPath]}>
<AcceptInvitePage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('AcceptInvitePage', () => {
beforeEach(() => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: ['google', 'microsoft'],
})
vi.clearAllMocks()
})
it('shows account name + locked email + accept buttons for a valid code', async () => {
vi.mocked(inviteApi.lookupAccountInvite).mockResolvedValue({
account_name: 'Acme MSP',
inviter_name: 'Alice Owner',
invited_email: 'bob@acme.example',
role: 'engineer',
})
renderPage('/accept-invite?code=VALIDINVITECODE0011223344556677')
// Inviter context (also confirms the lookup completed and rendered)
await waitFor(() => {
expect(
screen.getByText(/Alice Owner invited you as engineer/),
).toBeInTheDocument()
})
// Account name surfaces in the heading line.
expect(
screen.getByText((_content, node) => {
return (
node?.tagName.toLowerCase() === 'span' &&
/Acme MSP/.test(node.textContent || '')
)
}),
).toBeInTheDocument()
// Locked email — not an editable input
const emailDisplay = screen.getByTestId('invited-email')
expect(emailDisplay.tagName.toLowerCase()).not.toBe('input')
expect(emailDisplay).toHaveTextContent('bob@acme.example')
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
// OAuth buttons + password submit all rendered
expect(screen.getByTestId('oauth-google')).toBeInTheDocument()
expect(screen.getByTestId('oauth-microsoft')).toBeInTheDocument()
expect(screen.getByTestId('accept-submit')).toBeInTheDocument()
expect(screen.getByTestId('accept-submit')).toHaveTextContent(/Join Acme MSP/)
expect(inviteApi.lookupAccountInvite).toHaveBeenCalledWith(
'VALIDINVITECODE0011223344556677',
)
})
it('shows resend message + mailto link for an invalid invite code', async () => {
vi.mocked(inviteApi.lookupAccountInvite).mockRejectedValue(
Object.assign(new Error('not found'), {
response: {
status: 404,
data: { detail: { error: 'invite_invalid_or_expired_or_revoked' } },
},
}),
)
renderPage('/accept-invite?code=BADCODE')
await waitFor(() => {
expect(
screen.getByText(/This invite is no longer valid/i),
).toBeInTheDocument()
})
expect(
screen.getByText(/Ask the person who invited you to resend it/i),
).toBeInTheDocument()
const resendLink = screen.getByRole('link', { name: /Email your inviter/i })
expect(resendLink).toHaveAttribute(
'href',
expect.stringMatching(/^mailto:/),
)
// No accept form rendered when invite is invalid.
expect(screen.queryByTestId('accept-submit')).not.toBeInTheDocument()
expect(screen.queryByTestId('oauth-google')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { ContactSalesPage } from '../ContactSalesPage'
import { salesApi } from '@/api/sales'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
vi.mock('@/api/sales', () => ({
salesApi: {
createLead: vi.fn(),
},
}))
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/contact-sales']}>
<ContactSalesPage />
</MemoryRouter>
</HelmetProvider>,
)
}
function fillRequiredFields() {
fireEvent.change(screen.getByTestId('cs-name'), { target: { value: 'Jane Doe' } })
fireEvent.change(screen.getByTestId('cs-email'), { target: { value: 'jane@acme.com' } })
fireEvent.change(screen.getByTestId('cs-company'), { target: { value: 'Acme MSP' } })
}
describe('ContactSalesPage', () => {
beforeEach(() => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.clearAllMocks()
vi.unstubAllEnvs()
})
it('submits form and shows confirmation', async () => {
vi.stubEnv('VITE_CALENDLY_URL', 'https://calendly.com/resolutionflow/sales')
vi.mocked(salesApi.createLead).mockResolvedValue({
id: 'fake-uuid',
status: 'received',
})
renderPage()
fillRequiredFields()
fireEvent.change(screen.getByTestId('cs-team-size'), { target: { value: '11-25' } })
fireEvent.change(screen.getByTestId('cs-message'), {
target: { value: 'Looking at Enterprise pricing.' },
})
fireEvent.click(screen.getByTestId('cs-submit'))
await waitFor(() => {
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
})
const payload = vi.mocked(salesApi.createLead).mock.calls[0][0]
expect(payload).toMatchObject({
name: 'Jane Doe',
email: 'jane@acme.com',
company: 'Acme MSP',
team_size: '11-25',
message: 'Looking at Enterprise pricing.',
})
// Default source is landing_page (no /pricing in referrer in jsdom).
expect(payload.source).toBe('landing_page')
// Confirmation surface replaces the form.
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
expect(screen.getByText(/Thanks/i)).toBeInTheDocument()
})
it('hides Calendly section when VITE_CALENDLY_URL unset', async () => {
vi.stubEnv('VITE_CALENDLY_URL', '')
vi.mocked(salesApi.createLead).mockResolvedValue({
id: 'fake-uuid',
status: 'received',
})
renderPage()
fillRequiredFields()
fireEvent.click(screen.getByTestId('cs-submit'))
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
expect(screen.queryByTestId('calendly-block')).not.toBeInTheDocument()
expect(screen.queryByTestId('calendly-link')).not.toBeInTheDocument()
})
it('disables submit button while in flight to prevent duplicate submissions', async () => {
let resolveSubmit: (() => void) | null = null
vi.mocked(salesApi.createLead).mockImplementation(
() =>
new Promise((resolve) => {
resolveSubmit = () => resolve({ id: 'fake-uuid', status: 'received' })
}),
)
renderPage()
fillRequiredFields()
const submit = screen.getByTestId('cs-submit') as HTMLButtonElement
fireEvent.click(submit)
await waitFor(() => {
expect(submit.disabled).toBe(true)
})
// A second click while in flight should be a no-op.
fireEvent.click(submit)
expect(salesApi.createLead).toHaveBeenCalledTimes(1)
resolveSubmit?.()
await waitFor(() => {
expect(screen.getByTestId('contact-sales-confirmation')).toBeInTheDocument()
})
})
it('returns 404 when self_serve_enabled is false', () => {
__resetAppConfigCache()
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
expect(screen.getByTestId('contact-sales-not-found')).toBeInTheDocument()
expect(screen.queryByTestId('contact-sales-form')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import LandingPage from '../LandingPage'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
// jsdom does not provide IntersectionObserver. LandingPage uses it for
// scroll-reveal animations; stub a no-op so the page can mount.
beforeAll(() => {
// @ts-expect-error — test-only stub
globalThis.IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return []
}
}
})
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/']}>
<LandingPage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('LandingPage', () => {
beforeEach(() => {
__resetAppConfigCache()
vi.clearAllMocks()
})
it('shows See pricing CTA when self_serve_enabled is true', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('landing-see-pricing')).toBeInTheDocument()
})
const cta = screen.getByTestId('landing-see-pricing')
expect(cta).toHaveAttribute('href', '/pricing')
expect(cta).toHaveTextContent(/See pricing/i)
})
it('hides See pricing CTA when self_serve_enabled is false', async () => {
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
// Hero "Start Free" still renders, but the gated /pricing CTA does not.
expect(screen.queryByTestId('landing-see-pricing')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,121 @@
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(),
},
}))
const mockSetTokens = vi.fn()
const mockFetchUser = vi.fn().mockResolvedValue(undefined)
vi.mock('@/store/authStore', () => ({
useAuthStore: () => ({
setTokens: mockSetTokens,
fetchUser: mockFetchUser,
}),
}))
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()
})
})
describe('OAuthCallbackPage successful callback', () => {
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
sessionStorage.clear()
localStorage.clear()
})
it('persists tokens via setTokens (which marks the store authenticated) and fetches the user', async () => {
sessionStorage.setItem('rf-oauth-state', 'csrf-value')
;(authApi.googleCallback as ReturnType<typeof vi.fn>).mockResolvedValue({
access_token: 'access-123',
refresh_token: 'refresh-456',
token_type: 'bearer',
is_new_user: false,
})
renderAt('/auth/google/callback?code=auth-code-123&state=csrf-value')
await waitFor(() => {
expect(mockSetTokens).toHaveBeenCalledWith({
access_token: 'access-123',
refresh_token: 'refresh-456',
token_type: 'bearer',
})
})
expect(mockFetchUser).toHaveBeenCalled()
// Tokens are also persisted for the apiClient interceptor.
expect(localStorage.getItem('access_token')).toBe('access-123')
expect(localStorage.getItem('refresh_token')).toBe('refresh-456')
})
})

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { PricingPage } from '../PricingPage'
import { plansApi, type PublicPlanResponse } from '@/api/plans'
import {
__resetAppConfigCache,
__setAppConfigCache,
} from '@/hooks/useAppConfig'
vi.mock('@/api/plans', () => ({
plansApi: {
getPublic: vi.fn(),
},
}))
const STARTER: PublicPlanResponse = {
plan: 'starter',
display_name: 'Starter',
description: 'For solo techs.',
monthly_price_cents: 1900,
annual_price_cents: 19000,
max_seats: 3,
sort_order: 10,
is_public: true,
}
const PRO: PublicPlanResponse = {
plan: 'pro',
display_name: 'Pro',
description: 'For growing MSP teams.',
monthly_price_cents: 4900,
annual_price_cents: 49000,
max_seats: 10,
sort_order: 20,
is_public: true,
}
const ENTERPRISE: PublicPlanResponse = {
plan: 'enterprise',
display_name: 'Enterprise',
description: 'Custom seats + branding.',
monthly_price_cents: null,
annual_price_cents: null,
max_seats: null,
sort_order: 30,
is_public: true,
}
function renderPage() {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={['/pricing']}>
<PricingPage />
</MemoryRouter>
</HelmetProvider>,
)
}
describe('PricingPage', () => {
beforeEach(() => {
__resetAppConfigCache()
vi.clearAllMocks()
})
it('shows three plan cards with prices from API', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
await waitFor(() => {
expect(plansApi.getPublic).toHaveBeenCalled()
})
// Three plan cards present.
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
// Prices from API rendered.
await waitFor(() => {
expect(screen.getByText('$19')).toBeInTheDocument()
expect(screen.getByText('$49')).toBeInTheDocument()
})
// Enterprise card hides price (shows "Custom pricing" instead).
expect(screen.getByText(/Custom pricing/i)).toBeInTheDocument()
// Pro is recommended.
expect(screen.getByTestId('recommended-badge')).toBeInTheDocument()
})
it('Start free trial button navigates to /register?plan=pro', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
const proCta = await screen.findByTestId('cta-pro')
expect(proCta).toHaveAttribute('href', '/register?plan=pro')
expect(proCta).toHaveTextContent(/Start free trial/i)
const starterCta = screen.getByTestId('cta-starter')
expect(starterCta).toHaveAttribute('href', '/register?plan=starter')
})
it('Talk to sales button navigates to /contact-sales', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
const enterpriseCta = await screen.findByTestId('cta-enterprise')
expect(enterpriseCta).toHaveAttribute('href', '/contact-sales')
expect(enterpriseCta).toHaveTextContent(/Talk to sales/i)
})
it('returns 404 when self_serve_enabled is false', async () => {
__setAppConfigCache({
self_serve_enabled: false,
oauth_providers: [],
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('pricing-not-found')).toBeInTheDocument()
})
expect(screen.getByText(/Page not found/i)).toBeInTheDocument()
// No plan cards rendered, no API call made.
expect(screen.queryByTestId('plan-card-starter')).not.toBeInTheDocument()
expect(plansApi.getPublic).not.toHaveBeenCalled()
})
it('uses softer trust language (no SOC2/DPA claim yet)', async () => {
__setAppConfigCache({
self_serve_enabled: true,
oauth_providers: [],
})
vi.mocked(plansApi.getPublic).mockResolvedValue([STARTER, PRO, ENTERPRISE])
renderPage()
const trust = await screen.findByTestId('trust-strip')
expect(trust).toHaveTextContent(/Built on Stripe \+ AWS/i)
expect(trust).toHaveTextContent(/Encrypted in transit and at rest/i)
expect(trust).not.toHaveTextContent(/SOC ?2/i)
})
})

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import type { OnboardingStatus } from '@/api/onboarding'
import { useAuthStore } from '@/store/authStore'
import { useBillingStore } from '@/store/billingStore'
// Mock heavy dashboard children — they pull in axios + zustand stores we
// don't care about for this toggle test.
vi.mock('@/components/dashboard/StartSessionInput', () => ({
StartSessionInput: () => <div data-testid="mock-start-session" />,
}))
vi.mock('@/components/dashboard/PendingEscalations', () => ({
PendingEscalations: () => null,
}))
vi.mock('@/components/dashboard/ActiveFlowPilotSessions', () => ({
ActiveFlowPilotSessions: () => null,
}))
vi.mock('@/components/dashboard/TicketQueue', () => ({
TicketQueue: () => null,
}))
vi.mock('@/components/dashboard/PerformanceCards', () => ({
PerformanceCards: () => null,
}))
vi.mock('@/components/dashboard/KnowledgeBaseCards', () => ({
KnowledgeBaseCards: () => null,
}))
vi.mock('@/components/dashboard/TeamSummary', () => ({
TeamSummary: () => null,
}))
vi.mock('@/api/onboarding', () => {
const mockGet = vi.fn()
return {
getOnboardingStatus: mockGet,
dismissOnboarding: vi.fn(),
}
})
import { QuickStartPage } from '../QuickStartPage'
import { getOnboardingStatus as _getOnboardingStatus } from '@/api/onboarding'
const getOnboardingStatus = _getOnboardingStatus as unknown as ReturnType<typeof vi.fn>
function makeStatus(overrides: Partial<OnboardingStatus> = {}): OnboardingStatus {
return {
created_flow: false,
ran_session: false,
exported_session: false,
tried_ai_assistant: false,
invited_teammate: false,
connected_psa: false,
is_team_user: false,
dismissed: false,
email_verified: true, // skip past verify so the next-step card is not the noisy thing here.
shop_setup_done: false,
...overrides,
}
}
describe('QuickStartPage', () => {
beforeEach(() => {
getOnboardingStatus.mockReset()
useAuthStore.setState({
user: {
id: 'u-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'engineer',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: '2026-05-01T00:00:00Z',
},
token: 'tok',
isAuthenticated: true,
})
useBillingStore.setState({
subscription: {
status: 'complimentary',
plan: 'pro',
current_period_start: '2026-05-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: true,
is_paid: true,
},
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('Show all setup steps toggle reveals unified checklist with no SOLO/TEAM headers', async () => {
getOnboardingStatus.mockResolvedValue(makeStatus())
render(
<BrowserRouter>
<QuickStartPage />
</BrowserRouter>,
)
// Wait for initial fetch.
await waitFor(() => expect(getOnboardingStatus).toHaveBeenCalled())
// Checklist is hidden by default.
expect(screen.queryByTestId('setup-checklist')).toBeNull()
// Toggle visible.
const toggle = screen.getByTestId('toggle-setup-checklist')
expect(toggle).toHaveTextContent(/Show all setup steps/i)
fireEvent.click(toggle)
// Checklist now rendered. (`SetupChecklist` runs its own fetch — same mock.)
await waitFor(() => {
expect(screen.getByTestId('setup-checklist')).toBeInTheDocument()
})
// No SOLO/TEAM section headers in the unified list.
expect(screen.queryByText(/^SOLO$/)).toBeNull()
expect(screen.queryByText(/^TEAM$/)).toBeNull()
expect(screen.queryByText(/Solo users/i)).toBeNull()
expect(screen.queryByText(/Team users/i)).toBeNull()
// Toggle label flips after clicking.
expect(toggle).toHaveTextContent(/Hide setup steps/i)
})
})

View File

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

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import { VerifyEmailPage } from '../VerifyEmailPage'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
vi.mock('@/api/auth', () => ({
authApi: {
verifyEmail: vi.fn(),
sendVerificationEmail: vi.fn(),
me: vi.fn(),
},
}))
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}))
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'engineer',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
...overrides,
}
}
function renderPage(initialPath: string) {
return render(
<HelmetProvider>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
}
describe('VerifyEmailPage', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
useAuthStore.setState({
user: null,
token: null,
isAuthenticated: false,
})
vi.mocked(authApi.me).mockResolvedValue(
makeUser({ email_verified_at: '2026-05-06T00:00:00Z' }),
)
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('shows success and redirects on valid token', async () => {
useAuthStore.setState({ user: makeUser() })
// Override fetchUser to avoid hitting axios/XHR in jsdom — the page calls
// it after a successful verify to refresh `email_verified_at`.
useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) })
vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined)
renderPage('/verify-email?token=valid-token')
await waitFor(() => {
expect(authApi.verifyEmail).toHaveBeenCalledWith('valid-token')
})
await waitFor(() => {
expect(screen.getByText(/Email verified/i)).toBeInTheDocument()
})
// Advance past the redirect delay.
vi.advanceTimersByTime(2000)
await waitFor(() => {
expect(screen.getByText('dashboard')).toBeInTheDocument()
})
})
it('shows already-verified state when user is already verified', async () => {
useAuthStore.setState({
user: makeUser({ email_verified_at: '2026-05-05T00:00:00Z' }),
})
renderPage('/verify-email?token=any-token')
await waitFor(() => {
expect(
screen.getByText(/already verified/i),
).toBeInTheDocument()
})
// The verify endpoint must NOT have been called when the user is already
// verified — that would burn a perfectly good token for no reason.
expect(authApi.verifyEmail).not.toHaveBeenCalled()
})
it('only calls verifyEmail once even if the effect re-runs (strict-mode guard)', async () => {
useAuthStore.setState({ user: makeUser() })
useAuthStore.setState({ fetchUser: vi.fn().mockResolvedValue(undefined) })
vi.mocked(authApi.verifyEmail).mockResolvedValue(undefined)
const { rerender } = render(
<HelmetProvider>
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
// Force a re-render to simulate React 19 strict-mode double-invoke.
rerender(
<HelmetProvider>
<MemoryRouter initialEntries={['/verify-email?token=valid-token']}>
<Routes>
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>
</HelmetProvider>,
)
await waitFor(() => {
expect(authApi.verifyEmail).toHaveBeenCalled()
})
expect(authApi.verifyEmail).toHaveBeenCalledTimes(1)
})
it('shows an error state with a resend CTA on invalid token', async () => {
useAuthStore.setState({ user: makeUser() })
vi.mocked(authApi.verifyEmail).mockRejectedValue(
Object.assign(new Error('boom'), {
response: { data: { detail: 'Token expired' } },
}),
)
renderPage('/verify-email?token=stale-token')
await waitFor(() => {
expect(screen.getByText(/Verification failed/i)).toBeInTheDocument()
})
expect(screen.getByText(/Token expired/i)).toBeInTheDocument()
expect(screen.getByTestId('resend-button')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,267 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { CreditCard, AlertCircle, Loader2, ExternalLink, Crown } from 'lucide-react'
import { billingApi } from '@/api/billing'
import { Button } from '@/components/ui/Button'
import { PageMeta } from '@/components/common/PageMeta'
import { useBillingStore } from '@/store/billingStore'
import { BillingPortalError } from '@/types/billing'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
function formatDate(value: string | null | undefined): string {
if (!value) return '—'
return new Date(value).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function statusLabel(status: string): string {
switch (status) {
case 'trialing':
return 'Trialing'
case 'active':
return 'Active'
case 'past_due':
return 'Past due'
case 'canceled':
return 'Canceled'
case 'incomplete':
return 'Incomplete'
case 'complimentary':
return 'Complimentary'
default:
return status
}
}
function statusToneClass(status: string): string {
switch (status) {
case 'active':
case 'complimentary':
return 'text-success'
case 'trialing':
return 'text-info'
case 'past_due':
case 'incomplete':
return 'text-warning'
case 'canceled':
return 'text-danger'
default:
return 'text-muted-foreground'
}
}
export function BillingPage() {
const subscription = useBillingStore((s) => s.subscription)
const planBilling = useBillingStore((s) => s.planBilling)
const isLoading = useBillingStore((s) => s.isLoading)
const [openingPortal, setOpeningPortal] = useState(false)
const status = subscription?.status ?? null
const isComplimentary = status === 'complimentary'
const isTrialing = status === 'trialing'
const isPastDue = status === 'past_due'
const isCanceled = status === 'canceled'
const handleOpenPortal = async () => {
setOpeningPortal(true)
try {
const { url } = await billingApi.getPortalSession()
window.location.href = url
} catch (err) {
if (err instanceof BillingPortalError) {
if (err.code === 'no_stripe_customer') {
toast.error('Complete checkout first to access billing portal.')
} else {
toast.error('Billing portal is not available right now.')
}
} else {
toast.error('Failed to open billing portal.')
}
setOpeningPortal(false)
}
}
if (isLoading && !subscription) {
return (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
return (
<>
<PageMeta title="Billing" />
<div>
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="mb-8">
<div className="flex items-center gap-3">
<CreditCard className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
Billing
</h1>
</div>
<p className="mt-2 text-muted-foreground">
Manage your subscription, payment method, and billing history.
</p>
</div>
{/* ── Past-due banner ────────────────────────────────────────────── */}
{isPastDue && (
<div
data-testid="past-due-banner"
className={cn(
'mb-6 flex flex-wrap items-start gap-3 rounded-lg border border-warning/30',
'bg-warning-dim p-4 text-foreground',
)}
>
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Your last payment failed.</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Update your payment method to keep access to ResolutionFlow.
</p>
</div>
<Button
size="sm"
loading={openingPortal}
onClick={handleOpenPortal}
data-testid="past-due-update-payment"
>
Update payment method
</Button>
</div>
)}
{/* ── Subscription summary card ──────────────────────────────────── */}
<div className="card-flat max-w-xl space-y-5 p-6">
<div className="flex items-baseline justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Crown className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">
{planBilling?.display_name ?? 'No active plan'}
</span>
</div>
{subscription && (
<div
className={cn(
'mt-1 text-xs',
statusToneClass(subscription.status),
)}
>
{statusLabel(subscription.status)}
{subscription.cancel_at_period_end && ' · cancels at period end'}
</div>
)}
</div>
{subscription?.seat_limit != null && (
<div className="text-right">
<div className="text-xs text-muted-foreground">Seats</div>
<div className="text-sm tabular-nums text-foreground">
{subscription.seat_limit}
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-3 border-t border-border pt-4 sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">
{isCanceled ? 'Ends' : isTrialing ? 'Trial ends' : 'Next renewal'}
</div>
<div className="text-sm tabular-nums text-foreground">
{isComplimentary ? '—' : formatDate(subscription?.current_period_end)}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Plan started</div>
<div className="text-sm tabular-nums text-foreground">
{formatDate(subscription?.current_period_start)}
</div>
</div>
</div>
{/* State-specific messaging ------------------------------------ */}
{isComplimentary && (
<div
data-testid="complimentary-message"
className="rounded-md bg-success-dim p-3 text-xs text-success"
>
Complimentary Pro no billing required.
</div>
)}
{isTrialing && (
<div
data-testid="trial-message"
className="rounded-md bg-info-dim p-3 text-xs text-info"
>
Trial ends {formatDate(subscription?.current_period_end)} pick a plan
to continue.
</div>
)}
{isCanceled && (
<div
data-testid="canceled-message"
className="rounded-md bg-muted p-3 text-xs text-muted-foreground"
>
Subscription canceled. Reactivate by picking a plan.
</div>
)}
</div>
{/* ── Actions ────────────────────────────────────────────────────── */}
{!isComplimentary && (
<div className="mt-6 flex max-w-xl flex-wrap gap-3">
{(isTrialing || isCanceled) && (
<Link
to="/account/billing/select-plan"
data-testid="select-plan-link"
className={cn(
'inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2',
'text-sm font-semibold text-white hover:brightness-110',
)}
>
Pick a plan
</Link>
)}
{!isTrialing && !isCanceled && (
<Link
to="/account/billing/select-plan"
data-testid="change-plan-link"
className={cn(
'inline-flex items-center gap-2 rounded-lg border border-border',
'bg-input px-4 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover',
)}
>
Change plan
</Link>
)}
<Button
variant="secondary"
loading={openingPortal}
onClick={handleOpenPortal}
data-testid="manage-billing-button"
>
<ExternalLink className="h-4 w-4" />
Manage billing
</Button>
</div>
)}
</div>
</>
)
}
export default BillingPage

View File

@@ -0,0 +1,354 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Check, CreditCard, Loader2 } from 'lucide-react'
import { billingApi } from '@/api/billing'
import { plansApi, type PublicPlanResponse } from '@/api/plans'
import { Button } from '@/components/ui/Button'
import { PageMeta } from '@/components/common/PageMeta'
import { useBillingStore } from '@/store/billingStore'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
import type { BillingInterval, CheckoutPlan } from '@/types/billing'
function formatPrice(cents: number | null | undefined): string {
if (cents == null) return ''
const dollars = cents / 100
return `$${Math.round(dollars).toLocaleString()}`
}
const PLAN_FALLBACK_FEATURES: Record<string, string[]> = {
starter: ['AI Builder', 'Up to 1 seat', 'Email support'],
pro: [
'PSA Integration',
'KB Accelerator',
'AI Builder',
'Priority support',
],
team: [
'Everything in Pro',
'Multi-seat collaboration',
'Shared categories',
],
enterprise: [
'Custom seats and SSO',
'Custom branding',
'Dedicated success contact',
],
}
interface PlanCardProps {
plan: PublicPlanResponse
interval: BillingInterval
isCurrent: boolean
isEnterprise: boolean
onSelect: (planKey: CheckoutPlan) => void
isSubmitting: boolean
}
function PlanCard({
plan,
interval,
isCurrent,
isEnterprise,
onSelect,
isSubmitting,
}: PlanCardProps) {
const planKey = plan.plan.toLowerCase() as CheckoutPlan
const cents =
interval === 'annual' ? plan.annual_price_cents : plan.monthly_price_cents
const features = PLAN_FALLBACK_FEATURES[planKey] ?? []
return (
<div
data-testid={`plan-card-${planKey}`}
className={cn(
'flex flex-col gap-4 rounded-xl border p-6',
isCurrent
? 'border-primary/40 bg-primary/5'
: 'border-border bg-card hover:border-border-hover',
)}
>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-lg font-semibold text-foreground">
{plan.display_name}
</h3>
{isCurrent && (
<span
data-testid={`plan-current-${planKey}`}
className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
>
Current plan
</span>
)}
</div>
{plan.description && (
<p className="text-sm text-muted-foreground">{plan.description}</p>
)}
<div className="min-h-[3rem]">
{isEnterprise ? (
<div className="text-base font-medium text-foreground">
Custom pricing
</div>
) : cents != null ? (
<div>
<span className="text-3xl font-bold text-foreground">
{formatPrice(cents)}
</span>
<span className="ml-1 text-sm text-muted-foreground">
/ {interval === 'annual' ? 'year' : 'month'}
</span>
</div>
) : (
<div className="text-sm text-muted-foreground">Contact us</div>
)}
</div>
<ul className="space-y-1.5 text-sm text-muted-foreground">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-success" />
<span>{feature}</span>
</li>
))}
</ul>
<div className="mt-auto pt-2">
{isEnterprise ? (
<Link
to="/contact-sales"
data-testid={`plan-cta-${planKey}`}
className={cn(
'inline-flex w-full items-center justify-center rounded-lg border border-border',
'bg-input px-4 py-2 text-sm font-medium text-foreground',
'hover:border-border-hover',
)}
>
Talk to sales
</Link>
) : (
<Button
data-testid={`plan-cta-${planKey}`}
disabled={isCurrent || isSubmitting}
loading={isSubmitting}
onClick={() => onSelect(planKey)}
className="w-full"
>
{isCurrent ? 'Current plan' : 'Continue to checkout'}
</Button>
)}
</div>
</div>
)
}
export function SelectPlanPage() {
const subscription = useBillingStore((s) => s.subscription)
const currentPlan = subscription?.plan ?? null
const isCurrentActive =
subscription?.status === 'active' || subscription?.status === 'trialing'
const [plans, setPlans] = useState<PublicPlanResponse[] | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [interval, setInterval] = useState<BillingInterval>('monthly')
const [seats, setSeats] = useState<number>(1)
const [submittingPlan, setSubmittingPlan] = useState<CheckoutPlan | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
plansApi
.getPublic()
.then((data) => {
if (cancelled) return
// Sort by sort_order so the layout is stable.
const sorted = [...data].sort((a, b) => a.sort_order - b.sort_order)
setPlans(sorted)
setLoadError(null)
})
.catch(() => {
if (cancelled) return
setLoadError('Unable to load plans. Please try again.')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const seedSeats = useMemo(() => {
return subscription?.seat_limit && subscription.seat_limit > 0
? subscription.seat_limit
: 1
}, [subscription?.seat_limit])
useEffect(() => {
setSeats(seedSeats)
}, [seedSeats])
const handleSelectPlan = async (planKey: CheckoutPlan) => {
if (planKey === 'enterprise') return
setSubmittingPlan(planKey)
try {
const { url } = await billingApi.createCheckoutSession({
plan: planKey,
seats: Math.max(1, Math.floor(seats)),
billing_interval: interval,
})
window.location.href = url
} catch {
toast.error('Could not start checkout. Please try again.')
setSubmittingPlan(null)
}
}
return (
<>
<PageMeta title="Pick a plan" />
<div>
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="mb-8">
<div className="flex items-center gap-3">
<CreditCard className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
Pick a plan
</h1>
</div>
<p className="mt-2 text-muted-foreground">
Choose the plan that fits your team. You can change or cancel any
time.
</p>
</div>
{/* ── Controls ───────────────────────────────────────────────────── */}
<div className="mb-6 flex flex-wrap items-end gap-6">
<div>
<span className="block text-xs font-medium text-muted-foreground">
Billing interval
</span>
<div
role="tablist"
aria-label="Billing interval"
className="mt-2 inline-flex rounded-lg border border-border bg-card p-1 text-sm"
>
<button
type="button"
role="tab"
aria-selected={interval === 'monthly'}
data-testid="interval-monthly"
onClick={() => setInterval('monthly')}
className={cn(
'rounded-md px-3 py-1.5 font-medium',
interval === 'monthly'
? 'bg-primary text-white'
: 'text-muted-foreground hover:text-foreground',
)}
>
Monthly
</button>
<button
type="button"
role="tab"
aria-selected={interval === 'annual'}
data-testid="interval-annual"
onClick={() => setInterval('annual')}
className={cn(
'rounded-md px-3 py-1.5 font-medium',
interval === 'annual'
? 'bg-primary text-white'
: 'text-muted-foreground hover:text-foreground',
)}
>
Annual
</button>
</div>
</div>
<div>
<label
htmlFor="seats-input"
className="block text-xs font-medium text-muted-foreground"
>
Seats
</label>
<input
id="seats-input"
data-testid="seats-input"
type="number"
min={1}
step={1}
value={seats}
onChange={(e) => {
const next = Number.parseInt(e.target.value, 10)
if (Number.isFinite(next) && next >= 1) {
setSeats(next)
} else if (e.target.value === '') {
setSeats(1)
}
}}
className={cn(
'mt-2 w-24 rounded-lg border border-border bg-card px-3 py-1.5',
'text-sm text-foreground',
'focus:border-primary/30 focus:outline-hidden focus:ring-1 focus:ring-primary/20',
)}
/>
</div>
</div>
{/* ── Plan cards ─────────────────────────────────────────────────── */}
{loading && (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{loadError && !loading && (
<div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-sm text-danger">
{loadError}
</div>
)}
{!loading && !loadError && plans && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((plan) => {
const planKey = plan.plan.toLowerCase()
const isEnterprise = planKey === 'enterprise'
const isCurrent = !!(
isCurrentActive &&
currentPlan &&
currentPlan.toLowerCase() === planKey
)
return (
<PlanCard
key={plan.plan}
plan={plan}
interval={interval}
isCurrent={isCurrent}
isEnterprise={isEnterprise}
onSelect={handleSelectPlan}
isSubmitting={submittingPlan === planKey}
/>
)
})}
</div>
)}
<div className="mt-6">
<Link
to="/account/billing"
className="text-sm text-muted-foreground hover:text-foreground"
>
Back to billing
</Link>
</div>
</div>
</>
)
}
export default SelectPlanPage

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { BillingPage } from '../BillingPage'
import { useBillingStore } from '@/store/billingStore'
import { BillingPortalError } from '@/types/billing'
import type { SubscriptionState, PlanBillingState } from '@/types/billing'
vi.mock('@/api/billing', () => ({
billingApi: {
getPortalSession: vi.fn(),
},
}))
vi.mock('@/lib/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
},
}))
import { billingApi } from '@/api/billing'
import { toast } from '@/lib/toast'
const getPortalSession = billingApi.getPortalSession as unknown as ReturnType<typeof vi.fn>
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
function setBilling(opts: {
subscription: SubscriptionState | null
planBilling?: PlanBillingState | null
}) {
useBillingStore.setState({
subscription: opts.subscription,
planBilling:
opts.planBilling ??
({
display_name: 'Pro',
description: 'Pro plan',
monthly_price_cents: 4900,
annual_price_cents: 49000,
} as PlanBillingState),
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
}
function renderPage() {
return render(
<MemoryRouter>
<BillingPage />
</MemoryRouter>,
)
}
describe('BillingPage', () => {
beforeEach(() => {
getPortalSession.mockReset()
toastError.mockReset()
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('renders subscription summary from useBillingStore', () => {
setBilling({
subscription: {
status: 'active',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: true,
},
})
renderPage()
expect(screen.getByRole('heading', { name: 'Billing' })).toBeInTheDocument()
expect(screen.getByText('Pro')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
// Seats shown
expect(screen.getByText('5')).toBeInTheDocument()
})
it('shows trial-ends message + Pick a plan CTA when trialing', () => {
setBilling({
subscription: {
status: 'trialing',
plan: 'pro',
current_period_start: '2026-04-22T00:00:00Z',
current_period_end: '2026-05-06T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: false,
},
})
renderPage()
expect(screen.getByTestId('trial-message').textContent).toMatch(/Trial ends/)
const pickPlan = screen.getByTestId('select-plan-link')
expect(pickPlan.getAttribute('href')).toBe('/account/billing/select-plan')
})
it('shows past-due banner with update payment CTA when status=past_due', () => {
setBilling({
subscription: {
status: 'past_due',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: false,
is_paid: true,
},
})
renderPage()
expect(screen.getByTestId('past-due-banner')).toBeInTheDocument()
expect(screen.getByTestId('past-due-update-payment')).toBeInTheDocument()
})
it('renders complimentary message and hides CTAs when complimentary', () => {
setBilling({
subscription: {
status: 'complimentary',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: null,
cancel_at_period_end: false,
seat_limit: null,
has_pro_entitlement: true,
is_paid: true,
},
})
renderPage()
expect(screen.getByTestId('complimentary-message')).toBeInTheDocument()
expect(screen.queryByTestId('manage-billing-button')).not.toBeInTheDocument()
expect(screen.queryByTestId('select-plan-link')).not.toBeInTheDocument()
expect(screen.queryByTestId('change-plan-link')).not.toBeInTheDocument()
})
it('renders canceled message + Pick a plan CTA when canceled', () => {
setBilling({
subscription: {
status: 'canceled',
plan: 'pro',
current_period_start: '2026-03-01T00:00:00Z',
current_period_end: '2026-04-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: false,
is_paid: false,
},
})
renderPage()
expect(screen.getByTestId('canceled-message')).toBeInTheDocument()
expect(screen.getByTestId('select-plan-link')).toBeInTheDocument()
})
it('shows toast when portal session fails with no_stripe_customer', async () => {
setBilling({
subscription: {
status: 'active',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: true,
},
})
getPortalSession.mockRejectedValueOnce(
new BillingPortalError('no_stripe_customer'),
)
renderPage()
fireEvent.click(screen.getByTestId('manage-billing-button'))
await waitFor(() => {
expect(toastError).toHaveBeenCalledWith(
'Complete checkout first to access billing portal.',
)
})
})
})

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { SelectPlanPage } from '../SelectPlanPage'
import { useBillingStore } from '@/store/billingStore'
vi.mock('@/api/billing', () => ({
billingApi: {
createCheckoutSession: vi.fn(),
},
}))
vi.mock('@/api/plans', () => ({
plansApi: {
getPublic: vi.fn(),
},
}))
vi.mock('@/lib/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
import { billingApi } from '@/api/billing'
import { plansApi } from '@/api/plans'
const createCheckoutSession = billingApi.createCheckoutSession as unknown as ReturnType<typeof vi.fn>
const getPublic = plansApi.getPublic as unknown as ReturnType<typeof vi.fn>
const PLAN_FIXTURE = [
{
plan: 'starter',
display_name: 'Starter',
description: 'For solo techs.',
monthly_price_cents: 1900,
annual_price_cents: 19000,
max_seats: 1,
sort_order: 1,
is_public: true,
},
{
plan: 'pro',
display_name: 'Pro',
description: 'For growing teams.',
monthly_price_cents: 4900,
annual_price_cents: 49000,
max_seats: 5,
sort_order: 2,
is_public: true,
},
{
plan: 'enterprise',
display_name: 'Enterprise',
description: 'Custom.',
monthly_price_cents: null,
annual_price_cents: null,
max_seats: null,
sort_order: 3,
is_public: true,
},
]
function renderPage() {
return render(
<MemoryRouter>
<SelectPlanPage />
</MemoryRouter>,
)
}
describe('SelectPlanPage', () => {
// Stub window.location.href setter so we can assert without a real navigation.
let assignedHref: string | null = null
const originalLocation = window.location
beforeEach(() => {
getPublic.mockReset()
createCheckoutSession.mockReset()
assignedHref = null
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
get href() {
return assignedHref ?? originalLocation.href
},
set href(v: string) {
assignedHref = v
},
},
})
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('renders plan cards from plansApi', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-card-starter')).toBeInTheDocument()
})
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
expect(screen.getByTestId('plan-card-enterprise')).toBeInTheDocument()
})
it('Continue to checkout calls createCheckoutSession and redirects', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
createCheckoutSession.mockResolvedValueOnce({ url: 'https://checkout.stripe.com/abc' })
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-card-pro')).toBeInTheDocument()
})
// Bump seats and switch to annual.
fireEvent.change(screen.getByTestId('seats-input'), { target: { value: '3' } })
fireEvent.click(screen.getByTestId('interval-annual'))
fireEvent.click(screen.getByTestId('plan-cta-pro'))
await waitFor(() => {
expect(createCheckoutSession).toHaveBeenCalledWith({
plan: 'pro',
seats: 3,
billing_interval: 'annual',
})
})
await waitFor(() => {
expect(assignedHref).toBe('https://checkout.stripe.com/abc')
})
})
it('Talk to sales links to /contact-sales for enterprise', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-cta-enterprise')).toBeInTheDocument()
})
const cta = screen.getByTestId('plan-cta-enterprise') as HTMLAnchorElement
expect(cta.getAttribute('href')).toBe('/contact-sales')
})
it('marks the active current plan as Current plan and disables its CTA', async () => {
getPublic.mockResolvedValueOnce(PLAN_FIXTURE)
useBillingStore.setState({
subscription: {
status: 'active',
plan: 'pro',
current_period_start: '2026-04-01T00:00:00Z',
current_period_end: '2026-05-01T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: true,
},
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
renderPage()
await waitFor(() => {
expect(screen.getByTestId('plan-current-pro')).toBeInTheDocument()
})
const cta = screen.getByTestId('plan-cta-pro') as HTMLButtonElement
expect(cta).toBeDisabled()
})
})

View File

@@ -0,0 +1,31 @@
import { Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { PageLoader } from '@/components/common/PageLoader'
/**
* `/welcome` index — redirect to the next incomplete step (or `/` if done /
* dismissed). Decision table:
*
* onboarding_dismissed === true → /
* onboarding_step_completed >= 3 → /
* onboarding_step_completed === null/0 → /welcome/step-1
* onboarding_step_completed === 1 → /welcome/step-2
* onboarding_step_completed === 2 → /welcome/step-3
*/
export function WelcomeRouter() {
const user = useAuthStore((s) => s.user)
// Auth gate sits above us — but if the user object is still loading, render
// the page loader rather than racing past the redirect.
if (!user) return <PageLoader />
if (user.onboarding_dismissed) return <Navigate to="/" replace />
const completed = user.onboarding_step_completed ?? 0
if (completed >= 3) return <Navigate to="/" replace />
if (completed === 2) return <Navigate to="/welcome/step-3" replace />
if (completed === 1) return <Navigate to="/welcome/step-2" replace />
return <Navigate to="/welcome/step-1" replace />
}
export default WelcomeRouter

View File

@@ -0,0 +1,248 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import {
onboardingApi,
type RoleAtSignup,
type TeamSizeBucket,
} from '@/api/onboarding'
import { cn } from '@/lib/utils'
const TEAM_SIZE_OPTIONS: { value: TeamSizeBucket; label: string }[] = [
{ value: '1-2', label: '12' },
{ value: '3-5', label: '35' },
{ value: '6-10', label: '610' },
{ value: '11-25', label: '1125' },
{ value: '26+', label: '26+' },
]
const ROLE_OPTIONS: { value: RoleAtSignup; label: string }[] = [
{ value: 'owner', label: 'Owner' },
{ value: 'lead_tech', label: 'Lead Tech' },
{ value: 'tech', label: 'Tech' },
{ value: 'other', label: 'Other' },
]
/**
* `/welcome/step-1` — first step of the welcome wizard. Captures shop context
* (company name, team size, role). Persists server-side before navigating.
*/
export function WelcomeStep1() {
const navigate = useNavigate()
const account = useAuthStore((s) => s.account)
const fetchUser = useAuthStore((s) => s.fetchUser)
const [companyName, setCompanyName] = useState<string>(account?.name ?? '')
const [teamSize, setTeamSize] = useState<TeamSizeBucket | ''>('')
const [role, setRole] = useState<RoleAtSignup | ''>('')
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
const [error, setError] = useState<string | null>(null)
const isBusy = submitting !== null
const handleContinue = async (e: FormEvent) => {
e.preventDefault()
if (isBusy) return
setError(null)
setSubmitting('continue')
try {
await onboardingApi.updateStep({
step: 1,
action: 'complete',
data: {
company_name: companyName.trim() || undefined,
team_size_bucket: teamSize || undefined,
role_at_signup: role || undefined,
},
})
await fetchUser()
navigate('/welcome/step-2')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleSkipStep = async () => {
if (isBusy) return
setError(null)
setSubmitting('skip')
try {
await onboardingApi.updateStep({ step: 1, action: 'skip' })
await fetchUser()
navigate('/welcome/step-2')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleDismissRest = async () => {
if (isBusy) return
setError(null)
setSubmitting('dismiss')
try {
await onboardingApi.dismissRest()
await fetchUser()
navigate('/')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const inputClass = 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',
)
return (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 1 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Your shop
</h1>
<p className="mt-2 text-sm text-muted-foreground">
A couple of quick questions so we can tailor ResolutionFlow to your team.
</p>
</header>
<form
onSubmit={handleContinue}
className="rounded-2xl border border-border bg-card p-6 space-y-5"
data-testid="welcome-step-1-form"
>
<div>
<label
htmlFor="company_name"
className="block text-sm font-medium text-foreground"
>
Company name
</label>
<input
id="company_name"
name="company_name"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
className={inputClass}
placeholder="Acme MSP"
data-testid="welcome-step-1-company-name"
/>
</div>
<div>
<label
htmlFor="team_size"
className="block text-sm font-medium text-foreground"
>
Team size
</label>
<select
id="team_size"
name="team_size"
value={teamSize}
onChange={(e) => setTeamSize(e.target.value as TeamSizeBucket | '')}
className={inputClass}
data-testid="welcome-step-1-team-size"
>
<option value="">Select team size</option>
{TEAM_SIZE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="role"
className="block text-sm font-medium text-foreground"
>
Your role
</label>
<select
id="role"
name="role"
value={role}
onChange={(e) => setRole(e.target.value as RoleAtSignup | '')}
className={inputClass}
data-testid="welcome-step-1-role"
>
<option value="">Select your role</option>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-1-error">
{error}
</p>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={isBusy}
data-testid="welcome-step-1-continue"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'continue' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue
</button>
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-1-skip"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'skip' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Skip this step
</button>
</div>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-1-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving…' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep1

View File

@@ -0,0 +1,208 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { onboardingApi, type PrimaryPsa } from '@/api/onboarding'
import { cn } from '@/lib/utils'
const PSA_OPTIONS: { value: PrimaryPsa; label: string; description: string }[] = [
{ value: 'connectwise', label: 'ConnectWise', description: 'Manage / PSA' },
{ value: 'autotask', label: 'Autotask', description: 'Datto Autotask PSA' },
{ value: 'halopsa', label: 'HaloPSA', description: 'Halo Service Solutions' },
{ value: 'none', label: 'No PSA yet', description: "We'll add one later" },
]
/**
* `/welcome/step-2` — second step of the welcome wizard. Captures the PSA the
* shop primarily uses. Selecting a non-`none` tile reveals a quiet "Connect
* now" link that navigates out to `/account/integrations`. The wizard's
* primary action is "Continue" — credential entry is intentionally OUT of
* the wizard (per spec).
*/
export function WelcomeStep2() {
const navigate = useNavigate()
const fetchUser = useAuthStore((s) => s.fetchUser)
const [primaryPsa, setPrimaryPsa] = useState<PrimaryPsa | null>(null)
const [submitting, setSubmitting] = useState<'continue' | 'skip' | 'dismiss' | null>(null)
const [error, setError] = useState<string | null>(null)
const isBusy = submitting !== null
const showConnectNow = primaryPsa !== null && primaryPsa !== 'none'
const handleContinue = async () => {
if (isBusy) return
setError(null)
setSubmitting('continue')
try {
await onboardingApi.updateStep({
step: 2,
action: 'complete',
data: primaryPsa ? { primary_psa: primaryPsa } : undefined,
})
await fetchUser()
navigate('/welcome/step-3')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleSkipStep = async () => {
if (isBusy) return
setError(null)
setSubmitting('skip')
try {
await onboardingApi.updateStep({ step: 2, action: 'skip' })
await fetchUser()
navigate('/welcome/step-3')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleDismissRest = async () => {
if (isBusy) return
setError(null)
setSubmitting('dismiss')
try {
await onboardingApi.dismissRest()
await fetchUser()
navigate('/')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
return (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 2 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Your PSA
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Pick the PSA your team uses today. We'll wire it up later — no
credentials needed yet.
</p>
</header>
<div
className="rounded-2xl border border-border bg-card p-6 space-y-5"
data-testid="welcome-step-2-form"
>
<div
role="radiogroup"
aria-label="Primary PSA"
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
>
{PSA_OPTIONS.map((opt) => {
const selected = primaryPsa === opt.value
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setPrimaryPsa(opt.value)}
disabled={isBusy}
data-testid={`welcome-step-2-tile-${opt.value}`}
className={cn(
'rounded-xl border px-4 py-3 text-left transition-colors btn-press',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
selected
? 'border-primary bg-primary/5'
: 'border-border bg-card hover:border-primary/40 hover:bg-foreground/5',
)}
>
<div className="text-sm font-semibold text-foreground">
{opt.label}
</div>
<div className="text-xs text-muted-foreground">
{opt.description}
</div>
</button>
)
})}
</div>
{showConnectNow && (
<div className="pt-1">
<Link
to="/account/integrations"
data-testid="welcome-step-2-connect-now"
className="text-xs text-muted-foreground hover:underline"
>
Connect now &rarr;
</Link>
</div>
)}
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-2-error">
{error}
</p>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleContinue}
disabled={isBusy}
data-testid="welcome-step-2-continue"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'continue' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue
</button>
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-2-skip"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'skip' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Skip this step
</button>
</div>
</div>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-2-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep2

View File

@@ -0,0 +1,374 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, Plus, X } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { onboardingApi } from '@/api/onboarding'
import { accountsApi, type BulkInviteRow } from '@/api/accounts'
import { toast } from '@/lib/toast'
import { cn } from '@/lib/utils'
const MAX_ROWS = 10
const DEFAULT_ROW_COUNT = 3
type RowRole = 'engineer' | 'viewer'
interface InviteRow {
email: string
role: RowRole
/**
* Server-returned per-row error (from `failed[]`). Kept on the row so
* users can fix and retry without losing the rest of their input.
*/
error?: string
}
const ROLE_OPTIONS: { value: RowRole; label: string }[] = [
{ value: 'engineer', label: 'Tech' },
{ value: 'viewer', label: 'Viewer' },
]
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function makeEmptyRow(): InviteRow {
return { email: '', role: 'engineer' }
}
/**
* `/welcome/step-3` — final step of the welcome wizard. Captures up to
* `MAX_ROWS` teammate invites. On submit:
*
* 1. POST `/accounts/me/invites/bulk` with populated rows.
* 2. PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
* 3. Navigate to `/?welcome=true` and fire a "You're all set" toast.
*
* Partial-failure UX: rows in `failed[]` keep their input and show an
* inline error. The wizard does NOT auto-advance when there are failures —
* the user can edit and retry, OR click "Continue anyway" to mark step 3
* complete and head to the dashboard.
*
* Empty rows are filtered before submit, so empty-form + "Send" is a no-op
* that just marks the step complete. (Skip does the same with `action: skip`.)
*/
export function WelcomeStep3() {
const navigate = useNavigate()
const fetchUser = useAuthStore((s) => s.fetchUser)
const [rows, setRows] = useState<InviteRow[]>(() =>
Array.from({ length: DEFAULT_ROW_COUNT }, makeEmptyRow),
)
const [submitting, setSubmitting] = useState<
'send' | 'skip' | 'dismiss' | 'continue-anyway' | null
>(null)
const [error, setError] = useState<string | null>(null)
const [hasUnresolvedFailures, setHasUnresolvedFailures] = useState(false)
const isBusy = submitting !== null
const updateRow = (idx: number, patch: Partial<InviteRow>) => {
setRows((prev) =>
prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)),
)
}
const removeRow = (idx: number) => {
setRows((prev) => {
if (prev.length <= 1) return [makeEmptyRow()]
return prev.filter((_, i) => i !== idx)
})
}
const addRow = () => {
setRows((prev) =>
prev.length >= MAX_ROWS ? prev : [...prev, makeEmptyRow()],
)
}
/**
* Validate populated rows. Empty-email rows are dropped silently.
* Returns either the list of valid rows OR a per-index error map.
*/
const validatePopulated = useMemo(
() => () => {
const errs: Record<number, string> = {}
const populated: { idx: number; row: BulkInviteRow }[] = []
rows.forEach((row, idx) => {
const email = row.email.trim()
if (!email) return
if (!EMAIL_RE.test(email)) {
errs[idx] = 'Invalid email'
return
}
populated.push({ idx, row: { email, role: row.role } })
})
return { errs, populated }
},
[rows],
)
const completeWizardAndExit = async () => {
await onboardingApi.updateStep({ step: 3, action: 'complete' })
await fetchUser()
toast.success("You're all set!")
navigate('/?welcome=true')
}
const handleSendInvites = async () => {
if (isBusy) return
setError(null)
const { errs, populated } = validatePopulated()
if (Object.keys(errs).length > 0) {
// Surface client-side validation errors inline.
setRows((prev) =>
prev.map((row, idx) =>
errs[idx] ? { ...row, error: errs[idx] } : { ...row, error: undefined },
),
)
return
}
setSubmitting('send')
try {
let failedSet = new Map<string, string>()
if (populated.length > 0) {
const result = await accountsApi.bulkInvite(populated.map((p) => p.row))
failedSet = new Map(result.failed.map((f) => [f.email, f.error]))
}
if (failedSet.size > 0) {
// Stamp errors on the matching rows; do NOT auto-advance.
setRows((prev) =>
prev.map((row) => {
const email = row.email.trim()
const err = email ? failedSet.get(email) : undefined
return { ...row, error: err }
}),
)
setHasUnresolvedFailures(true)
setSubmitting(null)
return
}
// All-clear (or zero invites sent): mark step complete and exit.
await completeWizardAndExit()
} catch {
setError('Could not send invites. Please try again.')
setSubmitting(null)
}
}
const handleContinueAnyway = async () => {
if (isBusy) return
setError(null)
setSubmitting('continue-anyway')
try {
await completeWizardAndExit()
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleSkipStep = async () => {
if (isBusy) return
setError(null)
setSubmitting('skip')
try {
await onboardingApi.updateStep({ step: 3, action: 'skip' })
await fetchUser()
toast.success("You're all set!")
navigate('/?welcome=true')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const handleDismissRest = async () => {
if (isBusy) return
setError(null)
setSubmitting('dismiss')
try {
await onboardingApi.dismissRest()
await fetchUser()
navigate('/')
} catch {
setError('Could not save. Please try again.')
setSubmitting(null)
}
}
const inputClass = cn(
'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',
)
return (
<div className="mx-auto w-full max-w-2xl px-4 py-10">
<header className="mb-8">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
Step 3 of 3
</p>
<h1 className="mt-2 text-2xl font-heading font-bold text-text-heading">
Invite your team
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Add up to {MAX_ROWS} teammates. They'll get an email with a link to
join. Leave blank to do this later.
</p>
</header>
<div
className="rounded-2xl border border-border bg-card p-6 space-y-4"
data-testid="welcome-step-3-form"
>
<div className="space-y-3">
{rows.map((row, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-start gap-2">
<input
type="email"
value={row.email}
onChange={(e) => updateRow(idx, { email: e.target.value, error: undefined })}
placeholder="teammate@example.com"
className={cn(inputClass, 'flex-1')}
data-testid={`welcome-step-3-email-${idx}`}
disabled={isBusy}
/>
<select
value={row.role}
onChange={(e) =>
updateRow(idx, { role: e.target.value as RowRole })
}
className={cn(inputClass, 'w-32 flex-shrink-0')}
data-testid={`welcome-step-3-role-${idx}`}
disabled={isBusy}
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
type="button"
onClick={() => removeRow(idx)}
disabled={isBusy || rows.length <= 1}
data-testid={`welcome-step-3-remove-${idx}`}
aria-label="Remove row"
className={cn(
'inline-flex h-10 w-10 items-center justify-center rounded-xl',
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-30 disabled:hover:bg-transparent',
)}
>
<X className="h-4 w-4" />
</button>
</div>
{row.error && (
<p
className="pl-1 text-xs text-red-400"
data-testid={`welcome-step-3-row-error-${idx}`}
>
{row.error}
</p>
)}
</div>
))}
</div>
<button
type="button"
onClick={addRow}
disabled={isBusy || rows.length >= MAX_ROWS}
data-testid="welcome-step-3-add-row"
className={cn(
'inline-flex items-center gap-1.5 rounded-xl px-2 py-1 text-xs font-medium',
'text-muted-foreground hover:bg-foreground/5 hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-50',
)}
>
<Plus className="h-3.5 w-3.5" />
Add another
</button>
{error && (
<p className="text-xs text-red-400" data-testid="welcome-step-3-error">
{error}
</p>
)}
<div className="flex flex-wrap items-center gap-3 pt-2">
<button
type="button"
onClick={handleSendInvites}
disabled={isBusy}
data-testid="welcome-step-3-send"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-hidden focus:ring-2 focus:ring-primary/30',
'disabled:opacity-60',
)}
>
{submitting === 'send' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Send invites and continue
</button>
{hasUnresolvedFailures && (
<button
type="button"
onClick={handleContinueAnyway}
disabled={isBusy}
data-testid="welcome-step-3-continue-anyway"
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium btn-press',
'bg-card border border-border text-foreground hover:bg-foreground/5',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20',
'disabled:opacity-60',
)}
>
{submitting === 'continue-anyway' && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
Continue anyway
</button>
)}
<button
type="button"
onClick={handleSkipStep}
disabled={isBusy}
data-testid="welcome-step-3-skip"
className={cn(
'text-sm text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'skip' ? 'Saving' : 'Skip'}
</button>
</div>
</div>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleDismissRest}
disabled={isBusy}
data-testid="welcome-step-3-dismiss-rest"
className={cn(
'text-xs text-muted-foreground hover:underline',
'disabled:opacity-60',
)}
>
{submitting === 'dismiss' ? 'Saving' : 'Skip the rest'}
</button>
</div>
</div>
)
}
export default WelcomeStep3

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { WelcomeRouter } from '../WelcomeRouter'
import { useAuthStore } from '@/store/authStore'
import type { User } from '@/types'
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'owner',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
onboarding_step_completed: null,
onboarding_dismissed: false,
...overrides,
}
}
function renderRouter() {
return render(
<MemoryRouter initialEntries={['/welcome']}>
<Routes>
<Route path="/welcome" element={<WelcomeRouter />} />
<Route path="/welcome/step-1" element={<div>step-1</div>} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/welcome/step-3" element={<div>step-3</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
describe('WelcomeRouter', () => {
beforeEach(() => {
useAuthStore.setState({
user: null,
account: null,
subscription: null,
token: null,
isAuthenticated: false,
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('redirects to step-1 on null onboarding_step_completed', async () => {
useAuthStore.setState({
user: makeUser({ onboarding_step_completed: null }),
})
renderRouter()
await waitFor(() => {
expect(screen.getByText('step-1')).toBeInTheDocument()
})
})
it('redirects to step-1 when onboarding_step_completed is 0', async () => {
useAuthStore.setState({
user: makeUser({ onboarding_step_completed: 0 }),
})
renderRouter()
await waitFor(() => {
expect(screen.getByText('step-1')).toBeInTheDocument()
})
})
it('redirects to step-2 when onboarding_step_completed is 1', async () => {
useAuthStore.setState({
user: makeUser({ onboarding_step_completed: 1 }),
})
renderRouter()
await waitFor(() => {
expect(screen.getByText('step-2')).toBeInTheDocument()
})
})
it('redirects to step-3 when onboarding_step_completed is 2', async () => {
useAuthStore.setState({
user: makeUser({ onboarding_step_completed: 2 }),
})
renderRouter()
await waitFor(() => {
expect(screen.getByText('step-3')).toBeInTheDocument()
})
})
it('redirects to / when onboarding_step_completed >= 3', async () => {
useAuthStore.setState({
user: makeUser({ onboarding_step_completed: 3 }),
})
renderRouter()
await waitFor(() => {
expect(screen.getByText('dashboard')).toBeInTheDocument()
})
})
it('redirects to / when onboarding_dismissed is true', async () => {
useAuthStore.setState({
user: makeUser({
onboarding_step_completed: 1,
onboarding_dismissed: true,
}),
})
renderRouter()
await waitFor(() => {
expect(screen.getByText('dashboard')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { WelcomeStep1 } from '../WelcomeStep1'
import { useAuthStore } from '@/store/authStore'
import { onboardingApi } from '@/api/onboarding'
import type { Account, User } from '@/types'
vi.mock('@/api/onboarding', async () => {
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
'@/api/onboarding',
)
return {
...actual,
onboardingApi: {
...actual.onboardingApi,
updateStep: vi.fn(),
dismissRest: vi.fn(),
},
}
})
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'owner',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
onboarding_step_completed: null,
onboarding_dismissed: false,
...overrides,
}
}
function makeAccount(overrides: Partial<Account> = {}): Account {
return {
id: 'acct-1',
name: 'Acme MSP',
display_code: 'ACME',
owner_id: 'user-1',
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
...overrides,
}
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/welcome/step-1']}>
<Routes>
<Route path="/welcome/step-1" element={<WelcomeStep1 />} />
<Route path="/welcome/step-2" element={<div>step-2</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
describe('WelcomeStep1', () => {
beforeEach(() => {
useAuthStore.setState({
user: makeUser(),
account: makeAccount(),
subscription: null,
token: null,
isAuthenticated: true,
// Stub fetchUser so it doesn't try to hit the network in jsdom.
fetchUser: vi.fn().mockResolvedValue(undefined),
})
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
onboarding_step_completed: 1,
onboarding_dismissed: false,
})
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
onboarding_step_completed: null,
onboarding_dismissed: true,
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('pre-fills the company name from the auth store account', () => {
renderPage()
const input = screen.getByTestId('welcome-step-1-company-name') as HTMLInputElement
expect(input.value).toBe('Acme MSP')
})
it('Continue persists data and navigates to /welcome/step-2', async () => {
const user = userEvent.setup()
renderPage()
const teamSize = screen.getByTestId('welcome-step-1-team-size') as HTMLSelectElement
await user.selectOptions(teamSize, '3-5')
const role = screen.getByTestId('welcome-step-1-role') as HTMLSelectElement
await user.selectOptions(role, 'owner')
await user.click(screen.getByTestId('welcome-step-1-continue'))
await waitFor(() => {
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
step: 1,
action: 'complete',
data: {
company_name: 'Acme MSP',
team_size_bucket: '3-5',
role_at_signup: 'owner',
},
})
})
await waitFor(() => {
expect(screen.getByText('step-2')).toBeInTheDocument()
})
})
it('Skip this step calls updateStep with action=skip and navigates to /welcome/step-2', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByTestId('welcome-step-1-skip'))
await waitFor(() => {
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
step: 1,
action: 'skip',
})
})
await waitFor(() => {
expect(screen.getByText('step-2')).toBeInTheDocument()
})
})
it('Skip-the-rest dismisses and navigates to /', async () => {
const user = userEvent.setup()
renderPage()
const dismiss = screen.getByTestId('welcome-step-1-dismiss-rest')
// Sanity check: it's a quiet text link, not a primary button.
expect(dismiss.className).toMatch(/text-muted-foreground/)
expect(dismiss.className).toMatch(/hover:underline/)
expect(dismiss.className).toMatch(/text-xs/)
expect(dismiss.className).not.toMatch(/bg-primary/)
await user.click(dismiss)
await waitFor(() => {
expect(onboardingApi.dismissRest).toHaveBeenCalled()
})
await waitFor(() => {
expect(screen.getByText('dashboard')).toBeInTheDocument()
})
})
it('shows an error when the persist call fails and stays on the page', async () => {
vi.mocked(onboardingApi.updateStep).mockRejectedValueOnce(
new Error('boom'),
)
const user = userEvent.setup()
renderPage()
await user.click(screen.getByTestId('welcome-step-1-continue'))
await waitFor(() => {
expect(screen.getByTestId('welcome-step-1-error')).toBeInTheDocument()
})
// Should not have navigated.
expect(screen.queryByText('step-2')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { WelcomeStep2 } from '../WelcomeStep2'
import { useAuthStore } from '@/store/authStore'
import { onboardingApi } from '@/api/onboarding'
import type { Account, User } from '@/types'
vi.mock('@/api/onboarding', async () => {
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
'@/api/onboarding',
)
return {
...actual,
onboardingApi: {
...actual.onboardingApi,
updateStep: vi.fn(),
dismissRest: vi.fn(),
},
}
})
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'owner',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
onboarding_step_completed: 1,
onboarding_dismissed: false,
...overrides,
}
}
function makeAccount(overrides: Partial<Account> = {}): Account {
return {
id: 'acct-1',
name: 'Acme MSP',
display_code: 'ACME',
owner_id: 'user-1',
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
...overrides,
}
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/welcome/step-2']}>
<Routes>
<Route path="/welcome/step-2" element={<WelcomeStep2 />} />
<Route path="/welcome/step-3" element={<div>step-3</div>} />
<Route path="/account/integrations" element={<div>integrations</div>} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
describe('WelcomeStep2', () => {
beforeEach(() => {
useAuthStore.setState({
user: makeUser(),
account: makeAccount(),
subscription: null,
token: null,
isAuthenticated: true,
fetchUser: vi.fn().mockResolvedValue(undefined),
})
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
onboarding_step_completed: 2,
onboarding_dismissed: false,
})
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
onboarding_step_completed: null,
onboarding_dismissed: true,
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('selecting PSA persists primary_psa', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByTestId('welcome-step-2-tile-connectwise'))
// Selecting a real PSA reveals the inline "Connect now" link.
expect(screen.getByTestId('welcome-step-2-connect-now')).toBeInTheDocument()
await user.click(screen.getByTestId('welcome-step-2-continue'))
await waitFor(() => {
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
step: 2,
action: 'complete',
data: { primary_psa: 'connectwise' },
})
})
await waitFor(() => {
expect(screen.getByText('step-3')).toBeInTheDocument()
})
})
it('Skip advances without writing primary_psa', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByTestId('welcome-step-2-skip'))
await waitFor(() => {
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
step: 2,
action: 'skip',
})
})
// Confirm no `data` key on the call (skip doesn't persist primary_psa).
const call = vi.mocked(onboardingApi.updateStep).mock.calls[0]?.[0]
expect(call?.data).toBeUndefined()
await waitFor(() => {
expect(screen.getByText('step-3')).toBeInTheDocument()
})
})
it('"No PSA yet" tile does NOT show the Connect now link', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByTestId('welcome-step-2-tile-none'))
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
})
it('default action is Continue (not Connect now)', () => {
renderPage()
// Continue is rendered as a primary button.
const continueBtn = screen.getByTestId('welcome-step-2-continue')
expect(continueBtn.className).toMatch(/bg-primary/)
// Connect-now is hidden until a real PSA is picked.
expect(screen.queryByTestId('welcome-step-2-connect-now')).not.toBeInTheDocument()
})
it('Skip-the-rest dismisses and navigates to /', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByTestId('welcome-step-2-dismiss-rest'))
await waitFor(() => {
expect(onboardingApi.dismissRest).toHaveBeenCalled()
})
await waitFor(() => {
expect(screen.getByText('dashboard')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,279 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { WelcomeStep3 } from '../WelcomeStep3'
import { useAuthStore } from '@/store/authStore'
import { onboardingApi } from '@/api/onboarding'
import { accountsApi } from '@/api/accounts'
import type { Account, User } from '@/types'
vi.mock('@/api/onboarding', async () => {
const actual = await vi.importActual<typeof import('@/api/onboarding')>(
'@/api/onboarding',
)
return {
...actual,
onboardingApi: {
...actual.onboardingApi,
updateStep: vi.fn(),
dismissRest: vi.fn(),
},
}
})
vi.mock('@/api/accounts', async () => {
const actual = await vi.importActual<typeof import('@/api/accounts')>(
'@/api/accounts',
)
return {
...actual,
accountsApi: {
...actual.accountsApi,
bulkInvite: vi.fn(),
},
}
})
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
promise: vi.fn(),
},
}))
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'engineer',
is_super_admin: false,
is_active: true,
must_change_password: false,
account_id: 'acct-1',
account_role: 'owner',
team_id: null,
created_at: '2026-05-01T00:00:00Z',
last_login: null,
phone: null,
job_title: null,
timezone: 'UTC',
avatar_url: null,
email_verified_at: null,
onboarding_step_completed: 2,
onboarding_dismissed: false,
...overrides,
}
}
function makeAccount(overrides: Partial<Account> = {}): Account {
return {
id: 'acct-1',
name: 'Acme MSP',
display_code: 'ACME',
owner_id: 'user-1',
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
...overrides,
}
}
function renderPage() {
return render(
<MemoryRouter initialEntries={['/welcome/step-3']}>
<Routes>
<Route path="/welcome/step-3" element={<WelcomeStep3 />} />
<Route path="/" element={<div>dashboard</div>} />
</Routes>
</MemoryRouter>,
)
}
describe('WelcomeStep3', () => {
beforeEach(() => {
useAuthStore.setState({
user: makeUser(),
account: makeAccount(),
subscription: null,
token: null,
isAuthenticated: true,
fetchUser: vi.fn().mockResolvedValue(undefined),
})
vi.mocked(onboardingApi.updateStep).mockResolvedValue({
onboarding_step_completed: 3,
onboarding_dismissed: false,
})
vi.mocked(onboardingApi.dismissRest).mockResolvedValue({
onboarding_step_completed: null,
onboarding_dismissed: true,
})
vi.mocked(accountsApi.bulkInvite).mockResolvedValue({
created: [],
failed: [],
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('valid emails create invites and complete wizard', async () => {
const user = userEvent.setup()
vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({
created: [
{
id: 'inv-1',
account_id: 'acct-1',
email: 'a@example.com',
role: 'engineer',
code: 'c1',
expires_at: null,
used_at: null,
created_at: '2026-05-06T00:00:00Z',
},
{
id: 'inv-2',
account_id: 'acct-1',
email: 'b@example.com',
role: 'viewer',
code: 'c2',
expires_at: null,
used_at: null,
created_at: '2026-05-06T00:00:00Z',
},
],
failed: [],
})
renderPage()
await user.type(screen.getByTestId('welcome-step-3-email-0'), 'a@example.com')
await user.type(screen.getByTestId('welcome-step-3-email-1'), 'b@example.com')
await user.selectOptions(screen.getByTestId('welcome-step-3-role-1'), 'viewer')
await user.click(screen.getByTestId('welcome-step-3-send'))
await waitFor(() => {
expect(accountsApi.bulkInvite).toHaveBeenCalledWith([
{ email: 'a@example.com', role: 'engineer' },
{ email: 'b@example.com', role: 'viewer' },
])
})
await waitFor(() => {
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
step: 3,
action: 'complete',
})
})
await waitFor(() => {
expect(screen.getByText('dashboard')).toBeInTheDocument()
})
})
it('partial-failure shows inline error per failed email', async () => {
const user = userEvent.setup()
vi.mocked(accountsApi.bulkInvite).mockResolvedValueOnce({
created: [
{
id: 'inv-1',
account_id: 'acct-1',
email: 'good@example.com',
role: 'engineer',
code: 'c1',
expires_at: null,
used_at: null,
created_at: '2026-05-06T00:00:00Z',
},
],
failed: [
{ email: 'bad@example.com', error: 'Email already invited' },
],
})
renderPage()
await user.type(screen.getByTestId('welcome-step-3-email-0'), 'good@example.com')
await user.type(screen.getByTestId('welcome-step-3-email-1'), 'bad@example.com')
await user.click(screen.getByTestId('welcome-step-3-send'))
await waitFor(() => {
expect(accountsApi.bulkInvite).toHaveBeenCalled()
})
// The bad-email row shows the error text.
await waitFor(() => {
expect(screen.getByTestId('welcome-step-3-row-error-1')).toHaveTextContent(
/already invited/i,
)
})
// Wizard did NOT auto-advance — onboarding-step is unchanged.
expect(onboardingApi.updateStep).not.toHaveBeenCalled()
expect(screen.queryByText('dashboard')).not.toBeInTheDocument()
// "Continue anyway" is offered.
expect(screen.getByTestId('welcome-step-3-continue-anyway')).toBeInTheDocument()
})
it('empty + Skip advances without sending invites', async () => {
const user = userEvent.setup()
renderPage()
await user.click(screen.getByTestId('welcome-step-3-skip'))
await waitFor(() => {
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
step: 3,
action: 'skip',
})
})
// No bulk-invite call.
expect(accountsApi.bulkInvite).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByText('dashboard')).toBeInTheDocument()
})
})
it('empty + Send is a no-op bulk call but still completes the step', async () => {
const user = userEvent.setup()
renderPage()
// All rows blank — Send should skip the bulk call entirely and just
// mark the step complete.
await user.click(screen.getByTestId('welcome-step-3-send'))
await waitFor(() => {
expect(onboardingApi.updateStep).toHaveBeenCalledWith({
step: 3,
action: 'complete',
})
})
expect(accountsApi.bulkInvite).not.toHaveBeenCalled()
})
it('+ Add another adds a row, capped at 10', async () => {
const user = userEvent.setup()
renderPage()
// Starts with 3 default rows.
expect(screen.getByTestId('welcome-step-3-email-0')).toBeInTheDocument()
expect(screen.getByTestId('welcome-step-3-email-1')).toBeInTheDocument()
expect(screen.getByTestId('welcome-step-3-email-2')).toBeInTheDocument()
expect(screen.queryByTestId('welcome-step-3-email-3')).not.toBeInTheDocument()
const addBtn = screen.getByTestId('welcome-step-3-add-row')
// Click 7 more times → 10 total.
for (let i = 0; i < 7; i++) await user.click(addBtn)
expect(screen.getByTestId('welcome-step-3-email-9')).toBeInTheDocument()
// Capped — button disabled at 10.
expect(addBtn).toBeDisabled()
})
})

View File

@@ -22,9 +22,13 @@ const SurveyPage = lazyWithRetry(() => import('@/pages/SurveyPage'))
const SurveyThankYouPage = lazyWithRetry(() => import('@/pages/SurveyThankYouPage'))
const PrivacyPage = lazyWithRetry(() => import('@/pages/PrivacyPage'))
const TermsPage = lazyWithRetry(() => import('@/pages/TermsPage'))
const PricingPage = lazyWithRetry(() => import('@/pages/PricingPage'))
const ContactSalesPage = lazyWithRetry(() => import('@/pages/ContactSalesPage'))
// Standalone auth pages
const VerifyEmailPage = lazyWithRetry(() => import('@/pages/VerifyEmailPage'))
const OAuthCallbackPage = lazyWithRetry(() => import('@/pages/OAuthCallbackPage'))
const AcceptInvitePage = lazyWithRetry(() => import('@/pages/AcceptInvitePage'))
const ChangePasswordPage = lazyWithRetry(() => import('@/pages/ChangePasswordPage'))
const ForgotPasswordPage = lazyWithRetry(() => import('@/pages/ForgotPasswordPage'))
const ResetPasswordPage = lazyWithRetry(() => import('@/pages/ResetPasswordPage'))
@@ -65,6 +69,11 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage'))
// Welcome wizard (Phase 2)
const WelcomeRouter = lazyWithRetry(() => import('@/pages/welcome/WelcomeRouter'))
const WelcomeStep1 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep1'))
const WelcomeStep2 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep2'))
const WelcomeStep3 = lazyWithRetry(() => import('@/pages/welcome/WelcomeStep3'))
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
// Admin pages
@@ -91,6 +100,8 @@ const TargetListsPage = lazyWithRetry(() => import('@/pages/account/TargetListsP
const ChatRetentionSettingsPage = lazyWithRetry(() => import('@/pages/account/ChatRetentionSettingsPage'))
const IntegrationsPage = lazyWithRetry(() => import('@/pages/account/IntegrationsPage'))
const BrandingSettingsPage = lazyWithRetry(() => import('@/pages/account/BrandingSettingsPage'))
const BillingPage = lazyWithRetry(() => import('@/pages/account/BillingPage'))
const SelectPlanPage = lazyWithRetry(() => import('@/pages/account/SelectPlanPage'))
/** Wraps a lazy-loaded page with Suspense + ErrorBoundary */
function page(Component: React.LazyExoticComponent<React.ComponentType>) {
@@ -124,6 +135,16 @@ export const router = sentryCreateBrowserRouter([
element: page(TermsPage),
errorElement: <RouteError />,
},
{
path: '/pricing',
element: page(PricingPage),
errorElement: <RouteError />,
},
{
path: '/contact-sales',
element: page(ContactSalesPage),
errorElement: <RouteError />,
},
{
path: '/login',
element: <LoginPage />,
@@ -149,6 +170,21 @@ export const router = sentryCreateBrowserRouter([
element: page(VerifyEmailPage),
errorElement: <RouteError />,
},
{
path: '/accept-invite',
element: page(AcceptInvitePage),
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),
@@ -223,6 +259,12 @@ export const router = sentryCreateBrowserRouter([
{ path: 'dev/branching', element: page(DevBranchingPage) },
{ path: 'guides', element: page(GuidesHubPage) },
{ path: 'guides/:slug', element: page(GuideDetailPage) },
// Welcome wizard (Phase 2). Mounted inside AppLayout so the email-
// verification banner persists above each step.
{ path: 'welcome', element: page(WelcomeRouter) },
{ path: 'welcome/step-1', element: page(WelcomeStep1) },
{ path: 'welcome/step-2', element: page(WelcomeStep2) },
{ path: 'welcome/step-3', element: page(WelcomeStep3) },
// Admin routes
{
path: 'admin',
@@ -298,6 +340,8 @@ export const router = sentryCreateBrowserRouter([
</ProtectedRoute>
),
},
{ path: 'billing', element: page(BillingPage) },
{ path: 'billing/select-plan', element: page(SelectPlanPage) },
],
},
],

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthStore } from './authStore'
import type { Token } from '@/types'
// Avoid pulling in real analytics / Sentry side effects during tests.
vi.mock('@/lib/analytics', () => ({
identifyUser: vi.fn(),
resetAnalytics: vi.fn(),
analytics: {
loginSuccess: vi.fn(),
accountCreated: vi.fn(),
},
}))
vi.mock('@sentry/react', () => ({
setUser: vi.fn(),
}))
describe('authStore.setTokens', () => {
beforeEach(() => {
// Reset store to initial state between tests.
useAuthStore.setState({
user: null,
token: null,
account: null,
subscription: null,
isAuthenticated: false,
isLoading: false,
error: null,
})
})
it('marks the store as authenticated and persists the token', () => {
const fakeToken: Token = {
access_token: 'access-abc',
refresh_token: 'refresh-xyz',
token_type: 'bearer',
}
useAuthStore.getState().setTokens(fakeToken)
const state = useAuthStore.getState()
expect(state.token).toEqual(fakeToken)
expect(state.isAuthenticated).toBe(true)
})
it('keeps isAuthenticated true when called again (refresh-token path)', () => {
// Simulate an already-authenticated session (refresh interceptor case).
useAuthStore.setState({
token: {
access_token: 'old',
refresh_token: 'old-r',
token_type: 'bearer',
},
isAuthenticated: true,
})
useAuthStore.getState().setTokens({
access_token: 'new',
refresh_token: 'new-r',
token_type: 'bearer',
})
const state = useAuthStore.getState()
expect(state.token?.access_token).toBe('new')
expect(state.isAuthenticated).toBe(true)
})
})

View File

@@ -6,6 +6,7 @@ import { authApi } from '@/api/auth'
import { identifyUser, resetAnalytics, analytics } from '@/lib/analytics'
import { apiClient } from '@/api/client'
import { clearCachedQuota } from '@/hooks/useCachedQuota'
import { useBillingStore } from '@/store/billingStore'
interface AuthState {
user: User | null
@@ -85,6 +86,7 @@ export const useAuthStore = create<AuthState>()(
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
clearCachedQuota()
useBillingStore.getState().reset()
Sentry.setUser(null)
resetAnalytics()
set({ user: null, token: null, account: null, subscription: null, isAuthenticated: false, error: null })
@@ -117,6 +119,11 @@ export const useAuthStore = create<AuthState>()(
identifyUser({ id: user.id, email: user.email, role: user.role, is_super_admin: user.is_super_admin, account_id: account?.id })
set({ user, account, subscription, isLoading: false })
// Kick off billing-state fetch alongside auth — fire-and-forget so
// a billing error never breaks login. The billing store records
// its own error state.
void useBillingStore.getState().fetch()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to fetch user'
set({ error: message, isLoading: false })
@@ -124,7 +131,13 @@ export const useAuthStore = create<AuthState>()(
}
},
setTokens: (token: Token) => set({ token }),
// Storing tokens implies an active session — mark the store as
// authenticated so <ProtectedRoute> doesn't bounce the user back to
// /landing while fetchUser() is still inflight (e.g. immediately after
// the OAuth callback exchange). The refresh interceptor in api/client.ts
// also calls this; that path is already authenticated, so flipping the
// flag has no effect there.
setTokens: (token: Token) => set({ token, isAuthenticated: true }),
clearError: () => set({ error: null }),
setLoading: (loading: boolean) => set({ isLoading: loading }),
}),

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useBillingStore } from './billingStore'
import { billingApi } from '@/api/billing'
import type { BillingStatePayload } from '@/types'
vi.mock('@/api/billing', () => ({
billingApi: {
getState: vi.fn(),
},
default: {
getState: vi.fn(),
},
}))
const mockGetState = billingApi.getState as ReturnType<typeof vi.fn>
const INITIAL_PAYLOAD: BillingStatePayload = {
subscription: {
status: 'trialing',
plan: 'pro',
current_period_start: '2026-05-01T00:00:00Z',
current_period_end: '2026-05-15T00:00:00Z',
cancel_at_period_end: false,
seat_limit: 5,
has_pro_entitlement: true,
is_paid: false,
},
planBilling: {
display_name: 'Pro',
description: 'Pro plan',
monthly_price_cents: 4900,
annual_price_cents: 49000,
},
planLimits: { seats: 5 },
enabledFeatures: { ai_assistant: true },
}
describe('useBillingStore', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset store to empty initial state.
useBillingStore.setState({
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
})
})
it('useBillingStore fetches on login and populates subscription', async () => {
mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD)
// Sanity: starts empty.
expect(useBillingStore.getState().subscription).toBeNull()
await useBillingStore.getState().fetch()
const state = useBillingStore.getState()
expect(mockGetState).toHaveBeenCalledOnce()
expect(state.subscription).toEqual(INITIAL_PAYLOAD.subscription)
expect(state.planBilling).toEqual(INITIAL_PAYLOAD.planBilling)
expect(state.planLimits).toEqual(INITIAL_PAYLOAD.planLimits)
expect(state.enabledFeatures).toEqual(INITIAL_PAYLOAD.enabledFeatures)
expect(state.isLoading).toBe(false)
expect(state.error).toBeNull()
})
it('useBillingStore resets on logout', async () => {
mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD)
await useBillingStore.getState().fetch()
expect(useBillingStore.getState().subscription).not.toBeNull()
useBillingStore.getState().reset()
const state = useBillingStore.getState()
expect(state.subscription).toBeNull()
expect(state.planBilling).toBeNull()
expect(state.planLimits).toEqual({})
expect(state.enabledFeatures).toEqual({})
expect(state.isLoading).toBe(false)
expect(state.error).toBeNull()
})
it('useBillingStore refetch overwrites stale data', async () => {
mockGetState.mockResolvedValueOnce(INITIAL_PAYLOAD)
await useBillingStore.getState().fetch()
expect(useBillingStore.getState().subscription?.status).toBe('trialing')
const updatedPayload: BillingStatePayload = {
...INITIAL_PAYLOAD,
subscription: {
...INITIAL_PAYLOAD.subscription!,
status: 'active',
is_paid: true,
},
enabledFeatures: { ai_assistant: true, advanced_reports: true },
}
// Hold the refetch promise open so we can observe mid-flight isLoading=true.
let resolveSecond: (value: BillingStatePayload) => void = () => {}
mockGetState.mockImplementationOnce(
() => new Promise<BillingStatePayload>((resolve) => { resolveSecond = resolve })
)
const refetchPromise = useBillingStore.getState().refetch()
expect(useBillingStore.getState().isLoading).toBe(true)
resolveSecond(updatedPayload)
await refetchPromise
const state = useBillingStore.getState()
expect(mockGetState).toHaveBeenCalledTimes(2)
expect(state.subscription?.status).toBe('active')
expect(state.subscription?.is_paid).toBe(true)
expect(state.enabledFeatures).toEqual({ ai_assistant: true, advanced_reports: true })
expect(state.isLoading).toBe(false)
})
})

View File

@@ -0,0 +1,82 @@
import { create } from 'zustand'
import { billingApi } from '@/api/billing'
import type {
BillingSubscriptionState,
PlanBillingState,
} from '@/types'
interface BillingState {
subscription: BillingSubscriptionState | null
planBilling: PlanBillingState | null
planLimits: Record<string, unknown>
enabledFeatures: Record<string, boolean>
isLoading: boolean
error: string | null
}
interface BillingActions {
/** Fetch billing state. Sets `isLoading` while in flight. */
fetch: () => Promise<void>
/** Same as `fetch` but intended for explicit refresh after Stripe Checkout. */
refetch: () => Promise<void>
/** Reset to empty initial state — call on logout. */
reset: () => void
}
export type BillingStore = BillingState & BillingActions
const INITIAL_STATE: BillingState = {
subscription: null,
planBilling: null,
planLimits: {},
enabledFeatures: {},
isLoading: false,
error: null,
}
export const useBillingStore = create<BillingStore>((set) => ({
...INITIAL_STATE,
fetch: async () => {
set({ isLoading: true, error: null })
try {
const data = await billingApi.getState()
set({
subscription: data.subscription,
planBilling: data.planBilling,
planLimits: data.planLimits,
enabledFeatures: data.enabledFeatures,
isLoading: false,
error: null,
})
} catch (error: unknown) {
// 401s are handled globally by the apiClient response interceptor
// (token-refresh + logout), so we just record any other error here.
const message = error instanceof Error ? error.message : 'Failed to load billing state'
set({ isLoading: false, error: message })
}
},
refetch: async () => {
// Same semantics as fetch — separate name documents intent at the call site.
set({ isLoading: true, error: null })
try {
const data = await billingApi.getState()
set({
subscription: data.subscription,
planBilling: data.planBilling,
planLimits: data.planLimits,
enabledFeatures: data.enabledFeatures,
isLoading: false,
error: null,
})
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to load billing state'
set({ isLoading: false, error: message })
}
},
reset: () => set({ ...INITIAL_STATE }),
}))
export default useBillingStore

View File

@@ -0,0 +1,93 @@
/**
* Billing state types for the unified `/billing/state` endpoint.
*
* The backend returns snake_case keys (`plan_billing`, `enabled_features`);
* the API client (`frontend/src/api/billing.ts`) transforms the payload to
* camelCase before it reaches the rest of the frontend.
*/
export type SubscriptionStatus =
| 'trialing'
| 'active'
| 'past_due'
| 'canceled'
| 'incomplete'
| 'complimentary'
export interface SubscriptionState {
status: SubscriptionStatus
plan: string
/** ISO 8601 string or null */
current_period_start: string | null
/** ISO 8601 string or null */
current_period_end: string | null
cancel_at_period_end: boolean
seat_limit: number | null
has_pro_entitlement: boolean
is_paid: boolean
}
export interface PlanBillingState {
display_name: string
description: string | null
monthly_price_cents: number | null
annual_price_cents: number | null
}
/** Camel-cased billing-state payload, post-transform. */
export interface BillingStatePayload {
subscription: SubscriptionState | null
planBilling: PlanBillingState | null
planLimits: Record<string, unknown>
enabledFeatures: Record<string, boolean>
}
/** Raw snake_case payload returned by the backend. */
export interface BillingStateApiResponse {
subscription: SubscriptionState | null
plan_billing: PlanBillingState | null
plan_limits: Record<string, unknown>
enabled_features: Record<string, boolean>
}
/* ---------------------------------------------------------------------------
* Checkout / Customer-Portal session types
* ------------------------------------------------------------------------- */
export type CheckoutPlan = 'starter' | 'pro' | 'team' | 'enterprise'
export type BillingInterval = 'monthly' | 'annual'
export interface CheckoutSessionRequest {
plan: CheckoutPlan
seats: number
billing_interval: BillingInterval
}
export interface CheckoutSessionResponse {
url: string
}
export interface BillingPortalSessionResponse {
url: string
}
/**
* Typed error codes returned by the portal-session endpoint when the call
* cannot succeed for a reason the UI should explain to the user.
*
* - `stripe_not_configured` (HTTP 503): Stripe isn't wired up server-side
* (rare — env-misconfig / dev mode).
* - `no_stripe_customer` (HTTP 400): The account has never been billed, so
* there's no Customer Portal session to open. UX: "Complete checkout
* first to access billing portal."
*/
export type BillingPortalErrorCode = 'stripe_not_configured' | 'no_stripe_customer'
export class BillingPortalError extends Error {
code: BillingPortalErrorCode
constructor(code: BillingPortalErrorCode, message?: string) {
super(message ?? code)
this.name = 'BillingPortalError'
this.code = code
}
}

View File

@@ -93,6 +93,21 @@ export type {
KBQuotaResponse,
} from './kbAccelerator'
export type {
SubscriptionStatus,
SubscriptionState as BillingSubscriptionState,
PlanBillingState,
BillingStatePayload,
BillingStateApiResponse,
CheckoutPlan,
BillingInterval,
CheckoutSessionRequest,
CheckoutSessionResponse,
BillingPortalSessionResponse,
BillingPortalErrorCode,
} from './billing'
export { BillingPortalError } from './billing'
export * from './scripts'
export * from './script-builder'
export * from './integrations'

View File

@@ -18,6 +18,8 @@ export interface User {
timezone: string
avatar_url: string | null
email_verified_at: string | null
onboarding_step_completed: number | null
onboarding_dismissed: boolean
}
export interface UserCreate {
@@ -26,6 +28,8 @@ export interface UserCreate {
name: string
role?: UserRole
invite_code?: string
/** Account invite code to join an existing account (issued via /accounts/me/invites). */
account_invite_code?: string
}
export interface UserLogin {