Files
Stirling-PDF/frontend/src/saas/auth/UseSession.tsx
T
James BruntonandGitHub dbff05814f Fix any type usage in the saas/ folder (#5934)
# Description of Changes
Ages ago I made #4835 to try and fix all the `any` type usage in the
system but never got it finished, and there were just too many to review
and ensure it still worked. There's even more now.

My new tactic is to fix folder by folder. This fixes the `any` typing in
the `saas/` folder, and also enables `no-unnecessary-type-assertion`,
which really helps reduce pointless `as` casts that AI generates when
the type is already known. I hope to expand both of these to the rest of
the folders soon, but one folder is better than none.
2026-03-16 11:51:16 +00:00

584 lines
20 KiB
TypeScript

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: unknown) {
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: unknown) {
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: unknown) {
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: unknown) {
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: unknown) {
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
}