From 33ac828d3dc8728fc49cecb2b711b8ceb259c345 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Fri, 21 Nov 2025 19:49:32 -0800
Subject: [PATCH] fix(settings): settings components and behavior consolidation
(#2100)
* fix(settings): settings components and behavior consolidation
* ack PR comments
---
.../organizations/[id]/invitations/route.ts | 38 ++-
.../components/account/account.tsx | 6 +-
.../components/api-keys/api-keys.tsx | 4 +-
.../creator-profile/creator-profile.tsx | 10 +-
.../components/custom-tools/custom-tools.tsx | 22 +-
.../settings-modal/components/files/files.tsx | 4 +-
.../mcp/components/add-server-form.tsx | 4 +-
.../settings-modal/components/mcp/mcp.tsx | 35 +--
.../settings-modal/components/sso/sso.tsx | 72 +++---
.../member-invitation-card.tsx | 239 +++++++++--------
.../no-organization-view.tsx | 47 ++--
.../remove-member-dialog.tsx | 10 +
.../components/team-seats/team-seats.tsx | 8 +
.../components/team-usage/team-usage.tsx | 12 +-
.../team-management/team-management.tsx | 242 ++++--------------
apps/sim/hooks/queries/organization.ts | 2 +-
apps/sim/hooks/queries/workspace.ts | 90 +++++++
17 files changed, 406 insertions(+), 439 deletions(-)
diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts
index 85fa235c57..1506a89164 100644
--- a/apps/sim/app/api/organizations/[id]/invitations/route.ts
+++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts
@@ -244,16 +244,40 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const emailsToInvite = newEmails.filter((email: string) => !pendingEmails.includes(email))
if (emailsToInvite.length === 0) {
+ const isSingleEmail = processedEmails.length === 1
+ const existingMembersEmails = processedEmails.filter((email: string) =>
+ existingEmails.includes(email)
+ )
+ const pendingInvitationEmails = processedEmails.filter((email: string) =>
+ pendingEmails.includes(email)
+ )
+
+ if (isSingleEmail) {
+ if (existingMembersEmails.length > 0) {
+ return NextResponse.json(
+ {
+ error: 'Failed to send invitation. User is already a part of the organization.',
+ },
+ { status: 400 }
+ )
+ }
+ if (pendingInvitationEmails.length > 0) {
+ return NextResponse.json(
+ {
+ error:
+ 'Failed to send invitation. A pending invitation already exists for this email.',
+ },
+ { status: 400 }
+ )
+ }
+ }
+
return NextResponse.json(
{
- error: 'All emails are already members or have pending invitations',
+ error: 'All emails are already members or have pending invitations.',
details: {
- existingMembers: processedEmails.filter((email: string) =>
- existingEmails.includes(email)
- ),
- pendingInvitations: processedEmails.filter((email: string) =>
- pendingEmails.includes(email)
- ),
+ existingMembers: existingMembersEmails,
+ pendingInvitations: pendingInvitationEmails,
},
},
{ status: 400 }
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx
index 40ca2e22d2..d2747bb063 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx
@@ -232,7 +232,11 @@ export function Account(_props: AccountProps) {
{profile?.name || ''}
{profile?.email || ''}
- {uploadError &&
{uploadError}
}
+ {uploadError && (
+
+ {uploadError}
+
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx
index 5a9b28a485..0ace598589 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/api-keys/api-keys.tsx
@@ -512,9 +512,9 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
autoFocus
/>
{createError && (
-
+
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx
index ffbda998ce..cb9cbd063c 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx
@@ -291,7 +291,11 @@ export function CreatorProfile() {
/>
- {uploadError &&
{uploadError}
}
+ {uploadError && (
+
+ {uploadError}
+
+ )}
PNG or JPEG (max 5MB)
@@ -411,9 +415,9 @@ export function CreatorProfile() {
{/* Error Message */}
{saveError && (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx
index b26ac39813..c05f1b947b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/custom-tools/custom-tools.tsx
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
-import { AlertCircle, Plus, Search } from 'lucide-react'
+import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -13,7 +13,7 @@ import {
ModalHeader,
ModalTitle,
} from '@/components/emcn'
-import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
+import { Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
@@ -84,7 +84,6 @@ export function CustomTools() {
setShowDeleteDialog(false)
try {
- // Pass null workspaceId for user-scoped tools (legacy tools without workspaceId)
await deleteToolMutation.mutateAsync({
workspaceId: tool.workspaceId ?? null,
toolId: toolToDelete.id,
@@ -105,7 +104,6 @@ export function CustomTools() {
const handleToolSaved = () => {
setShowAddForm(false)
setEditingTool(null)
- // React Query will automatically refetch via cache invalidation
refetchTools()
}
@@ -113,16 +111,6 @@ export function CustomTools() {
{/* Fixed Header with Search */}
- {/* Error Alert - only show when modal is not open */}
- {error && !showAddForm && !editingTool && (
-
-
-
- {error instanceof Error ? error.message : 'An error occurred'}
-
-
- )}
-
{/* Search Input */}
{isLoading ? (
@@ -148,6 +136,12 @@ export function CustomTools() {
+ ) : error ? (
+
+
+ {error instanceof Error ? error.message : 'Failed to load tools'}
+
+
) : tools.length === 0 && !showAddForm && !editingTool ? (
Click "Create Tool" below to get started
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx
index fd1acde0a9..9c8e8f1152 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx
@@ -273,9 +273,9 @@ export function Files() {
{/* Error message */}
{uploadError && (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/components/add-server-form.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/components/add-server-form.tsx
index b670c1ead0..331c480ea1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/components/add-server-form.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/components/add-server-form.tsx
@@ -253,9 +253,9 @@ export function AddServerForm({
{/* Error message above buttons */}
{testResult && !testResult.success && (
-
+
{testResult.error || testResult.message}
-
+
)}
{/* Buttons row */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx
index b0c10a5486..8b45d2050b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/mcp/mcp.tsx
@@ -1,10 +1,10 @@
'use client'
import { useCallback, useRef, useState } from 'react'
-import { AlertCircle, Plus, Search } from 'lucide-react'
+import { Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/emcn'
-import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
+import { Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { createMcpToolId } from '@/lib/mcp/utils'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
@@ -24,7 +24,6 @@ export function MCP() {
const params = useParams()
const workspaceId = params.workspaceId as string
- // React Query hooks
const {
data: servers = [],
isLoading: serversLoading,
@@ -42,7 +41,7 @@ export function MCP() {
transport: 'streamable-http',
url: '',
timeout: 30000,
- headers: {}, // Start with no headers
+ headers: {},
})
const [showEnvVars, setShowEnvVars] = useState(false)
@@ -207,7 +206,6 @@ export function MCP() {
try {
await deleteServerMutation.mutateAsync({ workspaceId, serverId })
- // TanStack Query mutations automatically invalidate and refetch tools
logger.info(`Removed MCP server: ${serverId}`)
} catch (error) {
@@ -264,27 +262,23 @@ export function MCP() {
/>
)}
-
- {/* Error Alert */}
- {(toolsError || serversError) && (
-
-
-
- {toolsError instanceof Error
- ? toolsError.message
- : serversError instanceof Error
- ? serversError.message
- : 'An error occurred'}
-
-
- )}
{/* Scrollable Content */}
{/* Server List */}
- {serversLoading ? (
+ {toolsError || serversError ? (
+
+
+ {toolsError instanceof Error
+ ? toolsError.message
+ : serversError instanceof Error
+ ? serversError.message
+ : 'Failed to load MCP servers'}
+
+
+ ) : serversLoading ? (
@@ -342,7 +336,6 @@ export function MCP() {
) : (
{filteredServers.map((server: any) => {
- // Add defensive checks for server properties
if (!server || !server.id) {
return null
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx
index 016bfda858..26f90dba4f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/sso/sso.tsx
@@ -2,8 +2,8 @@
import { useState } from 'react'
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
-import { Button, Combobox } from '@/components/emcn'
-import { Alert, AlertDescription, Input, Label } from '@/components/ui'
+import { Button, Combobox, Input, Label } from '@/components/emcn'
+import { Alert, AlertDescription } from '@/components/ui'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth-client'
import { isBillingEnabled } from '@/lib/environment'
@@ -79,7 +79,6 @@ export function SSO() {
const { data: subscriptionData } = useSubscriptionData()
const activeOrganization = orgsData?.activeOrganization
- // Determine if we should fetch SSO providers
const userEmail = session?.user?.email
const userId = session?.user?.id
const userRole = getUserRole(activeOrganization, userEmail)
@@ -89,14 +88,12 @@ export function SSO() {
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
const hasEnterprisePlan = subscriptionStatus.isEnterprise
- // Use React Query to fetch SSO providers
const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders()
const providers = providersData?.providers || []
const isSSOProviderOwner =
!isBillingEnabled && userId ? providers.some((p: any) => p.userId === userId) : null
- // Use mutation hook for configuring SSO
const configureSSOMutation = useConfigureSSO()
const [error, setError] = useState
(null)
@@ -331,12 +328,10 @@ export function SSO() {
}
}
- // Use the mutation hook - this will automatically invalidate the cache
await configureSSOMutation.mutateAsync(requestBody)
logger.info('SSO provider configured', { providerId: formData.providerId })
- // Reset form
setFormData({
providerType: 'oidc',
providerId: '',
@@ -408,7 +403,6 @@ export function SSO() {
const handleReconfigure = (provider: SSOProvider) => {
try {
- // Parse config based on provider type
let clientId = ''
let clientSecret = ''
let scopes = 'openid,profile,email'
@@ -480,14 +474,7 @@ export function SSO() {
/>
- {error && (
-
- {error}
-
- )}
-
{showStatus ? (
- // SSO Provider Status View
{providers.map((provider: SSOProvider) => (
@@ -558,7 +545,6 @@ export function SSO() {
))}
) : (
- // SSO Configuration Form
<>
{hasProviders && (
@@ -631,9 +617,9 @@ export function SSO() {
)}
/>
{showErrors && errors.providerId.length > 0 && (
-
-
{errors.providerId.join(' ')}
-
+
+ {errors.providerId.join(' ')}
+
)}
Select a pre-configured provider ID from the trusted providers list
@@ -662,9 +648,9 @@ export function SSO() {
)}
/>
{showErrors && errors.issuerUrl.length > 0 && (
-
-
{errors.issuerUrl.join(' ')}
-
+
+ {errors.issuerUrl.join(' ')}
+
)}
@@ -691,9 +677,9 @@ export function SSO() {
)}
/>
{showErrors && errors.domain.length > 0 && (
-
-
{errors.domain.join(' ')}
-
+
+ {errors.domain.join(' ')}
+
)}
@@ -722,9 +708,9 @@ export function SSO() {
)}
/>
{showErrors && errors.clientId.length > 0 && (
-
-
{errors.clientId.join(' ')}
-
+
+ {errors.clientId.join(' ')}
+
)}
@@ -775,9 +761,9 @@ export function SSO() {
{showErrors && errors.clientSecret.length > 0 && (
-
-
{errors.clientSecret.join(' ')}
-
+
+ {errors.clientSecret.join(' ')}
+
)}
@@ -800,9 +786,9 @@ export function SSO() {
)}
/>
{showErrors && errors.scopes.length > 0 && (
-
-
{errors.scopes.join(' ')}
-
+
+ {errors.scopes.join(' ')}
+
)}
Comma-separated list of OIDC scopes to request
@@ -830,9 +816,9 @@ export function SSO() {
)}
/>
{showErrors && errors.entryPoint.length > 0 && (
-
-
{errors.entryPoint.join(' ')}
-
+
+ {errors.entryPoint.join(' ')}
+
)}
@@ -856,9 +842,9 @@ export function SSO() {
rows={4}
/>
{showErrors && errors.cert.length > 0 && (
-
-
{errors.cert.join(' ')}
-
+
+ {errors.cert.join(' ')}
+
)}
@@ -964,6 +950,12 @@ export function SSO() {
>
)}
+ {error && (
+
+ {error}
+
+ )}
+
(
)
return (
-
- {permissionOptions.map((option, index) => (
-
+ {permissionOptions.map((option) => (
+ !disabled && onChange(option.value)}
disabled={disabled}
title={option.description}
- className={cn(
- 'px-2.5 py-1.5 font-medium text-xs transition-colors focus:outline-none',
- 'first:rounded-l-[11px] last:rounded-r-[11px]',
- disabled && 'cursor-not-allowed opacity-50',
- value === option.value
- ? 'bg-foreground text-background'
- : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
- index > 0 && 'border-input border-l'
- )}
+ className='h-[22px] min-w-[38px] px-[6px] py-0 text-[11px]'
>
{option.label}
-
+
))}
)
@@ -71,12 +68,8 @@ interface MemberInvitationCardProps {
inviteSuccess: boolean
availableSeats?: number
maxSeats?: number
-}
-
-function ButtonSkeleton() {
- return (
-
- )
+ invitationError?: Error | null
+ isLoadingWorkspaces?: boolean
}
export function MemberInvitationCard({
@@ -93,12 +86,13 @@ export function MemberInvitationCard({
inviteSuccess,
availableSeats = 0,
maxSeats = 0,
+ invitationError = null,
+ isLoadingWorkspaces = false,
}: MemberInvitationCardProps) {
const selectedCount = selectedWorkspaces.length
const hasAvailableSeats = availableSeats > 0
const [emailError, setEmailError] = useState('')
- // Email validation function using existing lib
const validateEmailInput = (email: string) => {
if (!email.trim()) {
setEmailError('')
@@ -116,14 +110,12 @@ export function MemberInvitationCard({
const handleEmailChange = (e: React.ChangeEvent) => {
const value = e.target.value
setInviteEmail(value)
- // Clear error when user starts typing again
if (emailError) {
setEmailError('')
}
}
const handleInviteClick = () => {
- // Validate email before proceeding
if (inviteEmail.trim()) {
validateEmailInput(inviteEmail)
const validation = quickValidateEmail(inviteEmail.trim())
@@ -132,7 +124,6 @@ export function MemberInvitationCard({
}
}
- // If validation passes or email is empty, proceed with original invite
onInviteMember()
}
@@ -163,114 +154,118 @@ export function MemberInvitationCard({
)}
-
{
- setShowWorkspaceInvite(!showWorkspaceInvite)
- if (!showWorkspaceInvite) {
+ {
+ setShowWorkspaceInvite(open)
+ if (open) {
onLoadUserWorkspaces()
}
}}
- disabled={isInviting || !hasAvailableSeats}
>
-
+
+
+ Workspaces
+ {selectedCount > 0 && ` (${selectedCount})`}
+
+
+
+
+
+ {isLoadingWorkspaces ? (
+
+ ) : userWorkspaces.length === 0 ? (
+
+
No workspaces available
+
+ ) : (
+
+ {userWorkspaces.map((workspace) => {
+ const isSelected = selectedWorkspaces.some(
+ (w) => w.workspaceId === workspace.id
+ )
+ const selectedWorkspace = selectedWorkspaces.find(
+ (w) => w.workspaceId === workspace.id
+ )
+
+ return (
+
+
{
+ if (isSelected) {
+ onWorkspaceToggle(workspace.id, '')
+ } else {
+ onWorkspaceToggle(workspace.id, 'read')
+ }
+ }}
+ active={isSelected}
+ disabled={isInviting}
+ >
+
+ {workspace.name}
+
+ {isSelected && (
+
+
Access:
+
onWorkspaceToggle(workspace.id, permission)}
+ disabled={isInviting}
+ />
+
+ )}
+
+ )
+ })}
+
)}
- />
- Workspaces
-
+
+
- {isInviting ? : hasAvailableSeats ? 'Invite' : 'No Seats'}
+ {isInviting ? 'Inviting...' : hasAvailableSeats ? 'Invite' : 'No Seats'}
- {/* Workspace selection - collapsible */}
- {showWorkspaceInvite && (
-
-
-
-
Workspace Access
- (Optional)
-
- {selectedCount > 0 && (
-
{selectedCount} selected
- )}
-
-
- {userWorkspaces.length === 0 ? (
-
-
No workspaces available
-
- ) : (
-
- {userWorkspaces.map((workspace) => {
- const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
- const selectedWorkspace = selectedWorkspaces.find(
- (w) => w.workspaceId === workspace.id
- )
-
- return (
-
-
-
- {
- if (checked) {
- onWorkspaceToggle(workspace.id, 'read')
- } else {
- onWorkspaceToggle(workspace.id, '')
- }
- }}
- disabled={isInviting}
- />
-
- {workspace.name}
-
- {workspace.isOwner && (
-
- Owner
-
- )}
-
-
-
- {isSelected && (
-
onWorkspaceToggle(workspace.id, permission)}
- disabled={isInviting}
- className='w-auto'
- />
- )}
-
- )
- })}
-
- )}
-
+ {/* Invitation error - inline */}
+ {invitationError && (
+
+ {invitationError instanceof Error && invitationError.message
+ ? invitationError.message
+ : String(invitationError)}
+
)}
{/* Success message */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx
index 0ce2c18f7f..cc8333a47e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx
@@ -8,7 +8,6 @@ import {
ModalHeader,
ModalTitle,
} from '@/components/emcn'
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -87,22 +86,22 @@ export function NoOrganizationView({
- {error && (
-
- Error
- {error}
-
- )}
-
-
-
- {isCreatingOrg && }
- Create Team Workspace
-
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {isCreatingOrg && }
+ Create Team Workspace
+
+
@@ -117,13 +116,6 @@ export function NoOrganizationView({
- {error && (
-
- Error
- {error}
-
- )}
-
Organization Name
@@ -153,6 +145,12 @@ export function NoOrganizationView({
+ {error && (
+
+ {error}
+
+ )}
+
{
- // Open the subscription tab
const event = new CustomEvent('open-settings', {
detail: { tab: 'subscription' },
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx
index 387e5954f5..06c459f5fe 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx
@@ -13,6 +13,7 @@ interface RemoveMemberDialogProps {
memberName: string
shouldReduceSeats: boolean
isSelfRemoval?: boolean
+ error?: Error | null
onOpenChange: (open: boolean) => void
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
onConfirmRemove: (shouldReduceSeats: boolean) => Promise
@@ -23,6 +24,7 @@ export function RemoveMemberDialog({
open,
memberName,
shouldReduceSeats,
+ error,
onOpenChange,
onShouldReduceSeatsChange,
onConfirmRemove,
@@ -64,6 +66,14 @@ export function RemoveMemberDialog({
)}
+ {error && (
+
+
+ {error instanceof Error && error.message ? error.message : String(error)}
+
+
+ )}
+
Cancel
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats/team-seats.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats/team-seats.tsx
index be8caaa7f3..cd4c8d6f10 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats/team-seats.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-seats/team-seats.tsx
@@ -23,6 +23,7 @@ interface TeamSeatsProps {
currentSeats?: number
initialSeats?: number
isLoading: boolean
+ error?: Error | null
onConfirm: (seats: number) => Promise
confirmButtonText: string
showCostBreakdown?: boolean
@@ -37,6 +38,7 @@ export function TeamSeats({
currentSeats,
initialSeats = 1,
isLoading,
+ error,
onConfirm,
confirmButtonText,
showCostBreakdown = false,
@@ -103,6 +105,12 @@ export function TeamSeats({
)}
+
+ {error && (
+
+ {error instanceof Error && error.message ? error.message : String(error)}
+
+ )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx
index 09b8fc8048..b1255e6312 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components/team-usage/team-usage.tsx
@@ -1,6 +1,4 @@
import { useRef } from 'react'
-import { AlertCircle } from 'lucide-react'
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Skeleton } from '@/components/ui/skeleton'
import { useActiveOrganization } from '@/lib/auth-client'
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
@@ -53,13 +51,11 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
if (error) {
return (
-
-
- Error
-
+
+
{error instanceof Error ? error.message : 'Failed to load billing data'}
-
-
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx
index b40299bed6..5ca0bb837b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/team-management.tsx
@@ -1,19 +1,11 @@
import { useCallback, useEffect, useState } from 'react'
-import { useQueryClient } from '@tanstack/react-query'
-import { Alert, AlertDescription, AlertTitle, Skeleton } from '@/components/ui'
+import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
-import {
- generateSlug,
- getUsedSeats,
- getUserRole,
- isAdminOrOwner,
- type Workspace,
-} from '@/lib/organization'
-import { getSubscriptionStatus } from '@/lib/subscription'
+import { generateSlug, getUsedSeats, getUserRole, isAdminOrOwner } from '@/lib/organization'
import {
MemberInvitationCard,
NoOrganizationView,
@@ -23,7 +15,7 @@ import {
TeamSeatsOverview,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components'
import {
- organizationKeys,
+ useCreateOrganization,
useInviteMember,
useOrganization,
useOrganizationSubscription,
@@ -32,25 +24,21 @@ import {
useUpdateSeats,
} from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
+import { useAdminWorkspaces } from '@/hooks/queries/workspace'
const logger = createLogger('TeamManagement')
export function TeamManagement() {
const { data: session } = useSession()
- const queryClient = useQueryClient()
- // Fetch organizations and billing data using React Query
const { data: organizationsData } = useOrganizations()
const activeOrganization = organizationsData?.activeOrganization
const billingData = organizationsData?.billingData?.data
const hasTeamPlan = billingData?.isTeam ?? false
const hasEnterprisePlan = billingData?.isEnterprise ?? false
- // Fetch user subscription data
const { data: userSubscriptionData } = useSubscriptionData()
- const subscriptionStatus = getSubscriptionStatus(userSubscriptionData?.data)
- // Use React Query hooks for data fetching and mutations
const {
data: organization,
isLoading,
@@ -66,8 +54,8 @@ export function TeamManagement() {
const inviteMutation = useInviteMember()
const removeMemberMutation = useRemoveMember()
const updateSeatsMutation = useUpdateSeats()
+ const createOrgMutation = useCreateOrganization()
- // Track invitation success for UI feedback
const [inviteSuccess, setInviteSuccess] = useState(false)
const [inviteEmail, setInviteEmail] = useState('')
@@ -88,70 +76,15 @@ export function TeamManagement() {
const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false)
const [newSeatCount, setNewSeatCount] = useState(1)
const [isUpdatingSeats, setIsUpdatingSeats] = useState(false)
- const [isCreatingOrg, setIsCreatingOrg] = useState(false)
- const [userWorkspaces, setUserWorkspaces] = useState([])
- // Compute user role and permissions
+ const { data: adminWorkspaces = [], isLoading: isLoadingWorkspaces } = useAdminWorkspaces(
+ session?.user?.id
+ )
+
const userRole = getUserRole(organization, session?.user?.email)
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
const usedSeats = getUsedSeats(organization)
- // Load user workspaces
- const loadUserWorkspaces = useCallback(async (userId?: string) => {
- try {
- const workspacesResponse = await fetch('/api/workspaces')
- if (!workspacesResponse.ok) {
- logger.error('Failed to fetch workspaces')
- return
- }
-
- const workspacesData = await workspacesResponse.json()
- const allUserWorkspaces = workspacesData.workspaces || []
-
- // Filter to only show workspaces where user has admin permissions
- const adminWorkspaces = []
-
- for (const workspace of allUserWorkspaces) {
- try {
- const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
- if (permissionResponse.ok) {
- const permissionData = await permissionResponse.json()
-
- let hasAdminAccess = false
-
- if (userId && permissionData.users) {
- const currentUserPermission = permissionData.users.find(
- (user: any) => user.id === userId || user.userId === userId
- )
- hasAdminAccess = currentUserPermission?.permissionType === 'admin'
- }
-
- const isOwner = workspace.isOwner || workspace.ownerId === userId
-
- if (hasAdminAccess || isOwner) {
- adminWorkspaces.push({
- ...workspace,
- isOwner: isOwner,
- canInvite: true,
- })
- }
- }
- } catch (error) {
- logger.warn(`Failed to check permissions for workspace ${workspace.id}:`, error)
- }
- }
-
- setUserWorkspaces(adminWorkspaces)
- logger.info('Loaded admin workspaces for invitation', {
- total: allUserWorkspaces.length,
- adminWorkspaces: adminWorkspaces.length,
- userId: userId || 'not provided',
- })
- } catch (error) {
- logger.error('Failed to load workspaces:', error)
- }
- }, [])
-
useEffect(() => {
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
const defaultName = `${session.user.name}'s Team`
@@ -160,13 +93,6 @@ export function TeamManagement() {
}
}, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])
- const activeOrgId = activeOrganization?.id
- useEffect(() => {
- if (session?.user?.id && activeOrgId && adminOrOwner) {
- loadUserWorkspaces(session.user.id)
- }
- }, [session?.user?.id, activeOrgId, adminOrOwner, loadUserWorkspaces])
-
const handleOrgNameChange = useCallback((e: React.ChangeEvent) => {
const newName = e.target.value
setOrgName(newName)
@@ -176,58 +102,35 @@ export function TeamManagement() {
const handleCreateOrganization = useCallback(async () => {
if (!session?.user || !orgName.trim()) return
- setIsCreatingOrg(true)
try {
- const response = await fetch('/api/organizations', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- name: orgName.trim(),
- slug: orgSlug.trim(),
- }),
+ await createOrgMutation.mutateAsync({
+ name: orgName.trim(),
+ slug: orgSlug.trim(),
})
- if (!response.ok) {
- throw new Error(`Failed to create organization: ${response.statusText}`)
- }
-
- const result = await response.json()
-
- if (!result.success || !result.organizationId) {
- throw new Error('Failed to create organization')
- }
-
- // Refresh organization data using React Query
- await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
-
setCreateOrgDialogOpen(false)
setOrgName('')
setOrgSlug('')
} catch (error) {
logger.error('Failed to create organization', error)
- } finally {
- setIsCreatingOrg(false)
}
- }, [session?.user?.id, orgName, orgSlug, queryClient])
+ }, [orgName, orgSlug, createOrgMutation])
const handleInviteMember = useCallback(async () => {
- if (!session?.user || !activeOrgId || !inviteEmail.trim()) return
+ if (!session?.user || !activeOrganization?.id || !inviteEmail.trim()) return
try {
- // Map selectedWorkspaces to the format expected by the API
const workspaceInvitations =
selectedWorkspaces.length > 0
? selectedWorkspaces.map((w) => ({
id: w.workspaceId,
- name: userWorkspaces.find((uw) => uw.id === w.workspaceId)?.name || '',
+ name: adminWorkspaces.find((uw) => uw.id === w.workspaceId)?.name || '',
}))
: undefined
await inviteMutation.mutateAsync({
email: inviteEmail.trim(),
- orgId: activeOrgId,
+ orgId: activeOrganization.id,
workspaceInvitations,
})
@@ -244,10 +147,10 @@ export function TeamManagement() {
}
}, [
session?.user?.id,
- activeOrgId,
+ activeOrganization?.id,
inviteEmail,
selectedWorkspaces,
- userWorkspaces,
+ adminWorkspaces,
inviteMutation,
])
@@ -269,9 +172,8 @@ export function TeamManagement() {
const handleRemoveMember = useCallback(
async (member: any) => {
- if (!session?.user || !activeOrgId) return
+ if (!session?.user || !activeOrganization?.id) return
- // The member object should have user.id - that's the actual user ID
if (!member.user?.id) {
logger.error('Member object missing user ID', { member })
return
@@ -290,18 +192,18 @@ export function TeamManagement() {
isSelfRemoval: isLeavingSelf,
})
},
- [session?.user, activeOrgId]
+ [session?.user, activeOrganization?.id]
)
const confirmRemoveMember = useCallback(
async (shouldReduceSeats = false) => {
const { memberId } = removeMemberDialog
- if (!session?.user || !activeOrgId || !memberId) return
+ if (!session?.user || !activeOrganization?.id || !memberId) return
try {
await removeMemberMutation.mutateAsync({
memberId,
- orgId: activeOrgId,
+ orgId: activeOrganization?.id,
shouldReduceSeats,
})
setRemoveMemberDialog({
@@ -314,11 +216,11 @@ export function TeamManagement() {
logger.error('Failed to remove member', error)
}
},
- [removeMemberDialog.memberId, session?.user?.id, activeOrgId, removeMemberMutation]
+ [removeMemberDialog.memberId, session?.user?.id, activeOrganization?.id, removeMemberMutation]
)
const handleReduceSeats = useCallback(async () => {
- if (!session?.user || !activeOrgId || !subscriptionData) return
+ if (!session?.user || !activeOrganization?.id || !subscriptionData) return
if (checkEnterprisePlan(subscriptionData)) return
const currentSeats = subscriptionData.seats || 0
@@ -329,14 +231,14 @@ export function TeamManagement() {
try {
await updateSeatsMutation.mutateAsync({
- orgId: activeOrgId,
+ orgId: activeOrganization?.id,
seats: currentSeats - 1,
subscriptionId: subscriptionData.id,
})
} catch (error) {
logger.error('Failed to reduce seats', error)
}
- }, [session?.user?.id, activeOrgId, subscriptionData, usedSeats, updateSeatsMutation])
+ }, [session?.user?.id, activeOrganization?.id, subscriptionData, usedSeats, updateSeatsMutation])
const handleAddSeatDialog = useCallback(() => {
if (subscriptionData) {
@@ -347,14 +249,14 @@ export function TeamManagement() {
const confirmAddSeats = useCallback(
async (selectedSeats?: number) => {
- if (!subscriptionData || !activeOrgId) return
+ if (!subscriptionData || !activeOrganization?.id) return
const seatsToUse = selectedSeats || newSeatCount
setIsUpdatingSeats(true)
try {
await updateSeatsMutation.mutateAsync({
- orgId: activeOrgId,
+ orgId: activeOrganization?.id,
seats: seatsToUse,
subscriptionId: subscriptionData.id,
})
@@ -365,19 +267,18 @@ export function TeamManagement() {
setIsUpdatingSeats(false)
}
},
- [subscriptionData, activeOrgId, newSeatCount, updateSeatsMutation]
+ [subscriptionData, activeOrganization?.id, newSeatCount, updateSeatsMutation]
)
const confirmTeamUpgrade = useCallback(
async (seats: number) => {
- if (!session?.user || !activeOrgId) return
- logger.info('Team upgrade requested', { seats, organizationId: activeOrgId })
+ if (!session?.user || !activeOrganization?.id) return
+ logger.info('Team upgrade requested', { seats, organizationId: activeOrganization?.id })
alert(`Team upgrade to ${seats} seats - integration needed`)
},
- [session?.user?.id, activeOrgId]
+ [session?.user?.id, activeOrganization?.id]
)
- // Combine errors from different sources
const queryError = orgError || subscriptionError
const errorMessage = queryError instanceof Error ? queryError.message : null
const displayOrganization = organization || activeOrganization
@@ -459,7 +360,7 @@ export function TeamManagement() {
setOrgSlug={setOrgSlug}
onOrgNameChange={handleOrgNameChange}
onCreateOrganization={handleCreateOrganization}
- isCreatingOrg={isCreatingOrg}
+ isCreatingOrg={createOrgMutation.isPending}
error={errorMessage}
createOrgDialogOpen={createOrgDialogOpen}
setCreateOrgDialogOpen={setCreateOrgDialogOpen}
@@ -470,51 +371,6 @@ export function TeamManagement() {
return (
- {queryError && (
-
- Error
-
- {queryError instanceof Error
- ? queryError.message
- : 'Failed to load organization data'}
-
-
- )}
-
- {/* Mutation errors */}
- {inviteMutation.error && (
-
- Invitation Failed
-
- {inviteMutation.error instanceof Error
- ? inviteMutation.error.message
- : 'Failed to invite member'}
-
-
- )}
-
- {removeMemberMutation.error && (
-
- Remove Member Failed
-
- {removeMemberMutation.error instanceof Error
- ? removeMemberMutation.error.message
- : 'Failed to remove member'}
-
-
- )}
-
- {updateSeatsMutation.error && (
-
- Update Seats Failed
-
- {updateSeatsMutation.error instanceof Error
- ? updateSeatsMutation.error.message
- : 'Failed to update seats'}
-
-
- )}
-
{/* Seats Overview - Full Width */}
{adminOrOwner && (
@@ -530,16 +386,6 @@ export function TeamManagement() {
)}
- {/* Main Content: Team Members */}
-
-
-
-
{/* Action: Invite New Members */}
{adminOrOwner && (
@@ -550,17 +396,29 @@ export function TeamManagement() {
showWorkspaceInvite={showWorkspaceInvite}
setShowWorkspaceInvite={setShowWorkspaceInvite}
selectedWorkspaces={selectedWorkspaces}
- userWorkspaces={userWorkspaces}
+ userWorkspaces={adminWorkspaces}
onInviteMember={handleInviteMember}
- onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
+ onLoadUserWorkspaces={async () => {}} // No-op: data is auto-loaded by React Query
onWorkspaceToggle={handleWorkspaceToggle}
inviteSuccess={inviteSuccess}
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
maxSeats={subscriptionData?.seats || 0}
+ invitationError={inviteMutation.error}
+ isLoadingWorkspaces={isLoadingWorkspaces}
/>
)}
+ {/* Main Content: Team Members */}
+
+
+
+
{/* Additional Info - Subtle and collapsed */}
{/* Single Organization Notice */}
@@ -575,7 +433,7 @@ export function TeamManagement() {
{/* Team Information */}
-
+
Team Information
-
+
Billing Information
{
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
}}
@@ -685,6 +544,7 @@ export function TeamManagement() {
currentSeats={subscriptionData?.seats || 1}
initialSeats={newSeatCount}
isLoading={isUpdatingSeats}
+ error={updateSeatsMutation.error}
onConfirm={async (selectedSeats: number) => {
setNewSeatCount(selectedSeats)
await confirmAddSeats(selectedSeats)
diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts
index f8f260a925..74897c7cd3 100644
--- a/apps/sim/hooks/queries/organization.ts
+++ b/apps/sim/hooks/queries/organization.ts
@@ -274,7 +274,7 @@ export function useInviteMember() {
if (!response.ok) {
const error = await response.json()
- throw new Error(error.message || 'Failed to invite member')
+ throw new Error(error.error || error.message || 'Failed to invite member')
}
return response.json()
diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts
index 508ab7d6ec..47978895b2 100644
--- a/apps/sim/hooks/queries/workspace.ts
+++ b/apps/sim/hooks/queries/workspace.ts
@@ -9,6 +9,8 @@ export const workspaceKeys = {
detail: (id: string) => [...workspaceKeys.details(), id] as const,
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
+ adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
+ adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
}
/**
@@ -82,3 +84,91 @@ export function useUpdateWorkspaceSettings() {
},
})
}
+
+/**
+ * Workspace type returned by admin workspaces query
+ */
+export interface AdminWorkspace {
+ id: string
+ name: string
+ isOwner: boolean
+ ownerId?: string
+ canInvite: boolean
+}
+
+/**
+ * Fetch workspaces where user has admin access
+ */
+async function fetchAdminWorkspaces(userId: string | undefined): Promise {
+ if (!userId) {
+ return []
+ }
+
+ const workspacesResponse = await fetch('/api/workspaces')
+ if (!workspacesResponse.ok) {
+ throw new Error('Failed to fetch workspaces')
+ }
+
+ const workspacesData = await workspacesResponse.json()
+ const allUserWorkspaces = workspacesData.workspaces || []
+
+ const permissionPromises = allUserWorkspaces.map(
+ async (workspace: { id: string; name: string; isOwner?: boolean; ownerId?: string }) => {
+ try {
+ const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
+ if (!permissionResponse.ok) {
+ return null
+ }
+ const permissionData = await permissionResponse.json()
+ return { workspace, permissionData }
+ } catch (error) {
+ return null
+ }
+ }
+ )
+
+ const results = await Promise.all(permissionPromises)
+
+ const adminWorkspaces: AdminWorkspace[] = []
+ for (const result of results) {
+ if (!result) continue
+
+ const { workspace, permissionData } = result
+ let hasAdminAccess = false
+
+ if (permissionData.users) {
+ const currentUserPermission = permissionData.users.find(
+ (user: { id: string; userId?: string; permissionType: string }) =>
+ user.id === userId || user.userId === userId
+ )
+ hasAdminAccess = currentUserPermission?.permissionType === 'admin'
+ }
+
+ const isOwner = workspace.isOwner || workspace.ownerId === userId
+
+ if (hasAdminAccess || isOwner) {
+ adminWorkspaces.push({
+ id: workspace.id,
+ name: workspace.name,
+ isOwner,
+ ownerId: workspace.ownerId,
+ canInvite: true,
+ })
+ }
+ }
+
+ return adminWorkspaces
+}
+
+/**
+ * Hook to fetch workspaces where user has admin access
+ */
+export function useAdminWorkspaces(userId: string | undefined) {
+ return useQuery({
+ queryKey: workspaceKeys.adminList(userId),
+ queryFn: () => fetchAdminWorkspaces(userId),
+ enabled: Boolean(userId),
+ staleTime: 60 * 1000, // Cache for 60 seconds
+ placeholderData: keepPreviousData,
+ })
+}