Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/realtime/src/handlers/subblocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
socket.emit('operation-failed', {
operationId,
error: 'User session not found',
retryable: false,
retryable: true,
})
}
return
Expand Down Expand Up @@ -250,7 +250,7 @@ async function flushSubblockUpdate(
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Workflow not found',
retryable: false,
retryable: true,
})
})
return
Expand Down Expand Up @@ -352,7 +352,7 @@ async function flushSubblockUpdate(
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Block no longer exists',
retryable: false,
retryable: true,
})
})
}
Expand Down
6 changes: 3 additions & 3 deletions apps/realtime/src/handlers/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
socket.emit('operation-failed', {
operationId,
error: 'User session not found',
retryable: false,
retryable: true,
})
}
return
Expand Down Expand Up @@ -236,7 +236,7 @@ async function flushVariableUpdate(
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Workflow not found',
retryable: false,
retryable: true,
})
})
return
Expand Down Expand Up @@ -318,7 +318,7 @@ async function flushVariableUpdate(
io.to(socketId).emit('operation-failed', {
operationId: opId,
error: 'Variable no longer exists',
retryable: false,
retryable: true,
})
})
}
Expand Down
9 changes: 8 additions & 1 deletion apps/realtime/src/middleware/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export function checkRolePermission(
return { allowed: true }
}

/**
* Verifies a user's access to a workflow via workspace permissions.
*
* Returns `hasAccess: false` only for genuine denials (workflow missing/archived
* or no workspace permission). Transient failures (DB errors) are rethrown so the
* caller can report them as retryable instead of a permanent access denial.
*/
export async function verifyWorkflowAccess(
userId: string,
workflowId: string
Expand Down Expand Up @@ -129,6 +136,6 @@ export async function verifyWorkflowAccess(
`Error verifying workflow access for user ${userId}, workflow ${workflowId}:`,
error
)
return { hasAccess: false }
throw error
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import type React from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
Expand All @@ -19,6 +19,60 @@ import { useOperationQueueStore } from '@/stores/operation-queue/store'

const logger = createLogger('WorkspacePermissionsProvider')

interface PersistentToastOptions {
description?: string
action?: { label: string; onClick: () => void }
}

/**
* Shows a persistent error toast while `message` is non-null, replaces it when
* the message changes, and dismisses it when the message becomes null or the
* owning component unmounts.
*/
function usePersistentErrorToast(message: string | null, options?: PersistentToastOptions) {
const { toast } = useToast()
const toastIdRef = useRef<string | null>(null)
const shownMessageRef = useRef<string | null>(null)
const optionsRef = useRef(options)
optionsRef.current = options

const dismiss = useCallback(() => {
if (!toastIdRef.current) {
return
}

toast.dismiss(toastIdRef.current)
toastIdRef.current = null
shownMessageRef.current = null
}, [])

useEffect(() => {
if (!message) {
dismiss()
return
}

if (toastIdRef.current && shownMessageRef.current === message) {
return
}

dismiss()

try {
toastIdRef.current = toast.error(message, {
...optionsRef.current,
duration: 0,
persistAcrossRoutes: true,
})
shownMessageRef.current = message
} catch (error) {
logger.error('Failed to show persistent notification', { error, message })
}
}, [dismiss, message])

useEffect(() => dismiss, [dismiss])
}

interface WorkspacePermissionsContextType {
workspacePermissions: WorkspacePermissions | null
permissionsLoading: boolean
Expand Down Expand Up @@ -55,56 +109,34 @@ interface WorkspacePermissionsProviderProps {
export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsProviderProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const urlWorkflowId = params?.workflowId as string | undefined
const queryClient = useQueryClient()
const { toast } = useToast()

const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
const { isReconnecting, isRetryingWorkflowJoin } = useSocket()
const realtimeStatusNotificationIdRef = useRef<string | null>(null)
const realtimeStatusNotificationMessageRef = useRef<string | null>(null)
const { isReconnecting, isRetryingWorkflowJoin, blockedJoinWorkflowId } = useSocket()

const isOfflineMode = hasOperationError
const realtimeStatusMessage = isReconnecting
? 'Reconnecting...'
: isRetryingWorkflowJoin
? 'Joining workflow...'
: null

const clearRealtimeStatusNotification = useCallback(() => {
if (!realtimeStatusNotificationIdRef.current) {
return
}

toast.dismiss(realtimeStatusNotificationIdRef.current)
realtimeStatusNotificationIdRef.current = null
realtimeStatusNotificationMessageRef.current = null
}, [])

useEffect(() => {
if (isOfflineMode || !realtimeStatusMessage) {
clearRealtimeStatusNotification()
return
}

if (
realtimeStatusNotificationIdRef.current &&
realtimeStatusNotificationMessageRef.current === realtimeStatusMessage
) {
return
}

clearRealtimeStatusNotification()

const id = toast.error(realtimeStatusMessage, { duration: 0, persistAcrossRoutes: true })

realtimeStatusNotificationIdRef.current = id
realtimeStatusNotificationMessageRef.current = realtimeStatusMessage
}, [clearRealtimeStatusNotification, isOfflineMode, realtimeStatusMessage])

useEffect(() => {
return clearRealtimeStatusNotification
}, [clearRealtimeStatusNotification])
const isJoinBlocked = Boolean(blockedJoinWorkflowId) && blockedJoinWorkflowId === urlWorkflowId
const realtimeStatusMessage = isOfflineMode
? null
: isReconnecting
? 'Reconnecting...'
: isRetryingWorkflowJoin
? 'Joining workflow...'
: null

usePersistentErrorToast(realtimeStatusMessage)
// Offline mode only recovers via workspace switch or refresh; the join block
// lifts when the user targets a different workflow or refreshes.
usePersistentErrorToast(isOfflineMode ? 'Connection unavailable' : null, {
description: 'Recent changes may not have been saved. Refresh to resync.',
action: { label: 'Refresh', onClick: () => window.location.reload() },
})
usePersistentErrorToast(isJoinBlocked ? 'Unable to connect to workflow' : null, {
description: 'Changes cannot be saved. Refresh to retry.',
action: { label: 'Refresh', onClick: () => window.location.reload() },
})

useRegisterGlobalCommands(() =>
createCommands([
Expand All @@ -120,25 +152,6 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
])
)

useEffect(() => {
if (!isOfflineMode || hasShownOfflineNotification) {
return
}

clearRealtimeStatusNotification()

try {
toast.error('Connection unavailable', {
duration: 0,
persistAcrossRoutes: true,
action: { label: 'Refresh', onClick: () => window.location.reload() },
})
setHasShownOfflineNotification(true)
} catch (error) {
logger.error('Failed to add offline notification', { error })
}
}, [clearRealtimeStatusNotification, hasShownOfflineNotification, isOfflineMode])

const {
data: workspacePermissions,
isLoading: permissionsLoading,
Expand Down Expand Up @@ -167,21 +180,21 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
)

const userPermissions = useMemo((): WorkspaceUserPermissions & { isOfflineMode?: boolean } => {
if (isOfflineMode) {
if (isOfflineMode || isJoinBlocked) {
return {
...baseUserPermissions,
canEdit: false,
canAdmin: false,
canRead: baseUserPermissions.canRead,
isOfflineMode: true,
isOfflineMode,
}
}

return {
...baseUserPermissions,
isOfflineMode: false,
}
}, [baseUserPermissions, isOfflineMode])
}, [baseUserPermissions, isOfflineMode, isJoinBlocked])

const contextValue = useMemo(
() => ({
Expand Down
Loading
Loading