Add SaaS frontend code (#5879)

# Description of Changes
Adds the code for the SaaS frontend as proprietary code to the OSS repo.
This version of the code is adapted from 22/1/2026, which was the last
SaaS version based on the 'V2' design. This will move us closer to being
able to have the OSS products understand whether the user has a SaaS
account, and provide the correct UI in those cases.
This commit is contained in:
James Brunton
2026-03-11 11:53:54 +00:00
committed by GitHub
parent 8bc37bf5ae
commit fa8c52b2be
114 changed files with 12408 additions and 99 deletions
+583
View File
@@ -0,0 +1,583 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
import { supabase } from '@app/auth/supabase'
import type { Session, User as SupabaseUser, AuthError } from '@supabase/supabase-js'
import { CreditSummary, SubscriptionInfo, CreditCheckResult, ApiCredits } from '@app/types/credits'
import apiClient, { setGlobalCreditUpdateCallback } from '@app/services/apiClient'
import { synchronizeUserUpgrade } from '@app/services/userService'
import { syncOAuthAvatar, getProfilePictureMetadata, type ProfilePictureMetadata } from '@app/services/avatarSyncService'
// Extend Supabase User to include optional username for compatibility
export type User = SupabaseUser & { username?: string };
export interface TrialStatus {
isTrialing: boolean
trialEnd: string
daysRemaining: number
hasPaymentMethod: boolean
hasScheduledSub: boolean
status: string
}
interface AuthContextType {
session: Session | null
user: User | null
loading: boolean
error: AuthError | null
creditBalance: number | null
subscription: SubscriptionInfo | null
creditSummary: CreditSummary | null
isPro: boolean | null
trialStatus: TrialStatus | null
profilePictureUrl: string | null
profilePictureMetadata: ProfilePictureMetadata | null
signOut: () => Promise<void>
refreshSession: () => Promise<void>
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult
updateCredits: (newBalance: number) => void
refreshCredits: () => Promise<void>
refreshProStatus: () => Promise<void>
refreshTrialStatus: () => Promise<void>
refreshProfilePicture: () => Promise<void>
refreshProfilePictureMetadata: () => Promise<void>
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
loading: true,
error: null,
creditBalance: null,
subscription: null,
creditSummary: null,
isPro: null,
trialStatus: null,
profilePictureUrl: null,
profilePictureMetadata: null,
signOut: async () => {},
refreshSession: async () => {},
hasSufficientCredits: () => ({ hasSufficientCredits: false, currentBalance: 0, requiredCredits: 0 }),
updateCredits: () => {},
refreshCredits: async () => {},
refreshProStatus: async () => {},
refreshTrialStatus: async () => {},
refreshProfilePicture: async () => {},
refreshProfilePictureMetadata: async () => {}
})
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<AuthError | null>(null)
const [creditBalance, setCreditBalance] = useState<number | null>(null)
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null)
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(null)
const [isPro, setIsPro] = useState<boolean | null>(null)
const [trialStatus, setTrialStatus] = useState<TrialStatus | null>(null)
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null)
const [profilePictureMetadata, setProfilePictureMetadata] = useState<ProfilePictureMetadata | null>(null)
const fetchCredits = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping credit fetch')
setCreditBalance(null)
setCreditSummary(null)
setSubscription(null)
return
}
try {
console.debug('[Auth Debug] Fetching credits for user:', currentSession.user.id)
const response = await apiClient.get<ApiCredits>('/api/v1/credits')
const apiCredits = response.data
// Map server payload to app CreditSummary
const credits: CreditSummary = {
currentCredits: apiCredits.totalAvailableCredits,
maxCredits: apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
creditsUsed: (apiCredits.weeklyCreditsAllocated - apiCredits.weeklyCreditsRemaining) + (apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
creditsRemaining: apiCredits.totalAvailableCredits,
resetDate: apiCredits.weeklyResetDate,
weeklyAllowance: apiCredits.weeklyCreditsAllocated
}
setCreditSummary(credits)
setCreditBalance(credits.creditsRemaining)
const subscriptionInfo: SubscriptionInfo = {
status: 'active',
tier: (credits.weeklyAllowance || 0) > 100 ? 'premium' : 'free',
creditsPerWeek: credits.weeklyAllowance,
maxCredits: credits.maxCredits
}
setSubscription(subscriptionInfo)
console.debug('[Auth Debug] Credits fetched successfully:', credits)
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch credits:', error)
// Don't set error state for credit fetching failures to avoid disrupting auth flow
// Credits might not be available in all deployments
setCreditBalance(null)
setCreditSummary(null)
setSubscription(null)
}
}, [session])
const refreshCredits = useCallback(async () => {
await fetchCredits()
}, [fetchCredits])
const fetchProStatus = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping pro status fetch')
setIsPro(null)
return
}
try {
console.debug('[Auth Debug] Fetching pro status for user:', currentSession.user.id)
const { data: proStatus, error } = await supabase.rpc('is_pro')
if (error) {
console.error('[Auth Debug] Error checking Pro status:', error)
setIsPro(false) // Default to false if there's an error
} else {
const isProUser = Boolean(proStatus)
setIsPro(isProUser)
console.debug('[Auth Debug] Pro status fetched:', isProUser)
}
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch pro status:', error)
setIsPro(false) // Default to false if there's an error
}
}, [session])
const refreshProStatus = useCallback(async () => {
await fetchProStatus()
}, [fetchProStatus])
const fetchTrialStatus = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping trial status fetch')
setTrialStatus(null)
return
}
try {
console.debug('[Auth Debug] Fetching trial status for user:', currentSession.user.id)
const { data, error } = await supabase
.from('billing_subscriptions')
.select('status, trial_end, has_payment_method, scheduled_subscription_id')
.in('status', ['trialing', 'incomplete_expired', 'canceled'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) {
console.error('[Auth Debug] Error fetching trial status:', error)
setTrialStatus(null)
return
}
if (data?.trial_end) {
const trialEnd = new Date(data.trial_end)
const now = new Date()
const daysRemaining = Math.ceil((trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
setTrialStatus({
isTrialing: data.status === 'trialing' && daysRemaining > 0,
trialEnd: data.trial_end,
daysRemaining: Math.max(0, daysRemaining),
hasPaymentMethod: data.has_payment_method || false,
hasScheduledSub: !!data.scheduled_subscription_id,
status: data.status
})
console.debug('[Auth Debug] Trial status fetched:', {
status: data.status,
daysRemaining: Math.max(0, daysRemaining),
hasPaymentMethod: data.has_payment_method,
isTrialing: data.status === 'trialing' && daysRemaining > 0
})
} else {
setTrialStatus(null)
}
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch trial status:', error)
setTrialStatus(null)
}
}, [session])
const refreshTrialStatus = useCallback(async () => {
await fetchTrialStatus()
}, [fetchTrialStatus])
const fetchProfilePicture = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping profile picture fetch')
setProfilePictureUrl(null)
return
}
try {
const PROFILE_BUCKET = 'profile-pictures'
const profilePath = `${currentSession.user.id}/avatar`
console.debug('[Auth Debug] Fetching profile picture for user:', currentSession.user.id)
const { data, error } = await supabase
.storage
.from(PROFILE_BUCKET)
.createSignedUrl(profilePath, 60 * 60)
if (error) {
// Profile picture not found is expected for users without uploads
console.debug('[Auth Debug] Profile picture not available:', error.message)
setProfilePictureUrl(null)
} else {
setProfilePictureUrl(data.signedUrl)
console.debug('[Auth Debug] Profile picture URL fetched successfully')
}
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch profile picture:', error)
setProfilePictureUrl(null)
}
}, [session])
const refreshProfilePicture = useCallback(async () => {
await fetchProfilePicture()
}, [fetchProfilePicture])
const fetchProfilePictureMetadata = useCallback(async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session
if (!currentSession?.user) {
console.debug('[Auth Debug] No user session, skipping profile picture metadata fetch')
setProfilePictureMetadata(null)
return
}
try {
console.debug('[Auth Debug] Fetching profile picture metadata for user:', currentSession.user.id)
const metadata = await getProfilePictureMetadata(currentSession.user.id)
setProfilePictureMetadata(metadata)
console.debug('[Auth Debug] Profile picture metadata fetched:', metadata)
} catch (error: any) {
console.debug('[Auth Debug] Failed to fetch profile picture metadata:', error)
setProfilePictureMetadata(null)
}
}, [session])
const refreshProfilePictureMetadata = useCallback(async () => {
await fetchProfilePictureMetadata()
}, [fetchProfilePictureMetadata])
const updateCredits = useCallback((newBalance: number) => {
console.debug('[Auth Debug] Updating credit balance:', { from: creditBalance, to: newBalance })
setCreditBalance(newBalance)
// Also update the creditSummary if it exists
if (creditSummary) {
const updatedSummary: CreditSummary = {
...creditSummary,
creditsRemaining: newBalance,
currentCredits: newBalance
}
setCreditSummary(updatedSummary)
}
}, [creditSummary])
const hasSufficientCredits = useCallback((requiredCredits: number): CreditCheckResult => {
const currentBalance = creditBalance ?? 0
const hasSufficient = currentBalance >= requiredCredits
console.debug('[Auth Debug] Credit check:', { requiredCredits, currentBalance, hasSufficient })
return {
hasSufficientCredits: hasSufficient,
currentBalance,
requiredCredits,
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance
}
}, [creditBalance])
const refreshSession = async () => {
try {
setLoading(true)
setError(null)
const { data, error } = await supabase.auth.refreshSession()
if (error) {
console.error('[Auth Debug] Session refresh error:', error)
setError(error)
setSession(null)
} else {
console.debug('[Auth Debug] Session refreshed successfully')
setSession(data.session)
}
} catch (err) {
console.error('[Auth Debug] Unexpected error during session refresh:', err)
setError(err as AuthError)
} finally {
setLoading(false)
}
}
const signOut = async () => {
try {
setError(null)
const { error } = await supabase.auth.signOut()
if (error) {
console.error('[Auth Debug] Sign out error:', error)
setError(error)
} else {
console.debug('[Auth Debug] Signed out successfully')
setSession(null)
}
} catch (err) {
console.error('[Auth Debug] Unexpected error during sign out:', err)
setError(err as AuthError)
}
}
// Set up global credit update callback
useEffect(() => {
setGlobalCreditUpdateCallback(updateCredits)
}, [updateCredits])
useEffect(() => {
let mounted = true
// Load current session on first mount
const initializeAuth = async () => {
try {
console.debug('[Auth Debug] Initializing auth...')
const { data, error } = await supabase.auth.getSession()
if (!mounted) return
if (error) {
console.error('[Auth Debug] Initial session error:', error)
setError(error)
} else {
console.debug('[Auth Debug] Initial session loaded:', {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email
})
setSession(data.session)
// Fetch credits, pro status, trial status, profile picture metadata, and profile picture using the session from the response
if (data.session?.user) {
// Sync OAuth avatar in background
syncOAuthAvatar(data.session.user).catch((err) => {
console.debug('[Auth Debug] Failed to sync OAuth avatar on init:', err)
})
await fetchCredits(data.session)
await fetchProStatus(data.session)
await fetchTrialStatus(data.session)
await fetchProfilePictureMetadata(data.session)
// Small delay to allow avatar sync to complete if quick
setTimeout(() => {
fetchProfilePicture(data.session)
}, 500)
}
}
} catch (err) {
console.error('[Auth Debug] Unexpected error during auth initialization:', err)
if (mounted) {
setError(err as AuthError)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
initializeAuth()
// Subscribe to auth state changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, newSession) => {
if (!mounted) return
console.debug('[Auth Debug] Auth state change:', {
event,
hasSession: !!newSession,
userId: newSession?.user?.id,
email: newSession?.user?.email,
timestamp: new Date().toISOString()
})
// Don't run supabase calls inside this callback; schedule them
setTimeout(() => {
if (mounted) {
setSession(newSession)
setError(null)
// Additional handling for specific events
if (event === 'SIGNED_OUT') {
console.debug('[Auth Debug] User signed out, clearing session')
// Clear credit data, pro status, trial status, profile picture, and metadata on sign out
setCreditBalance(null)
setCreditSummary(null)
setSubscription(null)
setIsPro(null)
setTrialStatus(null)
setProfilePictureUrl(null)
setProfilePictureMetadata(null)
} else if (event === 'SIGNED_IN') {
console.debug('[Auth Debug] User signed in successfully')
if (newSession?.user) {
setLoading(true)
// Sync OAuth avatar in background (don't block other fetches)
syncOAuthAvatar(newSession.user).catch((err) => {
console.debug('[Auth Debug] Failed to sync OAuth avatar:', err)
})
// Fetch user data in parallel
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
]).then(() => {
// Fetch profile picture AFTER sync has had time to complete
// Use a small delay to allow avatar sync to finish if it's quick
setTimeout(() => {
fetchProfilePicture(newSession).finally(() => {
setLoading(false)
console.debug('[Auth Debug] User data fully loaded after sign in')
})
}, 500)
})
}
} else if (event === 'TOKEN_REFRESHED') {
console.debug('[Auth Debug] Token refreshed')
// Optionally refresh credits, pro status, trial status, profile picture metadata, and profile picture on token refresh
if (newSession?.user) {
Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession)
]).then(() => {
console.debug('[Auth Debug] User data refreshed after token refresh')
})
}
} else if (event === 'USER_UPDATED') {
console.debug('[Auth Debug] User updated')
// Check if this is a pending OAuth upgrade completion
const pendingUpgrade = sessionStorage.getItem('pendingUpgrade')
const upgradeProvider = sessionStorage.getItem('upgradeProvider')
if (pendingUpgrade && newSession?.user && newSession.user.is_anonymous === false) {
console.debug('[Auth Debug] Processing pending OAuth upgrade:', upgradeProvider)
// Clear the flags first to prevent loops
sessionStorage.removeItem('pendingUpgrade')
sessionStorage.removeItem('upgradeProvider')
// Synchronize with backend
synchronizeUserUpgrade(upgradeProvider || undefined)
.then(() => {
console.debug('[Auth Debug] User upgrade synchronized successfully')
// Refresh credits, pro status, trial status, profile picture metadata, and profile picture after upgrade
if (newSession?.user) {
return Promise.all([
fetchCredits(newSession),
fetchProStatus(newSession),
fetchTrialStatus(newSession),
fetchProfilePictureMetadata(newSession),
fetchProfilePicture(newSession)
])
}
})
.then(() => {
console.debug('[Auth Debug] User data refreshed after upgrade')
})
.catch((err) => {
console.error('[Auth Debug] Failed to synchronize user upgrade:', err)
})
}
}
}
}, 0)
}
)
return () => {
mounted = false
subscription.unsubscribe()
}
}, [])
const value: AuthContextType = {
session,
user: session?.user ?? null,
loading,
error,
creditBalance,
subscription,
creditSummary,
isPro,
trialStatus,
profilePictureUrl,
profilePictureMetadata,
signOut,
refreshSession,
hasSufficientCredits,
updateCredits,
refreshCredits,
refreshProStatus,
refreshTrialStatus,
refreshProfilePicture,
refreshProfilePictureMetadata
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
// Debug hook to expose auth state for debugging
export function useAuthDebug() {
const auth = useAuth()
useEffect(() => {
console.debug('[Auth Debug] Current auth state:', {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
hasError: !!auth.error,
userId: auth.user?.id,
email: auth.user?.email,
provider: auth.user?.app_metadata?.provider
})
}, [auth.session, auth.user, auth.loading, auth.error])
return auth
}