From 33f9d645baa9e660c87356809f7ee2ada6352850 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 1 Jul 2026 09:54:31 -0700 Subject: [PATCH 01/28] fix(careers): remove /careers redirect so the in-app page is reachable (#5320) A pre-existing permanent redirect /careers -> jobs.ashbyhq.com/sim shadowed the new first-party careers page (#5316), sending visitors straight to the Ashby board. Drop it so /careers serves the in-app page (which itself pulls the Ashby roles). --- apps/sim/next.config.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 40c28328ee1..534ff891176 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -307,11 +307,6 @@ const nextConfig: NextConfig = { source: '/team', destination: 'https://cal.com/emirkarabeg/sim-team', permanent: false, - }, - { - source: '/careers', - destination: 'https://jobs.ashbyhq.com/sim', - permanent: true, } ) From 0575875be1d266e4dc4ec57ceae2c272a27e6992 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 1 Jul 2026 10:59:22 -0700 Subject: [PATCH 02/28] fix(custom-tool): restore modal body scroll so Save stays reachable (#5321) * fix(custom-tool): restore modal body scroll so Save stays reachable The Edit/Create Agent Tool modal clipped its footer (Save/Update) and could not scroll with long code or schema content. Root cause: #4354 migrated the modal to ChipModal and changed the body from a scroll region (flex-1 overflow-y-auto) to flex-none overflow-visible so the hand-positioned EnvVar/Tag autocompletes could spill past it. That removed the scroll region, so tall content grew the body past the modal's max-h-[84vh] cap and the overflow-hidden surface clipped the footer. Restore ChipModalBody as the scroll region (its documented behavior) and switch the EnvVar/Tag dropdowns to portaled inputRef caret-anchoring, matching the canonical Function-block editor, so they anchor to the caret in a portal and are never clipped by the scroll boundary. * fix(custom-tool): keep dropdown anchors content-relative on body scroll Review follow-up. The autocomplete popovers already portal their menus (never clipped by the body's scroll boundary), so the fix is only to restore the body as the scroll region. Reverting the dropdowns to their content-relative absolute anchors keeps them glued to the caret while the body scrolls; the caret-viewport inputRef anchoring used fixed viewport coordinates that only refreshed on edits, detaching the menu on scroll. --- .../custom-tool-modal/custom-tool-modal.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx index 0dca34ea4ce..ab399baa0de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal.tsx @@ -832,12 +832,14 @@ try { {/* - flex-none + overflow-visible opt this body out of the chrome's - scroll container: the caret-anchored EnvVar/Tag autocomplete - dropdowns are absolute-positioned inside it and must spill past - the body's bounds rather than clip against a scroll boundary. + The body is the scroll region so tall schema/code content stays inside + the modal and the footer (Next/Save) is always reachable. The EnvVar, + Tag, and schema-param autocompletes render their menus in portaled + popovers (never clipped by this scroll boundary) and anchor to a + caret-positioned element inside the editor wrapper, so the menus track + the caret as the body scrolls. */} - + Date: Wed, 1 Jul 2026 11:02:08 -0700 Subject: [PATCH 03/28] improvement(settings): react health pass across settings surface (#5324) - workflow-mcp: move tool-edit state seeding from a derive effect into the Edit event handler - admin: drop 2 inert useMemo (page math) and 1 unused useCallback - inbox + teammates: migrate filter/search view-state from useState to nuqs URL params (shareable, debounced writes) - team roster: memoize O(workspaces x members) grouping so keystroke search stays cheap at scale - remove dead code (unused props/locals: currentUserEmail, setOrgName, isLoadingWorkflows, isTeam) --- .../settings/components/admin/admin.tsx | 28 ++++------ .../billing/subscription-permissions.ts | 2 +- .../inbox-task-list/inbox-task-list.tsx | 37 ++++++++++-- .../components/inbox/search-params.ts | 32 +++++++++++ .../no-organization-view.tsx | 2 - .../organization-member-lists.tsx | 56 +++++++++++-------- .../team-management/team-management.tsx | 2 - .../components/teammates/search-params.ts | 18 ++++++ .../components/teammates/teammates.tsx | 13 ++++- .../create-workflow-mcp-server-modal.tsx | 2 - .../workflow-mcp-servers.tsx | 42 +++++++------- 11 files changed, 158 insertions(+), 76 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 56e5a9e33aa..f2fdfd98a44 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Badge, Button, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' @@ -77,11 +77,8 @@ export function Admin() { setSearchInput((current) => (current === searchQuery ? current : searchQuery)) }, [searchQuery]) - const totalPages = useMemo( - () => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE), - [usersData?.total] - ) - const currentPage = useMemo(() => Math.floor(usersOffset / PAGE_SIZE) + 1, [usersOffset]) + const totalPages = Math.ceil((usersData?.total ?? 0) / PAGE_SIZE) + const currentPage = Math.floor(usersOffset / PAGE_SIZE) + 1 const handleSuperUserModeToggle = async (checked: boolean) => { if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) { @@ -89,17 +86,14 @@ export function Admin() { } } - const handleMothershipEnvironmentChange = useCallback( - async (nextEnvironment: MothershipEnvironment) => { - if (nextEnvironment !== settings?.mothershipEnvironment && !updateSetting.isPending) { - await updateSetting.mutateAsync({ - key: 'mothershipEnvironment', - value: nextEnvironment, - }) - } - }, - [settings?.mothershipEnvironment, updateSetting] - ) + const handleMothershipEnvironmentChange = async (nextEnvironment: MothershipEnvironment) => { + if (nextEnvironment !== settings?.mothershipEnvironment && !updateSetting.isPending) { + await updateSetting.mutateAsync({ + key: 'mothershipEnvironment', + value: nextEnvironment, + }) + } + } const handleImport = () => { if (!workflowId.trim()) return diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts index 681edb0490b..94e514d1112 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions.ts @@ -36,7 +36,7 @@ export function getSubscriptionPermissions( subscription: SubscriptionState, userRole: UserRole ): SubscriptionPermissions { - const { isFree, isPro, isTeam, isEnterprise, isPaid, isOrgScoped } = subscription + const { isFree, isPro, isEnterprise, isPaid, isOrgScoped } = subscription const { isTeamAdmin } = userRole // Non-admin org members see the "team member" view: no edit / no cancel diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index f7d9280d2a4..3b52de1ea3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -1,10 +1,16 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn' import { formatRelativeTime } from '@sim/utils/formatting' import { ArrowRight, Paperclip } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { debounce, useQueryStates } from 'nuqs' +import { + type InboxStatusFilter, + inboxTaskParsers, + inboxTaskUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/inbox/search-params' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import type { InboxTaskItem } from '@/hooks/queries/inbox' import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox' @@ -18,7 +24,10 @@ const STATUS_OPTIONS = [ { value: 'rejected', label: 'Rejected' }, ] as const -type StatusFilter = (typeof STATUS_OPTIONS)[number]['value'] +type StatusFilter = InboxStatusFilter + +/** Debounce window for `search` URL writes; the input itself stays instant. */ +const SEARCH_DEBOUNCE_MS = 300 as const const STATUS_BADGES: Record< string, @@ -36,8 +45,26 @@ export function InboxTaskList() { const router = useRouter() const workspaceId = params.workspaceId as string - const [statusFilter, setStatusFilter] = useState('all') - const [searchTerm, setSearchTerm] = useState('') + const [{ status: statusFilter, search: searchTerm }, setInboxFilters] = useQueryStates( + inboxTaskParsers, + inboxTaskUrlKeys + ) + + /** + * The input is controlled directly by the instant nuqs value; only the URL + * write is debounced. Filtering below is cheap in-memory over the loaded + * tasks, so it reads the instant value too. + */ + const setSearchTerm = useCallback( + (value: string) => { + const next = value.length > 0 ? value : null + setInboxFilters( + { search: next }, + next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) } + ) + }, + [setInboxFilters] + ) const { data: config } = useInboxConfig(workspaceId) const { data: tasksData, isLoading } = useInboxTasks(workspaceId, { @@ -80,7 +107,7 @@ export function InboxTaskList() { value={statusFilter} onChange={(value) => { if (STATUS_OPTIONS.some((option) => option.value === value)) { - setStatusFilter(value as StatusFilter) + setInboxFilters({ status: value as StatusFilter }) } }} options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts new file mode 100644 index 00000000000..f21a66f2d11 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts @@ -0,0 +1,32 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' + +/** Selectable status filters for the inbox task list. */ +export const INBOX_STATUS_FILTERS = [ + 'all', + 'completed', + 'processing', + 'received', + 'failed', + 'rejected', +] as const + +export type InboxStatusFilter = (typeof INBOX_STATUS_FILTERS)[number] + +/** + * Co-located, typed URL query-param definitions for the inbox task list. + * + * - `status` is the active status filter (feeds the tasks query key). + * - `search` is the subject/sender/body name filter. The input is controlled + * directly by the nuqs value; only its URL write is debounced via + * `limitUrlUpdates` (`debounce`) on the setter — never written per keystroke. + */ +export const inboxTaskParsers = { + status: parseAsStringLiteral(INBOX_STATUS_FILTERS).withDefault('all'), + search: parseAsString.withDefault(''), +} as const + +/** Status/search view-state: clean URLs, no back-stack churn. */ +export const inboxTaskUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index f51fe878a99..f3f30c7bca0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -15,7 +15,6 @@ interface NoOrganizationViewProps { hasTeamPlan: boolean hasEnterprisePlan: boolean orgName: string - setOrgName: (name: string) => void orgSlug: string setOrgSlug: (slug: string) => void onOrgNameChange: (e: React.ChangeEvent) => void @@ -30,7 +29,6 @@ export function NoOrganizationView({ hasTeamPlan, hasEnterprisePlan, orgName, - setOrgName, orgSlug, setOrgSlug, onOrgNameChange, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index 1ae42a9543f..23582d801eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -71,7 +71,6 @@ interface OrganizationMemberListsProps { roster: OrganizationRoster | null | undefined isLoadingRoster: boolean currentUserId: string - currentUserEmail: string onRemoveMember: (member: Member) => void onTransferOwnership?: () => void } @@ -87,7 +86,6 @@ export function OrganizationMemberLists({ roster, isLoadingRoster, currentUserId, - currentUserEmail, onRemoveMember, onTransferOwnership, }: OrganizationMemberListsProps) { @@ -376,6 +374,38 @@ export function OrganizationMemberLists({ const hasOrgMatches = filteredOrgMembers.length + filteredOrgPending.length > 0 const showMembersSection = !isActiveSearch || hasOrgMatches + /** + * Group each workspace's members and pending invites once per roster change. + * This is O(workspaces × members) and independent of the search query, so + * hoisting it out of render keeps keystroke filtering cheap on large orgs. + */ + const workspaceGroups = useMemo( + () => + workspaces.map((workspace) => { + const workspaceMembers = members + .map((member) => ({ + member, + access: member.workspaces.find((w) => w.workspaceId === workspace.id), + })) + .filter((entry): entry is { member: RosterMember; access: RosterWorkspaceAccess } => + Boolean(entry.access) + ) + const workspaceInvites = pendingInvitations + .map((invitation) => ({ + invitation, + access: invitation.workspaces.find((w) => w.workspaceId === workspace.id), + })) + .filter( + ( + entry + ): entry is { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess } => + Boolean(entry.access) + ) + return { workspace, workspaceMembers, workspaceInvites } + }), + [workspaces, members, pendingInvitations] + ) + return ( <>
@@ -399,27 +429,7 @@ export function OrganizationMemberLists({ )} - {workspaces.map((workspace) => { - const workspaceMembers = members - .map((member) => ({ - member, - access: member.workspaces.find((w) => w.workspaceId === workspace.id), - })) - .filter((entry): entry is { member: RosterMember; access: RosterWorkspaceAccess } => - Boolean(entry.access) - ) - const workspaceInvites = pendingInvitations - .map((invitation) => ({ - invitation, - access: invitation.workspaces.find((w) => w.workspaceId === workspace.id), - })) - .filter( - ( - entry - ): entry is { invitation: RosterPendingInvitation; access: RosterWorkspaceAccess } => - Boolean(entry.access) - ) - + {workspaceGroups.map(({ workspace, workspaceMembers, workspaceInvites }) => { const visibleMembers = workspaceMembers.filter(({ member }) => matches(member.name, member.email) ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 60776f5da79..334d9f32e37 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -293,7 +293,6 @@ export function TeamManagement() { hasTeamPlan={hasTeamPlan} hasEnterprisePlan={hasEnterprisePlan} orgName={orgName} - setOrgName={setOrgName} orgSlug={orgSlug} setOrgSlug={setOrgSlug} onOrgNameChange={handleOrgNameChange} @@ -337,7 +336,6 @@ export function TeamManagement() { roster={roster ?? null} isLoadingRoster={isLoadingRoster} currentUserId={session?.user?.id ?? ''} - currentUserEmail={session?.user?.email ?? ''} onRemoveMember={handleRemoveMember} onTransferOwnership={handleOpenTransferDialog} /> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts new file mode 100644 index 00000000000..3366760fbc4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/search-params.ts @@ -0,0 +1,18 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definition for the Teammates view. + * + * `search` is the name/email filter. The input is controlled directly by the + * nuqs value; only its URL write is debounced via `limitUrlUpdates`. + */ +export const teammatesSearchParam = { + key: 'search', + parser: parseAsString.withDefault(''), +} as const + +/** Search view-state: clean URLs, no back-stack churn. */ +export const teammatesUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx index 5ecf4da0c52..404069d7829 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/teammates/teammates.tsx @@ -5,6 +5,7 @@ import { ChipDropdown, Plus, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' +import { debounce, useQueryState } from 'nuqs' import { RoleLockTooltip, type WorkspaceRoleSource, @@ -18,6 +19,10 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { + teammatesSearchParam, + teammatesUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/teammates/search-params' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal' import { @@ -71,7 +76,11 @@ export function Teammates() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const [searchTerm, setSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useQueryState(teammatesSearchParam.key, { + ...teammatesSearchParam.parser, + ...teammatesUrlKeys, + limitUrlUpdates: debounce(300), + }) const [isInviteModalOpen, setIsInviteModalOpen] = useState(false) const { data: permissions, isPending: permissionsLoading } = @@ -167,7 +176,7 @@ export function Teammates() { void setSearchTerm(value), placeholder: 'Search teammates...', }} actions={[ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx index 59805c404b7..a130cb3c418 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx @@ -29,7 +29,6 @@ interface CreateWorkflowMcpServerModalProps { onOpenChange: (open: boolean) => void workspaceId: string workflowOptions?: ComboboxOption[] - isLoadingWorkflows?: boolean } export function CreateWorkflowMcpServerModal({ @@ -37,7 +36,6 @@ export function CreateWorkflowMcpServerModal({ onOpenChange, workspaceId, workflowOptions, - isLoadingWorkflows = false, }: CreateWorkflowMcpServerModalProps) { const createServerMutation = useCreateWorkflowMcpServer() diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 60deec155c3..bd6121440b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -109,28 +109,28 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro const [editServerIsPublic, setEditServerIsPublic] = useState(false) const [activeServerTab, setActiveServerTab] = useState<'workflows' | 'details'>('details') - useEffect(() => { - if (toolToView) { - setEditingDescription(toolToView.toolDescription || '') - const schema = toolToView.parameterSchema as - | { properties?: Record } - | undefined - const properties = schema?.properties - if (properties) { - const descriptions: Record = {} - for (const [name, prop] of Object.entries(properties)) { - descriptions[name] = prop.description || '' - } - setEditingParameterDescriptions(descriptions) - } else { - setEditingParameterDescriptions({}) - } - } - }, [toolToView]) const [selectedWorkflowId, setSelectedWorkflowId] = useState(null) const mcpServerUrl = `${getBaseUrl()}/api/mcp/serve/${serverId}` + const handleOpenToolEdit = (tool: WorkflowMcpTool) => { + setToolToView(tool) + setEditingDescription(tool.toolDescription || '') + const schema = tool.parameterSchema as + | { properties?: Record } + | undefined + const properties = schema?.properties + if (properties) { + const descriptions: Record = {} + for (const [name, prop] of Object.entries(properties)) { + descriptions[name] = prop.description || '' + } + setEditingParameterDescriptions(descriptions) + } else { + setEditingParameterDescriptions({}) + } + } + const handleDeleteTool = async () => { if (!toolToDelete) return try { @@ -425,7 +425,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro setToolToView(tool) }, + { label: 'Edit', onSelect: () => handleOpenToolEdit(tool) }, { label: 'Remove', destructive: true, @@ -860,8 +860,7 @@ export function WorkflowMcpServers() { const searchParams = useSearchParams() const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId) - const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = - useDeployedWorkflows(workspaceId) + const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId) const deleteServerMutation = useDeleteWorkflowMcpServer() const [searchTerm, setSearchTerm] = useState('') @@ -1004,7 +1003,6 @@ export function WorkflowMcpServers() { onOpenChange={setShowAddModal} workspaceId={workspaceId} workflowOptions={workflowOptions} - isLoadingWorkflows={isLoadingWorkflows} /> Date: Wed, 1 Jul 2026 11:19:08 -0700 Subject: [PATCH 04/28] fix(connectors): allow self-hosted private DB hosts via opt-in flag (#5322) * fix(connectors): allow self-hosted private DB hosts via opt-in flag Database/connector tools rejected any host resolving to a private/reserved/ loopback IP, blocking the common self-hosted topology where the DB is reached by a Docker/K8s/Swarm service name. Add an opt-in ALLOW_PRIVATE_DATABASE_HOSTS flag that bypasses the private-host block in validateDatabaseHost while still resolving and pinning DNS. Blocked on the hosted platform regardless of the env var, mirroring DISABLE_AUTH. Fixes #4319 * fix(connectors): pin postgres IP in all ssl modes; strip IPv6 brackets Address review on #5322: - validateDatabaseHost now strips surrounding IPv6 brackets before the localhost/private-IP checks and DNS lookup, so a bracketed loopback like [::1] is classified correctly instead of failing as unresolvable. - PostgreSQL connector always connects to the validated, pinned IP (removed the ssl='preferred' carve-out that passed the original hostname and let the driver re-resolve during connection). Matches the MySQL/MongoDB pin pattern. - Add postgres connector pinning tests and bracketed-IPv6 host tests. * fix(connectors): rename flag to isPrivateDatabaseHostsAllowed; trim comment - Rename env-flag const to satisfy the env-flags 'is' prefix CI check (env var ALLOW_PRIVATE_DATABASE_HOSTS is unchanged). - Tighten the postgres pinning comment to a single line. --- apps/sim/.env.example | 3 + .../app/api/tools/postgresql/utils.test.ts | 73 +++++++++++++++++ apps/sim/app/api/tools/postgresql/utils.ts | 4 +- apps/sim/lib/core/config/env-flags.ts | 29 +++++++ apps/sim/lib/core/config/env.ts | 1 + .../core/security/input-validation.server.ts | 22 +++-- .../core/security/input-validation.test.ts | 81 +++++++++++++++++++ packages/testing/src/mocks/env-flags.mock.ts | 1 + 8 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 apps/sim/app/api/tools/postgresql/utils.test.ts diff --git a/apps/sim/.env.example b/apps/sim/.env.example index d26ff64e52f..4083be6cdb0 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -12,6 +12,9 @@ BETTER_AUTH_URL=http://localhost:3000 # Authentication Bypass (Optional - for self-hosted deployments behind private networks) # DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests. +# Private Database Hosts (Optional - for self-hosted deployments only) +# ALLOW_PRIVATE_DATABASE_HOSTS=true # Uncomment to let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names, localhost). Loosens the SSRF boundary; only enable on a trusted private network. + # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL diff --git a/apps/sim/app/api/tools/postgresql/utils.test.ts b/apps/sim/app/api/tools/postgresql/utils.test.ts new file mode 100644 index 00000000000..a02f96b950f --- /dev/null +++ b/apps/sim/app/api/tools/postgresql/utils.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PostgresConnectionConfig } from '@/tools/postgresql/types' + +const { mockValidateDatabaseHost, mockPostgres } = vi.hoisted(() => ({ + mockValidateDatabaseHost: vi.fn(), + mockPostgres: vi.fn(() => ({})), +})) + +vi.mock('postgres', () => ({ default: mockPostgres })) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateDatabaseHost: mockValidateDatabaseHost, +})) + +import { createPostgresConnection } from '@/app/api/tools/postgresql/utils' + +function makeConfig(overrides: Partial = {}): PostgresConnectionConfig { + return { + host: 'db.example.com', + port: 5432, + database: 'app', + username: 'app', + password: 'secret', + ssl: 'required', + ...overrides, + } +} + +describe('createPostgresConnection DNS pinning', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateDatabaseHost.mockResolvedValue({ + isValid: true, + resolvedIP: '93.184.216.34', + originalHostname: 'db.example.com', + }) + }) + + it('never opens a connection when host validation fails (no SSRF window)', async () => { + mockValidateDatabaseHost.mockResolvedValue({ + isValid: false, + error: 'host resolves to a blocked IP address', + }) + + await expect( + createPostgresConnection(makeConfig({ host: 'rebind.attacker.example' })) + ).rejects.toThrow('host resolves to a blocked IP address') + expect(mockPostgres).not.toHaveBeenCalled() + }) + + it.each(['disabled', 'required', 'preferred'] as const)( + 'connects to the validated IP for ssl=%s (hostname never re-resolved)', + async (ssl) => { + await createPostgresConnection(makeConfig({ host: 'rebind.attacker.example', ssl })) + + expect(mockValidateDatabaseHost).toHaveBeenCalledWith('rebind.attacker.example', 'host') + const options = mockPostgres.mock.calls[0][0] + // The TCP target is always the validated IP — re-resolution can never happen. + expect(options.host).toBe('93.184.216.34') + } + ) + + it('preserves the hostname as the TLS servername for verifying ssl modes', async () => { + await createPostgresConnection(makeConfig({ host: 'db.example.com', ssl: 'required' })) + + const options = mockPostgres.mock.calls[0][0] + expect(options.host).toBe('93.184.216.34') + expect(options.ssl).toMatchObject({ servername: 'db.example.com' }) + }) +}) diff --git a/apps/sim/app/api/tools/postgresql/utils.ts b/apps/sim/app/api/tools/postgresql/utils.ts index dfeeab9eadb..983f983288c 100644 --- a/apps/sim/app/api/tools/postgresql/utils.ts +++ b/apps/sim/app/api/tools/postgresql/utils.ts @@ -9,7 +9,6 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) } const resolvedHost = hostValidation.resolvedIP ?? config.host - const pinIP = config.ssl !== 'preferred' const sslConfig: boolean | 'prefer' | { rejectUnauthorized: boolean; servername?: string } = config.ssl === 'disabled' @@ -19,7 +18,8 @@ export async function createPostgresConnection(config: PostgresConnectionConfig) : { rejectUnauthorized: false, servername: config.host } const sql = postgres({ - host: pinIP ? resolvedHost : config.host, + // Pin the validated IP (never the hostname) to prevent DNS rebinding; SNI stays the hostname above. + host: resolvedHost, port: config.port, database: config.database, username: config.username, diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 63037fd3cb1..ce8aef23e50 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -74,6 +74,35 @@ if (isTruthy(env.DISABLE_AUTH)) { }) } +/** + * Whether database/connector tools may connect to private, reserved, or loopback + * hosts (e.g. Docker/K8s service names, localhost). Off by default: the SSRF guard + * in {@link validateDatabaseHost} blocks these so an untrusted user cannot pivot + * into the deployment's internal network. Self-hosted operators can opt in when + * their database lives on the same private network. Blocked on the hosted platform + * regardless of the env var, mirroring {@link isAuthDisabled}. + */ +export const isPrivateDatabaseHostsAllowed = isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS) && !isHosted + +if (isTruthy(env.ALLOW_PRIVATE_DATABASE_HOSTS)) { + import('@sim/logger') + .then(({ createLogger }) => { + const logger = createLogger('EnvFlags') + if (isHosted) { + logger.error( + 'ALLOW_PRIVATE_DATABASE_HOSTS is set but ignored on hosted environment. Private/reserved database hosts remain blocked for security.' + ) + } else { + logger.warn( + 'ALLOW_PRIVATE_DATABASE_HOSTS is enabled. Database/connector tools may reach private, reserved, and loopback hosts. Only use this in trusted private networks.' + ) + } + }) + .catch(() => { + // Fallback during config compilation when logger is unavailable + }) +} + /** * Is user registration disabled */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7bc8eb44d9a..91659f96398 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -33,6 +33,7 @@ export const env = createEnv({ DISABLE_REGISTRATION: z.boolean().optional(), // Flag to disable new user registration EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Enable email/password authentication (server-side enforcement) DISABLE_AUTH: z.boolean().optional(), // Bypass authentication entirely (self-hosted only, creates anonymous session) + ALLOW_PRIVATE_DATABASE_HOSTS: z.boolean().optional(), // Opt-in (self-hosted only): let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names). Loosens the SSRF boundary; ignored on the hosted platform. ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com") diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index bd344e38997..a81853c5514 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import * as ipaddr from 'ipaddr.js' import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici' -import { isHosted } from '@/lib/core/config/env-flags' +import { isHosted, isPrivateDatabaseHostsAllowed } from '@/lib/core/config/env-flags' import { type ValidationResult, validateExternalUrl } from '@/lib/core/security/input-validation' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' @@ -152,6 +152,12 @@ export async function validateUrlWithDNS( * database hostnames (e.g. underscores in Docker/K8s service names). It only * blocks localhost and private/reserved IPs. * + * Self-hosted operators can set `ALLOW_PRIVATE_DATABASE_HOSTS` to reach databases + * on their private network (e.g. a Docker/Swarm service name that resolves to an + * internal IP). The opt-in only bypasses the private/reserved/loopback block; DNS + * is still resolved so the caller can pin the connection to the resolved IP. The + * bypass is never honored on the hosted platform (see {@link isPrivateDatabaseHostsAllowed}). + * * @param host - The database hostname to validate * @param paramName - Name of the parameter for error messages * @returns AsyncValidationResult with resolved IP @@ -165,19 +171,25 @@ export async function validateDatabaseHost( } const lowerHost = host.toLowerCase() + const cleanHost = + lowerHost.startsWith('[') && lowerHost.endsWith(']') ? lowerHost.slice(1, -1) : lowerHost - if (lowerHost === 'localhost') { + if (cleanHost === 'localhost' && !isPrivateDatabaseHostsAllowed) { return { isValid: false, error: `${paramName} cannot be localhost` } } - if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) { + if ( + ipaddr.isValid(cleanHost) && + isPrivateOrReservedIP(cleanHost) && + !isPrivateDatabaseHostsAllowed + ) { return { isValid: false, error: `${paramName} cannot be a private IP address` } } try { - const { address } = await dns.lookup(host, { verbatim: true }) + const { address } = await dns.lookup(cleanHost, { verbatim: true }) - if (isPrivateOrReservedIP(address)) { + if (isPrivateOrReservedIP(address) && !isPrivateDatabaseHostsAllowed) { logger.warn('Database host resolves to blocked IP address', { paramName, hostname: host, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 7e8be4caa12..e1fbf1f7171 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -28,6 +28,7 @@ import { } from '@/lib/core/security/input-validation' import { isPrivateOrReservedIP, + validateDatabaseHost, validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { sanitizeForLogging } from '@/lib/core/security/redaction' @@ -762,6 +763,86 @@ describe('validateUrlWithDNS', () => { }) }) +describe('validateDatabaseHost', () => { + afterEach(() => { + envFlagsMock.isPrivateDatabaseHostsAllowed = false + }) + + describe('default (SSRF guard on)', () => { + it('rejects a missing host', async () => { + const result = await validateDatabaseHost(undefined) + expect(result.isValid).toBe(false) + expect(result.error).toContain('required') + }) + + it('rejects localhost', async () => { + const result = await validateDatabaseHost('localhost') + expect(result.isValid).toBe(false) + expect(result.error).toContain('localhost') + }) + + it('rejects a literal private IP', async () => { + const result = await validateDatabaseHost('10.0.0.5') + expect(result.isValid).toBe(false) + expect(result.error).toContain('private IP') + }) + + it('rejects a literal loopback IP', async () => { + const result = await validateDatabaseHost('127.0.0.1') + expect(result.isValid).toBe(false) + expect(result.error).toContain('private IP') + }) + + it('rejects a bracketed IPv6 loopback as a private IP (not unresolvable)', async () => { + const result = await validateDatabaseHost('[::1]') + expect(result.isValid).toBe(false) + expect(result.error).toContain('private IP') + }) + + it('accepts a public IP and pins the resolved address', async () => { + const result = await validateDatabaseHost('1.1.1.1') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('1.1.1.1') + }) + }) + + describe('self-host opt-in (ALLOW_PRIVATE_DATABASE_HOSTS)', () => { + beforeEach(() => { + envFlagsMock.isPrivateDatabaseHostsAllowed = true + }) + + it('allows localhost and still resolves an IP to pin', async () => { + const result = await validateDatabaseHost('localhost') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBeDefined() + }) + + it('allows a literal private IP and pins it', async () => { + const result = await validateDatabaseHost('10.0.0.5') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('10.0.0.5') + }) + + it('allows a literal loopback IP and pins it', async () => { + const result = await validateDatabaseHost('127.0.0.1') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('127.0.0.1') + }) + + it('allows a bracketed IPv6 loopback and pins the unbracketed address', async () => { + const result = await validateDatabaseHost('[::1]') + expect(result.isValid).toBe(true) + expect(result.resolvedIP).toBe('::1') + }) + + it('still surfaces unresolvable hostnames', async () => { + const result = await validateDatabaseHost('this-host-does-not-exist.invalid') + expect(result.isValid).toBe(false) + expect(result.error).toContain('could not be resolved') + }) + }) +}) + describe('validateInteger', () => { describe('valid integers', () => { it.concurrent('should accept positive integers', () => { diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 6165c236fae..597d572090a 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -17,6 +17,7 @@ export const envFlagsMock = { isBillingEnabled: false, isEmailVerificationEnabled: false, isAuthDisabled: false, + isPrivateDatabaseHostsAllowed: false, isRegistrationDisabled: false, isEmailPasswordEnabled: false, isTriggerDevEnabled: false, From 78661d25ff773ab53c427a7ca3c7c947727a7584 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 1 Jul 2026 11:34:01 -0700 Subject: [PATCH 05/28] fix(providers): drop deprecated temperature capability from claude-sonnet-5 (#5328) Claude Sonnet 5 rejects the temperature parameter with a 400 ("`temperature` is deprecated for this model"), verified against the live Anthropic API. The model entry exposed temperature: { min: 0, max: 1 }, so supportsTemperature() returned true and the Anthropic request builder sent temperature whenever thinking was disabled, breaking those runs. Remove the capability (matching Opus 4.7/4.8, which omit it for the same reason) so temperature is never sent for Sonnet 5. Add a supportsTemperature regression assertion. --- apps/sim/providers/models.ts | 1 - apps/sim/providers/utils.test.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index 0c3e01ac01e..2cb735f928a 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -678,7 +678,6 @@ export const PROVIDER_DEFINITIONS: Record = { updatedAt: '2026-06-30', }, capabilities: { - temperature: { min: 0, max: 1 }, nativeStructuredOutputs: true, maxOutputTokens: 128000, thinking: { diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index e6eaf2122d1..41f76b6c3f6 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -214,6 +214,7 @@ describe('Model Capabilities', () => { it.concurrent('should return false for models that do not support temperature', () => { const unsupportedModels = [ 'unsupported-model', + 'claude-sonnet-5', 'cerebras/llama-3.3-70b', 'o1', 'o3', From c957aa2b94c1ec2df8a02b8c2e6b57e18edd0f16 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 1 Jul 2026 12:06:54 -0700 Subject: [PATCH 06/28] improvement(settings): react-doctor perf & correctness pass (#5327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(settings): react-doctor perf & correctness pass - combine multi-pass array iterations into single passes (api-keys, credential-sets, secrets-manager, team-management) - cache/hoist Intl formatters to module scope (billing); hoist pure functions and inline-default constants - stabilize react-query array fallbacks so memos stop recomputing while loading (api-keys, byok, workflow-mcp) - fix create-workflow-mcp modal reset via render-phase prevOpen compare instead of a state-adjusting effect - accessibility: aria-labels on real inputs, aria-hidden on autofill decoys, native setName(e.target.value)} onKeyDown={handleKeyDown} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx index f3976f33415..b8eb1683031 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle.tsx @@ -27,16 +27,13 @@ export function InboxEnableToggle() { const [isDisableOpen, setIsDisableOpen] = useState(false) const [enableUsername, setEnableUsername] = useState('') - const handleToggle = useCallback( - async (checked: boolean) => { - if (checked) { - setIsEnableOpen(true) - return - } - setIsDisableOpen(true) - }, - [workspaceId] - ) + const handleToggle = useCallback(async (checked: boolean) => { + if (checked) { + setIsEnableOpen(true) + return + } + setIsDisableOpen(true) + }, []) const handleDisable = useCallback(async () => { try { @@ -45,7 +42,7 @@ export function InboxEnableToggle() { } catch (error) { logger.error('Failed to disable inbox', { error }) } - }, [workspaceId]) + }, [workspaceId, toggleInbox.mutateAsync]) const handleEnable = useCallback(async () => { try { @@ -59,7 +56,7 @@ export function InboxEnableToggle() { } catch (error) { logger.error('Failed to enable inbox', { error }) } - }, [workspaceId, enableUsername]) + }, [workspaceId, enableUsername, toggleInbox.mutateAsync]) return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx index 942fd5d8acd..4bb93b6a2ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-settings-tab/inbox-settings-tab.tsx @@ -65,7 +65,7 @@ export function InboxSettingsTab() { } catch (error) { setEditAddressError(getErrorMessage(error, 'Failed to update address')) } - }, [workspaceId, newUsername]) + }, [workspaceId, newUsername, updateAddress.mutateAsync]) const handleAddSender = useCallback(async () => { if (!newSenderEmail.trim()) return @@ -82,7 +82,7 @@ export function InboxSettingsTab() { } catch (error) { setAddSenderError(getErrorMessage(error, 'Failed to add sender')) } - }, [workspaceId, newSenderEmail, newSenderLabel]) + }, [workspaceId, newSenderEmail, newSenderLabel, addSender.mutateAsync]) const handleRemoveSender = useCallback( async (senderId: string) => { @@ -93,7 +93,7 @@ export function InboxSettingsTab() { setRemoveSenderError(getErrorMessage(error, 'Failed to remove sender')) } }, - [workspaceId] + [workspaceId, removeSender.mutateAsync] ) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx index 3b52de1ea3f..6b4e88013c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-task-list/inbox-task-list.tsx @@ -133,25 +133,13 @@ export function InboxTaskList() { const statusBadge = STATUS_BADGES[task.status] || STATUS_BADGES.received const isClickable = task.chatId && (task.status === 'completed' || task.status === 'failed') - return ( -
handleTaskClick(task)} - onKeyDown={(e) => { - if (isClickable && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault() - handleTaskClick(task) - } - }} - > + const rowClassName = `flex w-full items-center gap-2.5 rounded-lg p-2 text-left transition-colors ${ + isClickable + ? 'cursor-pointer hover-hover:bg-[var(--surface-active)]' + : 'cursor-default' + }` + const rowContent = ( + <>
@@ -202,6 +190,21 @@ export function InboxTaskList() { )}
+ + ) + + return isClickable ? ( + + ) : ( +
+ {rowContent}
) })} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx index a7bbfc7d4a0..9c93dd8fa01 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx @@ -672,6 +672,7 @@ export function McpServerFormModal({ type='text' name='fakeusernameremembered' autoComplete='username' + aria-hidden='true' style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly @@ -680,6 +681,7 @@ export function McpServerFormModal({ type='password' name='fakepasswordremembered' autoComplete='current-password' + aria-hidden='true' style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 78f91e8510e..e1f79ef2cd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -303,7 +303,7 @@ export function RecentlyDeleted() { } const col = (activeSort ?? DEFAULT_SORT).column const dir = (activeSort ?? DEFAULT_SORT).direction - items = [...items].sort((a, b) => { + items.sort((a, b) => { let cmp = 0 switch (col) { case 'name': diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index 84b226c14ea..b49c4e801e5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -173,10 +173,14 @@ function parseEnvVarLine(line: string): UIEnvironmentVariable | null { /** Parses an array of raw text lines, returning only valid non-empty KEY=VALUE entries. */ function parseValidEnvVars(lines: string[]): UIEnvironmentVariable[] { - return lines - .map(parseEnvVarLine) - .filter((parsed): parsed is UIEnvironmentVariable => parsed !== null) - .filter(({ key, value }) => key && value) + const result: UIEnvironmentVariable[] = [] + for (const line of lines) { + const parsed = parseEnvVarLine(line) + if (parsed?.key && parsed.value) { + result.push(parsed) + } + } + return result } interface WorkspaceVariableRowProps { @@ -771,9 +775,10 @@ export function SecretsManager() { } const personalChanged = (() => { - const initialMap = new Map( - initialVarsRef.current.filter((v) => v.key && v.value).map((v) => [v.key, v.value]) - ) + const initialMap = new Map() + for (const v of initialVarsRef.current) { + if (v.key && v.value) initialMap.set(v.key, v.value) + } const currentKeys = Object.keys(validVariables) if (initialMap.size !== currentKeys.length) return true for (const [key, value] of Object.entries(validVariables)) { @@ -912,13 +917,14 @@ export function SecretsManager() { return ( <> -
+ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx index f3f30c7bca0..5b91277a2aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -65,6 +65,8 @@ export function NoOrganizationView({ style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }} tabIndex={-1} readOnly + aria-hidden='true' + aria-label='Ignore this field' />