Skip to content

Commit 8010d92

Browse files
fix(settings): redirect unavailable tabs to general
1 parent d5701d5 commit 8010d92

2 files changed

Lines changed: 198 additions & 6 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const {
7+
mockCanOpenOrganizationSettingsSection,
8+
mockGetSession,
9+
mockGetWorkspaceHostContext,
10+
mockHasWorkspaceInboxAccess,
11+
mockHasWorkspaceSandboxAccess,
12+
mockIsForkingAvailable,
13+
mockIsOrganizationOnEnterprisePlan,
14+
mockIsOrganizationSettingsSectionAvailable,
15+
mockNotFound,
16+
mockRedirect,
17+
mockResolveWorkspaceGroup,
18+
mockResolveWorkspaceNavigation,
19+
} = vi.hoisted(() => ({
20+
mockCanOpenOrganizationSettingsSection: vi.fn(),
21+
mockGetSession: vi.fn(),
22+
mockGetWorkspaceHostContext: vi.fn(),
23+
mockHasWorkspaceInboxAccess: vi.fn(),
24+
mockHasWorkspaceSandboxAccess: vi.fn(),
25+
mockIsForkingAvailable: vi.fn(),
26+
mockIsOrganizationOnEnterprisePlan: vi.fn(),
27+
mockIsOrganizationSettingsSectionAvailable: vi.fn(),
28+
mockNotFound: vi.fn(() => {
29+
throw new Error('NEXT_NOT_FOUND')
30+
}),
31+
mockRedirect: vi.fn((href: string) => {
32+
throw new Error(`NEXT_REDIRECT:${href}`)
33+
}),
34+
mockResolveWorkspaceGroup: vi.fn(),
35+
mockResolveWorkspaceNavigation: vi.fn(),
36+
}))
37+
38+
vi.mock('next/navigation', () => ({
39+
notFound: mockNotFound,
40+
redirect: mockRedirect,
41+
}))
42+
43+
vi.mock('@/components/settings/navigation', () => ({
44+
getOrganizationSettingsFeatures: vi.fn(() => ({})),
45+
isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable,
46+
resolveWorkspaceNavigation: mockResolveWorkspaceNavigation,
47+
}))
48+
49+
vi.mock('@/lib/auth', () => ({
50+
getSession: mockGetSession,
51+
}))
52+
53+
vi.mock('@/lib/billing', () => ({
54+
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
55+
}))
56+
57+
vi.mock('@/lib/billing/core/subscription', () => ({
58+
hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess,
59+
hasWorkspaceSandboxAccess: mockHasWorkspaceSandboxAccess,
60+
}))
61+
62+
vi.mock('@/lib/core/config/env', () => ({
63+
getEnv: vi.fn(),
64+
isTruthy: vi.fn(() => false),
65+
}))
66+
67+
vi.mock('@/lib/core/config/env-flags', () => ({
68+
isBillingEnabled: true,
69+
isHosted: true,
70+
}))
71+
72+
vi.mock('@/lib/organizations/settings-access', () => ({
73+
canOpenOrganizationSettingsSection: mockCanOpenOrganizationSettingsSection,
74+
}))
75+
76+
vi.mock('@/lib/permissions/super-user', () => ({
77+
isPlatformAdmin: vi.fn(() => false),
78+
}))
79+
80+
vi.mock('@/lib/workspaces/host-context', () => ({
81+
getWorkspaceHostContextForViewer: mockGetWorkspaceHostContext,
82+
}))
83+
84+
vi.mock('@/app/_shell/providers/get-query-client', () => ({
85+
getQueryClient: vi.fn(),
86+
}))
87+
88+
vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
89+
allNavigationItems: [{ id: 'general' }, { id: 'billing' }, { id: 'secrets' }, { id: 'sessions' }],
90+
getSettingsSectionMeta: vi.fn(() => null),
91+
}))
92+
93+
vi.mock('@/ee/access-control/utils/permission-check', () => ({
94+
resolveWorkspaceGroup: mockResolveWorkspaceGroup,
95+
}))
96+
97+
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
98+
isForkingAvailableForWorkspace: mockIsForkingAvailable,
99+
}))
100+
101+
vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({
102+
prefetchGeneralSettings: vi.fn(),
103+
}))
104+
105+
vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({
106+
SettingsPage: vi.fn(() => null),
107+
}))
108+
109+
import WorkspaceSettingsSectionPage from '@/app/workspace/[workspaceId]/settings/[section]/page'
110+
111+
const PERSONAL_HOST_CONTEXT = {
112+
workspace: {
113+
id: 'workspace-b',
114+
billedAccountUserId: 'owner-b',
115+
},
116+
hostOrganizationId: null,
117+
ownerBilling: {
118+
isEnterprise: false,
119+
},
120+
viewer: {
121+
permission: 'admin',
122+
isHostOrganizationAdmin: false,
123+
},
124+
}
125+
126+
function pageProps(section: string) {
127+
return {
128+
params: Promise.resolve({ workspaceId: 'workspace-b', section }),
129+
}
130+
}
131+
132+
describe('WorkspaceSettingsSectionPage unavailable sections', () => {
133+
beforeEach(() => {
134+
vi.clearAllMocks()
135+
mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } })
136+
mockGetWorkspaceHostContext.mockResolvedValue(PERSONAL_HOST_CONTEXT)
137+
mockResolveWorkspaceNavigation.mockReturnValue([])
138+
mockResolveWorkspaceGroup.mockResolvedValue(null)
139+
mockIsForkingAvailable.mockResolvedValue(false)
140+
mockHasWorkspaceInboxAccess.mockResolvedValue(false)
141+
mockHasWorkspaceSandboxAccess.mockResolvedValue(false)
142+
mockCanOpenOrganizationSettingsSection.mockResolvedValue(false)
143+
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
144+
mockIsOrganizationSettingsSectionAvailable.mockReturnValue(true)
145+
})
146+
147+
it('redirects an unavailable subscription section to General', async () => {
148+
await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow(
149+
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
150+
)
151+
})
152+
153+
it('redirects a workspace section hidden in the destination workspace to General', async () => {
154+
await expect(WorkspaceSettingsSectionPage(pageProps('secrets'))).rejects.toThrow(
155+
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
156+
)
157+
})
158+
159+
it('redirects an organization section when the destination has no organization', async () => {
160+
await expect(WorkspaceSettingsSectionPage(pageProps('sessions'))).rejects.toThrow(
161+
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
162+
)
163+
})
164+
165+
it('keeps unknown settings sections fail-fast', async () => {
166+
await expect(WorkspaceSettingsSectionPage(pageProps('unknown'))).rejects.toThrow(
167+
'NEXT_NOT_FOUND'
168+
)
169+
expect(mockGetWorkspaceHostContext).not.toHaveBeenCalled()
170+
})
171+
172+
it('keeps inaccessible workspaces fail-fast', async () => {
173+
mockGetWorkspaceHostContext.mockResolvedValue(null)
174+
175+
await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow(
176+
'NEXT_NOT_FOUND'
177+
)
178+
})
179+
})

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const ORGANIZATION_SECTION_MAP: Partial<Record<SettingsSection, OrganizationSett
6767
'access-control': 'access-control',
6868
'audit-logs': 'audit-logs',
6969
sso: 'sso',
70+
sessions: 'sessions',
7071
'data-retention': 'data-retention',
7172
'data-drains': 'data-drains',
7273
whitelabeling: 'whitelabeling',
@@ -79,6 +80,14 @@ function parseSection(section: string): SettingsSection | null {
7980
: null
8081
}
8182

83+
/**
84+
* Settings availability varies across workspaces, so a preserved section may
85+
* need to land on the destination workspace's universally available page.
86+
*/
87+
function redirectToGeneralSettings(workspaceId: string): never {
88+
redirect(`/workspace/${workspaceId}/settings/general`)
89+
}
90+
8291
export async function generateMetadata({
8392
params,
8493
}: WorkspaceSettingsSectionPageProps): Promise<Metadata> {
@@ -131,28 +140,32 @@ export default async function WorkspaceSettingsSectionPage({
131140
sandboxes,
132141
},
133142
})
134-
if (!navigation.some((item) => item.id === workspaceSection)) notFound()
143+
if (!navigation.some((item) => item.id === workspaceSection)) {
144+
redirectToGeneralSettings(workspaceId)
145+
}
135146
}
136147

137148
const organizationSection = ORGANIZATION_SECTION_MAP[parsed]
138149
if (organizationSection) {
139150
if (!isBillingEnabled && (parsed === 'billing' || parsed === 'organization')) {
140-
redirect(`/workspace/${workspaceId}/settings/general`)
151+
redirectToGeneralSettings(workspaceId)
141152
}
142153
if (!hostContext.hostOrganizationId) {
143154
if (parsed !== 'billing' || hostContext.workspace.billedAccountUserId !== session.user.id) {
144-
notFound()
155+
redirectToGeneralSettings(workspaceId)
145156
}
146157
} else {
147-
if (!hostContext.viewer.isHostOrganizationAdmin) notFound()
158+
if (!hostContext.viewer.isHostOrganizationAdmin) {
159+
redirectToGeneralSettings(workspaceId)
160+
}
148161
if (
149162
!(await canOpenOrganizationSettingsSection(
150163
hostContext.hostOrganizationId,
151164
session.user.id,
152165
organizationSection
153166
))
154167
) {
155-
notFound()
168+
redirectToGeneralSettings(workspaceId)
156169
}
157170
const hasEnterprisePlan =
158171
organizationSection !== 'members' &&
@@ -164,7 +177,7 @@ export default async function WorkspaceSettingsSectionPage({
164177
getOrganizationSettingsFeatures(hasEnterprisePlan)
165178
)
166179
) {
167-
notFound()
180+
redirectToGeneralSettings(workspaceId)
168181
}
169182
}
170183
}

0 commit comments

Comments
 (0)