mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-03 19:24:57 -05:00
fix(sidebar): right-click replaces selection, reset popover hover state (#3123)
* fix(sidebar): right-click replaces selection, reset popover hover state * fix(queries): add userId to superuser query key for cache isolation
This commit is contained in:
@@ -45,7 +45,7 @@ export function CredentialSelector({
|
||||
previewValue,
|
||||
}: CredentialSelectorProps) {
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
@@ -128,11 +128,7 @@ export function CredentialSelector({
|
||||
return ''
|
||||
}, [selectedCredentialSet, isForeignCredentialSet, selectedCredential, isForeign])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(resolvedLabel)
|
||||
}
|
||||
}, [resolvedLabel, isEditing])
|
||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||
|
||||
const invalidSelection =
|
||||
!isPreview &&
|
||||
@@ -295,7 +291,7 @@ export function CredentialSelector({
|
||||
const selectedCredentialProvider = selectedCredential?.provider ?? provider
|
||||
|
||||
const overlayContent = useMemo(() => {
|
||||
if (!inputValue) return null
|
||||
if (!displayValue) return null
|
||||
|
||||
if (isCredentialSetSelected && selectedCredentialSet) {
|
||||
return (
|
||||
@@ -303,7 +299,7 @@ export function CredentialSelector({
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
<Users className='h-3 w-3' />
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
<span className='truncate'>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -313,12 +309,12 @@ export function CredentialSelector({
|
||||
<div className='mr-2 flex-shrink-0 opacity-90'>
|
||||
{getProviderIcon(selectedCredentialProvider)}
|
||||
</div>
|
||||
<span className='truncate'>{inputValue}</span>
|
||||
<span className='truncate'>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
}, [
|
||||
getProviderIcon,
|
||||
inputValue,
|
||||
displayValue,
|
||||
selectedCredentialProvider,
|
||||
isCredentialSetSelected,
|
||||
selectedCredentialSet,
|
||||
@@ -335,7 +331,6 @@ export function CredentialSelector({
|
||||
const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length)
|
||||
const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId)
|
||||
if (matchedSet) {
|
||||
setInputValue(matchedSet.name)
|
||||
handleCredentialSetSelect(credentialSetId)
|
||||
return
|
||||
}
|
||||
@@ -343,13 +338,12 @@ export function CredentialSelector({
|
||||
|
||||
const matchedCred = credentials.find((c) => c.id === value)
|
||||
if (matchedCred) {
|
||||
setInputValue(matchedCred.name)
|
||||
handleSelect(value)
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
setInputValue(value)
|
||||
setEditingValue(value)
|
||||
},
|
||||
[credentials, credentialSets, handleAddCredential, handleSelect, handleCredentialSetSelect]
|
||||
)
|
||||
@@ -359,7 +353,7 @@ export function CredentialSelector({
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
groups={comboboxGroups}
|
||||
value={inputValue}
|
||||
value={displayValue}
|
||||
selectedValue={rawSelectedId}
|
||||
onChange={handleComboboxChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
|
||||
@@ -673,6 +673,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
|
||||
/**
|
||||
* Opens the detail view for a specific server.
|
||||
* Note: Tool refresh is handled by the useEffect that watches selectedServerId
|
||||
*/
|
||||
const handleViewDetails = useCallback((serverId: string) => {
|
||||
setSelectedServerId(serverId)
|
||||
|
||||
@@ -63,6 +63,7 @@ import { ssoKeys, useSSOProviders } from '@/ee/sso/hooks/sso'
|
||||
import { generalSettingsKeys, useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useSuperUserStatus } from '@/hooks/queries/user-profile'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
|
||||
@@ -204,13 +205,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const { data: generalSettings } = useGeneralSettings()
|
||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||
const { data: superUserData } = useSuperUserStatus(session?.user?.id)
|
||||
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
@@ -229,22 +230,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||
const hasOrganization = !!activeOrganization?.id
|
||||
|
||||
// Fetch superuser status
|
||||
useEffect(() => {
|
||||
const fetchSuperUserStatus = async () => {
|
||||
if (!userId) return
|
||||
try {
|
||||
const response = await fetch('/api/user/super-user')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setIsSuperUser(data.isSuperUser)
|
||||
}
|
||||
} catch {
|
||||
setIsSuperUser(false)
|
||||
}
|
||||
}
|
||||
fetchSuperUserStatus()
|
||||
}, [userId])
|
||||
const isSuperUser = superUserData?.isSuperUser ?? false
|
||||
|
||||
// Memoize SSO provider ownership check
|
||||
const isSSOProviderOwner = useMemo(() => {
|
||||
@@ -328,7 +314,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
generalSettings?.superUserModeEnabled,
|
||||
])
|
||||
|
||||
// Memoized callbacks to prevent infinite loops in child components
|
||||
const effectiveActiveSection = useMemo(() => {
|
||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||
return 'general'
|
||||
}
|
||||
return activeSection
|
||||
}, [activeSection])
|
||||
|
||||
const registerEnvironmentBeforeLeaveHandler = useCallback(
|
||||
(handler: (onProceed: () => void) => void) => {
|
||||
environmentBeforeLeaveHandler.current = handler
|
||||
@@ -342,19 +334,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(sectionId: SettingsSection) => {
|
||||
if (sectionId === activeSection) return
|
||||
if (sectionId === effectiveActiveSection) return
|
||||
|
||||
if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
if (effectiveActiveSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
|
||||
return
|
||||
}
|
||||
|
||||
setActiveSection(sectionId)
|
||||
},
|
||||
[activeSection]
|
||||
[effectiveActiveSection]
|
||||
)
|
||||
|
||||
// Apply initial section from store when modal opens
|
||||
useEffect(() => {
|
||||
if (open && initialSection) {
|
||||
setActiveSection(initialSection)
|
||||
@@ -365,7 +356,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}, [open, initialSection, mcpServerId, clearInitialState])
|
||||
|
||||
// Clear pending server ID when section changes away from MCP
|
||||
useEffect(() => {
|
||||
if (activeSection !== 'mcp') {
|
||||
setPendingMcpServerId(null)
|
||||
@@ -391,14 +381,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}, [onOpenChange])
|
||||
|
||||
// Redirect away from billing tabs if billing is disabled
|
||||
useEffect(() => {
|
||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||
setActiveSection('general')
|
||||
}
|
||||
}, [activeSection])
|
||||
|
||||
// Prefetch functions for React Query
|
||||
const prefetchGeneral = () => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: generalSettingsKeys.settings(),
|
||||
@@ -489,9 +471,17 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
// Handle dialog close - delegate to environment component if it's active
|
||||
const handleDialogOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen && activeSection === 'environment' && environmentBeforeLeaveHandler.current) {
|
||||
if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'environment' &&
|
||||
environmentBeforeLeaveHandler.current
|
||||
) {
|
||||
environmentBeforeLeaveHandler.current(() => onOpenChange(false))
|
||||
} else if (!newOpen && activeSection === 'integrations' && integrationsCloseHandler.current) {
|
||||
} else if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'integrations' &&
|
||||
integrationsCloseHandler.current
|
||||
) {
|
||||
integrationsCloseHandler.current(newOpen)
|
||||
} else {
|
||||
onOpenChange(newOpen)
|
||||
@@ -522,7 +512,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
{sectionItems.map((item) => (
|
||||
<SModalSidebarItem
|
||||
key={item.id}
|
||||
active={activeSection === item.id}
|
||||
active={effectiveActiveSection === item.id}
|
||||
icon={<item.icon />}
|
||||
onMouseEnter={() => handlePrefetch(item.id)}
|
||||
onClick={() => handleSectionChange(item.id)}
|
||||
@@ -538,35 +528,36 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
<SModalMain>
|
||||
<SModalMainHeader>
|
||||
{navigationItems.find((item) => item.id === activeSection)?.label || activeSection}
|
||||
{navigationItems.find((item) => item.id === effectiveActiveSection)?.label ||
|
||||
effectiveActiveSection}
|
||||
</SModalMainHeader>
|
||||
<SModalMainBody>
|
||||
{activeSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'environment' && (
|
||||
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'environment' && (
|
||||
<EnvironmentVariables
|
||||
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'template-profile' && <TemplateProfile />}
|
||||
{activeSection === 'integrations' && (
|
||||
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
|
||||
{effectiveActiveSection === 'integrations' && (
|
||||
<Integrations
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={registerIntegrationsCloseHandler}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'credential-sets' && <CredentialSets />}
|
||||
{activeSection === 'access-control' && <AccessControl />}
|
||||
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
|
||||
{activeSection === 'sso' && <SSO />}
|
||||
{activeSection === 'byok' && <BYOK />}
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{activeSection === 'debug' && <Debug />}
|
||||
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
|
||||
{effectiveActiveSection === 'access-control' && <AccessControl />}
|
||||
{effectiveActiveSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && effectiveActiveSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && effectiveActiveSection === 'team' && <TeamManagement />}
|
||||
{effectiveActiveSection === 'sso' && <SSO />}
|
||||
{effectiveActiveSection === 'byok' && <BYOK />}
|
||||
{effectiveActiveSection === 'copilot' && <Copilot />}
|
||||
{effectiveActiveSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{effectiveActiveSection === 'custom-tools' && <CustomTools />}
|
||||
{effectiveActiveSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{effectiveActiveSection === 'debug' && <Debug />}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -231,6 +231,8 @@ export function FolderItem({
|
||||
const isFolderSelected = store.selectedFolders.has(folder.id)
|
||||
|
||||
if (!isFolderSelected) {
|
||||
// Replace selection with just this folder (Finder/Explorer pattern)
|
||||
store.clearAllSelection()
|
||||
store.selectFolder(folder.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,9 @@ export function WorkflowItem({
|
||||
const isCurrentlySelected = store.selectedWorkflows.has(workflow.id)
|
||||
|
||||
if (!isCurrentlySelected) {
|
||||
// Replace selection with just this item (Finder/Explorer pattern)
|
||||
// This clears both workflow and folder selections
|
||||
store.clearAllSelection()
|
||||
store.selectWorkflow(workflow.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,9 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
setIsKeyboardNav(false)
|
||||
setSelectedIndex(-1)
|
||||
registeredItemsRef.current = []
|
||||
} else {
|
||||
// Reset hover state when opening to prevent stale submenu from previous menu
|
||||
setLastHoveredItem(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const logger = createLogger('UserProfileQuery')
|
||||
export const userProfileKeys = {
|
||||
all: ['userProfile'] as const,
|
||||
profile: () => [...userProfileKeys.all, 'profile'] as const,
|
||||
superUser: (userId?: string) => [...userProfileKeys.all, 'superUser', userId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,3 +110,37 @@ export function useUpdateUserProfile() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Superuser status response type
|
||||
*/
|
||||
interface SuperUserStatus {
|
||||
isSuperUser: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch superuser status from API
|
||||
*/
|
||||
async function fetchSuperUserStatus(): Promise<SuperUserStatus> {
|
||||
const response = await fetch('/api/user/super-user')
|
||||
|
||||
if (!response.ok) {
|
||||
return { isSuperUser: false }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { isSuperUser: data.isSuperUser ?? false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch superuser status
|
||||
* @param userId - User ID for cache isolation (required for proper per-user caching)
|
||||
*/
|
||||
export function useSuperUserStatus(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: userProfileKeys.superUser(userId),
|
||||
queryFn: fetchSuperUserStatus,
|
||||
enabled: Boolean(userId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - superuser status rarely changes
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user