mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
33dd59f7a7
commit
8c9e182e10
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
61
apps/sim/lib/session-context.tsx
Normal file
61
apps/sim/lib/session-context.tsx
Normal 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>
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user