fix(infinite-get-session): pass session once per tree using session provider + multiple fixes (#1085)

* fix(infinite-get-session): pass session using session provider

* prevent auto refetch

* fix typing:

* fix types

* fix

* fix oauth token for microsoft file selector

* fix start block required error
This commit is contained in:
Vikhyath Mondreti
2025-08-21 18:45:15 -07:00
committed by GitHub
parent 33dd59f7a7
commit 8c9e182e10
12 changed files with 156 additions and 47 deletions

View File

@@ -4,8 +4,9 @@ import { auth } from '@/lib/auth'
export async function POST() {
try {
const hdrs = await headers()
const response = await auth.api.generateOneTimeToken({
headers: await headers(),
headers: hdrs,
})
if (!response) {
@@ -14,7 +15,6 @@ export async function POST() {
return NextResponse.json({ token: response.token })
} catch (error) {
console.error('Error generating one-time token:', error)
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
}

View File

@@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getAssetUrl } from '@/lib/utils'
import '@/app/globals.css'
import { SessionProvider } from '@/lib/session-context'
import { ThemeProvider } from '@/app/theme-provider'
import { ZoomPrevention } from '@/app/zoom-prevention'
@@ -111,16 +112,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body suppressHydrationWarning>
<ThemeProvider>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
<SessionProvider>
<BrandedLayout>
<ZoomPrevention />
{children}
{isHosted && (
<>
<SpeedInsights />
<Analytics />
</>
)}
</BrandedLayout>
</SessionProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -88,6 +88,8 @@ export function MicrosoftFileSelector({
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [credentialsLoaded, setCredentialsLoaded] = useState(false)
const initialFetchRef = useRef(false)
// Track the last (credentialId, fileId) we attempted to resolve to avoid tight retry loops
const lastMetaAttemptRef = useRef<string>('')
// Handle Microsoft Planner task selection
const [plannerTasks, setPlannerTasks] = useState<PlannerTask[]>([])
@@ -496,11 +498,15 @@ export function MicrosoftFileSelector({
setSelectedFileId('')
onChange('')
}
// Reset memo when credential is cleared
lastMetaAttemptRef.current = ''
} else if (prevCredentialId && prevCredentialId !== selectedCredentialId) {
// Credentials changed (not initial load) - clear file info to force refetch
if (selectedFile) {
setSelectedFile(null)
}
// Reset memo when switching credentials
lastMetaAttemptRef.current = ''
}
}, [selectedCredentialId, selectedFile, onChange])
@@ -514,10 +520,17 @@ export function MicrosoftFileSelector({
(!selectedFile || selectedFile.id !== value) &&
!isLoadingSelectedFile
) {
// Avoid tight retry loops by memoizing the last attempt tuple
const attemptKey = `${selectedCredentialId}::${value}`
if (lastMetaAttemptRef.current === attemptKey) {
return
}
lastMetaAttemptRef.current = attemptKey
if (serviceId === 'microsoft-planner') {
void fetchPlannerTaskById(value)
} else {
fetchFileById(value)
void fetchFileById(value)
}
}
}, [

View File

@@ -65,7 +65,8 @@ export function useSubBlockValue<T = any>(
const storeValue = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return null
// If the active workflow ID isn't available yet, return undefined so we can fall back to initialValue
if (!activeWorkflowId) return undefined
return state.workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
[activeWorkflowId, blockId, subBlockId]

View File

@@ -13,7 +13,7 @@ export default function WorkspaceRootLayout({ children }: WorkspaceRootLayoutPro
const user = session.data?.user
? {
id: session.data.user.id,
name: session.data.user.name,
name: session.data.user.name ?? undefined,
email: session.data.user.email,
}
: undefined

View File

@@ -108,6 +108,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const [isConnecting, setIsConnecting] = useState(false)
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
const initializedRef = useRef(false)
// Get current workflow ID from URL params
const params = useParams()
@@ -131,16 +132,16 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
// Helper function to generate a fresh socket token
const generateSocketToken = async (): Promise<string> => {
const tokenResponse = await fetch('/api/auth/socket-token', {
// Avoid overlapping token requests
const res = await fetch('/api/auth/socket-token', {
method: 'POST',
credentials: 'include',
headers: { 'cache-control': 'no-store' },
})
if (!tokenResponse.ok) {
throw new Error('Failed to generate socket token')
}
const { token } = await tokenResponse.json()
if (!res.ok) throw new Error('Failed to generate socket token')
const body = await res.json().catch(() => ({}))
const token = body?.token
if (!token || typeof token !== 'string') throw new Error('Invalid socket token')
return token
}
@@ -149,12 +150,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
if (!user?.id) return
// Only initialize if we don't have a socket and aren't already connecting
if (socket || isConnecting) {
if (initializedRef.current || socket || isConnecting) {
logger.info('Socket already exists or is connecting, skipping initialization')
return
}
logger.info('Initializing socket connection for user:', user.id)
initializedRef.current = true
setIsConnecting(true)
const initializeSocket = async () => {
@@ -178,17 +180,14 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
reconnectionDelay: 1000, // Start with 1 second delay
reconnectionDelayMax: 30000, // Max 30 second delay
timeout: 10000, // Back to original timeout
auth: (cb) => {
// Generate a fresh token for each connection attempt (including reconnections)
generateSocketToken()
.then((freshToken) => {
logger.info('Generated fresh token for connection attempt')
cb({ token: freshToken })
})
.catch((error) => {
logger.error('Failed to generate fresh token for connection:', error)
cb({ token: null }) // This will cause authentication to fail gracefully
})
auth: async (cb) => {
try {
const freshToken = await generateSocketToken()
cb({ token: freshToken })
} catch (error) {
logger.error('Failed to generate fresh token for connection:', error)
cb({ token: null })
}
},
})

View File

@@ -34,8 +34,8 @@ export function useUserPermissions(
const { data: session } = useSession()
const userPermissions = useMemo((): WorkspaceUserPermissions => {
// If still loading or no session, return safe defaults
if (permissionsLoading || !session?.user?.email) {
const sessionEmail = session?.user?.email
if (permissionsLoading || !sessionEmail) {
return {
canRead: false,
canEdit: false,
@@ -48,13 +48,13 @@ export function useUserPermissions(
// Find current user in workspace permissions (case-insensitive)
const currentUser = workspacePermissions?.users?.find(
(user) => user.email.toLowerCase() === session.user.email.toLowerCase()
(user) => user.email.toLowerCase() === sessionEmail.toLowerCase()
)
// If user not found in workspace, they have no permissions
if (!currentUser) {
logger.warn('User not found in workspace permissions', {
userEmail: session.user.email,
userEmail: sessionEmail,
hasPermissions: !!workspacePermissions,
userCount: workspacePermissions?.users?.length || 0,
})

View File

@@ -1,8 +1,16 @@
import { useContext } from 'react'
import { stripeClient } from '@better-auth/stripe/client'
import { emailOTPClient, genericOAuthClient, organizationClient } from 'better-auth/client/plugins'
import {
customSessionClient,
emailOTPClient,
genericOAuthClient,
organizationClient,
} from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import type { auth } from '@/lib/auth'
import { env, getEnv } from '@/lib/env'
import { isDev, isProd } from '@/lib/environment'
import { SessionContext, type SessionHookResult } from '@/lib/session-context'
export function getBaseURL() {
let baseURL
@@ -25,6 +33,7 @@ export const client = createAuthClient({
plugins: [
emailOTPClient(),
genericOAuthClient(),
customSessionClient<typeof auth>(),
// Only include Stripe client in production
...(isProd
? [
@@ -37,7 +46,17 @@ export const client = createAuthClient({
],
})
export const { useSession, useActiveOrganization } = client
export function useSession(): SessionHookResult {
const ctx = useContext(SessionContext)
if (!ctx) {
throw new Error(
'SessionProvider is not mounted. Wrap your app with <SessionProvider> in app/layout.tsx.'
)
}
return ctx
}
export const { useActiveOrganization } = client
export const useSubscription = () => {
// In development, provide mock implementations

View File

@@ -4,6 +4,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { nextCookies } from 'better-auth/next-js'
import {
createAuthMiddleware,
customSession,
emailOTP,
genericOAuth,
oneTimeToken,
@@ -208,6 +209,10 @@ export const auth = betterAuth({
oneTimeToken({
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
}),
customSession(async ({ user, session }) => ({
user,
session,
})),
emailOTP({
sendVerificationOTP: async (data: {
email: string
@@ -1480,8 +1485,9 @@ export const auth = betterAuth({
// Server-side auth helpers
export async function getSession() {
const hdrs = await headers()
return await auth.api.getSession({
headers: await headers(),
headers: hdrs,
})
}

View File

@@ -0,0 +1,61 @@
'use client'
import type React from 'react'
import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { client } from '@/lib/auth-client'
export type AppSession = {
user: {
id: string
email: string
emailVerified?: boolean
name?: string | null
image?: string | null
createdAt?: Date
updatedAt?: Date
} | null
session?: {
id?: string
userId?: string
activeOrganizationId?: string
}
} | null
export type SessionHookResult = {
data: AppSession
isPending: boolean
error: Error | null
refetch: () => Promise<void>
}
export const SessionContext = createContext<SessionHookResult | null>(null)
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [data, setData] = useState<AppSession>(null)
const [isPending, setIsPending] = useState(true)
const [error, setError] = useState<Error | null>(null)
const loadSession = useCallback(async () => {
try {
setIsPending(true)
setError(null)
const res = await client.getSession()
setData(res?.data ?? null)
} catch (e) {
setError(e instanceof Error ? e : new Error('Failed to fetch session'))
} finally {
setIsPending(false)
}
}, [])
useEffect(() => {
loadSession()
}, [loadSession])
const value = useMemo<SessionHookResult>(
() => ({ data, isPending, error, refetch: loadSession }),
[data, isPending, error, loadSession]
)
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}

View File

@@ -74,6 +74,15 @@ export class Serializer {
// Extract parameters from UI state
const params = this.extractParams(block)
try {
const isTriggerCategory = blockConfig.category === 'triggers'
if (block.triggerMode === true || isTriggerCategory) {
params.triggerMode = true
}
} catch (_) {
// no-op: conservative, avoid blocking serialization if blockConfig is unexpected
}
// Validate required fields that only users can provide (before execution starts)
if (validateRequired) {
this.validateRequiredFieldsBeforeExecution(block, blockConfig, params)
@@ -385,6 +394,10 @@ export class Serializer {
subBlocks,
outputs: serializedBlock.outputs,
enabled: true,
// Restore trigger mode from serialized params; treat trigger category as triggers as well
triggerMode:
serializedBlock.config?.params?.triggerMode === true ||
serializedBlock.metadata?.category === 'triggers',
}
}
}

View File

@@ -482,9 +482,3 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
{ name: 'subscription-store' }
)
)
// Auto-load subscription data when store is first accessed
if (typeof window !== 'undefined') {
// Load data in parallel on store creation
useSubscriptionStore.getState().loadData()
}