From 70a006f7eba3d4b4218f4d8a08dd40cc22d93829 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 22 Jun 2025 19:53:46 -0700 Subject: [PATCH 1/6] improved permissions UI & access patterns, show outstanding invites --- .../components/invite-modal/invite-modal.tsx | 460 +++++++++++------- .../invites-sent/invites-sent.tsx | 142 ++++-- apps/sim/app/w/components/sidebar/sidebar.tsx | 134 ++--- apps/sim/lib/email/utils.test.ts | 113 +++++ apps/sim/lib/email/utils.ts | 10 + 5 files changed, 531 insertions(+), 328 deletions(-) create mode 100644 apps/sim/lib/email/utils.test.ts create mode 100644 apps/sim/lib/email/utils.ts diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx index e6b3e3970e4..b957d1dc26a 100644 --- a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx @@ -1,11 +1,14 @@ 'use client' -import { type KeyboardEvent, useState } from 'react' -import { Loader2, X } from 'lucide-react' +import { type KeyboardEvent, useEffect, useState } from 'react' +import { HelpCircle, Loader2, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' +import { Skeleton } from '@/components/ui/skeleton' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' +import { validateAndNormalizeEmail } from '@/lib/email/utils' import type { PermissionType } from '@/lib/permissions/utils' import { cn } from '@/lib/utils' import { useUserPermissions } from '@/hooks/use-user-permissions' @@ -34,6 +37,7 @@ interface UserPermissions { email: string permissionType: PermissionType isCurrentUser?: boolean + isPendingInvitation?: boolean } interface PermissionsTableProps { @@ -44,6 +48,16 @@ interface PermissionsTableProps { isSaving?: boolean workspacePermissions: WorkspacePermissions | null permissionsLoading: boolean + pendingInvitations: UserPermissions[] +} + +interface PendingInvitation { + id: string + workspaceId: string + email: string + permissions: PermissionType + status: string + createdAt: string } const EmailTag = ({ email, onRemove, disabled, isInvalid }: EmailTagProps) => ( @@ -90,7 +104,7 @@ const PermissionSelector = ({ onClick={() => !disabled && onChange(option.value)} disabled={disabled} className={cn( - 'px-3 py-1.5 font-medium text-sm transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'px-3 py-1.5 font-medium text-sm transition-colors focus:outline-none', 'first:rounded-l-md last:rounded-r-md', disabled && 'cursor-not-allowed opacity-50', value === option.value @@ -106,6 +120,50 @@ const PermissionSelector = ({ ) } +const PermissionsTableSkeleton = () => ( +
+
+

Member Permissions

+ + + + + +

Loading permissions...

+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+ + {i === 1 && } +
+
+ {i > 0 && } +
+
+
+ +
+
+ ))} +
+
+
+) + const PermissionsTable = ({ userPermissions, onPermissionChange, @@ -114,20 +172,25 @@ const PermissionsTable = ({ isSaving, workspacePermissions, permissionsLoading, + pendingInvitations, }: PermissionsTableProps) => { const { data: session } = useSession() const { activeWorkspaceId } = useWorkflowRegistry() const userPerms = useUserPermissions(activeWorkspaceId) + // Show skeleton while loading permissions data + if (permissionsLoading || userPerms.isLoading) { + return + } + if (userPermissions.length === 0 && !session?.user?.email && !workspacePermissions?.users?.length) return null - // Show loading state during save operations to prevent UI inconsistencies if (isSaving) { return ( -
-

Member Permissions

-
+
+

Member Permissions

+
@@ -135,19 +198,18 @@ const PermissionsTable = ({
-

- Please wait while we update the permissions. -

+
+

+ Please wait while we update the permissions. +

+
) } - // Convert workspace users to UserPermissions format, merging with pending changes const existingUsers: UserPermissions[] = workspacePermissions?.users?.map((user) => { const changes = existingUserPermissionChanges[user.userId] || {} - - // Use the single permissionType directly const permissionType = user.permissionType || 'read' return { @@ -159,126 +221,127 @@ const PermissionsTable = ({ } }) || [] - // Find current user from existing users or create fallback const currentUser: UserPermissions | null = session?.user?.email ? existingUsers.find((user) => user.isCurrentUser) || { email: session.user.email, - permissionType: 'admin', // Fallback if not found in workspace users + permissionType: 'admin', isCurrentUser: true, } : null - // Use the useUserPermissions hook for admin check instead of manual checking const currentUserIsAdmin = userPerms.canAdmin - - // Filter out current user from existing users to avoid duplication const filteredExistingUsers = existingUsers.filter((user) => !user.isCurrentUser) - // Combine current user, existing users, and new invites const allUsers: UserPermissions[] = [ ...(currentUser ? [currentUser] : []), ...filteredExistingUsers, ...userPermissions, + ...pendingInvitations, ] return ( -
-

Member Permissions

-
-
- - - - - - - - - {permissionsLoading && ( - - - +
+
+

Member Permissions

+ + + + + +
+ {userPerms.isLoading || permissionsLoading ? ( +

Loading permissions...

+ ) : !currentUserIsAdmin ? ( +

+ Only administrators can invite new members and modify permissions. +

+ ) : ( +
+

Admin grants all permissions automatically.

+
)} - {allUsers.map((user, index) => { - const isCurrentUser = user.isCurrentUser === true - const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email) - const isNewInvite = userPermissions.some((up) => up.email === user.email) - const userIdentifier = user.userId || user.email // Use userId for existing users, email for new invites - const hasChanges = existingUserPermissionChanges[userIdentifier] !== undefined - - return ( -
- - - - ) - })} - -
- Email - - Permission Level -
- - Loading workspace members... -
- {user.email} - {isCurrentUser && ( - - You - + + + + +
+ {allUsers.length > 0 && ( +
+ {allUsers.map((user) => { + const isCurrentUser = user.isCurrentUser === true + const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email) + const isPendingInvitation = user.isPendingInvitation === true + const userIdentifier = user.userId || user.email + const hasChanges = existingUserPermissionChanges[userIdentifier] !== undefined + + const uniqueKey = user.userId + ? `existing-${user.userId}` + : isPendingInvitation + ? `pending-${user.email}` + : `new-${user.email}` + + return ( +
+
+
+ {user.email} + {isPendingInvitation && ( + Sent )} +
+
{isExistingUser && !isCurrentUser && ( - - Member - - )} - {isNewInvite && ( - - New Invite - + Member )} {hasChanges && ( - - Modified - + Modified )} -
-
- - onPermissionChange(userIdentifier, newPermissionType) - } - disabled={ - disabled || - !currentUserIsAdmin || - (isCurrentUser && user.permissionType === 'admin') - } - /> -
-
-
+
+
+
+ + onPermissionChange(userIdentifier, newPermission) + } + disabled={ + disabled || + !currentUserIsAdmin || + isPendingInvitation || + (isCurrentUser && user.permissionType === 'admin') + } + className='w-auto' + /> +
+
+ ) + })} +
+ )} -

- {!currentUserIsAdmin - ? 'Only administrators can invite new members and modify permissions.' - : 'Admin grants all permissions automatically. Modified permissions are highlighted and require saving.'} -

) } -const isValidEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return emailRegex.test(email) +const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified') => { + switch (status) { + case 'sent': + return 'inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + case 'member': + return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400' + case 'modified': + return 'inline-flex items-center rounded-md bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + default: + return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300' + } } export function InviteModal({ open, onOpenChange }: InviteModalProps) { @@ -286,6 +349,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const [emails, setEmails] = useState([]) const [invalidEmails, setInvalidEmails] = useState([]) const [userPermissions, setUserPermissions] = useState([]) + const [pendingInvitations, setPendingInvitations] = useState([]) const [existingUserPermissionChanges, setExistingUserPermissionChanges] = useState< Record> >({}) @@ -303,39 +367,88 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } = useWorkspacePermissions(activeWorkspaceId) const userPerms = useUserPermissions(activeWorkspaceId) - // Check if there are pending changes to existing users const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0 - - // Check if there are new invites to send const hasNewInvites = emails.length > 0 || inputValue.trim() + const fetchPendingInvitations = async () => { + if (!activeWorkspaceId) return + + try { + const response = await fetch('/api/workspaces/invitations') + if (response.ok) { + const data = await response.json() + const workspacePendingInvitations = + data.invitations + ?.filter( + (inv: PendingInvitation) => + inv.status === 'pending' && inv.workspaceId === activeWorkspaceId + ) + .map((inv: PendingInvitation) => ({ + email: inv.email, + permissionType: inv.permissions, + isPendingInvitation: true, + })) || [] + + setPendingInvitations(workspacePendingInvitations) + } + } catch (error) { + console.error('Error fetching pending invitations:', error) + } + } + + useEffect(() => { + if (open && activeWorkspaceId) { + fetchPendingInvitations() + } + }, [open, activeWorkspaceId]) + + useEffect(() => { + setErrorMessage(null) + }, [pendingInvitations, workspacePermissions]) + const addEmail = (email: string) => { - // Normalize by trimming and converting to lowercase - const normalizedEmail = email.trim().toLowerCase() + if (!email.trim()) return false - if (!normalizedEmail) return false + const { isValid, normalized } = validateAndNormalizeEmail(email) - // Check for duplicates - if (emails.includes(normalizedEmail) || invalidEmails.includes(normalizedEmail)) { + if (emails.includes(normalized) || invalidEmails.includes(normalized)) { return false } - // Validate email format - if (!isValidEmail(normalizedEmail)) { - setInvalidEmails([...invalidEmails, normalizedEmail]) + const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized) + if (hasPendingInvitation) { + setErrorMessage(`${normalized} already has a pending invitation`) setInputValue('') return false } - // Add to emails array - setEmails([...emails, normalizedEmail]) + const isExistingMember = workspacePermissions?.users?.some((user) => user.email === normalized) + if (isExistingMember) { + setErrorMessage(`${normalized} is already a member of this workspace`) + setInputValue('') + return false + } + + if (session?.user?.email && session.user.email.toLowerCase() === normalized) { + setErrorMessage('You cannot invite yourself') + setInputValue('') + return false + } + + if (!isValid) { + setInvalidEmails([...invalidEmails, normalized]) + setInputValue('') + return false + } + + setErrorMessage(null) + setEmails([...emails, normalized]) - // Add to permissions table with default permissions setUserPermissions((prev) => [ ...prev, { - email: normalizedEmail, - permissionType: 'read', // Default: read access + email: normalized, + permissionType: 'read', }, ]) @@ -349,7 +462,6 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { newEmails.splice(index, 1) setEmails(newEmails) - // Remove from permissions table setUserPermissions((prev) => prev.filter((user) => user.email !== emailToRemove)) } @@ -360,17 +472,14 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } const handlePermissionChange = (identifier: string, permissionType: PermissionType) => { - // Check if this is an existing user by looking for userId in workspace permissions const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier) if (existingUser) { - // Handle existing user permission changes using userId setExistingUserPermissionChanges((prev) => ({ ...prev, [identifier]: { permissionType }, })) } else { - // Handle new invites (using email as identifier) setUserPermissions((prev) => prev.map((user) => (user.email === identifier ? { ...user, permissionType } : user)) ) @@ -384,7 +493,6 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { setErrorMessage(null) try { - // Convert existingUserPermissionChanges to the API format using userId const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({ userId, permissions: changes.permissionType || 'read', @@ -404,12 +512,10 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { throw new Error(data.error || 'Failed to update permissions') } - // Use the updated permissions from the API response - updated structure if (data.users && data.total !== undefined) { updatePermissions({ users: data.users, total: data.total }) } - // Clear staged changes now that we have fresh data setExistingUserPermissionChanges({}) setSuccessMessage( @@ -431,7 +537,6 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const handleRestoreChanges = () => { if (!userPerms.canAdmin || !hasPendingChanges) return - // Clear all pending changes to revert to original permissions setExistingUserPermissionChanges({}) setSuccessMessage('Changes restored to original permissions!') @@ -439,13 +544,11 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } const handleKeyDown = (e: KeyboardEvent) => { - // Add email on Enter, comma, or space if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) { e.preventDefault() addEmail(inputValue) } - // Remove the last email on Backspace if input is empty if (e.key === 'Backspace' && !inputValue) { if (invalidEmails.length > 0) { removeInvalidEmail(invalidEmails.length - 1) @@ -458,16 +561,16 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault() const pastedText = e.clipboardData.getData('text') - const pastedEmails = pastedText - .split(/[\s,;]+/) // Split by space, comma, or semicolon - .filter(Boolean) // Remove empty strings + const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean) - const validEmails = pastedEmails.filter((email) => { - return addEmail(email) + let addedCount = 0 + pastedEmails.forEach((email) => { + if (addEmail(email)) { + addedCount++ + } }) - // If we didn't add any emails, keep the current input value - if (validEmails.length === 0 && pastedEmails.length === 1) { + if (addedCount === 0 && pastedEmails.length === 1) { setInputValue(inputValue + pastedEmails[0]) } } @@ -475,16 +578,13 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - // Add current input as an email if it's valid if (inputValue.trim()) { addEmail(inputValue) } - // Clear any previous error or success messages setErrorMessage(null) setSuccessMessage(null) - // Don't proceed if no emails or no workspace if (emails.length === 0 || !activeWorkspaceId) { return } @@ -492,14 +592,11 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { setIsSubmitting(true) try { - // Track failed invitations const failedInvites: string[] = [] - // Send invitations in parallel const results = await Promise.all( emails.map(async (email) => { try { - // Find permissions for this email const userPermission = userPermissions.find((up) => up.email === email) const permissionType = userPermission?.permissionType || 'read' @@ -511,20 +608,18 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { body: JSON.stringify({ workspaceId: activeWorkspaceId, email: email, - role: 'member', // Default role for invited members (kept for compatibility) - permission: permissionType, // Single permission type - changed from 'permissions' to 'permission' + role: 'member', + permission: permissionType, }), }) const data = await response.json() if (!response.ok) { - // Don't add to invalid emails if it's already in the valid emails array if (!invalidEmails.includes(email)) { failedInvites.push(email) } - // Display the error message from the API if it exists if (data.error) { setErrorMessage(data.error) } @@ -533,8 +628,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } return true - } catch (_err) { - // Don't add to invalid emails if it's already in the valid emails array + } catch { if (!invalidEmails.includes(email)) { failedInvites.push(email) } @@ -546,34 +640,34 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const successCount = results.filter(Boolean).length if (successCount > 0) { - // Clear everything on success, but keep track of failed emails + fetchPendingInvitations() setInputValue('') - // Only keep emails that failed in the emails array if (failedInvites.length > 0) { setEmails(failedInvites) - // Keep permissions only for failed invites setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email))) } else { setEmails([]) setUserPermissions([]) - // Set success message when all invitations are successful setSuccessMessage( successCount === 1 ? 'Invitation sent successfully!' : `${successCount} invitations sent successfully!` ) + + setTimeout(() => { + onOpenChange(false) + }, 1500) } setInvalidEmails([]) setShowSent(true) - // Revert button text after 2 seconds setTimeout(() => { setShowSent(false) }, 4000) } - } catch (err: any) { + } catch (err) { console.error('Error inviting members:', err) setErrorMessage('An unexpected error occurred. Please try again.') } finally { @@ -586,6 +680,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { setEmails([]) setInvalidEmails([]) setUserPermissions([]) + setPendingInvitations([]) setExistingUserPermissionChanges({}) setIsSubmitting(false) setIsSaving(false) @@ -627,9 +722,29 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) {
- +
+ + + + + + +

+ Press Enter, comma, or space after each email address to add it to the list. +

+
+
+
0 || invalidEmails.length > 0 ? 'Add another email' - : 'Enter email addresses (comma or Enter to separate)' + : 'Enter email addresses' } className={cn( 'h-7 min-w-[180px] flex-1 border-none py-1 focus-visible:ring-0 focus-visible:ring-offset-0', @@ -675,20 +790,16 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { disabled={isSubmitting || !userPerms.canAdmin} />
-

- {errorMessage || - successMessage || - 'Press Enter, comma, or space after each email.'} -

+ {(errorMessage || successMessage) && ( +

+ {errorMessage || successMessage} +

+ )}
diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx b/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx index 8c7fb94b215..3d12be75687 100644 --- a/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx +++ b/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx @@ -10,16 +10,36 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { cn } from '@/lib/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired' + type Invitation = { id: string email: string - status: 'pending' | 'accepted' | 'rejected' | 'expired' + status: InvitationStatus createdAt: string } +const getInvitationStatusStyles = (status: InvitationStatus) => { + switch (status) { + case 'accepted': + return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400' + case 'pending': + return 'inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + case 'rejected': + return 'inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300' + case 'expired': + return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300' + default: + return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300' + } +} + +const formatInvitationStatus = (status: InvitationStatus): string => { + return status.charAt(0).toUpperCase() + status.slice(1) +} + export function InvitesSent() { const [invitations, setInvitations] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -28,7 +48,10 @@ export function InvitesSent() { useEffect(() => { async function fetchInvitations() { - if (!activeWorkspaceId) return + if (!activeWorkspaceId) { + setIsLoading(false) + return + } setIsLoading(true) setError(null) @@ -37,14 +60,19 @@ export function InvitesSent() { const response = await fetch('/api/workspaces/invitations') if (!response.ok) { - throw new Error('Failed to fetch invitations') + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(errorData.error || `Failed to fetch invitations (${response.status})`) } const data = await response.json() - setInvitations(data.invitations || []) + const filteredInvitations = (data.invitations || []).filter( + (inv: Invitation) => inv.status === 'pending' + ) + setInvitations(filteredInvitations) } catch (err) { console.error('Error fetching invitations:', err) - setError('Failed to load invitations') + const errorMessage = err instanceof Error ? err.message : 'Failed to load invitations' + setError(errorMessage) } finally { setIsLoading(false) } @@ -55,64 +83,78 @@ export function InvitesSent() { const TableSkeleton = () => (
- {Array(5) - .fill(0) - .map((_, i) => ( -
- - -
- ))} + {Array.from({ length: 3 }, (_, i) => ( +
+ + +
+ ))}
) if (error) { - return
{error}
+ return ( +
+

Sent Invitations

+
+

{error}

+
+
+ ) + } + + if (!activeWorkspaceId) { + return null } return (
-

Sent Invitations

+

Sent Invitations

{isLoading ? ( ) : invitations.length === 0 ? ( -
No invitations sent yet
+
+

No pending invitations

+
) : ( -
- - - - - Email - - - Status - - - - - {invitations.map((invitation) => ( - - {invitation.email} - - - {invitation.status.charAt(0).toUpperCase() + invitation.status.slice(1)} - - +
+
+
+ + + + Email + + + Status + - ))} - -
+ + + {invitations.map((invitation) => ( + + + + {invitation.email} + + + + + {formatInvitationStatus(invitation.status)} + + + + ))} + + +
)}
diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index 7a72043e4ce..76d3fcfc48b 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -21,6 +21,8 @@ import { SettingsModal } from './components/settings-modal/settings-modal' import { SidebarControl } from './components/sidebar-control/sidebar-control' import { WorkspaceHeader } from './components/workspace-header/workspace-header' +const IS_DEV = process.env.NODE_ENV === 'development' + export function Sidebar() { useRegistryLoading() useGlobalShortcuts() @@ -39,55 +41,26 @@ export function Sidebar() { const [showSettings, setShowSettings] = useState(false) const [showHelp, setShowHelp] = useState(false) const [showInviteMembers, setShowInviteMembers] = useState(false) - const [isDevEnvironment, setIsDevEnvironment] = useState(false) - const { - mode, - isExpanded, - toggleExpanded, - setMode, - workspaceDropdownOpen, - setWorkspaceDropdownOpen, - isAnyModalOpen, - setAnyModalOpen, - } = useSidebarStore() + const { mode, workspaceDropdownOpen, setWorkspaceDropdownOpen, isAnyModalOpen, setAnyModalOpen } = + useSidebarStore() const [isHovered, setIsHovered] = useState(false) const [explicitMouseEnter, setExplicitMouseEnter] = useState(false) useEffect(() => { - setIsDevEnvironment(process.env.NODE_ENV === 'development') - }, []) - - // Track when active workspace changes to ensure we refresh the UI - useEffect(() => { - if (activeWorkspaceId) { - // We don't need to do anything here, just force a re-render - // when activeWorkspaceId changes to ensure fresh data - } - }, [activeWorkspaceId]) - - // Update modal state in the store when settings or help modals open/close - useEffect(() => { - setAnyModalOpen(showSettings || showHelp || showInviteMembers) - }, [showSettings, showHelp, showInviteMembers, setAnyModalOpen]) - - // Reset explicit mouse enter state when modal state changes - useEffect(() => { - if (isAnyModalOpen) { + const anyModalIsOpen = showSettings || showHelp || showInviteMembers + setAnyModalOpen(anyModalIsOpen) + if (anyModalIsOpen) { setExplicitMouseEnter(false) } - }, [isAnyModalOpen]) + }, [showSettings, showHelp, showInviteMembers, setAnyModalOpen]) // Separate regular workflows from temporary marketplace workflows const { regularWorkflows, tempWorkflows } = useMemo(() => { const regular: WorkflowMetadata[] = [] const temp: WorkflowMetadata[] = [] - // Only process workflows when not in loading state if (!isLoading) { Object.values(workflows).forEach((workflow) => { - // Include workflows that either: - // 1. Belong to the active workspace, OR - // 2. Don't have a workspace ID (legacy workflows) if (workflow.workspaceId === activeWorkspaceId || !workflow.workspaceId) { if (workflow.marketplaceData?.status === 'temp') { temp.push(workflow) @@ -97,8 +70,8 @@ export function Sidebar() { } }) - // Sort regular workflows by last modified date (newest first) - regular.sort((a, b) => { + // Sort by last modified date (newest first) + const sortByLastModified = (a: WorkflowMetadata, b: WorkflowMetadata) => { const dateA = a.lastModified instanceof Date ? a.lastModified.getTime() @@ -108,34 +81,27 @@ export function Sidebar() { ? b.lastModified.getTime() : new Date(b.lastModified).getTime() return dateB - dateA - }) + } - // Sort temp workflows by last modified date (newest first) - temp.sort((a, b) => { - const dateA = - a.lastModified instanceof Date - ? a.lastModified.getTime() - : new Date(a.lastModified).getTime() - const dateB = - b.lastModified instanceof Date - ? b.lastModified.getTime() - : new Date(b.lastModified).getTime() - return dateB - dateA - }) + regular.sort(sortByLastModified) + temp.sort(sortByLastModified) } return { regularWorkflows: regular, tempWorkflows: temp } }, [workflows, isLoading, activeWorkspaceId]) - // Create workflow + // Create workflow handler const handleCreateWorkflow = async (folderId?: string) => { try { +<<<<<<< Updated upstream // Create the workflow and ensure it's associated with the active workspace and folder const id = createWorkflow({ +======= + const id = await createWorkflow({ +>>>>>>> Stashed changes workspaceId: activeWorkspaceId || undefined, - folderId: folderId || undefined, // Associate with folder if provided + folderId: folderId || undefined, }) - router.push(`/w/${id}`) } catch (error) { console.error('Error creating workflow:', error) @@ -149,7 +115,7 @@ export function Sidebar() { mode === 'collapsed' || (mode === 'hover' && ((!isHovered && !workspaceDropdownOpen) || isAnyModalOpen || !explicitMouseEnter)) - // Only show overlay effect when in hover mode and actually being hovered or dropdown is open + const showOverlay = mode === 'hover' && ((isHovered && !isAnyModalOpen && explicitMouseEnter) || workspaceDropdownOpen) @@ -159,8 +125,8 @@ export function Sidebar() { className={clsx( 'fixed inset-y-0 left-0 z-10 flex flex-col border-r bg-background transition-all duration-200 sm:flex', isCollapsed ? 'w-14' : 'w-60', - showOverlay ? 'shadow-lg' : '', - mode === 'hover' ? 'main-content-overlay' : '' + showOverlay && 'shadow-lg', + mode === 'hover' && 'main-content-overlay' )} onMouseEnter={() => { if (mode === 'hover' && !isAnyModalOpen) { @@ -173,12 +139,8 @@ export function Sidebar() { setIsHovered(false) } }} - style={{ - // When in hover mode and expanded, position above content without pushing it - position: showOverlay ? 'fixed' : 'fixed', - }} > - {/* Workspace Header - Fixed at top */} + {/* Workspace Header */}
- {/* Main navigation - Fixed at top below header */} - {/*
- - } - href="/w/1" - label="Home" - active={pathname === '/w/1'} - isCollapsed={isCollapsed} - /> - } - href="/w/templates" - label="Templates" - active={pathname === '/w/templates'} - isCollapsed={isCollapsed} - /> - } - href="/w/marketplace" - label="Marketplace" - active={pathname === '/w/marketplace'} - isCollapsed={isCollapsed} - /> - -
*/} - - {/* Scrollable Content Area - Contains Workflows and Logs/Settings */} + {/* Scrollable Content Area */}
{/* Workflows Section */}
- {/* Workflows Header with Create Menu */}
@@ -240,7 +174,7 @@ export function Sidebar() { />
- {/* Logs and Settings Navigation - Follows workflows */} + {/* Navigation Section */}
- {/* Push the bottom controls down when content is short */}
+ {/* Bottom Controls */} {isCollapsed ? (
- {/* Invite members button */} - {!isDevEnvironment && ( + {!IS_DEV && (
)} - {/* Help button */}
Help - {/* Sidebar control */} @@ -316,8 +247,7 @@ export function Sidebar() {
) : ( <> - {/* Invite members bar */} - {!isDevEnvironment && ( + {!IS_DEV && (
setShowInviteMembers(true)} @@ -329,10 +259,8 @@ export function Sidebar() {
)} - {/* Bottom buttons container */}
- {/* Sidebar control on left with tooltip */} @@ -340,7 +268,6 @@ export function Sidebar() { Toggle sidebar - {/* Help button on right with tooltip */}
)} + {/* Modals */} - {!isDevEnvironment && ( - - )} + {!IS_DEV && } ) } diff --git a/apps/sim/lib/email/utils.test.ts b/apps/sim/lib/email/utils.test.ts new file mode 100644 index 00000000000..f569d6c5868 --- /dev/null +++ b/apps/sim/lib/email/utils.test.ts @@ -0,0 +1,113 @@ +import { validateAndNormalizeEmail } from './utils' + +describe('validateAndNormalizeEmail', () => { + describe('valid emails', () => { + it.concurrent('should validate simple email addresses', () => { + const result = validateAndNormalizeEmail('test@example.com') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('test@example.com') + }) + + it.concurrent('should validate emails with subdomains', () => { + const result = validateAndNormalizeEmail('user@mail.example.com') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('user@mail.example.com') + }) + + it.concurrent('should validate emails with numbers and hyphens', () => { + const result = validateAndNormalizeEmail('user123@test-domain.co.uk') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('user123@test-domain.co.uk') + }) + + it.concurrent('should validate emails with special characters in local part', () => { + const result = validateAndNormalizeEmail('user.name+tag@example.com') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('user.name+tag@example.com') + }) + }) + + describe('invalid emails', () => { + it.concurrent('should reject emails without @ symbol', () => { + const result = validateAndNormalizeEmail('testexample.com') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('testexample.com') + }) + + it.concurrent('should reject emails without domain', () => { + const result = validateAndNormalizeEmail('test@') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('test@') + }) + + it.concurrent('should reject emails without local part', () => { + const result = validateAndNormalizeEmail('@example.com') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('@example.com') + }) + + it.concurrent('should reject emails without TLD', () => { + const result = validateAndNormalizeEmail('test@domain') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('test@domain') + }) + + it.concurrent('should reject empty strings', () => { + const result = validateAndNormalizeEmail('') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('') + }) + + it.concurrent('should reject emails with spaces', () => { + const result = validateAndNormalizeEmail('test @example.com') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('test @example.com') + }) + + it.concurrent('should reject emails with multiple @ symbols', () => { + const result = validateAndNormalizeEmail('test@@example.com') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('test@@example.com') + }) + }) + + describe('normalization', () => { + it.concurrent('should trim whitespace from email', () => { + const result = validateAndNormalizeEmail(' test@example.com ') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('test@example.com') + }) + + it.concurrent('should convert email to lowercase', () => { + const result = validateAndNormalizeEmail('Test.User@EXAMPLE.COM') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('test.user@example.com') + }) + + it.concurrent('should trim and convert to lowercase together', () => { + const result = validateAndNormalizeEmail(' Test.User@EXAMPLE.COM ') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('test.user@example.com') + }) + + it.concurrent('should normalize invalid emails as well', () => { + const result = validateAndNormalizeEmail(' INVALID EMAIL ') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('invalid email') + }) + }) + + describe('edge cases', () => { + it.concurrent('should handle only whitespace', () => { + const result = validateAndNormalizeEmail(' ') + expect(result.isValid).toBe(false) + expect(result.normalized).toBe('') + }) + + it.concurrent('should handle tab and newline characters', () => { + const result = validateAndNormalizeEmail('\t\ntest@example.com\t\n') + expect(result.isValid).toBe(true) + expect(result.normalized).toBe('test@example.com') + }) + }) +}) diff --git a/apps/sim/lib/email/utils.ts b/apps/sim/lib/email/utils.ts new file mode 100644 index 00000000000..8816f688b07 --- /dev/null +++ b/apps/sim/lib/email/utils.ts @@ -0,0 +1,10 @@ +export const validateAndNormalizeEmail = ( + email: string +): { isValid: boolean; normalized: string } => { + const normalized = email.trim().toLowerCase() + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return { + isValid: emailRegex.test(normalized), + normalized, + } +} From 7c797b4e5fbbec89cebfa8769702bf7f5da94e8e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 22 Jun 2025 19:55:51 -0700 Subject: [PATCH 2/6] added logger --- .../sidebar/components/invite-modal/invite-modal.tsx | 9 ++++++--- .../invite-modal/invites-sent/invites-sent.tsx | 5 ++++- apps/sim/app/w/components/sidebar/sidebar.tsx | 10 ++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx index b957d1dc26a..68bfc0aa238 100644 --- a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx @@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' import { validateAndNormalizeEmail } from '@/lib/email/utils' +import { createLogger } from '@/lib/logs/console-logger' import type { PermissionType } from '@/lib/permissions/utils' import { cn } from '@/lib/utils' import { useUserPermissions } from '@/hooks/use-user-permissions' @@ -19,6 +20,8 @@ import { import { API_ENDPOINTS } from '@/stores/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +const logger = createLogger('InviteModal') + interface InviteModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -392,7 +395,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { setPendingInvitations(workspacePendingInvitations) } } catch (error) { - console.error('Error fetching pending invitations:', error) + logger.error('Error fetching pending invitations:', error) } } @@ -523,7 +526,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { ) setTimeout(() => setSuccessMessage(null), 3000) } catch (error) { - console.error('Error saving permission changes:', error) + logger.error('Error saving permission changes:', error) const errorMsg = error instanceof Error ? error.message @@ -668,7 +671,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { }, 4000) } } catch (err) { - console.error('Error inviting members:', err) + logger.error('Error inviting members:', err) setErrorMessage('An unexpected error occurred. Please try again.') } finally { setIsSubmitting(false) diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx b/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx index 3d12be75687..dc04a9e719c 100644 --- a/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx +++ b/apps/sim/app/w/components/sidebar/components/invite-modal/invites-sent/invites-sent.tsx @@ -10,8 +10,11 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { createLogger } from '@/lib/logs/console-logger' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +const logger = createLogger('InvitesSent') + type InvitationStatus = 'pending' | 'accepted' | 'rejected' | 'expired' type Invitation = { @@ -70,7 +73,7 @@ export function InvitesSent() { ) setInvitations(filteredInvitations) } catch (err) { - console.error('Error fetching invitations:', err) + logger.error('Error fetching invitations:', err) const errorMessage = err instanceof Error ? err.message : 'Failed to load invitations' setError(errorMessage) } finally { diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index 76d3fcfc48b..4c44bb6f404 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter } from 'next/navigation' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useSession } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console-logger' import { getKeyboardShortcutText, useGlobalShortcuts } from '@/app/w/hooks/use-keyboard-shortcuts' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -21,6 +22,8 @@ import { SettingsModal } from './components/settings-modal/settings-modal' import { SidebarControl } from './components/sidebar-control/sidebar-control' import { WorkspaceHeader } from './components/workspace-header/workspace-header' +const logger = createLogger('Sidebar') + const IS_DEV = process.env.NODE_ENV === 'development' export function Sidebar() { @@ -93,18 +96,13 @@ export function Sidebar() { // Create workflow handler const handleCreateWorkflow = async (folderId?: string) => { try { -<<<<<<< Updated upstream - // Create the workflow and ensure it's associated with the active workspace and folder - const id = createWorkflow({ -======= const id = await createWorkflow({ ->>>>>>> Stashed changes workspaceId: activeWorkspaceId || undefined, folderId: folderId || undefined, }) router.push(`/w/${id}`) } catch (error) { - console.error('Error creating workflow:', error) + logger.error('Error creating workflow:', error) } } From 8de4fc53de27602ed3e2a9148f9bdb1e151dbdcb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 23 Jun 2025 10:15:55 -0700 Subject: [PATCH 3/6] added provider for workspace permissions, 85% reduction in API calls to get user permissions and improved performance for invitations --- .../app/api/workspaces/invitations/route.ts | 1 - .../components/control-bar/control-bar.tsx | 4 +- .../app/w/[id]/components/toolbar/toolbar.tsx | 113 +- .../workflow-block/workflow-block.tsx | 4 +- apps/sim/app/w/[id]/workflow.tsx | 134 +- .../app/w/components/providers/providers.tsx | 16 +- .../workspace-permissions-provider.tsx | 99 ++ .../components/invite-modal/invite-modal.tsx | 867 ++++++------ .../workspace-header/workspace-header.tsx | 1184 +++++++++-------- apps/sim/app/w/components/sidebar/sidebar.tsx | 2 +- apps/sim/hooks/use-user-permissions.ts | 35 +- 11 files changed, 1376 insertions(+), 1083 deletions(-) create mode 100644 apps/sim/app/w/components/providers/workspace-permissions-provider.tsx diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 6c7795f3549..0ae8c27b03a 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -22,7 +22,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('WorkspaceInvitationsAPI') const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null -// Define the permission type type PermissionType = (typeof permissionTypeEnum.enumValues)[number] // Get all invitations for the user's workspaces diff --git a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx index 0aaac0b6edc..8d60d1b28e0 100644 --- a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -40,7 +40,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console-logger' import { cn } from '@/lib/utils' -import { useUserPermissions } from '@/hooks/use-user-permissions' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { usePanelStore } from '@/stores/panel/store' @@ -109,7 +109,7 @@ export function ControlBar() { const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null // User permissions - use stable activeWorkspaceId from registry instead of deriving from currentWorkflow - const userPermissions = useUserPermissions(activeWorkspaceId) + const userPermissions = useUserPermissionsContext() // Debug mode state const { isDebugModeEnabled, toggleDebugMode } = useGeneralStore() diff --git a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx index 5a257b81b48..8d86f8407f5 100644 --- a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx @@ -1,14 +1,14 @@ 'use client' -import { useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { PanelLeftClose, PanelRight, Search } from 'lucide-react' import { useParams } from 'next/navigation' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import { getAllBlocks, getBlocksByCategory } from '@/blocks' import type { BlockCategory } from '@/blocks/types' -import { useUserPermissions } from '@/hooks/use-user-permissions' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { ToolbarBlock } from './components/toolbar-block/toolbar-block' @@ -16,25 +16,54 @@ import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block' import ParallelToolbarItem from './components/toolbar-parallel-block/toolbar-parallel-block' import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs' -export function Toolbar() { +interface ToolbarButtonProps { + onClick: () => void + className: string + children: React.ReactNode + tooltipContent: string + tooltipSide?: 'left' | 'right' | 'top' | 'bottom' +} + +const ToolbarButton = React.memo( + ({ onClick, className, children, tooltipContent, tooltipSide = 'right' }) => ( + + + + + {tooltipContent} + + ) +) + +ToolbarButton.displayName = 'ToolbarButton' + +export const Toolbar = React.memo(() => { const params = useParams() const workflowId = params?.id as string // Get the workspace ID from the workflow registry - const activeWorkspaceId = useWorkflowRegistry((state) => state.activeWorkspaceId) - const currentWorkflow = useWorkflowRegistry((state) => - workflowId ? state.workflows[workflowId] : null + const { activeWorkspaceId, workflows } = useWorkflowRegistry() + + const currentWorkflow = useMemo( + () => (workflowId ? workflows[workflowId] : null), + [workflowId, workflows] ) + const workspaceId = currentWorkflow?.workspaceId || activeWorkspaceId - const userPermissions = useUserPermissions(workspaceId) + const userPermissions = useUserPermissionsContext() const [activeTab, setActiveTab] = useState('blocks') const [searchQuery, setSearchQuery] = useState('') const { mode, isExpanded } = useSidebarStore() + // In hover mode, act as if sidebar is always collapsed for layout purposes - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' + const isSidebarCollapsed = useMemo( + () => (mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'), + [mode, isExpanded] + ) // State to track if toolbar is open - independent of sidebar state const [isToolbarOpen, setIsToolbarOpen] = useState(true) @@ -53,21 +82,34 @@ export function Toolbar() { }) }, [searchQuery, activeTab]) + const handleOpenToolbar = useCallback(() => { + setIsToolbarOpen(true) + }, []) + + const handleCloseToolbar = useCallback(() => { + setIsToolbarOpen(false) + }, []) + + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setSearchQuery(e.target.value) + }, []) + + const handleTabChange = useCallback((tab: BlockCategory) => { + setActiveTab(tab) + }, []) + // Show toolbar button when it's closed, regardless of sidebar state if (!isToolbarOpen) { return ( - - - - - Open Toolbar - + + + Open Toolbar + ) } @@ -83,7 +125,7 @@ export function Toolbar() { placeholder='Search...' className='rounded-md pl-9' value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={handleSearchChange} autoComplete='off' autoCorrect='off' autoCapitalize='off' @@ -94,7 +136,7 @@ export function Toolbar() { {!searchQuery && (
- +
)} @@ -115,20 +157,19 @@ export function Toolbar() {
- - - - - Close Toolbar - + + + Close Toolbar +
) -} +}) + +Toolbar.displayName = 'Toolbar' diff --git a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx b/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx index 4912c2e09df..c9e1de3b666 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/workflow-block.tsx @@ -7,8 +7,8 @@ import { Card } from '@/components/ui/card' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { parseCronToHumanReadable } from '@/lib/schedules/utils' import { cn, formatDateTime, validateName } from '@/lib/utils' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' -import { useUserPermissions } from '@/hooks/use-user-permissions' import { useExecutionStore } from '@/stores/execution/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' @@ -409,7 +409,7 @@ export function WorkflowBlock({ id, data }: NodeProps) { workflowId ? state.workflows[workflowId] : null ) const workspaceId = currentWorkflow?.workspaceId || null - const userPermissions = useUserPermissions(workspaceId) + const userPermissions = useUserPermissionsContext() return (
diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index 6b6d16da5bf..e5a492e05e1 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import ReactFlow, { Background, @@ -16,9 +16,8 @@ import { createLogger } from '@/lib/logs/console-logger' import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' import { NotificationList } from '@/app/w/[id]/components/notifications/notifications' import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import { getBlock } from '@/blocks' -import { useUserPermissions } from '@/hooks/use-user-permissions' -import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { useVariablesStore } from '@/stores/panel/variables/store' @@ -56,22 +55,36 @@ const nodeTypes: NodeTypes = { } const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge } -function WorkflowContent() { +interface SelectedEdgeInfo { + id: string + parentLoopId?: string + contextId?: string // Unique identifier combining edge ID and context +} + +interface BlockData { + id: string + type: string + position: { x: number; y: number } + distance: number +} + +const WorkflowContent = React.memo(() => { // State const [isWorkflowReady, setIsWorkflowReady] = useState(false) const { mode, isExpanded } = useSidebarStore() + // In hover mode, act as if sidebar is always collapsed for layout purposes - const isSidebarCollapsed = - mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' + const isSidebarCollapsed = useMemo( + () => (mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'), + [mode, isExpanded] + ) + // State for tracking node dragging const [draggedNodeId, setDraggedNodeId] = useState(null) const [potentialParentId, setPotentialParentId] = useState(null) // Enhanced edge selection with parent context and unique identifier - const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{ - id: string - parentLoopId?: string - contextId?: string // Unique identifier combining edge ID and context - } | null>(null) + const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) + // Hooks const params = useParams() const router = useRouter() @@ -89,18 +102,12 @@ function WorkflowContent() { updateWorkflow, duplicateWorkflow, } = useWorkflowRegistry() - const currentWorkflow = workflows[workflowId] - const workspaceId = currentWorkflow?.workspaceId - // Workspace permissions - only fetch if we have a workspace ID - const { - permissions: workspacePermissions, - loading: permissionsLoading, - error: permissionsError, - } = useWorkspacePermissions(workspaceId || '') + const currentWorkflow = useMemo(() => workflows[workflowId], [workflows, workflowId]) + const workspaceId = currentWorkflow?.workspaceId - // User permissions - get current user's specific permissions - const userPermissions = useUserPermissions(workspaceId || null) + // User permissions - get current user's specific permissions from context + const userPermissions = useUserPermissionsContext() // Store access const { @@ -123,30 +130,6 @@ function WorkflowContent() { const { isDebugModeEnabled } = useGeneralStore() const [dragStartParentId, setDragStartParentId] = useState(null) - // Log permissions when they load - useEffect(() => { - if (workspacePermissions) { - logger.info('Workspace permissions loaded in workflow', { - workspaceId, - userCount: workspacePermissions.total, - permissions: workspacePermissions.users.map((u) => ({ - email: u.email, - permissions: u.permissionType, - })), - }) - } - }, [workspacePermissions, workspaceId]) - - // Log permissions errors - useEffect(() => { - if (permissionsError) { - logger.error('Failed to load workspace permissions', { - workspaceId, - error: permissionsError, - }) - } - }, [permissionsError, workspaceId]) - // Helper function to update a node's parent with proper position calculation const updateNodeParent = useCallback( (nodeId: string, newParentId: string | null) => { @@ -204,22 +187,25 @@ function WorkflowContent() { const detectedOrientation = detectHandleOrientation(blocks) // Optimize spacing based on handle orientation - const orientationConfig = - detectedOrientation === 'vertical' - ? { - // Vertical handles: optimize for top-to-bottom flow - horizontalSpacing: 400, - verticalSpacing: 300, - startX: 200, - startY: 200, - } - : { - // Horizontal handles: optimize for left-to-right flow - horizontalSpacing: 600, - verticalSpacing: 200, - startX: 150, - startY: 300, - } + const orientationConfig = useMemo( + () => + detectedOrientation === 'vertical' + ? { + // Vertical handles: optimize for top-to-bottom flow + horizontalSpacing: 400, + verticalSpacing: 300, + startX: 200, + startY: 200, + } + : { + // Horizontal handles: optimize for left-to-right flow + horizontalSpacing: 600, + verticalSpacing: 200, + startX: 150, + startY: 300, + }, + [detectedOrientation] + ) applyAutoLayoutSmooth(blocks, edges, updateBlockPosition, fitView, resizeLoopNodesWrapper, { ...orientationConfig, @@ -313,7 +299,7 @@ function WorkflowContent() { // Handle drops const findClosestOutput = useCallback( - (newNodePosition: { x: number; y: number }) => { + (newNodePosition: { x: number; y: number }): BlockData | null => { const existingBlocks = Object.entries(blocks) .filter(([_, block]) => block.enabled) .map(([id, block]) => ({ @@ -327,7 +313,7 @@ function WorkflowContent() { })) .sort((a, b) => a.distance - b.distance) - return existingBlocks[0] ? existingBlocks[0] : null + return existingBlocks[0] || null }, [blocks] ) @@ -468,7 +454,15 @@ function WorkflowContent() { handleAddBlockFromToolbar as EventListener ) } - }, [project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle]) + }, [ + project, + blocks, + addBlock, + addEdge, + findClosestOutput, + determineSourceHandle, + userPermissions.canEdit, + ]) // Update the onDrop handler const onDrop = useCallback( @@ -1505,10 +1499,12 @@ function WorkflowContent() {
) -} +}) + +WorkflowContent.displayName = 'WorkflowContent' // Workflow wrapper -export default function Workflow() { +const Workflow = React.memo(() => { return ( @@ -1516,4 +1512,8 @@ export default function Workflow() { ) -} +}) + +Workflow.displayName = 'Workflow' + +export default Workflow diff --git a/apps/sim/app/w/components/providers/providers.tsx b/apps/sim/app/w/components/providers/providers.tsx index 86bb2e6c9b5..498e017263f 100644 --- a/apps/sim/app/w/components/providers/providers.tsx +++ b/apps/sim/app/w/components/providers/providers.tsx @@ -1,14 +1,24 @@ 'use client' +import React from 'react' import { TooltipProvider } from '@/components/ui/tooltip' import { ThemeProvider } from './theme-provider' +import { WorkspacePermissionsProvider } from './workspace-permissions-provider' -export default function Providers({ children }: { children: React.ReactNode }) { +interface ProvidersProps { + children: React.ReactNode +} + +const Providers = React.memo(({ children }) => { return ( - {children} + {children} ) -} +}) + +Providers.displayName = 'Providers' + +export default Providers diff --git a/apps/sim/app/w/components/providers/workspace-permissions-provider.tsx b/apps/sim/app/w/components/providers/workspace-permissions-provider.tsx new file mode 100644 index 00000000000..77b4dfbf5d8 --- /dev/null +++ b/apps/sim/app/w/components/providers/workspace-permissions-provider.tsx @@ -0,0 +1,99 @@ +'use client' + +import React, { createContext, useContext, useMemo } from 'react' +import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions' +import { + useWorkspacePermissions, + type WorkspacePermissions, +} from '@/hooks/use-workspace-permissions' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +interface WorkspacePermissionsContextType { + // Raw workspace permissions data + workspacePermissions: WorkspacePermissions | null + permissionsLoading: boolean + permissionsError: string | null + updatePermissions: (newPermissions: WorkspacePermissions) => void + + // Computed user permissions + userPermissions: WorkspaceUserPermissions +} + +const WorkspacePermissionsContext = createContext(null) + +interface WorkspacePermissionsProviderProps { + children: React.ReactNode +} + +const WorkspacePermissionsProvider = React.memo( + ({ children }) => { + const { activeWorkspaceId } = useWorkflowRegistry() + + // Fetch workspace permissions once + const { + permissions: workspacePermissions, + loading: permissionsLoading, + error: permissionsError, + updatePermissions, + } = useWorkspacePermissions(activeWorkspaceId) + + // Compute user permissions based on workspace permissions + const userPermissions = useUserPermissions( + workspacePermissions, + permissionsLoading, + permissionsError + ) + + const contextValue = useMemo( + () => ({ + workspacePermissions, + permissionsLoading, + permissionsError, + updatePermissions, + userPermissions, + }), + [ + workspacePermissions, + permissionsLoading, + permissionsError, + updatePermissions, + userPermissions, + ] + ) + + return ( + + {children} + + ) + } +) + +WorkspacePermissionsProvider.displayName = 'WorkspacePermissionsProvider' + +export { WorkspacePermissionsProvider } + +/** + * Hook to access workspace permissions context + * This replaces individual useWorkspacePermissions calls to avoid duplicate API requests + */ +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 + */ +export function useUserPermissionsContext(): WorkspaceUserPermissions { + const { userPermissions } = useWorkspacePermissionsContext() + return userPermissions +} diff --git a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx index 68bfc0aa238..fd28d8d910f 100644 --- a/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/w/components/sidebar/components/invite-modal/invite-modal.tsx @@ -1,6 +1,6 @@ 'use client' -import { type KeyboardEvent, useEffect, useState } from 'react' +import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { HelpCircle, Loader2, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -12,11 +12,11 @@ import { validateAndNormalizeEmail } from '@/lib/email/utils' import { createLogger } from '@/lib/logs/console-logger' import type { PermissionType } from '@/lib/permissions/utils' import { cn } from '@/lib/utils' -import { useUserPermissions } from '@/hooks/use-user-permissions' import { - useWorkspacePermissions, - type WorkspacePermissions, -} from '@/hooks/use-workspace-permissions' + useUserPermissionsContext, + useWorkspacePermissionsContext, +} from '@/app/w/components/providers/workspace-permissions-provider' +import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions' import { API_ENDPOINTS } from '@/stores/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -52,6 +52,7 @@ interface PermissionsTableProps { workspacePermissions: WorkspacePermissions | null permissionsLoading: boolean pendingInvitations: UserPermissions[] + isPendingInvitationsLoading: boolean } interface PendingInvitation { @@ -63,7 +64,7 @@ interface PendingInvitation { createdAt: string } -const EmailTag = ({ email, onRemove, disabled, isInvalid }: EmailTagProps) => ( +const EmailTag = React.memo(({ email, onRemove, disabled, isInvalid }) => (
@@ -79,51 +80,57 @@ const EmailTag = ({ email, onRemove, disabled, isInvalid }: EmailTagProps) => ( )}
-) +)) + +EmailTag.displayName = 'EmailTag' -const PermissionSelector = ({ - value, - onChange, - disabled = false, - className = '', -}: { +interface PermissionSelectorProps { value: PermissionType onChange: (value: PermissionType) => void disabled?: boolean className?: string -}) => { - const permissionOptions = [ - { value: 'read' as PermissionType, label: 'Read' }, - { value: 'write' as PermissionType, label: 'Write' }, - { value: 'admin' as PermissionType, label: 'Admin' }, - ] - - return ( -
- {permissionOptions.map((option, index) => ( - - ))} -
- ) } -const PermissionsTableSkeleton = () => ( +const PermissionSelector = React.memo( + ({ value, onChange, disabled = false, className = '' }) => { + const permissionOptions = useMemo( + () => [ + { value: 'read' as PermissionType, label: 'Read' }, + { value: 'write' as PermissionType, label: 'Write' }, + { value: 'admin' as PermissionType, label: 'Admin' }, + ], + [] + ) + + return ( +
+ {permissionOptions.map((option, index) => ( + + ))} +
+ ) + } +) + +PermissionSelector.displayName = 'PermissionSelector' + +const PermissionsTableSkeleton = React.memo(() => (

Member Permissions

@@ -165,187 +172,215 @@ const PermissionsTableSkeleton = () => (
-) +)) -const PermissionsTable = ({ - userPermissions, - onPermissionChange, - disabled, - existingUserPermissionChanges, - isSaving, - workspacePermissions, - permissionsLoading, - pendingInvitations, -}: PermissionsTableProps) => { - const { data: session } = useSession() - const { activeWorkspaceId } = useWorkflowRegistry() - const userPerms = useUserPermissions(activeWorkspaceId) +PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton' - // Show skeleton while loading permissions data - if (permissionsLoading || userPerms.isLoading) { - return +const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified'): string => { + switch (status) { + case 'sent': + return 'inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' + case 'member': + return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400' + case 'modified': + return 'inline-flex items-center rounded-md bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' + default: + return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300' } +} - if (userPermissions.length === 0 && !session?.user?.email && !workspacePermissions?.users?.length) - return null - - if (isSaving) { - return ( -
-

Member Permissions

-
-
-
- - Saving permission changes... -
-
-
-
-

- Please wait while we update the permissions. -

-
-
+const PermissionsTable = React.memo( + ({ + userPermissions, + onPermissionChange, + disabled, + existingUserPermissionChanges, + isSaving, + workspacePermissions, + permissionsLoading, + pendingInvitations, + isPendingInvitationsLoading, + }) => { + // Always call hooks first - before any conditional returns + const { data: session } = useSession() + const userPerms = useUserPermissionsContext() + + // All useMemo hooks must be called before any conditional returns + const existingUsers: UserPermissions[] = useMemo( + () => + workspacePermissions?.users?.map((user) => { + const changes = existingUserPermissionChanges[user.userId] || {} + const permissionType = user.permissionType || 'read' + + return { + userId: user.userId, + email: user.email, + permissionType: + changes.permissionType !== undefined ? changes.permissionType : permissionType, + isCurrentUser: user.email === session?.user?.email, + } + }) || [], + [workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email] ) - } - const existingUsers: UserPermissions[] = - workspacePermissions?.users?.map((user) => { - const changes = existingUserPermissionChanges[user.userId] || {} - const permissionType = user.permissionType || 'read' - - return { - userId: user.userId, - email: user.email, - permissionType: - changes.permissionType !== undefined ? changes.permissionType : permissionType, - isCurrentUser: user.email === session?.user?.email, - } - }) || [] + const currentUser: UserPermissions | null = useMemo( + () => + session?.user?.email + ? existingUsers.find((user) => user.isCurrentUser) || { + email: session.user.email, + permissionType: 'admin', + isCurrentUser: true, + } + : null, + [session?.user?.email, existingUsers] + ) - const currentUser: UserPermissions | null = session?.user?.email - ? existingUsers.find((user) => user.isCurrentUser) || { - email: session.user.email, - permissionType: 'admin', - isCurrentUser: true, - } - : null + const filteredExistingUsers = useMemo( + () => existingUsers.filter((user) => !user.isCurrentUser), + [existingUsers] + ) - const currentUserIsAdmin = userPerms.canAdmin - const filteredExistingUsers = existingUsers.filter((user) => !user.isCurrentUser) + const allUsers: UserPermissions[] = useMemo( + () => [ + ...(currentUser ? [currentUser] : []), + ...filteredExistingUsers, + ...userPermissions, + ...pendingInvitations, + ], + [currentUser, filteredExistingUsers, userPermissions, pendingInvitations] + ) - const allUsers: UserPermissions[] = [ - ...(currentUser ? [currentUser] : []), - ...filteredExistingUsers, - ...userPermissions, - ...pendingInvitations, - ] + // Now we can safely have conditional returns after all hooks are called + if (permissionsLoading || userPerms.isLoading || isPendingInvitationsLoading) { + return + } - return ( -
-
-

Member Permissions

- - - - - -
- {userPerms.isLoading || permissionsLoading ? ( -

Loading permissions...

- ) : !currentUserIsAdmin ? ( -

- Only administrators can invite new members and modify permissions. -

- ) : ( -
-

Admin grants all permissions automatically.

-
- )} + if ( + userPermissions.length === 0 && + !session?.user?.email && + !workspacePermissions?.users?.length + ) + return null + + if (isSaving) { + return ( +
+

Member Permissions

+
+
+
+ + Saving permission changes... +
- - -
-
- {allUsers.length > 0 && ( -
- {allUsers.map((user) => { - const isCurrentUser = user.isCurrentUser === true - const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email) - const isPendingInvitation = user.isPendingInvitation === true - const userIdentifier = user.userId || user.email - const hasChanges = existingUserPermissionChanges[userIdentifier] !== undefined - - const uniqueKey = user.userId - ? `existing-${user.userId}` - : isPendingInvitation - ? `pending-${user.email}` - : `new-${user.email}` - - return ( -
-
-
- {user.email} - {isPendingInvitation && ( - Sent - )} +
+
+

+ Please wait while we update the permissions. +

+
+
+ ) + } + + const currentUserIsAdmin = userPerms.canAdmin + + return ( +
+
+

Member Permissions

+ + + + + +
+ {userPerms.isLoading || permissionsLoading ? ( +

Loading permissions...

+ ) : !currentUserIsAdmin ? ( +

+ Only administrators can invite new members and modify permissions. +

+ ) : ( +
+

Admin grants all permissions automatically.

+
+ )} +
+
+
+
+
+ {allUsers.length > 0 && ( +
+ {allUsers.map((user) => { + const isCurrentUser = user.isCurrentUser === true + const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email) + const isPendingInvitation = user.isPendingInvitation === true + const userIdentifier = user.userId || user.email + const hasChanges = existingUserPermissionChanges[userIdentifier] !== undefined + + const uniqueKey = user.userId + ? `existing-${user.userId}` + : isPendingInvitation + ? `pending-${user.email}` + : `new-${user.email}` + + return ( +
+
+
+ + {user.email} + + {isPendingInvitation && ( + Sent + )} +
+
+ {isExistingUser && !isCurrentUser && ( + Member + )} + {hasChanges && ( + Modified + )} +
-
- {isExistingUser && !isCurrentUser && ( - Member - )} - {hasChanges && ( - Modified - )} +
+ + onPermissionChange(userIdentifier, newPermission) + } + disabled={ + disabled || + !currentUserIsAdmin || + isPendingInvitation || + (isCurrentUser && user.permissionType === 'admin') + } + className='w-auto' + />
-
- - onPermissionChange(userIdentifier, newPermission) - } - disabled={ - disabled || - !currentUserIsAdmin || - isPendingInvitation || - (isCurrentUser && user.permissionType === 'admin') - } - className='w-auto' - /> -
-
- ) - })} -
- )} + ) + })} +
+ )} +
-
- ) -} - -const getStatusBadgeStyles = (status: 'sent' | 'member' | 'modified') => { - switch (status) { - case 'sent': - return 'inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' - case 'member': - return 'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400' - case 'modified': - return 'inline-flex items-center rounded-md bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' - default: - return 'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300' + ) } -} +) + +PermissionsTable.displayName = 'PermissionsTable' export function InviteModal({ open, onOpenChange }: InviteModalProps) { const [inputValue, setInputValue] = useState('') @@ -353,6 +388,7 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const [invalidEmails, setInvalidEmails] = useState([]) const [userPermissions, setUserPermissions] = useState([]) const [pendingInvitations, setPendingInvitations] = useState([]) + const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false) const [existingUserPermissionChanges, setExistingUserPermissionChanges] = useState< Record> >({}) @@ -364,18 +400,19 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { const { activeWorkspaceId } = useWorkflowRegistry() const { data: session } = useSession() const { - permissions: workspacePermissions, - loading: permissionsLoading, + workspacePermissions, + permissionsLoading, updatePermissions, - } = useWorkspacePermissions(activeWorkspaceId) - const userPerms = useUserPermissions(activeWorkspaceId) + userPermissions: userPerms, + } = useWorkspacePermissionsContext() const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0 const hasNewInvites = emails.length > 0 || inputValue.trim() - const fetchPendingInvitations = async () => { + const fetchPendingInvitations = useCallback(async () => { if (!activeWorkspaceId) return + setIsPendingInvitationsLoading(true) try { const response = await fetch('/api/workspaces/invitations') if (response.ok) { @@ -396,100 +433,108 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } } catch (error) { logger.error('Error fetching pending invitations:', error) + } finally { + setIsPendingInvitationsLoading(false) } - } + }, [activeWorkspaceId]) useEffect(() => { if (open && activeWorkspaceId) { fetchPendingInvitations() } - }, [open, activeWorkspaceId]) + }, [open, fetchPendingInvitations]) useEffect(() => { setErrorMessage(null) }, [pendingInvitations, workspacePermissions]) - const addEmail = (email: string) => { - if (!email.trim()) return false - - const { isValid, normalized } = validateAndNormalizeEmail(email) + const addEmail = useCallback( + (email: string) => { + if (!email.trim()) return false - if (emails.includes(normalized) || invalidEmails.includes(normalized)) { - return false - } + const { isValid, normalized } = validateAndNormalizeEmail(email) - const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized) - if (hasPendingInvitation) { - setErrorMessage(`${normalized} already has a pending invitation`) - setInputValue('') - return false - } - - const isExistingMember = workspacePermissions?.users?.some((user) => user.email === normalized) - if (isExistingMember) { - setErrorMessage(`${normalized} is already a member of this workspace`) - setInputValue('') - return false - } - - if (session?.user?.email && session.user.email.toLowerCase() === normalized) { - setErrorMessage('You cannot invite yourself') - setInputValue('') - return false - } + if (emails.includes(normalized) || invalidEmails.includes(normalized)) { + return false + } - if (!isValid) { - setInvalidEmails([...invalidEmails, normalized]) - setInputValue('') - return false - } + const hasPendingInvitation = pendingInvitations.some((inv) => inv.email === normalized) + if (hasPendingInvitation) { + setErrorMessage(`${normalized} already has a pending invitation`) + setInputValue('') + return false + } - setErrorMessage(null) - setEmails([...emails, normalized]) + const isExistingMember = workspacePermissions?.users?.some( + (user) => user.email === normalized + ) + if (isExistingMember) { + setErrorMessage(`${normalized} is already a member of this workspace`) + setInputValue('') + return false + } - setUserPermissions((prev) => [ - ...prev, - { - email: normalized, - permissionType: 'read', - }, - ]) + if (session?.user?.email && session.user.email.toLowerCase() === normalized) { + setErrorMessage('You cannot invite yourself') + setInputValue('') + return false + } - setInputValue('') - return true - } + if (!isValid) { + setInvalidEmails((prev) => [...prev, normalized]) + setInputValue('') + return false + } - const removeEmail = (index: number) => { - const emailToRemove = emails[index] - const newEmails = [...emails] - newEmails.splice(index, 1) - setEmails(newEmails) + setErrorMessage(null) + setEmails((prev) => [...prev, normalized]) - setUserPermissions((prev) => prev.filter((user) => user.email !== emailToRemove)) - } + setUserPermissions((prev) => [ + ...prev, + { + email: normalized, + permissionType: 'read', + }, + ]) - const removeInvalidEmail = (index: number) => { - const newInvalidEmails = [...invalidEmails] - newInvalidEmails.splice(index, 1) - setInvalidEmails(newInvalidEmails) - } + setInputValue('') + return true + }, + [emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email] + ) - const handlePermissionChange = (identifier: string, permissionType: PermissionType) => { - const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier) + const removeEmail = useCallback( + (index: number) => { + const emailToRemove = emails[index] + setEmails((prev) => prev.filter((_, i) => i !== index)) + setUserPermissions((prev) => prev.filter((user) => user.email !== emailToRemove)) + }, + [emails] + ) - if (existingUser) { - setExistingUserPermissionChanges((prev) => ({ - ...prev, - [identifier]: { permissionType }, - })) - } else { - setUserPermissions((prev) => - prev.map((user) => (user.email === identifier ? { ...user, permissionType } : user)) - ) - } - } + const removeInvalidEmail = useCallback((index: number) => { + setInvalidEmails((prev) => prev.filter((_, i) => i !== index)) + }, []) + + const handlePermissionChange = useCallback( + (identifier: string, permissionType: PermissionType) => { + const existingUser = workspacePermissions?.users?.find((user) => user.userId === identifier) + + if (existingUser) { + setExistingUserPermissionChanges((prev) => ({ + ...prev, + [identifier]: { permissionType }, + })) + } else { + setUserPermissions((prev) => + prev.map((user) => (user.email === identifier ? { ...user, permissionType } : user)) + ) + } + }, + [workspacePermissions?.users] + ) - const handleSaveChanges = async () => { + const handleSaveChanges = useCallback(async () => { if (!userPerms.canAdmin || !hasPendingChanges || !activeWorkspaceId) return setIsSaving(true) @@ -535,162 +580,187 @@ export function InviteModal({ open, onOpenChange }: InviteModalProps) { } finally { setIsSaving(false) } - } + }, [ + userPerms.canAdmin, + hasPendingChanges, + activeWorkspaceId, + existingUserPermissionChanges, + updatePermissions, + ]) - const handleRestoreChanges = () => { + const handleRestoreChanges = useCallback(() => { if (!userPerms.canAdmin || !hasPendingChanges) return setExistingUserPermissionChanges({}) setSuccessMessage('Changes restored to original permissions!') setTimeout(() => setSuccessMessage(null), 3000) - } + }, [userPerms.canAdmin, hasPendingChanges]) - const handleKeyDown = (e: KeyboardEvent) => { - if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) { - e.preventDefault() - addEmail(inputValue) - } + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (['Enter', ',', ' '].includes(e.key) && inputValue.trim()) { + e.preventDefault() + addEmail(inputValue) + } - if (e.key === 'Backspace' && !inputValue) { - if (invalidEmails.length > 0) { - removeInvalidEmail(invalidEmails.length - 1) - } else if (emails.length > 0) { - removeEmail(emails.length - 1) + if (e.key === 'Backspace' && !inputValue) { + if (invalidEmails.length > 0) { + removeInvalidEmail(invalidEmails.length - 1) + } else if (emails.length > 0) { + removeEmail(emails.length - 1) + } } - } - } + }, + [inputValue, addEmail, invalidEmails, emails, removeInvalidEmail, removeEmail] + ) - const handlePaste = (e: React.ClipboardEvent) => { - e.preventDefault() - const pastedText = e.clipboardData.getData('text') - const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean) + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + e.preventDefault() + const pastedText = e.clipboardData.getData('text') + const pastedEmails = pastedText.split(/[\s,;]+/).filter(Boolean) + + let addedCount = 0 + pastedEmails.forEach((email) => { + if (addEmail(email)) { + addedCount++ + } + }) - let addedCount = 0 - pastedEmails.forEach((email) => { - if (addEmail(email)) { - addedCount++ + if (addedCount === 0 && pastedEmails.length === 1) { + setInputValue(inputValue + pastedEmails[0]) } - }) + }, + [addEmail, inputValue] + ) - if (addedCount === 0 && pastedEmails.length === 1) { - setInputValue(inputValue + pastedEmails[0]) - } - } + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + if (inputValue.trim()) { + addEmail(inputValue) + } - if (inputValue.trim()) { - addEmail(inputValue) - } + setErrorMessage(null) + setSuccessMessage(null) - setErrorMessage(null) - setSuccessMessage(null) - - if (emails.length === 0 || !activeWorkspaceId) { - return - } + if (emails.length === 0 || !activeWorkspaceId) { + return + } - setIsSubmitting(true) + setIsSubmitting(true) + + try { + const failedInvites: string[] = [] + + const results = await Promise.all( + emails.map(async (email) => { + try { + const userPermission = userPermissions.find((up) => up.email === email) + const permissionType = userPermission?.permissionType || 'read' + + const response = await fetch('/api/workspaces/invitations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + workspaceId: activeWorkspaceId, + email: email, + role: 'member', + permission: permissionType, + }), + }) + + const data = await response.json() + + if (!response.ok) { + if (!invalidEmails.includes(email)) { + failedInvites.push(email) + } + + if (data.error) { + setErrorMessage(data.error) + } + + return false + } - try { - const failedInvites: string[] = [] - - const results = await Promise.all( - emails.map(async (email) => { - try { - const userPermission = userPermissions.find((up) => up.email === email) - const permissionType = userPermission?.permissionType || 'read' - - const response = await fetch('/api/workspaces/invitations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workspaceId: activeWorkspaceId, - email: email, - role: 'member', - permission: permissionType, - }), - }) - - const data = await response.json() - - if (!response.ok) { + return true + } catch { if (!invalidEmails.includes(email)) { failedInvites.push(email) } - - if (data.error) { - setErrorMessage(data.error) - } - return false } + }) + ) + + const successCount = results.filter(Boolean).length + + if (successCount > 0) { + fetchPendingInvitations() + setInputValue('') + + if (failedInvites.length > 0) { + setEmails(failedInvites) + setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email))) + } else { + setEmails([]) + setUserPermissions([]) + setSuccessMessage( + successCount === 1 + ? 'Invitation sent successfully!' + : `${successCount} invitations sent successfully!` + ) - return true - } catch { - if (!invalidEmails.includes(email)) { - failedInvites.push(email) - } - return false + setTimeout(() => { + onOpenChange(false) + }, 1500) } - }) - ) - - const successCount = results.filter(Boolean).length - - if (successCount > 0) { - fetchPendingInvitations() - setInputValue('') - if (failedInvites.length > 0) { - setEmails(failedInvites) - setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email))) - } else { - setEmails([]) - setUserPermissions([]) - setSuccessMessage( - successCount === 1 - ? 'Invitation sent successfully!' - : `${successCount} invitations sent successfully!` - ) + setInvalidEmails([]) + setShowSent(true) setTimeout(() => { - onOpenChange(false) - }, 1500) + setShowSent(false) + }, 4000) } - - setInvalidEmails([]) - setShowSent(true) - - setTimeout(() => { - setShowSent(false) - }, 4000) + } catch (err) { + logger.error('Error inviting members:', err) + setErrorMessage('An unexpected error occurred. Please try again.') + } finally { + setIsSubmitting(false) } - } catch (err) { - logger.error('Error inviting members:', err) - setErrorMessage('An unexpected error occurred. Please try again.') - } finally { - setIsSubmitting(false) - } - } + }, + [ + inputValue, + addEmail, + emails, + activeWorkspaceId, + userPermissions, + invalidEmails, + fetchPendingInvitations, + onOpenChange, + ] + ) - const resetState = () => { + const resetState = useCallback(() => { setInputValue('') setEmails([]) setInvalidEmails([]) setUserPermissions([]) setPendingInvitations([]) + setIsPendingInvitationsLoading(false) setExistingUserPermissionChanges({}) setIsSubmitting(false) setIsSaving(false) setShowSent(false) setErrorMessage(null) setSuccessMessage(null) - } + }, []) return (
diff --git a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx index aac282e0dbd..4f9351d0154 100644 --- a/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { ChevronDown, Pencil, Trash2, X } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' @@ -29,7 +29,7 @@ import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { useSession } from '@/lib/auth-client' import { cn } from '@/lib/utils' -import { useUserPermissions } from '@/hooks/use-user-permissions' +import { useUserPermissionsContext } from '@/app/w/components/providers/workspace-permissions-provider' import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -53,78 +53,88 @@ interface WorkspaceModalProps { onCreateWorkspace: (name: string) => void } -function WorkspaceModal({ open, onOpenChange, onCreateWorkspace }: WorkspaceModalProps) { - const [workspaceName, setWorkspaceName] = useState('') +const WorkspaceModal = React.memo( + ({ open, onOpenChange, onCreateWorkspace }) => { + const [workspaceName, setWorkspaceName] = useState('') + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (workspaceName.trim()) { + onCreateWorkspace(workspaceName.trim()) + setWorkspaceName('') + onOpenChange(false) + } + }, + [workspaceName, onCreateWorkspace, onOpenChange] + ) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (workspaceName.trim()) { - onCreateWorkspace(workspaceName.trim()) - setWorkspaceName('') + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setWorkspaceName(e.target.value) + }, []) + + const handleClose = useCallback(() => { onOpenChange(false) - } - } + }, [onOpenChange]) - return ( - - - -
- Create New Workspace - -
-
- -
- -
-
- - setWorkspaceName(e.target.value)} - placeholder='Enter workspace name' - className='w-full' - autoFocus - /> -
-
- -
+ return ( + + + +
+ Create New Workspace +
- -
- -
- ) -} + + +
+
+
+
+ + +
+
+ +
+
+
+
+ +
+ ) + } +) + +WorkspaceModal.displayName = 'WorkspaceModal' // New WorkspaceEditModal component interface WorkspaceEditModalProps { @@ -134,529 +144,585 @@ interface WorkspaceEditModalProps { workspace: Workspace | null } -function WorkspaceEditModal({ - open, - onOpenChange, - onUpdateWorkspace, - workspace, -}: WorkspaceEditModalProps) { - const [workspaceName, setWorkspaceName] = useState('') - - useEffect(() => { - if (workspace && open) { - setWorkspaceName(workspace.name) - } - }, [workspace, open]) - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (workspace && workspaceName.trim()) { - onUpdateWorkspace(workspace.id, workspaceName.trim()) - setWorkspaceName('') +const WorkspaceEditModal = React.memo( + ({ open, onOpenChange, onUpdateWorkspace, workspace }) => { + const [workspaceName, setWorkspaceName] = useState('') + + useEffect(() => { + if (workspace && open) { + setWorkspaceName(workspace.name) + } + }, [workspace, open]) + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (workspace && workspaceName.trim()) { + onUpdateWorkspace(workspace.id, workspaceName.trim()) + setWorkspaceName('') + onOpenChange(false) + } + }, + [workspace, workspaceName, onUpdateWorkspace, onOpenChange] + ) + + const handleNameChange = useCallback((e: React.ChangeEvent) => { + setWorkspaceName(e.target.value) + }, []) + + const handleClose = useCallback(() => { onOpenChange(false) - } - } + }, [onOpenChange]) - return ( - - - -
- Edit Workspace - -
-
- -
-
-
-
- - setWorkspaceName(e.target.value)} - placeholder='Enter workspace name' - className='w-full' - autoFocus - /> -
-
- -
+ return ( + + + +
+ Edit Workspace +
- -
- -
- ) -} + + +
+
+
+
+ + +
+
+ +
+
+
+
+ + + ) + } +) + +WorkspaceEditModal.displayName = 'WorkspaceEditModal' + +export const WorkspaceHeader = React.memo( + ({ onCreateWorkflow, isCollapsed, onDropdownOpenChange }) => { + // Get sidebar store state to check current mode + const { mode, workspaceDropdownOpen, setAnyModalOpen } = useSidebarStore() + + // Keep local isOpen state in sync with the store (for internal component use) + const [isOpen, setIsOpen] = useState(workspaceDropdownOpen) + const { data: sessionData, isPending } = useSession() + const [plan, setPlan] = useState('Free Plan') + // Use client-side loading instead of isPending to avoid hydration mismatch + const [isClientLoading, setIsClientLoading] = useState(true) + const [workspaces, setWorkspaces] = useState([]) + const [activeWorkspace, setActiveWorkspace] = useState(null) + const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true) + const [isWorkspaceModalOpen, setIsWorkspaceModalOpen] = useState(false) + const [editingWorkspace, setEditingWorkspace] = useState(null) + const [isEditModalOpen, setIsEditModalOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const router = useRouter() + + // Get workflowRegistry state and actions + const { activeWorkspaceId, switchToWorkspace, setActiveWorkspaceId } = useWorkflowRegistry() + + // Get user permissions for the active workspace + const userPermissions = useUserPermissionsContext() + + const userName = useMemo( + () => sessionData?.user?.name || sessionData?.user?.email || 'User', + [sessionData?.user?.name, sessionData?.user?.email] + ) + + // Set isClientLoading to false after hydration + useEffect(() => { + setIsClientLoading(false) + }, []) + + const fetchSubscriptionStatus = useCallback(async (userId: string) => { + try { + const response = await fetch('/api/user/subscription') + const data = await response.json() + setPlan(data.isPro ? 'Pro Plan' : 'Free Plan') + } catch (err) { + console.error('Error fetching subscription status:', err) + } + }, []) -export function WorkspaceHeader({ - onCreateWorkflow, - isCollapsed, - onDropdownOpenChange, -}: WorkspaceHeaderProps) { - // Get sidebar store state to check current mode - const { mode, workspaceDropdownOpen, setAnyModalOpen } = useSidebarStore() - - // Keep local isOpen state in sync with the store (for internal component use) - const [isOpen, setIsOpen] = useState(workspaceDropdownOpen) - const { data: sessionData, isPending } = useSession() - const [plan, setPlan] = useState('Free Plan') - // Use client-side loading instead of isPending to avoid hydration mismatch - const [isClientLoading, setIsClientLoading] = useState(true) - const [workspaces, setWorkspaces] = useState([]) - const [activeWorkspace, setActiveWorkspace] = useState(null) - const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true) - const [isWorkspaceModalOpen, setIsWorkspaceModalOpen] = useState(false) - const [editingWorkspace, setEditingWorkspace] = useState(null) - const [isEditModalOpen, setIsEditModalOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const router = useRouter() - - // Get workflowRegistry state and actions - const { activeWorkspaceId, switchToWorkspace, setActiveWorkspaceId } = useWorkflowRegistry() - - // Get user permissions for the active workspace - const userPermissions = useUserPermissions(activeWorkspace?.id || '') - - const userName = sessionData?.user?.name || sessionData?.user?.email || 'User' - - // Set isClientLoading to false after hydration - useEffect(() => { - setIsClientLoading(false) - }, []) - - useEffect(() => { - // Fetch subscription status if user is logged in - if (sessionData?.user?.id) { - fetch('/api/user/subscription') - .then((res) => res.json()) - .then((data) => { - setPlan(data.isPro ? 'Pro Plan' : 'Free Plan') - }) - .catch((err) => { - console.error('Error fetching subscription status:', err) - }) - - // Fetch user's workspaces + const fetchWorkspaces = useCallback(async () => { setIsWorkspacesLoading(true) - fetch('/api/workspaces') - .then((res) => res.json()) - .then((data) => { - if (data.workspaces && Array.isArray(data.workspaces)) { - const fetchedWorkspaces = data.workspaces as Workspace[] - setWorkspaces(fetchedWorkspaces) - - // Only update workspace if we have a valid activeWorkspaceId from registry - if (activeWorkspaceId) { - const matchingWorkspace = fetchedWorkspaces.find( - (workspace) => workspace.id === activeWorkspaceId - ) - if (matchingWorkspace) { - setActiveWorkspace(matchingWorkspace) - } else { - // Active workspace not found, fallback to first workspace - const fallbackWorkspace = fetchedWorkspaces[0] - if (fallbackWorkspace) { - setActiveWorkspace(fallbackWorkspace) - setActiveWorkspaceId(fallbackWorkspace.id) - } + try { + const response = await fetch('/api/workspaces') + const data = await response.json() + + if (data.workspaces && Array.isArray(data.workspaces)) { + const fetchedWorkspaces = data.workspaces as Workspace[] + setWorkspaces(fetchedWorkspaces) + + // Only update workspace if we have a valid activeWorkspaceId from registry + if (activeWorkspaceId) { + const matchingWorkspace = fetchedWorkspaces.find( + (workspace) => workspace.id === activeWorkspaceId + ) + if (matchingWorkspace) { + setActiveWorkspace(matchingWorkspace) + } else { + // Active workspace not found, fallback to first workspace + const fallbackWorkspace = fetchedWorkspaces[0] + if (fallbackWorkspace) { + setActiveWorkspace(fallbackWorkspace) + setActiveWorkspaceId(fallbackWorkspace.id) } } - // If no activeWorkspaceId, let loadWorkspaceFromWorkflowId handle workspace selection } - setIsWorkspacesLoading(false) - }) - .catch((err) => { - console.error('Error fetching workspaces:', err) - setIsWorkspacesLoading(false) - }) - } - }, [sessionData?.user?.id, activeWorkspaceId, setActiveWorkspaceId]) - - const switchWorkspace = (workspace: Workspace) => { - // If already on this workspace, do nothing - if (activeWorkspace?.id === workspace.id) { - setIsOpen(false) - return - } - - setActiveWorkspace(workspace) - setIsOpen(false) - - // Use full workspace switch which now handles localStorage automatically - switchToWorkspace(workspace.id) - - // Update URL to include workspace ID - router.push(`/w/${workspace.id}`) - } - - const handleCreateWorkspace = (name: string) => { - setIsWorkspacesLoading(true) - - fetch('/api/workspaces', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - }) - .then((res) => res.json()) - .then((data) => { - if (data.workspace) { - const newWorkspace = data.workspace as Workspace - setWorkspaces((prev) => [...prev, newWorkspace]) - setActiveWorkspace(newWorkspace) - - // Use switchToWorkspace to properly load workflows for the new workspace - // This will clear existing workflows, set loading state, and fetch workflows from DB - switchToWorkspace(newWorkspace.id) - - // Update URL to include new workspace ID - router.push(`/w/${newWorkspace.id}`) + // If no activeWorkspaceId, let loadWorkspaceFromWorkflowId handle workspace selection } + } catch (err) { + console.error('Error fetching workspaces:', err) + } finally { setIsWorkspacesLoading(false) - }) - .catch((err) => { - console.error('Error creating workspace:', err) - setIsWorkspacesLoading(false) - }) - } - - const handleUpdateWorkspace = async (id: string, name: string) => { - // For update operations, we need to check permissions for the specific workspace - // Since we can only use hooks at the component level, we'll make the API call - // and let the backend handle the permission check - setIsWorkspacesLoading(true) - - try { - const response = await fetch(`/api/workspaces/${id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name }), - }) - - if (!response.ok) { - if (response.status === 403) { - console.error( - 'Permission denied: Only users with admin permissions can update workspaces' - ) - } - throw new Error('Failed to update workspace') } + }, [activeWorkspaceId, setActiveWorkspaceId]) - const { workspace: updatedWorkspace } = await response.json() + useEffect(() => { + // Fetch subscription status if user is logged in + if (sessionData?.user?.id) { + fetchSubscriptionStatus(sessionData.user.id) + fetchWorkspaces() + } + }, [sessionData?.user?.id, fetchSubscriptionStatus, fetchWorkspaces]) + + const switchWorkspace = useCallback( + (workspace: Workspace) => { + // If already on this workspace, do nothing + if (activeWorkspace?.id === workspace.id) { + setIsOpen(false) + return + } - // Update workspaces list - setWorkspaces((prevWorkspaces) => - prevWorkspaces.map((w) => - w.id === updatedWorkspace.id ? { ...w, name: updatedWorkspace.name } : w - ) - ) + setActiveWorkspace(workspace) + setIsOpen(false) - // If active workspace was updated, update it too - if (activeWorkspace?.id === updatedWorkspace.id) { - setActiveWorkspace({ ...activeWorkspace, name: updatedWorkspace.name } as Workspace) - } - } catch (err) { - console.error('Error updating workspace:', err) - } finally { - setIsWorkspacesLoading(false) - } - } + // Use full workspace switch which now handles localStorage automatically + switchToWorkspace(workspace.id) - const handleDeleteWorkspace = async (id: string) => { - // For delete operations, we need to check permissions for the specific workspace - // Since we can only use hooks at the component level, we'll make the API call - // and let the backend handle the permission check - setIsDeleting(true) - - try { - const response = await fetch(`/api/workspaces/${id}`, { - method: 'DELETE', - }) - - if (!response.ok) { - if (response.status === 403) { - console.error( - 'Permission denied: Only users with admin permissions can delete workspaces' - ) + // Update URL to include workspace ID + router.push(`/w/${workspace.id}`) + }, + [activeWorkspace?.id, switchToWorkspace, router] + ) + + const handleCreateWorkspace = useCallback( + async (name: string) => { + setIsWorkspacesLoading(true) + + try { + const response = await fetch('/api/workspaces', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }) + + const data = await response.json() + + if (data.workspace) { + const newWorkspace = data.workspace as Workspace + setWorkspaces((prev) => [...prev, newWorkspace]) + setActiveWorkspace(newWorkspace) + + // Use switchToWorkspace to properly load workflows for the new workspace + // This will clear existing workflows, set loading state, and fetch workflows from DB + switchToWorkspace(newWorkspace.id) + + // Update URL to include new workspace ID + router.push(`/w/${newWorkspace.id}`) + } + } catch (err) { + console.error('Error creating workspace:', err) + } finally { + setIsWorkspacesLoading(false) } - throw new Error('Failed to delete workspace') - } - - // Remove from workspace list - const updatedWorkspaces = workspaces.filter((w) => w.id !== id) - setWorkspaces(updatedWorkspaces) + }, + [switchToWorkspace, router] + ) + + const handleUpdateWorkspace = useCallback( + async (id: string, name: string) => { + // For update operations, we need to check permissions for the specific workspace + // Since we can only use hooks at the component level, we'll make the API call + // and let the backend handle the permission check + setIsWorkspacesLoading(true) + + try { + const response = await fetch(`/api/workspaces/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }) + + if (!response.ok) { + if (response.status === 403) { + console.error( + 'Permission denied: Only users with admin permissions can update workspaces' + ) + } + throw new Error('Failed to update workspace') + } - // If deleted workspace was active, switch to another workspace - if (activeWorkspace?.id === id && updatedWorkspaces.length > 0) { - // Use the specialized method for handling workspace deletion - const newWorkspaceId = updatedWorkspaces[0].id - useWorkflowRegistry.getState().handleWorkspaceDeletion(newWorkspaceId) - setActiveWorkspace(updatedWorkspaces[0]) - } + const { workspace: updatedWorkspace } = await response.json() - setIsOpen(false) - } catch (err) { - console.error('Error deleting workspace:', err) - } finally { - setIsDeleting(false) - } - } + // Update workspaces list + setWorkspaces((prevWorkspaces) => + prevWorkspaces.map((w) => + w.id === updatedWorkspace.id ? { ...w, name: updatedWorkspace.name } : w + ) + ) - const openEditModal = (workspace: Workspace, e: React.MouseEvent) => { - e.stopPropagation() - // Only show edit/delete options for the active workspace if user has admin permissions - if (activeWorkspace?.id !== workspace.id || !userPermissions.canAdmin) { - return - } - setEditingWorkspace(workspace) - setIsEditModalOpen(true) - } + // If active workspace was updated, update it too + if (activeWorkspace && activeWorkspace.id === updatedWorkspace.id) { + setActiveWorkspace({ + ...activeWorkspace, + name: updatedWorkspace.name, + }) + } + } catch (err) { + console.error('Error updating workspace:', err) + } finally { + setIsWorkspacesLoading(false) + } + }, + [activeWorkspace] + ) + + const handleDeleteWorkspace = useCallback( + async (id: string) => { + // For delete operations, we need to check permissions for the specific workspace + // Since we can only use hooks at the component level, we'll make the API call + // and let the backend handle the permission check + setIsDeleting(true) + + try { + const response = await fetch(`/api/workspaces/${id}`, { + method: 'DELETE', + }) + + if (!response.ok) { + if (response.status === 403) { + console.error( + 'Permission denied: Only users with admin permissions can delete workspaces' + ) + } + throw new Error('Failed to delete workspace') + } - // Determine URL for workspace links - const workspaceUrl = activeWorkspace ? `/w/${activeWorkspace.id}` : '/w' + // Remove from workspace list + const updatedWorkspaces = workspaces.filter((w) => w.id !== id) + setWorkspaces(updatedWorkspaces) - // Notify parent component when dropdown opens/closes - const handleDropdownOpenChange = (open: boolean) => { - setIsOpen(open) - // Inform the parent component about the dropdown state change - if (onDropdownOpenChange) { - onDropdownOpenChange(open) - } - } + // If deleted workspace was active, switch to another workspace + if (activeWorkspace?.id === id && updatedWorkspaces.length > 0) { + // Use the specialized method for handling workspace deletion + const newWorkspaceId = updatedWorkspaces[0].id + useWorkflowRegistry.getState().handleWorkspaceDeletion(newWorkspaceId) + setActiveWorkspace(updatedWorkspaces[0]) + } - // Special handling for click interactions in hover mode - const handleTriggerClick = (e: React.MouseEvent) => { - // When in hover mode, explicitly prevent bubbling for the trigger - if (mode === 'hover') { - e.stopPropagation() - e.preventDefault() - // Toggle dropdown state - handleDropdownOpenChange(!isOpen) - } - } + setIsOpen(false) + } catch (err) { + console.error('Error deleting workspace:', err) + } finally { + setIsDeleting(false) + } + }, + [workspaces, activeWorkspace?.id] + ) + + const openEditModal = useCallback( + (workspace: Workspace, e: React.MouseEvent) => { + e.stopPropagation() + // Only show edit/delete options for the active workspace if user has admin permissions + if (activeWorkspace?.id !== workspace.id || !userPermissions.canAdmin) { + return + } + setEditingWorkspace(workspace) + setIsEditModalOpen(true) + }, + [activeWorkspace?.id, userPermissions.canAdmin] + ) + + // Determine URL for workspace links + const workspaceUrl = useMemo( + () => (activeWorkspace ? `/w/${activeWorkspace.id}` : '/w'), + [activeWorkspace] + ) + + // Notify parent component when dropdown opens/closes + const handleDropdownOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open) + // Inform the parent component about the dropdown state change + if (onDropdownOpenChange) { + onDropdownOpenChange(open) + } + }, + [onDropdownOpenChange] + ) + + // Special handling for click interactions in hover mode + const handleTriggerClick = useCallback( + (e: React.MouseEvent) => { + // When in hover mode, explicitly prevent bubbling for the trigger + if (mode === 'hover') { + e.stopPropagation() + e.preventDefault() + // Toggle dropdown state + handleDropdownOpenChange(!isOpen) + } + }, + [mode, isOpen, handleDropdownOpenChange] + ) + + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + // In hover mode, prevent clicks on the container from collapsing the sidebar + if (mode === 'hover') { + e.stopPropagation() + } + }, + [mode] + ) + + const handleWorkspaceModalOpenChange = useCallback((open: boolean) => { + setIsWorkspaceModalOpen(open) + }, []) + + const handleEditModalOpenChange = useCallback((open: boolean) => { + setIsEditModalOpen(open) + }, []) + + // Handle modal open/close state + useEffect(() => { + // Update the modal state in the store + setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting) + }, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen]) + + return ( +
+ {/* Workspace Modal */} + + + {/* Edit Workspace Modal */} + + + +
+ {/* Hover background with consistent padding - only when not collapsed */} + {!isCollapsed && ( +
+ )} - // Handle modal open/close state - useEffect(() => { - // Update the modal state in the store - setAnyModalOpen(isWorkspaceModalOpen || isEditModalOpen || isDeleting) - }, [isWorkspaceModalOpen, isEditModalOpen, isDeleting, setAnyModalOpen]) - - return ( -
- {/* Workspace Modal */} - - - {/* Edit Workspace Modal */} - - - -
{ - // In hover mode, prevent clicks on the container from collapsing the sidebar - if (mode === 'hover') { - e.stopPropagation() - } - }} - > - {/* Hover background with consistent padding - only when not collapsed */} - {!isCollapsed &&
} - - {/* Content with consistent padding */} - {isCollapsed ? ( -
- - - -
- ) : ( -
- -
+ -
- { - if (isOpen) e.preventDefault() - }} - > - - + + +
+ ) : ( +
+ +
+
+ { + if (isOpen) e.preventDefault() + }} + > + + + {isClientLoading || isWorkspacesLoading ? ( + + ) : ( +
+ + {activeWorkspace?.name || `${userName}'s Workspace`} + + +
+ )} +
+
+
+
+ )} +
+ +
+
+
+
+ +
+
{isClientLoading || isWorkspacesLoading ? ( - + <> + + + ) : ( -
- + <> + {activeWorkspace?.name || `${userName}'s Workspace`} - -
+ {plan} + )}
- -
- )} -
- -
-
-
-
- -
-
- {isClientLoading || isWorkspacesLoading ? ( - <> - - - - ) : ( - <> - - {activeWorkspace?.name || `${userName}'s Workspace`} - - {plan} - - )} -
-
- + - {/* Workspaces list */} -
-
Workspaces
- {isWorkspacesLoading ? ( -
- -
- ) : ( -
- {workspaces.map((workspace) => ( - switchWorkspace(workspace)} - > - {workspace.name} - {userPermissions.canAdmin && activeWorkspace?.id === workspace.id && ( -
- - - - - - - - - Delete Workspace - - Are you sure you want to delete "{workspace.name}"? This action - cannot be undone. - - - - e.stopPropagation()}> - Cancel - - { - e.stopPropagation() - handleDeleteWorkspace(workspace.id) - }} - className='bg-destructive text-destructive-foreground hover:bg-destructive/90' + {/* Workspaces list */} +
+
Workspaces
+ {isWorkspacesLoading ? ( +
+ +
+ ) : ( +
+ {workspaces.map((workspace) => ( + switchWorkspace(workspace)} + > + {workspace.name} + {userPermissions.canAdmin && activeWorkspace?.id === workspace.id && ( +
+ + + + +
- )} -
- ))} -
- )} + + Delete + + + + + Delete Workspace + + Are you sure you want to delete "{workspace.name}"? This action + cannot be undone. + + + + e.stopPropagation()}> + Cancel + + { + e.stopPropagation() + handleDeleteWorkspace(workspace.id) + }} + className='bg-destructive text-destructive-foreground hover:bg-destructive/90' + > + Delete + + + + +
+ )} + + ))} +
+ )} - {/* Create new workspace button */} - setIsWorkspaceModalOpen(true)} - > - + New workspace - -
- - -
- ) -} + {/* Create new workspace button */} + setIsWorkspaceModalOpen(true)} + > + + New workspace + +
+ + +
+ ) + } +) + +WorkspaceHeader.displayName = 'WorkspaceHeader' diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index 4c44bb6f404..fb9b7099a08 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -24,7 +24,7 @@ import { WorkspaceHeader } from './components/workspace-header/workspace-header' const logger = createLogger('Sidebar') -const IS_DEV = process.env.NODE_ENV === 'development' +const IS_DEV = false export function Sidebar() { useRegistryLoading() diff --git a/apps/sim/hooks/use-user-permissions.ts b/apps/sim/hooks/use-user-permissions.ts index da8ec1e444e..030f1f7dd80 100644 --- a/apps/sim/hooks/use-user-permissions.ts +++ b/apps/sim/hooks/use-user-permissions.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console-logger' -import { type PermissionType, useWorkspacePermissions } from './use-workspace-permissions' +import type { PermissionType, WorkspacePermissions } from './use-workspace-permissions' const logger = createLogger('useUserPermissions') @@ -19,37 +19,44 @@ export interface WorkspaceUserPermissions { /** * Custom hook to check current user's permissions within a workspace + * This version accepts workspace permissions to avoid duplicate API calls * - * @param workspaceId - The workspace ID to check permissions for + * @param workspacePermissions - The workspace permissions data + * @param permissionsLoading - Whether permissions are currently loading + * @param permissionsError - Any error from fetching permissions * @returns Object containing permission flags and utility properties */ -export function useUserPermissions(workspaceId: string | null): WorkspaceUserPermissions { +export function useUserPermissions( + workspacePermissions: WorkspacePermissions | null, + permissionsLoading = false, + permissionsError: string | null = null +): WorkspaceUserPermissions { const { data: session } = useSession() - const { permissions, loading, error } = useWorkspacePermissions(workspaceId) const userPermissions = useMemo((): WorkspaceUserPermissions => { // If still loading or no session, return safe defaults - if (loading || !session?.user?.email) { + if (permissionsLoading || !session?.user?.email) { return { canRead: false, canEdit: false, canAdmin: false, userPermissions: 'read', - isLoading: loading, - error, + isLoading: permissionsLoading, + error: permissionsError, } } // Find current user in workspace permissions - const currentUser = permissions?.users?.find((user) => user.email === session.user.email) + const currentUser = workspacePermissions?.users?.find( + (user) => user.email === session.user.email + ) // If user not found in workspace, they have no permissions if (!currentUser) { logger.warn('User not found in workspace permissions', { userEmail: session.user.email, - workspaceId, - hasPermissions: !!permissions, - userCount: permissions?.users?.length || 0, + hasPermissions: !!workspacePermissions, + userCount: workspacePermissions?.users?.length || 0, }) return { @@ -58,7 +65,7 @@ export function useUserPermissions(workspaceId: string | null): WorkspaceUserPer canAdmin: false, userPermissions: 'read', isLoading: false, - error: error || 'User not found in workspace', + error: permissionsError || 'User not found in workspace', } } @@ -75,9 +82,9 @@ export function useUserPermissions(workspaceId: string | null): WorkspaceUserPer canAdmin, userPermissions: userPerms, isLoading: false, - error, + error: permissionsError, } - }, [session, permissions, loading, error, workspaceId]) + }, [session, workspacePermissions, permissionsLoading, permissionsError]) return userPermissions } From 73840df13b9f99e98fdd41904916e650cc68db06 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 23 Jun 2025 11:28:34 -0700 Subject: [PATCH 4/6] ack PR comments --- apps/sim/app/w/[id]/workflow.tsx | 35 +++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index e5a492e05e1..2dae7a2b38f 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -187,25 +187,22 @@ const WorkflowContent = React.memo(() => { const detectedOrientation = detectHandleOrientation(blocks) // Optimize spacing based on handle orientation - const orientationConfig = useMemo( - () => - detectedOrientation === 'vertical' - ? { - // Vertical handles: optimize for top-to-bottom flow - horizontalSpacing: 400, - verticalSpacing: 300, - startX: 200, - startY: 200, - } - : { - // Horizontal handles: optimize for left-to-right flow - horizontalSpacing: 600, - verticalSpacing: 200, - startX: 150, - startY: 300, - }, - [detectedOrientation] - ) + const orientationConfig = + detectedOrientation === 'vertical' + ? { + // Vertical handles: optimize for top-to-bottom flow + horizontalSpacing: 400, + verticalSpacing: 300, + startX: 200, + startY: 200, + } + : { + // Horizontal handles: optimize for left-to-right flow + horizontalSpacing: 600, + verticalSpacing: 200, + startX: 150, + startY: 300, + } applyAutoLayoutSmooth(blocks, edges, updateBlockPosition, fitView, resizeLoopNodesWrapper, { ...orientationConfig, From c5c6d116135c140e84f08217a8d0bdb8f05338a8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 23 Jun 2025 11:50:00 -0700 Subject: [PATCH 5/6] cleanup --- .../api/workflows/[id]/deploy/route.test.ts | 57 --- .../app/api/workflows/[id]/variables/route.ts | 1 - .../api/workspaces/[id]/permissions/route.ts | 29 +- apps/sim/app/api/workspaces/members/route.ts | 41 +- apps/sim/lib/permissions/utils.test.ts | 427 ++++++++++++++++-- apps/sim/lib/permissions/utils.ts | 57 ++- 6 files changed, 435 insertions(+), 177 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.test.ts b/apps/sim/app/api/workflows/[id]/deploy/route.test.ts index a78ddee6034..88f6640ab02 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.test.ts @@ -10,22 +10,18 @@ describe('Workflow Deployment API Route', () => { beforeEach(() => { vi.resetModules() - // Mock utils vi.doMock('@/lib/utils', () => ({ generateApiKey: vi.fn().mockReturnValue('sim_testkeygenerated12345'), })) - // Mock UUID generation vi.doMock('uuid', () => ({ v4: vi.fn().mockReturnValue('mock-uuid-1234'), })) - // Mock crypto for request ID vi.stubGlobal('crypto', { randomUUID: vi.fn().mockReturnValue('mock-request-id'), }) - // Mock logger vi.doMock('@/lib/logs/console-logger', () => ({ createLogger: vi.fn().mockReturnValue({ debug: vi.fn(), @@ -35,7 +31,6 @@ describe('Workflow Deployment API Route', () => { }), })) - // Mock the middleware to pass validation vi.doMock('../../middleware', () => ({ validateWorkflowAccess: vi.fn().mockResolvedValue({ workflow: { @@ -45,7 +40,6 @@ describe('Workflow Deployment API Route', () => { }), })) - // Mock the response utils vi.doMock('../../utils', () => ({ createSuccessResponse: vi.fn().mockImplementation((data) => { return new Response(JSON.stringify(data), { @@ -70,7 +64,6 @@ describe('Workflow Deployment API Route', () => { * Test GET deployment status */ it('should fetch deployment info successfully', async () => { - // Mock the database with proper workflow data vi.doMock('@/db', () => ({ db: { select: vi.fn().mockReturnValue({ @@ -89,25 +82,18 @@ describe('Workflow Deployment API Route', () => { }, })) - // Create a mock request const req = createMockRequest('GET') - // Create params similar to what Next.js would provide const params = Promise.resolve({ id: 'workflow-id' }) - // Import the handler after mocks are set up const { GET } = await import('./route') - // Call the handler const response = await GET(req, { params }) - // Check response expect(response.status).toBe(200) - // Parse the response body const data = await response.json() - // Verify response structure expect(data).toHaveProperty('isDeployed', false) expect(data).toHaveProperty('apiKey', null) expect(data).toHaveProperty('deployedAt', null) @@ -118,7 +104,6 @@ describe('Workflow Deployment API Route', () => { * This should generate a new API key */ it('should create new API key when deploying workflow for user with no API key', async () => { - // Mock DB for this test const mockInsert = vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue(undefined), }) @@ -156,30 +141,22 @@ describe('Workflow Deployment API Route', () => { }, })) - // Create a mock request const req = createMockRequest('POST') - // Create params const params = Promise.resolve({ id: 'workflow-id' }) - // Import required modules after mocks are set up const { POST } = await import('./route') - // Call the handler const response = await POST(req, { params }) - // Check response expect(response.status).toBe(200) - // Parse the response body const data = await response.json() - // Verify API key was generated expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345') expect(data).toHaveProperty('isDeployed', true) expect(data).toHaveProperty('deployedAt') - // Verify database calls expect(mockInsert).toHaveBeenCalled() expect(mockUpdate).toHaveBeenCalled() }) @@ -189,7 +166,6 @@ describe('Workflow Deployment API Route', () => { * This should use the existing API key */ it('should use existing API key when deploying workflow', async () => { - // Mock DB for this test const mockInsert = vi.fn() const mockUpdate = vi.fn().mockReturnValue({ @@ -229,29 +205,21 @@ describe('Workflow Deployment API Route', () => { }, })) - // Create a mock request const req = createMockRequest('POST') - // Create params const params = Promise.resolve({ id: 'workflow-id' }) - // Import required modules after mocks are set up const { POST } = await import('./route') - // Call the handler const response = await POST(req, { params }) - // Check response expect(response.status).toBe(200) - // Parse the response body const data = await response.json() - // Verify existing API key was used expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345') expect(data).toHaveProperty('isDeployed', true) - // Verify database calls - should NOT have inserted a new API key expect(mockInsert).not.toHaveBeenCalled() expect(mockUpdate).toHaveBeenCalled() }) @@ -260,7 +228,6 @@ describe('Workflow Deployment API Route', () => { * Test DELETE undeployment */ it('should undeploy workflow successfully', async () => { - // Mock the DB for this test const mockUpdate = vi.fn().mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]), @@ -273,30 +240,22 @@ describe('Workflow Deployment API Route', () => { }, })) - // Create a mock request const req = createMockRequest('DELETE') - // Create params const params = Promise.resolve({ id: 'workflow-id' }) - // Import the handler after mocks are set up const { DELETE } = await import('./route') - // Call the handler const response = await DELETE(req, { params }) - // Check response expect(response.status).toBe(200) - // Parse the response body const data = await response.json() - // Verify response structure expect(data).toHaveProperty('isDeployed', false) expect(data).toHaveProperty('deployedAt', null) expect(data).toHaveProperty('apiKey', null) - // Verify database calls expect(mockUpdate).toHaveBeenCalled() }) @@ -304,7 +263,6 @@ describe('Workflow Deployment API Route', () => { * Test error handling */ it('should handle errors when workflow is not found', async () => { - // Mock middleware to simulate an error vi.doMock('../../middleware', () => ({ validateWorkflowAccess: vi.fn().mockResolvedValue({ error: { @@ -314,25 +272,18 @@ describe('Workflow Deployment API Route', () => { }), })) - // Create a mock request const req = createMockRequest('POST') - // Create params with an invalid ID const params = Promise.resolve({ id: 'invalid-id' }) - // Import the handler after mocks are set up const { POST } = await import('./route') - // Call the handler const response = await POST(req, { params }) - // Check response expect(response.status).toBe(404) - // Parse the response body const data = await response.json() - // Verify error message expect(data).toHaveProperty('error', 'Workflow not found') }) @@ -340,7 +291,6 @@ describe('Workflow Deployment API Route', () => { * Test unauthorized access */ it('should handle unauthorized access to workflow', async () => { - // Mock middleware to simulate unauthorized access vi.doMock('../../middleware', () => ({ validateWorkflowAccess: vi.fn().mockResolvedValue({ error: { @@ -350,25 +300,18 @@ describe('Workflow Deployment API Route', () => { }), })) - // Create a mock request const req = createMockRequest('POST') - // Create params const params = Promise.resolve({ id: 'workflow-id' }) - // Import the handler after mocks are set up const { POST } = await import('./route') - // Call the handler const response = await POST(req, { params }) - // Check response expect(response.status).toBe(403) - // Parse the response body const data = await response.json() - // Verify error message expect(data).toHaveProperty('error', 'Unauthorized access') }) }) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 1529720f922..880593ec822 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -9,7 +9,6 @@ import type { Variable } from '@/stores/panel/variables/types' const logger = createLogger('WorkflowVariablesAPI') -// Schema for workflow variables updates const VariablesSchema = z.object({ variables: z.array( z.object({ diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 84d3d7c618f..a84c3f787db 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -1,10 +1,10 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { getUsersWithPermissions } from '@/lib/permissions/utils' import { db } from '@/db' -import { permissions, type permissionTypeEnum, user, workspaceMember } from '@/db/schema' +import { permissions, type permissionTypeEnum, workspaceMember } from '@/db/schema' -// Extract the enum type from Drizzle schema type PermissionType = (typeof permissionTypeEnum.enumValues)[number] interface UpdatePermissionsRequest { @@ -14,31 +14,6 @@ interface UpdatePermissionsRequest { }> } -// Helper function to fetch users with permissions for a workspace -async function getUsersWithPermissions(workspaceId: string) { - const usersWithPermissions = await db - .select({ - userId: user.id, - email: user.email, - name: user.name, - image: user.image, - permissionType: permissions.permissionType, - }) - .from(permissions) - .innerJoin(user, eq(permissions.userId, user.id)) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) - .orderBy(user.email) - - // Since each user has only one permission, we can use the results directly - return usersWithPermissions.map((row) => ({ - userId: row.userId, - email: row.email, - name: row.name, - image: row.image, - permissionType: row.permissionType, - })) -} - /** * GET /api/workspaces/[id]/permissions * diff --git a/apps/sim/app/api/workspaces/members/route.ts b/apps/sim/app/api/workspaces/members/route.ts index 2820fa1dd98..6f76cf8ed10 100644 --- a/apps/sim/app/api/workspaces/members/route.ts +++ b/apps/sim/app/api/workspaces/members/route.ts @@ -1,51 +1,12 @@ import { and, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { hasAdminPermission } from '@/lib/permissions/utils' import { db } from '@/db' import { permissions, type permissionTypeEnum, user, workspaceMember } from '@/db/schema' -// Extract the enum type from Drizzle schema type PermissionType = (typeof permissionTypeEnum.enumValues)[number] -/** - * Helper function to check if a user has admin permission for a workspace - */ -async function hasAdminPermission(userId: string, workspaceId: string): Promise { - const result = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.permissionType, 'admin') - ) - ) - .limit(1) - - return result.length > 0 -} - -/** - * Helper function to create default permissions for a new member - */ -async function createMemberPermissions( - userId: string, - workspaceId: string, - memberPermission: PermissionType = 'read' -): Promise { - await db.insert(permissions).values({ - id: crypto.randomUUID(), - userId, - entityType: 'workspace' as const, - entityId: workspaceId, - permissionType: memberPermission, - createdAt: new Date(), - updatedAt: new Date(), - }) -} - // Add a member to a workspace export async function POST(req: Request) { const session = await getSession() diff --git a/apps/sim/lib/permissions/utils.test.ts b/apps/sim/lib/permissions/utils.test.ts index acd1e0f28a1..ba28da4023a 100644 --- a/apps/sim/lib/permissions/utils.test.ts +++ b/apps/sim/lib/permissions/utils.test.ts @@ -1,12 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getUserEntityPermissions } from './utils' +import { getUserEntityPermissions, getUsersWithPermissions, hasAdminPermission } from './utils' -// Mock the imports - all mock objects must be inside the factory functions vi.mock('@/db', () => ({ db: { select: vi.fn(), from: vi.fn(), where: vi.fn(), + limit: vi.fn(), + innerJoin: vi.fn(), + orderBy: vi.fn(), }, })) @@ -20,6 +22,12 @@ vi.mock('@/db/schema', () => ({ permissionTypeEnum: { enumValues: ['admin', 'write', 'read'] as const, }, + user: { + id: 'user_id', + email: 'user_email', + name: 'user_name', + image: 'user_image', + }, })) vi.mock('drizzle-orm', () => ({ @@ -27,58 +35,58 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn().mockReturnValue('eq-condition'), })) -// Define the enum type for testing +import { db } from '@/db' +import { permissions, user } from '@/db/schema' + +const mockDb = db as any + type PermissionType = 'admin' | 'write' | 'read' describe('Permission Utils', () => { - // Get the mocked modules - let mockDb: any - let mockPermissions: any - - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks() - // Import the mocked modules - const { db } = await import('@/db') - const { permissions } = await import('@/db/schema') - - mockDb = db - mockPermissions = permissions - - // Setup default mock chain mockDb.select.mockReturnValue(mockDb) mockDb.from.mockReturnValue(mockDb) - mockDb.where.mockResolvedValue([]) + mockDb.where.mockReturnValue(mockDb) + mockDb.limit.mockResolvedValue([]) + mockDb.innerJoin.mockReturnValue(mockDb) + mockDb.orderBy.mockReturnValue(mockDb) }) describe('getUserEntityPermissions', () => { - it('should return null when user has no permissions', async () => { + it.concurrent('should return null when user has no permissions', async () => { mockDb.where.mockResolvedValue([]) const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') expect(result).toBeNull() - expect(mockDb.select).toHaveBeenCalledWith({ permissionType: mockPermissions.permissionType }) - expect(mockDb.from).toHaveBeenCalledWith(mockPermissions) + expect(mockDb.select).toHaveBeenCalledWith({ permissionType: permissions.permissionType }) + expect(mockDb.from).toHaveBeenCalledWith(permissions) expect(mockDb.where).toHaveBeenCalledWith('and-condition') }) - it('should return the highest permission when user has multiple permissions', async () => { - const mockResults = [ - { permissionType: 'read' as PermissionType }, - { permissionType: 'admin' as PermissionType }, - { permissionType: 'write' as PermissionType }, - ] - mockDb.where.mockResolvedValue(mockResults) - - const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') - - expect(result).toBe('admin') - expect(mockDb.select).toHaveBeenCalledWith({ permissionType: mockPermissions.permissionType }) - expect(mockDb.from).toHaveBeenCalledWith(mockPermissions) - }) - - it('should return single permission when user has only one', async () => { + it.concurrent( + 'should return the highest permission when user has multiple permissions', + async () => { + const mockResults = [ + { permissionType: 'read' as PermissionType }, + { permissionType: 'admin' as PermissionType }, + { permissionType: 'write' as PermissionType }, + ] + mockDb.where.mockResolvedValue(mockResults) + + const result = await getUserEntityPermissions('user123', 'workspace', 'workspace456') + + expect(result).toBe('admin') + expect(mockDb.select).toHaveBeenCalledWith({ + permissionType: permissions.permissionType, + }) + expect(mockDb.from).toHaveBeenCalledWith(permissions) + } + ) + + it.concurrent('should return single permission when user has only one', async () => { const mockResults = [{ permissionType: 'read' as PermissionType }] mockDb.where.mockResolvedValue(mockResults) @@ -87,7 +95,7 @@ describe('Permission Utils', () => { expect(result).toBe('read') }) - it('should handle different entity types', async () => { + it.concurrent('should handle different entity types', async () => { const mockResults = [{ permissionType: 'write' as PermissionType }] mockDb.where.mockResolvedValue(mockResults) @@ -96,19 +104,22 @@ describe('Permission Utils', () => { expect(result).toBe('write') }) - it('should return highest permission when multiple exist (admin > write > read)', async () => { - const mockResults = [ - { permissionType: 'read' as PermissionType }, - { permissionType: 'write' as PermissionType }, - ] - mockDb.where.mockResolvedValue(mockResults) + it.concurrent( + 'should return highest permission when multiple exist (admin > write > read)', + async () => { + const mockResults = [ + { permissionType: 'read' as PermissionType }, + { permissionType: 'write' as PermissionType }, + ] + mockDb.where.mockResolvedValue(mockResults) - const result = await getUserEntityPermissions('user789', 'workspace', 'workspace123') + const result = await getUserEntityPermissions('user789', 'workspace', 'workspace123') - expect(result).toBe('write') - }) + expect(result).toBe('write') + } + ) - it('should prioritize admin over other permissions', async () => { + it.concurrent('should prioritize admin over other permissions', async () => { const mockResults = [ { permissionType: 'write' as PermissionType }, { permissionType: 'admin' as PermissionType }, @@ -121,7 +132,7 @@ describe('Permission Utils', () => { expect(result).toBe('admin') }) - it('should handle edge case with single admin permission', async () => { + it.concurrent('should handle edge case with single admin permission', async () => { const mockResults = [{ permissionType: 'admin' as PermissionType }] mockDb.where.mockResolvedValue(mockResults) @@ -130,11 +141,11 @@ describe('Permission Utils', () => { expect(result).toBe('admin') }) - it('should correctly prioritize write over read', async () => { + it.concurrent('should correctly prioritize write over read', async () => { const mockResults = [ { permissionType: 'read' as PermissionType }, { permissionType: 'write' as PermissionType }, - { permissionType: 'read' as PermissionType }, // duplicate to test deduplication logic + { permissionType: 'read' as PermissionType }, ] mockDb.where.mockResolvedValue(mockResults) @@ -143,4 +154,320 @@ describe('Permission Utils', () => { expect(result).toBe('write') }) }) + + describe('hasAdminPermission', () => { + it.concurrent('should return true when user has admin permission for workspace', async () => { + const mockResult = [ + { + /* some admin permission record */ + }, + ] + mockDb.limit.mockResolvedValue(mockResult) + + const result = await hasAdminPermission('admin-user', 'workspace123') + + expect(result).toBe(true) + expect(mockDb.select).toHaveBeenCalledWith() + expect(mockDb.from).toHaveBeenCalledWith(permissions) + expect(mockDb.where).toHaveBeenCalledWith('and-condition') + expect(mockDb.limit).toHaveBeenCalledWith(1) + }) + + it.concurrent( + 'should return false when user has no admin permission for workspace', + async () => { + mockDb.limit.mockResolvedValue([]) + + const result = await hasAdminPermission('regular-user', 'workspace123') + + expect(result).toBe(false) + expect(mockDb.select).toHaveBeenCalledWith() + expect(mockDb.from).toHaveBeenCalledWith(permissions) + expect(mockDb.where).toHaveBeenCalledWith('and-condition') + expect(mockDb.limit).toHaveBeenCalledWith(1) + } + ) + + it.concurrent('should handle different user and workspace combinations', async () => { + // Test with no admin permission + mockDb.limit.mockResolvedValue([]) + + const result1 = await hasAdminPermission('user456', 'workspace789') + expect(result1).toBe(false) + + // Test with admin permission + const mockAdminResult = [{ permissionType: 'admin' }] + mockDb.limit.mockResolvedValue(mockAdminResult) + + const result2 = await hasAdminPermission('admin789', 'workspace456') + expect(result2).toBe(true) + }) + + it.concurrent( + 'should call database with correct parameters for workspace admin check', + async () => { + mockDb.limit.mockResolvedValue([]) + + await hasAdminPermission('test-user-id', 'test-workspace-id') + + expect(mockDb.select).toHaveBeenCalledWith() + expect(mockDb.from).toHaveBeenCalledWith(permissions) + expect(mockDb.where).toHaveBeenCalledWith('and-condition') + expect(mockDb.limit).toHaveBeenCalledWith(1) + } + ) + + it.concurrent( + 'should return true even if multiple admin records exist (due to limit 1)', + async () => { + // This shouldn't happen in practice, but tests the limit functionality + const mockResult = [{ permissionType: 'admin' }] // Only one record due to limit(1) + mockDb.limit.mockResolvedValue(mockResult) + + const result = await hasAdminPermission('super-admin', 'workspace999') + + expect(result).toBe(true) + expect(mockDb.limit).toHaveBeenCalledWith(1) + } + ) + + it.concurrent('should handle edge cases with empty strings', async () => { + mockDb.limit.mockResolvedValue([]) + + const result = await hasAdminPermission('', '') + + expect(result).toBe(false) + expect(mockDb.select).toHaveBeenCalled() + }) + + it.concurrent('should return false for non-existent workspace', async () => { + mockDb.limit.mockResolvedValue([]) + + const result = await hasAdminPermission('user123', 'non-existent-workspace') + + expect(result).toBe(false) + }) + + it.concurrent('should return false for non-existent user', async () => { + mockDb.limit.mockResolvedValue([]) + + const result = await hasAdminPermission('non-existent-user', 'workspace123') + + expect(result).toBe(false) + }) + }) + + describe('getUsersWithPermissions', () => { + it.concurrent( + 'should return empty array when no users have permissions for workspace', + async () => { + mockDb.orderBy.mockResolvedValue([]) + + const result = await getUsersWithPermissions('workspace123') + + expect(result).toEqual([]) + expect(mockDb.select).toHaveBeenCalledWith({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + permissionType: permissions.permissionType, + }) + expect(mockDb.from).toHaveBeenCalledWith(permissions) + expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition') + expect(mockDb.where).toHaveBeenCalledWith('and-condition') + expect(mockDb.orderBy).toHaveBeenCalledWith(user.email) + } + ) + + it.concurrent('should return users with their permissions for workspace', async () => { + const mockResults = [ + { + userId: 'user1', + email: 'alice@example.com', + name: 'Alice Smith', + image: 'https://example.com/alice.jpg', + permissionType: 'admin' as PermissionType, + }, + { + userId: 'user2', + email: 'bob@example.com', + name: 'Bob Johnson', + image: 'https://example.com/bob.jpg', + permissionType: 'write' as PermissionType, + }, + { + userId: 'user3', + email: 'charlie@example.com', + name: 'Charlie Brown', + image: null, + permissionType: 'read' as PermissionType, + }, + ] + mockDb.orderBy.mockResolvedValue(mockResults) + + const result = await getUsersWithPermissions('workspace456') + + expect(result).toEqual([ + { + userId: 'user1', + email: 'alice@example.com', + name: 'Alice Smith', + image: 'https://example.com/alice.jpg', + permissionType: 'admin', + }, + { + userId: 'user2', + email: 'bob@example.com', + name: 'Bob Johnson', + image: 'https://example.com/bob.jpg', + permissionType: 'write', + }, + { + userId: 'user3', + email: 'charlie@example.com', + name: 'Charlie Brown', + image: null, + permissionType: 'read', + }, + ]) + expect(mockDb.select).toHaveBeenCalledWith({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + permissionType: permissions.permissionType, + }) + expect(mockDb.from).toHaveBeenCalledWith(permissions) + expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition') + expect(mockDb.where).toHaveBeenCalledWith('and-condition') + expect(mockDb.orderBy).toHaveBeenCalledWith(user.email) + }) + + it.concurrent('should handle single user with permission', async () => { + const mockResults = [ + { + userId: 'solo-user', + email: 'solo@example.com', + name: 'Solo User', + image: 'https://example.com/solo.jpg', + permissionType: 'admin' as PermissionType, + }, + ] + mockDb.orderBy.mockResolvedValue(mockResults) + + const result = await getUsersWithPermissions('workspace-solo') + + expect(result).toEqual([ + { + userId: 'solo-user', + email: 'solo@example.com', + name: 'Solo User', + image: 'https://example.com/solo.jpg', + permissionType: 'admin', + }, + ]) + }) + + it.concurrent('should handle users with null names and images', async () => { + const mockResults = [ + { + userId: 'user-minimal', + email: 'minimal@example.com', + name: null, + image: null, + permissionType: 'read' as PermissionType, + }, + ] + mockDb.orderBy.mockResolvedValue(mockResults) + + const result = await getUsersWithPermissions('workspace-minimal') + + expect(result).toEqual([ + { + userId: 'user-minimal', + email: 'minimal@example.com', + name: null, + image: null, + permissionType: 'read', + }, + ]) + }) + + it.concurrent('should call database with correct parameters', async () => { + mockDb.orderBy.mockResolvedValue([]) + + await getUsersWithPermissions('test-workspace-123') + + expect(mockDb.select).toHaveBeenCalledWith({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + permissionType: permissions.permissionType, + }) + expect(mockDb.from).toHaveBeenCalledWith(permissions) + expect(mockDb.innerJoin).toHaveBeenCalledWith(user, 'eq-condition') + expect(mockDb.where).toHaveBeenCalledWith('and-condition') + expect(mockDb.orderBy).toHaveBeenCalledWith(user.email) + }) + + it.concurrent('should handle different workspace IDs', async () => { + mockDb.orderBy.mockResolvedValue([]) + + const result1 = await getUsersWithPermissions('workspace-abc-123') + const result2 = await getUsersWithPermissions('workspace-xyz-789') + + expect(result1).toEqual([]) + expect(result2).toEqual([]) + expect(mockDb.select).toHaveBeenCalled() + expect(mockDb.from).toHaveBeenCalled() + expect(mockDb.innerJoin).toHaveBeenCalled() + expect(mockDb.where).toHaveBeenCalled() + expect(mockDb.orderBy).toHaveBeenCalled() + }) + + it.concurrent('should handle all permission types correctly', async () => { + const mockResults = [ + { + userId: 'admin-user', + email: 'admin@example.com', + name: 'Admin User', + image: 'admin.jpg', + permissionType: 'admin' as PermissionType, + }, + { + userId: 'write-user', + email: 'writer@example.com', + name: 'Write User', + image: 'writer.jpg', + permissionType: 'write' as PermissionType, + }, + { + userId: 'read-user', + email: 'reader@example.com', + name: 'Read User', + image: 'reader.jpg', + permissionType: 'read' as PermissionType, + }, + ] + mockDb.orderBy.mockResolvedValue(mockResults) + + const result = await getUsersWithPermissions('workspace-all-perms') + + expect(result).toHaveLength(3) + expect(result[0].permissionType).toBe('admin') + expect(result[1].permissionType).toBe('write') + expect(result[2].permissionType).toBe('read') + }) + + it.concurrent('should handle empty workspace ID', async () => { + mockDb.orderBy.mockResolvedValue([]) + + const result = await getUsersWithPermissions('') + + expect(result).toEqual([]) + expect(mockDb.select).toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/lib/permissions/utils.ts b/apps/sim/lib/permissions/utils.ts index b80c71c364d..aa433b33bf1 100644 --- a/apps/sim/lib/permissions/utils.ts +++ b/apps/sim/lib/permissions/utils.ts @@ -1,8 +1,7 @@ import { and, eq } from 'drizzle-orm' import { db } from '@/db' -import { permissions, type permissionTypeEnum } from '@/db/schema' +import { permissions, type permissionTypeEnum, user } from '@/db/schema' -// Extract the enum type from Drizzle schema export type PermissionType = (typeof permissionTypeEnum.enumValues)[number] /** @@ -43,3 +42,57 @@ export async function getUserEntityPermissions( return highestPermission.permissionType } + +/** + * Check if a user has admin permission for a specific workspace + * + * @param userId - The ID of the user to check permissions for + * @param workspaceId - The ID of the workspace to check admin permission for + * @returns Promise - True if the user has admin permission for the workspace, false otherwise + */ +export async function hasAdminPermission(userId: string, workspaceId: string): Promise { + const result = await db + .select() + .from(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId), + eq(permissions.permissionType, 'admin') + ) + ) + .limit(1) + + return result.length > 0 +} + +/** + * Retrieves a list of users with their associated permissions for a given workspace. + * + * @param workspaceId - The ID of the workspace to retrieve user permissions for. + * @returns A promise that resolves to an array of user objects, each containing user details and their permission type. + */ +export async function getUsersWithPermissions(workspaceId: string) { + const usersWithPermissions = await db + .select({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + permissionType: permissions.permissionType, + }) + .from(permissions) + .innerJoin(user, eq(permissions.userId, user.id)) + .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) + .orderBy(user.email) + + // Since each user has only one permission, we can use the results directly + return usersWithPermissions.map((row) => ({ + userId: row.userId, + email: row.email, + name: row.name, + image: row.image, + permissionType: row.permissionType, + })) +} From 9425801dbc2445588a09bc2a71537cd73c9f8e25 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 23 Jun 2025 13:58:15 -0700 Subject: [PATCH 6/6] fix disabled tooltips --- .../deployment-controls.tsx | 2 +- .../components/control-bar/control-bar.tsx | 116 +++++++++++------- apps/sim/app/w/components/sidebar/sidebar.tsx | 51 ++++++-- 3 files changed, 112 insertions(+), 57 deletions(-) diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx index cf1571a1194..588cc5ec158 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -66,7 +66,7 @@ export function DeploymentControls({ const getTooltipText = () => { if (!canDeploy) { - return 'Admin permissions required to deploy workflows as API' + return 'Admin permissions required to deploy workflows' } if (isDeploying) { return 'Deploying...' diff --git a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx index 8d60d1b28e0..ec4c8b6b993 100644 --- a/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -626,7 +626,7 @@ export function ControlBar() { {!canEdit && ( - Edit permissions required to rename workflows + Admin permission required to rename workflows )} )} @@ -651,22 +651,30 @@ export function ControlBar() { const isDisabled = !canEdit || !hasMultipleWorkflows const getTooltipText = () => { - if (!canEdit) return 'Edit permissions required to delete workflows' + if (!canEdit) return 'Admin permission required to delete workflows' if (!hasMultipleWorkflows) return 'Cannot delete the last workflow' return 'Delete Workflow' } + if (isDisabled) { + return ( + + +
+ +
+
+ {getTooltipText()} +
+ ) + } + return ( - @@ -864,19 +872,24 @@ export function ControlBar() { return ( - + {canEdit ? ( + + ) : ( +
+ +
+ )}
- {canEdit ? 'Duplicate Workflow' : 'Edit permissions required to duplicate workflows'} + {canEdit ? 'Duplicate Workflow' : 'Admin permission required to duplicate workflows'}
) @@ -899,20 +912,25 @@ export function ControlBar() { return ( - + {isDisabled ? ( +
+ +
+ ) : ( + + )}
{!userPermissions.canEdit - ? 'Edit permissions required to use auto-layout' + ? 'Admin permission required to use auto-layout' : 'Auto Layout'}
@@ -985,6 +1003,7 @@ export function ControlBar() { */ const renderDebugModeToggle = () => { const canDebug = userPermissions.canRead // Debug mode now requires only read permissions + const isDisabled = isExecuting || isMultiRunning || !canDebug const handleToggleDebugMode = () => { if (!canDebug) return @@ -1001,23 +1020,30 @@ export function ControlBar() { return ( - + {isDisabled ? ( +
+ +
+ ) : ( + + )}
{!canDebug - ? 'Read permissions required to use debug mode' + ? 'Read permission required to use debug mode' : isDebugModeEnabled ? 'Disable Debug Mode' : 'Enable Debug Mode'} @@ -1108,7 +1134,7 @@ export function ControlBar() {
{!canRun && !isLoadingPermissions ? ( - 'Read permissions required to run workflows' + 'Read permission required to run workflows' ) : usageExceeded ? (

Usage Limit Exceeded

diff --git a/apps/sim/app/w/components/sidebar/sidebar.tsx b/apps/sim/app/w/components/sidebar/sidebar.tsx index fb9b7099a08..e8485572e82 100644 --- a/apps/sim/app/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/w/components/sidebar/sidebar.tsx @@ -13,6 +13,7 @@ import { useSidebarStore } from '@/stores/sidebar/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { useRegistryLoading } from '../../hooks/use-registry-loading' +import { useUserPermissionsContext } from '../providers/workspace-permissions-provider' import { CreateMenu } from './components/create-menu/create-menu' import { FolderTree } from './components/folder-tree/folder-tree' import { HelpModal } from './components/help-modal/help-modal' @@ -24,7 +25,7 @@ import { WorkspaceHeader } from './components/workspace-header/workspace-header' const logger = createLogger('Sidebar') -const IS_DEV = false +const IS_DEV = process.env.NODE_ENV === 'development' export function Sidebar() { useRegistryLoading() @@ -37,6 +38,7 @@ export function Sidebar() { isLoading: workflowsLoading, } = useWorkflowRegistry() const { isPending: sessionLoading } = useSession() + const userPermissions = useUserPermissionsContext() const isLoading = workflowsLoading || sessionLoading const router = useRouter() const pathname = usePathname() @@ -213,13 +215,24 @@ export function Sidebar() {
setShowInviteMembers(true)} - className='mx-auto flex h-8 w-8 cursor-pointer items-center justify-center rounded-md font-medium text-muted-foreground text-sm hover:bg-accent/50' + onClick={ + userPermissions.canAdmin ? () => setShowInviteMembers(true) : undefined + } + className={clsx( + 'mx-auto flex h-8 w-8 items-center justify-center rounded-md font-medium text-sm', + userPermissions.canAdmin + ? 'cursor-pointer text-muted-foreground hover:bg-accent/50' + : 'cursor-not-allowed text-muted-foreground/50' + )} >
- Invite Members + + {userPermissions.canAdmin + ? 'Invite Members' + : 'Admin permission required to invite members'} +
)} @@ -247,13 +260,29 @@ export function Sidebar() { <> {!IS_DEV && (
-
setShowInviteMembers(true)} - className='flex cursor-pointer items-center rounded-md px-2 py-1.5 font-medium text-muted-foreground text-sm hover:bg-accent/50' - > - - Invite members -
+ + +
setShowInviteMembers(true) : undefined + } + className={clsx( + 'flex items-center rounded-md px-2 py-1.5 font-medium text-sm', + userPermissions.canAdmin + ? 'cursor-pointer text-muted-foreground hover:bg-accent/50' + : 'cursor-not-allowed text-muted-foreground/50' + )} + > + + Invite members +
+
+ + {userPermissions.canAdmin + ? 'Invite new members to this workspace' + : 'Admin permission required to invite members'} + +
)}