Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,58 @@
import { notFound, redirect } from 'next/navigation'
import {
SettingsHeaderProvider,
SettingsHeaderShell,
} from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'

/**
* Persistent chrome for the settings panel pages. The header bar, title,
* description, scroll region, and centered column live in the shell and stay
* mounted across section navigation — only the body swaps. Scoped to `[section]`
* so detail routes (e.g. `secrets/[credentialId]`) keep their own chrome.
* Sections that were promoted out of settings into their own workspace routes. Kept as
* segment-level rewrites so old links and bookmarks still land somewhere sensible.
*/
export default function SettingsSectionLayout({ children }: { children: React.ReactNode }) {
const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => string>> = {
integrations: (workspaceId) => `/workspace/${workspaceId}/integrations`,
skills: (workspaceId) => `/workspace/${workspaceId}/skills`,
/** Cookie preferences moved into General. */
privacy: (workspaceId) => `/workspace/${workspaceId}/settings/general?view=privacy`,
}

/**
* Persistent chrome for the settings panel pages: the header bar, title, description, scroll
* region and centered column. Scoped to `[section]` so detail routes (e.g.
* `secrets/[credentialId]`) keep their own chrome.
*
* The heading is resolved here rather than pushed up from the section body, so it renders with
* the shell instead of waiting on the body's lazily-loaded chunk.
*
* Whether a segment names a section at all is decided here too, above the sibling
* `loading.tsx`. Inside that Suspense boundary a `notFound()` or `redirect()` can no longer set
* the response status — React replays the boundary on the client and the shell still flushes
* 200 — so a bad or legacy URL loaded directly would answer 200 and redirect in a second round
* trip. Deciding it above the boundary keeps the 404 and the 307. Whether the *viewer* may open
* a section is a different question and stays in the page, where it belongs; those checks need
* the database and are reached almost entirely by client navigation.
*
* Authentication is already enforced by the ancestor workspace layout, so this runs only for a
* signed-in viewer.
*/
export default async function SettingsSectionLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ workspaceId: string; section: string }>
}) {
const { workspaceId, section } = await params

const topLevelHref = TOP_LEVEL_REDIRECTS[section]?.(workspaceId)
if (topLevelHref) redirect(topLevelHref)

const resolved = resolveSettingsSection(section)
if (!resolved) notFound()

return (
<SettingsHeaderProvider>
<SettingsHeaderShell>{children}</SettingsHeaderShell>
<SettingsHeaderShell meta={resolved.meta}>{children}</SettingsHeaderShell>
</SettingsHeaderProvider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Route-transition fallback for the workspace settings sections.
*
* Its job is to exist. Without a loading boundary the App Router holds the outgoing section on
* screen until the incoming page's access gate resolves, so a click reads as a dead click; with
* one, the navigation commits immediately and the heading changes with it. It is also what
* makes the sidebar's `router.prefetch` worth anything — with no loading boundary in the
* subtree the scheduler skips the segment request entirely, and an `AUTO` prefetch caches the
* shell only as far as the nearest boundary.
*
* It renders no body of its own because the shell that owns the header, heading and scroll
* region renders above it and is already resolved by this point. That lands in the same place
* as the two neighbouring settings fallbacks — credit-usage renders its title and description
* over an empty body, `ResourceChromeFallback` renders a real header and column headers over
* zero rows — without restating chrome this route already has.
*/
export default function WorkspaceSettingsSectionLoading() {
return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ const {
mockCanOpenOrganizationSettingsSection,
mockGetSession,
mockGetWorkspaceHostContext,
mockHasWorkspaceInboxAccess,
mockHasWorkspaceSandboxAccess,
mockIsForkingAvailable,
mockIsOrganizationOnEnterprisePlan,
mockIsOrganizationSettingsSectionAvailable,
Expand All @@ -20,8 +18,6 @@ const {
mockCanOpenOrganizationSettingsSection: vi.fn(),
mockGetSession: vi.fn(),
mockGetWorkspaceHostContext: vi.fn(),
mockHasWorkspaceInboxAccess: vi.fn(),
mockHasWorkspaceSandboxAccess: vi.fn(),
mockIsForkingAvailable: vi.fn(),
mockIsOrganizationOnEnterprisePlan: vi.fn(),
mockIsOrganizationSettingsSectionAvailable: vi.fn(),
Expand Down Expand Up @@ -54,11 +50,6 @@ vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
}))

vi.mock('@/lib/billing/core/subscription', () => ({
hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess,
hasWorkspaceSandboxAccess: mockHasWorkspaceSandboxAccess,
}))

vi.mock('@/lib/core/config/env', () => ({
env: {},
getEnv: vi.fn(),
Expand All @@ -84,11 +75,30 @@ vi.mock('@/lib/workspaces/host-context', () => ({
}))

vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: vi.fn(),
getQueryClient: mockGetQueryClient,
}))

const { mockGetQueryClient, mockPrefetchGeneralSettings } = vi.hoisted(() => ({
mockGetQueryClient: vi.fn(),
mockPrefetchGeneralSettings: vi.fn(),
}))

const { mockSections, mockAliases } = vi.hoisted(() => ({
mockSections: ['general', 'billing', 'secrets', 'sessions', 'admin'],
/** Mirrors the real alias table so a legacy segment behaves here as it does in production. */
mockAliases: {
subscription: 'billing',
team: 'organization',
'api-keys': 'apikeys',
domains: 'sso',
} as Record<string, string>,
}))

vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
allNavigationItems: [{ id: 'general' }, { id: 'billing' }, { id: 'secrets' }, { id: 'sessions' }],
resolveSettingsSection: vi.fn((section: string) => {
const id = mockAliases[section] ?? section
return mockSections.includes(id) ? { id, meta: { title: id } } : null
}),
getSettingsSectionMeta: vi.fn(() => null),
}))

Expand All @@ -101,13 +111,14 @@ vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
}))

vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({
prefetchGeneralSettings: vi.fn(),
prefetchGeneralSettings: mockPrefetchGeneralSettings,
}))

vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({
SettingsPage: vi.fn(() => null),
}))

import { QueryClient } from '@tanstack/react-query'
import WorkspaceSettingsSectionPage from '@/app/workspace/[workspaceId]/settings/[section]/page'

const PERSONAL_HOST_CONTEXT = {
Expand Down Expand Up @@ -139,11 +150,10 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
mockResolveWorkspaceNavigation.mockReturnValue([])
mockResolveWorkspaceGroup.mockResolvedValue(null)
mockIsForkingAvailable.mockResolvedValue(false)
mockHasWorkspaceInboxAccess.mockResolvedValue(false)
mockHasWorkspaceSandboxAccess.mockResolvedValue(false)
mockCanOpenOrganizationSettingsSection.mockResolvedValue(false)
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
mockIsOrganizationSettingsSectionAvailable.mockReturnValue(true)
mockGetQueryClient.mockReturnValue(new QueryClient())
})

it('redirects an unavailable subscription section to General', async () => {
Expand Down Expand Up @@ -171,6 +181,29 @@ describe('WorkspaceSettingsSectionPage unavailable sections', () => {
expect(mockGetWorkspaceHostContext).not.toHaveBeenCalled()
})

it('hydrates general settings only for the sections whose body reads them', async () => {
// The saving this gate exists for: the other ~25 sections no longer block on a query they
// never touch. `general` still does, and so does an alias that resolves onto the set.
mockResolveWorkspaceNavigation.mockReturnValue([{ id: 'secrets' }])

await WorkspaceSettingsSectionPage(pageProps('general'))
expect(mockPrefetchGeneralSettings).toHaveBeenCalledTimes(1)

mockPrefetchGeneralSettings.mockClear()
await WorkspaceSettingsSectionPage(pageProps('secrets'))
expect(mockPrefetchGeneralSettings).not.toHaveBeenCalled()
})

it('gates the hydration on the resolved section, not the raw segment', async () => {
// `/settings/subscription` is a legacy link for billing, which does read the key. Billing on
// a personal workspace is only reachable by the billed account owner.
mockGetSession.mockResolvedValue({ user: { id: 'owner-b' } })

await WorkspaceSettingsSectionPage(pageProps('subscription'))

expect(mockPrefetchGeneralSettings).toHaveBeenCalledTimes(1)
})

it('keeps inaccessible workspaces fail-fast', async () => {
mockGetWorkspaceHostContext.mockResolvedValue(null)

Expand Down
Loading
Loading