fix(sockets): force user to refresh on disconnect in order to mkae changes, add read-only offline mode (#641)

* force user to refresh on disconnect in order to mkae changes, add read-only offline mode

* remove unused hook

* style

* update tooltip msg

* remove unnecessary useMemo around log
This commit is contained in:
Waleed Latif
2025-07-08 20:09:33 -07:00
committed by GitHub
parent d9046042af
commit 2ce68aedf5
10 changed files with 245 additions and 149 deletions

View File

@@ -1,53 +1,57 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
interface ConnectionStatusProps {
isConnected: boolean
}
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
const [showOfflineNotice, setShowOfflineNotice] = useState(false)
const userPermissions = useUserPermissionsContext()
useEffect(() => {
let timeoutId: NodeJS.Timeout
const handleRefresh = () => {
window.location.reload()
}
if (!isConnected) {
// Show offline notice after 6 seconds of being disconnected
timeoutId = setTimeout(() => {
setShowOfflineNotice(true)
}, 6000) // 6 seconds
} else {
// Hide notice immediately when reconnected
setShowOfflineNotice(false)
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}, [isConnected])
// Don't render anything if connected or if we haven't been disconnected long enough
if (!showOfflineNotice) {
// Don't render anything if not in offline mode
if (!userPermissions.isOfflineMode) {
return null
}
return (
<div className='flex items-center gap-1.5'>
<div className='flex items-center gap-1.5 text-red-600'>
<div className='flex items-center gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2'>
<div className='flex items-center gap-2 text-red-700'>
<div className='relative flex items-center justify-center'>
<div className='absolute h-3 w-3 animate-ping rounded-full bg-red-500/20' />
<div className='relative h-2 w-2 rounded-full bg-red-500' />
{!isConnected && (
<div className='absolute h-4 w-4 animate-ping rounded-full bg-red-500/20' />
)}
<AlertTriangle className='relative h-4 w-4' />
</div>
<div className='flex flex-col'>
<span className='font-medium text-xs leading-tight'>Connection lost</span>
<span className='text-xs leading-tight opacity-90'>
Changes not saved - please refresh
<span className='font-medium text-xs leading-tight'>
{isConnected ? 'Reconnected' : 'Connection lost - please refresh'}
</span>
<span className='text-red-600 text-xs leading-tight'>
{isConnected ? 'Refresh to continue editing' : 'Read-only mode active'}
</span>
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={handleRefresh}
variant='ghost'
size='sm'
className='h-7 w-7 p-0 text-red-700 hover:bg-red-100 hover:text-red-800'
>
<RefreshCw className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent className='z-[9999]'>Refresh page to continue editing</TooltipContent>
</Tooltip>
</div>
)
}

View File

@@ -44,16 +44,6 @@ export function UserAvatarStack({
}
}, [users, maxVisible])
// Show connection status component regardless of user count
// This will handle the offline notice when disconnected for 15 seconds
const connectionStatusElement = <ConnectionStatus isConnected={isConnected} />
// Only show presence when there are multiple users (>1)
// But always show connection status
if (users.length <= 1) {
return connectionStatusElement
}
// Determine spacing based on size
const spacingClass = {
sm: '-space-x-1',
@@ -62,46 +52,55 @@ export function UserAvatarStack({
}[size]
return (
<div className={`flex items-center ${spacingClass} ${className}`}>
{/* Connection status - always present */}
{connectionStatusElement}
<div className={`flex items-center gap-3 ${className}`}>
{/* Connection status - always check, shows when offline */}
<ConnectionStatus isConnected={isConnected} />
{/* Render visible user avatars */}
{visibleUsers.map((user, index) => (
<UserAvatar
key={user.connectionId}
connectionId={user.connectionId}
name={user.name}
color={user.color}
size={size}
index={index}
tooltipContent={
user.name ? (
<div className='text-center'>
<div className='font-medium'>{user.name}</div>
{user.info && <div className='mt-1 text-muted-foreground text-xs'>{user.info}</div>}
</div>
) : null
}
/>
))}
{/* Only show avatar stack when there are multiple users (>1) */}
{users.length > 1 && (
<div className={`flex items-center ${spacingClass}`}>
{/* Render visible user avatars */}
{visibleUsers.map((user, index) => (
<UserAvatar
key={user.connectionId}
connectionId={user.connectionId}
name={user.name}
color={user.color}
size={size}
index={index}
tooltipContent={
user.name ? (
<div className='text-center'>
<div className='font-medium'>{user.name}</div>
{user.info && (
<div className='mt-1 text-muted-foreground text-xs'>{user.info}</div>
)}
</div>
) : null
}
/>
))}
{/* Render overflow indicator if there are more users */}
{overflowCount > 0 && (
<UserAvatar
connectionId='overflow-indicator' // Use a unique string identifier
name={`+${overflowCount}`}
size={size}
index={visibleUsers.length}
tooltipContent={
<div className='text-center'>
<div className='font-medium'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</div>
<div className='mt-1 text-muted-foreground text-xs'>{users.length} total online</div>
</div>
}
/>
{/* Render overflow indicator if there are more users */}
{overflowCount > 0 && (
<UserAvatar
connectionId='overflow-indicator' // Use a unique string identifier
name={`+${overflowCount}`}
size={size}
index={visibleUsers.length}
tooltipContent={
<div className='text-center'>
<div className='font-medium'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</div>
<div className='mt-1 text-muted-foreground text-xs'>
{users.length} total online
</div>
</div>
}
/>
)}
</div>
)}
</div>
)

View File

@@ -670,7 +670,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
</h2>
</TooltipTrigger>
{!canEdit && (
<TooltipContent>Edit permissions required to rename workflows</TooltipContent>
<TooltipContent>
{userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Edit permissions required to rename workflows'}
</TooltipContent>
)}
</Tooltip>
)}
@@ -934,7 +938,11 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
)}
</TooltipTrigger>
<TooltipContent>
{canEdit ? 'Duplicate Workflow' : 'Admin permission required to duplicate workflows'}
{canEdit
? 'Duplicate Workflow'
: userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Admin permission required to duplicate workflows'}
</TooltipContent>
</Tooltip>
)
@@ -975,7 +983,9 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
</TooltipTrigger>
<TooltipContent command='Shift+L'>
{!userPermissions.canEdit
? 'Admin permission required to use auto-layout'
? userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Admin permission required to use auto-layout'
: 'Auto Layout'}
</TooltipContent>
</Tooltip>

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import type { BlockConfig } from '@/blocks/types'
export type ToolbarBlockProps = {
@@ -9,6 +10,8 @@ export type ToolbarBlockProps = {
}
export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) {
const userPermissions = useUserPermissionsContext()
const handleDragStart = (e: React.DragEvent) => {
if (disabled) {
e.preventDefault()
@@ -66,7 +69,11 @@ export function ToolbarBlock({ config, disabled = false }: ToolbarBlockProps) {
return (
<Tooltip>
<TooltipTrigger asChild>{blockContent}</TooltipTrigger>
<TooltipContent>Edit permissions required to add blocks</TooltipContent>
<TooltipContent>
{userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Edit permissions required to add blocks'}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { LoopTool } from '../../../loop-node/loop-config'
type LoopToolbarItemProps = {
@@ -9,6 +10,8 @@ type LoopToolbarItemProps = {
// Custom component for the Loop Tool
export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemProps) {
const userPermissions = useUserPermissionsContext()
const handleDragStart = (e: React.DragEvent) => {
if (disabled) {
e.preventDefault()
@@ -74,7 +77,11 @@ export default function LoopToolbarItem({ disabled = false }: LoopToolbarItemPro
return (
<Tooltip>
<TooltipTrigger asChild>{blockContent}</TooltipTrigger>
<TooltipContent>Edit permissions required to add blocks</TooltipContent>
<TooltipContent>
{userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Edit permissions required to add blocks'}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { ParallelTool } from '../../../parallel-node/parallel-config'
type ParallelToolbarItemProps = {
@@ -9,6 +10,7 @@ type ParallelToolbarItemProps = {
// Custom component for the Parallel Tool
export default function ParallelToolbarItem({ disabled = false }: ParallelToolbarItemProps) {
const userPermissions = useUserPermissionsContext()
const handleDragStart = (e: React.DragEvent) => {
if (disabled) {
e.preventDefault()
@@ -75,7 +77,11 @@ export default function ParallelToolbarItem({ disabled = false }: ParallelToolba
return (
<Tooltip>
<TooltipTrigger asChild>{blockContent}</TooltipTrigger>
<TooltipContent>Edit permissions required to add blocks</TooltipContent>
<TooltipContent>
{userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Edit permissions required to add blocks'}
</TooltipContent>
</Tooltip>
)
}

View File

@@ -2,6 +2,7 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Copy, Trash2 } from 'lu
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/w/components/providers/workspace-permissions-provider'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -22,9 +23,17 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
const horizontalHandles = useWorkflowStore(
(state) => state.blocks[blockId]?.horizontalHandles ?? false
)
const userPermissions = useUserPermissionsContext()
const isStarterBlock = blockType === 'starter'
const getTooltipMessage = (defaultMessage: string) => {
if (disabled) {
return userPermissions.isOfflineMode ? 'Connection lost - please refresh' : 'Read-only mode'
}
return defaultMessage
}
return (
<div
className={cn(
@@ -68,7 +77,7 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
</Button>
</TooltipTrigger>
<TooltipContent side='right'>
{disabled ? 'Read-only mode' : isEnabled ? 'Disable Block' : 'Enable Block'}
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</TooltipContent>
</Tooltip>
@@ -89,9 +98,7 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
<Copy className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>
{disabled ? 'Read-only mode' : 'Duplicate Block'}
</TooltipContent>
<TooltipContent side='right'>{getTooltipMessage('Duplicate Block')}</TooltipContent>
</Tooltip>
)}
@@ -116,7 +123,7 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
</Button>
</TooltipTrigger>
<TooltipContent side='right'>
{disabled ? 'Read-only mode' : horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports'}
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</TooltipContent>
</Tooltip>
@@ -140,9 +147,7 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='right'>
{disabled ? 'Read-only mode' : 'Delete Block'}
</TooltipContent>
<TooltipContent side='right'>{getTooltipMessage('Delete Block')}</TooltipContent>
</Tooltip>
)}
</div>

View File

@@ -654,7 +654,9 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
</TooltipTrigger>
<TooltipContent side='top'>
{!userPermissions.canEdit
? 'Read-only mode'
? userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Read-only mode'
: blockAdvancedMode
? 'Switch to Basic Mode'
: 'Switch to Advanced Mode'}
@@ -750,7 +752,9 @@ export function WorkflowBlock({ id, data }: NodeProps<WorkflowBlockProps>) {
</TooltipTrigger>
<TooltipContent side='top'>
{!userPermissions.canEdit
? 'Read-only mode'
? userPermissions.isOfflineMode
? 'Connection lost - please refresh'
: 'Read-only mode'
: isWide
? 'Narrow Block'
: 'Expand Block'}

View File

@@ -1,6 +1,7 @@
'use client'
import React, { createContext, useContext, useMemo } from 'react'
import type React from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { createLogger } from '@/lib/logs/console-logger'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
@@ -8,6 +9,7 @@ import {
useWorkspacePermissions,
type WorkspacePermissions,
} from '@/hooks/use-workspace-permissions'
import { usePresence } from '../../[workflowId]/hooks/use-presence'
const logger = createLogger('WorkspacePermissionsProvider')
@@ -18,88 +20,140 @@ interface WorkspacePermissionsContextType {
permissionsError: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
// Computed user permissions
userPermissions: WorkspaceUserPermissions
// Computed user permissions (connection-aware)
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
// Connection state management
setOfflineMode: (isOffline: boolean) => void
}
const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextType | null>(null)
const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextType>({
workspacePermissions: null,
permissionsLoading: false,
permissionsError: null,
updatePermissions: () => {},
userPermissions: {
canRead: false,
canEdit: false,
canAdmin: false,
userPermissions: 'read',
isLoading: false,
error: null,
},
setOfflineMode: () => {},
})
interface WorkspacePermissionsProviderProps {
children: React.ReactNode
}
const WorkspacePermissionsProvider = React.memo<WorkspacePermissionsProviderProps>(
({ children }) => {
const params = useParams()
const workspaceId = params.workspaceId as string
/**
* Provider that manages workspace permissions and user access
* Also provides connection-aware permissions that enforce read-only mode when offline
*/
export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsProviderProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
if (!workspaceId) {
logger.warn('Workspace ID is undefined from params:', params)
// Manage offline mode state locally
const [isOfflineMode, setIsOfflineMode] = useState(false)
const [hasBeenConnected, setHasBeenConnected] = useState(false)
// Fetch workspace permissions and loading state
const {
permissions: workspacePermissions,
loading: permissionsLoading,
error: permissionsError,
updatePermissions,
} = useWorkspacePermissions(workspaceId)
// Get base user permissions from workspace permissions
const baseUserPermissions = useUserPermissions(
workspacePermissions,
permissionsLoading,
permissionsError
)
// Get connection status and update offline mode accordingly
const { isConnected } = usePresence()
useEffect(() => {
if (isConnected) {
// Mark that we've been connected at least once
setHasBeenConnected(true)
// On initial connection, allow going online
if (!hasBeenConnected) {
setIsOfflineMode(false)
}
// If we were previously connected and this is a reconnection, stay offline (user must refresh)
} else if (hasBeenConnected) {
// Only enter offline mode if we were previously connected and now disconnected
setIsOfflineMode(true)
}
// If not connected and never been connected, stay in initial state (not offline mode)
}, [isConnected, hasBeenConnected])
// Create connection-aware permissions that override user permissions when offline
const userPermissions = useMemo((): WorkspaceUserPermissions & { isOfflineMode?: boolean } => {
if (isOfflineMode) {
// In offline mode, force read-only permissions regardless of actual user permissions
return {
...baseUserPermissions,
canEdit: false,
canAdmin: false,
// Keep canRead true so users can still view content
canRead: baseUserPermissions.canRead,
isOfflineMode: true,
}
}
const {
permissions: workspacePermissions,
loading: permissionsLoading,
error: permissionsError,
updatePermissions,
} = useWorkspacePermissions(workspaceId)
// When online, use normal permissions
return {
...baseUserPermissions,
isOfflineMode: false,
}
}, [baseUserPermissions, isOfflineMode])
const userPermissions = useUserPermissions(
const contextValue = useMemo(
() => ({
workspacePermissions,
permissionsLoading,
permissionsError
)
permissionsError,
updatePermissions,
userPermissions,
setOfflineMode: setIsOfflineMode,
}),
[workspacePermissions, permissionsLoading, permissionsError, updatePermissions, userPermissions]
)
const contextValue = useMemo(
() => ({
workspacePermissions,
permissionsLoading,
permissionsError,
updatePermissions,
userPermissions,
}),
[
workspacePermissions,
permissionsLoading,
permissionsError,
updatePermissions,
userPermissions,
]
)
return (
<WorkspacePermissionsContext.Provider value={contextValue}>
{children}
</WorkspacePermissionsContext.Provider>
)
}
)
WorkspacePermissionsProvider.displayName = 'WorkspacePermissionsProvider'
export { WorkspacePermissionsProvider }
return (
<WorkspacePermissionsContext.Provider value={contextValue}>
{children}
</WorkspacePermissionsContext.Provider>
)
}
/**
* Hook to access workspace permissions context
* This replaces individual useWorkspacePermissions calls to avoid duplicate API requests
* Hook to access workspace permissions and data from context
* This provides both raw workspace permissions and computed user permissions
*/
export function useWorkspacePermissionsContext(): WorkspacePermissionsContextType {
const context = useContext(WorkspacePermissionsContext)
if (!context) {
throw new Error(
'useWorkspacePermissionsContext must be used within a WorkspacePermissionsProvider'
)
}
return context
}
/**
* Hook to access user permissions from context
* This replaces individual useUserPermissions calls
* This replaces individual useUserPermissions calls and includes connection-aware permissions
*/
export function useUserPermissionsContext(): WorkspaceUserPermissions {
export function useUserPermissionsContext(): WorkspaceUserPermissions & {
isOfflineMode?: boolean
} {
const { userPermissions } = useWorkspacePermissionsContext()
return userPermissions
}

View File

@@ -150,9 +150,9 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
const socketInstance = io(socketUrl, {
transports: ['websocket', 'polling'], // Keep polling fallback for reliability
withCredentials: true,
reconnectionAttempts: 5, // Socket.IO handles base reconnection
reconnectionAttempts: Number.POSITIVE_INFINITY, // Socket.IO handles base reconnection
reconnectionDelay: 1000, // Start with 1 second delay
reconnectionDelayMax: 5000, // Max 5 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)