Skip to content

Commit 94d5ade

Browse files
committed
fix(secrets): restore unsaved-changes guard for settings tab navigation
- Add useSettingsDirtyStore (stores/settings/dirty) to track dirty state across the settings sidebar and section components - Wire credentials-manager and integrations-manager to sync dirty state to the store and clean up on unmount; also reset store synchronously in handleDiscardAndNavigate - Update settings-sidebar to check dirty state before tab switches and Back navigation, showing an Unsaved Changes dialog if needed - Remove dead stores/settings/environment directory; move EnvironmentVariable type into lib/environment/api
1 parent 8e11c32 commit 94d5ade

10 files changed

Lines changed: 151 additions & 34 deletions

File tree

apps/sim/app/api/environment/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
1010
import { generateRequestId } from '@/lib/core/utils/request'
1111
import { generateId } from '@/lib/core/utils/uuid'
1212
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
13-
import type { EnvironmentVariable } from '@/stores/settings/environment'
13+
import type { EnvironmentVariable } from '@/lib/environment/api'
1414

1515
const logger = createLogger('EnvironmentAPI')
1616

apps/sim/app/workspace/[workspaceId]/settings/components/credentials/credentials-manager.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
type WorkspaceEnvironmentData,
5252
} from '@/hooks/queries/environment'
5353
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
54+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
5455

5556
const logger = createLogger('SecretsManager')
5657

@@ -482,6 +483,15 @@ export function CredentialsManager() {
482483
hasChangesRef.current = hasChanges
483484
shouldBlockNavRef.current = hasChanges || isDetailsDirty
484485

486+
const setNavGuardDirty = useSettingsDirtyStore((s) => s.setDirty)
487+
const resetNavGuard = useSettingsDirtyStore((s) => s.reset)
488+
489+
useEffect(() => {
490+
setNavGuardDirty(hasChanges || isDetailsDirty)
491+
}, [hasChanges, isDetailsDirty, setNavGuardDirty])
492+
493+
useEffect(() => () => resetNavGuard(), [resetNavGuard])
494+
485495
// --- Effects ---
486496
useEffect(() => {
487497
if (hasSavedRef.current) return
@@ -981,6 +991,7 @@ export function CredentialsManager() {
981991

982992
const handleDiscardAndNavigate = useCallback(() => {
983993
shouldBlockNavRef.current = false
994+
resetNavGuard()
984995
resetToSaved()
985996
setSelectedCredentialId(null)
986997

@@ -989,7 +1000,7 @@ export function CredentialsManager() {
9891000
pendingNavigationUrlRef.current = null
9901001
router.push(url)
9911002
}
992-
}, [router, resetToSaved])
1003+
}, [router, resetToSaved, resetNavGuard])
9931004

9941005
const renderEnvVarRow = useCallback(
9951006
(envVar: UIEnvironmentVariable, originalIndex: number) => {

apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
} from '@/hooks/queries/oauth/oauth-connections'
5555
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
5656
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
57+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
5758

5859
const logger = createLogger('IntegrationsManager')
5960

@@ -247,6 +248,15 @@ export function IntegrationsManager() {
247248

248249
const isDetailsDirty = isDescriptionDirty || isDisplayNameDirty
249250

251+
const setNavGuardDirty = useSettingsDirtyStore((s) => s.setDirty)
252+
const resetNavGuard = useSettingsDirtyStore((s) => s.reset)
253+
254+
useEffect(() => {
255+
setNavGuardDirty(isDetailsDirty)
256+
}, [isDetailsDirty, setNavGuardDirty])
257+
258+
useEffect(() => () => resetNavGuard(), [resetNavGuard])
259+
250260
const handleSaveDetails = async () => {
251261
if (!selectedCredential || !isSelectedAdmin || !isDetailsDirty || updateCredential.isPending)
252262
return

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
'use client'
22

3-
import { useCallback, useMemo } from 'react'
3+
import { useCallback, useMemo, useState } from 'react'
44
import { useQueryClient } from '@tanstack/react-query'
55
import { useParams, usePathname, useRouter } from 'next/navigation'
6-
import { ChevronDown, Skeleton } from '@/components/emcn'
6+
import {
7+
Button,
8+
ChevronDown,
9+
Modal,
10+
ModalBody,
11+
ModalContent,
12+
ModalFooter,
13+
ModalHeader,
14+
Skeleton,
15+
} from '@/components/emcn'
716
import { useSession } from '@/lib/auth/auth-client'
817
import { getSubscriptionAccessState } from '@/lib/billing/client'
918
import { isHosted } from '@/lib/core/config/feature-flags'
@@ -23,6 +32,7 @@ import { useOrganizations } from '@/hooks/queries/organization'
2332
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
2433
import { usePermissionConfig } from '@/hooks/use-permission-config'
2534
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
35+
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
2636

2737
const SKELETON_SECTIONS = [3, 2, 2] as const
2838

@@ -41,6 +51,13 @@ export function SettingsSidebar({
4151
const router = useRouter()
4252

4353
const queryClient = useQueryClient()
54+
55+
const requestNavigation = useSettingsDirtyStore((s) => s.requestNavigation)
56+
const confirmNavigation = useSettingsDirtyStore((s) => s.confirmNavigation)
57+
const cancelNavigation = useSettingsDirtyStore((s) => s.cancelNavigation)
58+
const isDirty = useSettingsDirtyStore((s) => s.isDirty)
59+
const [showDiscardDialog, setShowDiscardDialog] = useState(false)
60+
4461
const { data: session, isPending: sessionLoading } = useSession()
4562
const { data: organizationsData, isLoading: orgsLoading } = useOrganizations()
4663
const { data: generalSettings } = useGeneralSettings()
@@ -180,8 +197,28 @@ export function SettingsSidebar({
180197
const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation()
181198

182199
const handleBack = useCallback(() => {
200+
if (isDirty) {
201+
setShowDiscardDialog(true)
202+
return
203+
}
183204
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
184-
}, [router, popSettingsReturnUrl, workspaceId])
205+
}, [router, popSettingsReturnUrl, workspaceId, isDirty])
206+
207+
const handleConfirmDiscard = useCallback(() => {
208+
const section = confirmNavigation()
209+
setShowDiscardDialog(false)
210+
if (section) {
211+
router.replace(getSettingsHref({ section }), { scroll: false })
212+
} else {
213+
// Triggered by the back button — no pending section was set
214+
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
215+
}
216+
}, [confirmNavigation, router, getSettingsHref, popSettingsReturnUrl, workspaceId])
217+
218+
const handleCancelDiscard = useCallback(() => {
219+
cancelNavigation()
220+
setShowDiscardDialog(false)
221+
}, [cancelNavigation])
185222

186223
return (
187224
<>
@@ -286,11 +323,14 @@ export function SettingsSidebar({
286323
className={itemClassName}
287324
onMouseEnter={() => handlePrefetch(item.id)}
288325
onFocus={() => handlePrefetch(item.id)}
289-
onClick={() =>
290-
router.replace(getSettingsHref({ section: item.id as SettingsSection }), {
291-
scroll: false,
292-
})
293-
}
326+
onClick={() => {
327+
const section = item.id as SettingsSection
328+
if (!requestNavigation(section)) {
329+
setShowDiscardDialog(true)
330+
return
331+
}
332+
router.replace(getSettingsHref({ section }), { scroll: false })
333+
}}
294334
>
295335
{content}
296336
</button>
@@ -312,6 +352,25 @@ export function SettingsSidebar({
312352
})
313353
)}
314354
</div>
355+
356+
<Modal open={showDiscardDialog} onOpenChange={(open) => !open && handleCancelDiscard()}>
357+
<ModalContent size='sm'>
358+
<ModalHeader>Unsaved Changes</ModalHeader>
359+
<ModalBody>
360+
<p className='text-[var(--text-secondary)]'>
361+
You have unsaved changes. Are you sure you want to discard them?
362+
</p>
363+
</ModalBody>
364+
<ModalFooter>
365+
<Button variant='default' onClick={handleCancelDiscard}>
366+
Keep Editing
367+
</Button>
368+
<Button variant='destructive' onClick={handleConfirmDiscard}>
369+
Discard Changes
370+
</Button>
371+
</ModalFooter>
372+
</ModalContent>
373+
</Modal>
315374
</>
316375
)
317376
}

apps/sim/hooks/queries/environment.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { createLogger } from '@sim/logger'
22
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
3-
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
3+
import type { EnvironmentVariable, WorkspaceEnvironmentData } from '@/lib/environment/api'
44
import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api'
55
import { workspaceCredentialKeys } from '@/hooks/queries/credentials'
66
import { API_ENDPOINTS } from '@/stores/constants'
7-
import type { EnvironmentVariable } from '@/stores/settings/environment'
87

9-
export type { WorkspaceEnvironmentData } from '@/lib/environment/api'
10-
export type { EnvironmentVariable } from '@/stores/settings/environment'
8+
export type { EnvironmentVariable, WorkspaceEnvironmentData } from '@/lib/environment/api'
119

1210
const logger = createLogger('EnvironmentQueries')
1311

apps/sim/lib/environment/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { API_ENDPOINTS } from '@/stores/constants'
2-
import type { EnvironmentVariable } from '@/stores/settings/environment'
2+
3+
export interface EnvironmentVariable {
4+
key: string
5+
value: string
6+
}
37

48
export interface WorkspaceEnvironmentData {
59
workspace: Record<string, string>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { create } from 'zustand'
2+
import { devtools } from 'zustand/middleware'
3+
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
4+
5+
interface SettingsDirtyStore {
6+
isDirty: boolean
7+
pendingSection: SettingsSection | null
8+
setDirty: (dirty: boolean) => void
9+
/**
10+
* Call before navigating to a new section. Returns `true` if navigation may
11+
* proceed immediately; returns `false` if there are unsaved changes — in that
12+
* case `pendingSection` is set so a confirmation dialog can be shown.
13+
*/
14+
requestNavigation: (section: SettingsSection) => boolean
15+
/** Clears dirty + pending state and returns the section to navigate to. */
16+
confirmNavigation: () => SettingsSection | null
17+
/** Cancels a pending navigation without clearing dirty state. */
18+
cancelNavigation: () => void
19+
/** Resets all state — call on component unmount. */
20+
reset: () => void
21+
}
22+
23+
const initialState = {
24+
isDirty: false,
25+
pendingSection: null as SettingsSection | null,
26+
}
27+
28+
export const useSettingsDirtyStore = create<SettingsDirtyStore>()(
29+
devtools(
30+
(set, get) => ({
31+
...initialState,
32+
33+
setDirty: (dirty) => set({ isDirty: dirty }),
34+
35+
requestNavigation: (section) => {
36+
if (!get().isDirty) return true
37+
set({ pendingSection: section })
38+
return false
39+
},
40+
41+
confirmNavigation: () => {
42+
const { pendingSection } = get()
43+
set({ ...initialState })
44+
return pendingSection
45+
},
46+
47+
cancelNavigation: () => set({ pendingSection: null }),
48+
49+
reset: () => set({ ...initialState }),
50+
}),
51+
{ name: 'settings-dirty-store' }
52+
)
53+
)

apps/sim/stores/settings/environment/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

apps/sim/stores/settings/environment/types.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

apps/sim/tools/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { createLogger } from '@sim/logger'
22
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
3+
import type { EnvironmentVariable } from '@/lib/environment/api'
34
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
45
import type { CustomToolDefinition } from '@/hooks/queries/custom-tools'
56
import { environmentKeys } from '@/hooks/queries/environment'
6-
import type { EnvironmentVariable } from '@/stores/settings/environment'
77
import { tools } from '@/tools/registry'
88
import type { ToolConfig } from '@/tools/types'
99

0 commit comments

Comments
 (0)