feat: self-serve signup Phase 2 (frontend cutover) (#162)
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:
68
frontend/src/store/authStore.test.ts
Normal file
68
frontend/src/store/authStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
118
frontend/src/store/billingStore.test.ts
Normal file
118
frontend/src/store/billingStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
82
frontend/src/store/billingStore.ts
Normal file
82
frontend/src/store/billingStore.ts
Normal 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
|
||||
Reference in New Issue
Block a user