fix(settings): redirect unavailable tabs to general (#6710)

This commit is contained in:
Theodore Li
2026-08-14 18:10:38 -04:00
committed by GitHub
parent d5701d5b2b
commit 41923b8e95
2 changed files with 198 additions and 6 deletions
@@ -0,0 +1,179 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockCanOpenOrganizationSettingsSection,
mockGetSession,
mockGetWorkspaceHostContext,
mockHasWorkspaceInboxAccess,
mockHasWorkspaceSandboxAccess,
mockIsForkingAvailable,
mockIsOrganizationOnEnterprisePlan,
mockIsOrganizationSettingsSectionAvailable,
mockNotFound,
mockRedirect,
mockResolveWorkspaceGroup,
mockResolveWorkspaceNavigation,
} = vi.hoisted(() => ({
mockCanOpenOrganizationSettingsSection: vi.fn(),
mockGetSession: vi.fn(),
mockGetWorkspaceHostContext: vi.fn(),
mockHasWorkspaceInboxAccess: vi.fn(),
mockHasWorkspaceSandboxAccess: vi.fn(),
mockIsForkingAvailable: vi.fn(),
mockIsOrganizationOnEnterprisePlan: vi.fn(),
mockIsOrganizationSettingsSectionAvailable: vi.fn(),
mockNotFound: vi.fn(() => {
throw new Error('NEXT_NOT_FOUND')
}),
mockRedirect: vi.fn((href: string) => {
throw new Error(`NEXT_REDIRECT:${href}`)
}),
mockResolveWorkspaceGroup: vi.fn(),
mockResolveWorkspaceNavigation: vi.fn(),
}))
vi.mock('next/navigation', () => ({
notFound: mockNotFound,
redirect: mockRedirect,
}))
vi.mock('@/components/settings/navigation', () => ({
getOrganizationSettingsFeatures: vi.fn(() => ({})),
isOrganizationSettingsSectionAvailable: mockIsOrganizationSettingsSectionAvailable,
resolveWorkspaceNavigation: mockResolveWorkspaceNavigation,
}))
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))
vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
}))
vi.mock('@/lib/billing/core/subscription', () => ({
hasWorkspaceInboxAccess: mockHasWorkspaceInboxAccess,
hasWorkspaceSandboxAccess: mockHasWorkspaceSandboxAccess,
}))
vi.mock('@/lib/core/config/env', () => ({
getEnv: vi.fn(),
isTruthy: vi.fn(() => false),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isBillingEnabled: true,
isHosted: true,
}))
vi.mock('@/lib/organizations/settings-access', () => ({
canOpenOrganizationSettingsSection: mockCanOpenOrganizationSettingsSection,
}))
vi.mock('@/lib/permissions/super-user', () => ({
isPlatformAdmin: vi.fn(() => false),
}))
vi.mock('@/lib/workspaces/host-context', () => ({
getWorkspaceHostContextForViewer: mockGetWorkspaceHostContext,
}))
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: vi.fn(),
}))
vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
allNavigationItems: [{ id: 'general' }, { id: 'billing' }, { id: 'secrets' }, { id: 'sessions' }],
getSettingsSectionMeta: vi.fn(() => null),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
resolveWorkspaceGroup: mockResolveWorkspaceGroup,
}))
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
isForkingAvailableForWorkspace: mockIsForkingAvailable,
}))
vi.mock('@/app/workspace/[workspaceId]/settings/[section]/prefetch', () => ({
prefetchGeneralSettings: vi.fn(),
}))
vi.mock('@/app/workspace/[workspaceId]/settings/[section]/settings', () => ({
SettingsPage: vi.fn(() => null),
}))
import WorkspaceSettingsSectionPage from '@/app/workspace/[workspaceId]/settings/[section]/page'
const PERSONAL_HOST_CONTEXT = {
workspace: {
id: 'workspace-b',
billedAccountUserId: 'owner-b',
},
hostOrganizationId: null,
ownerBilling: {
isEnterprise: false,
},
viewer: {
permission: 'admin',
isHostOrganizationAdmin: false,
},
}
function pageProps(section: string) {
return {
params: Promise.resolve({ workspaceId: 'workspace-b', section }),
}
}
describe('WorkspaceSettingsSectionPage unavailable sections', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } })
mockGetWorkspaceHostContext.mockResolvedValue(PERSONAL_HOST_CONTEXT)
mockResolveWorkspaceNavigation.mockReturnValue([])
mockResolveWorkspaceGroup.mockResolvedValue(null)
mockIsForkingAvailable.mockResolvedValue(false)
mockHasWorkspaceInboxAccess.mockResolvedValue(false)
mockHasWorkspaceSandboxAccess.mockResolvedValue(false)
mockCanOpenOrganizationSettingsSection.mockResolvedValue(false)
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(false)
mockIsOrganizationSettingsSectionAvailable.mockReturnValue(true)
})
it('redirects an unavailable subscription section to General', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('billing'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
)
})
it('redirects a workspace section hidden in the destination workspace to General', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('secrets'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
)
})
it('redirects an organization section when the destination has no organization', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('sessions'))).rejects.toThrow(
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
)
})
it('keeps unknown settings sections fail-fast', async () => {
await expect(WorkspaceSettingsSectionPage(pageProps('unknown'))).rejects.toThrow(
'NEXT_NOT_FOUND'
)
expect(mockGetWorkspaceHostContext).not.toHaveBeenCalled()
})
it('keeps inaccessible workspaces fail-fast', async () => {
mockGetWorkspaceHostContext.mockResolvedValue(null)
await expect(WorkspaceSettingsSectionPage(pageProps('general'))).rejects.toThrow(
'NEXT_NOT_FOUND'
)
})
})
@@ -67,6 +67,7 @@ const ORGANIZATION_SECTION_MAP: Partial<Record<SettingsSection, OrganizationSett
'access-control': 'access-control',
'audit-logs': 'audit-logs',
sso: 'sso',
sessions: 'sessions',
'data-retention': 'data-retention',
'data-drains': 'data-drains',
whitelabeling: 'whitelabeling',
@@ -79,6 +80,14 @@ function parseSection(section: string): SettingsSection | null {
: null
}
/**
* Settings availability varies across workspaces, so a preserved section may
* need to land on the destination workspace's universally available page.
*/
function redirectToGeneralSettings(workspaceId: string): never {
redirect(`/workspace/${workspaceId}/settings/general`)
}
export async function generateMetadata({
params,
}: WorkspaceSettingsSectionPageProps): Promise<Metadata> {
@@ -131,20 +140,24 @@ export default async function WorkspaceSettingsSectionPage({
sandboxes,
},
})
if (!navigation.some((item) => item.id === workspaceSection)) notFound()
if (!navigation.some((item) => item.id === workspaceSection)) {
redirectToGeneralSettings(workspaceId)
}
}
const organizationSection = ORGANIZATION_SECTION_MAP[parsed]
if (organizationSection) {
if (!isBillingEnabled && (parsed === 'billing' || parsed === 'organization')) {
redirect(`/workspace/${workspaceId}/settings/general`)
redirectToGeneralSettings(workspaceId)
}
if (!hostContext.hostOrganizationId) {
if (parsed !== 'billing' || hostContext.workspace.billedAccountUserId !== session.user.id) {
notFound()
redirectToGeneralSettings(workspaceId)
}
} else {
if (!hostContext.viewer.isHostOrganizationAdmin) notFound()
if (!hostContext.viewer.isHostOrganizationAdmin) {
redirectToGeneralSettings(workspaceId)
}
if (
!(await canOpenOrganizationSettingsSection(
hostContext.hostOrganizationId,
@@ -152,7 +165,7 @@ export default async function WorkspaceSettingsSectionPage({
organizationSection
))
) {
notFound()
redirectToGeneralSettings(workspaceId)
}
const hasEnterprisePlan =
organizationSection !== 'members' &&
@@ -164,7 +177,7 @@ export default async function WorkspaceSettingsSectionPage({
getOrganizationSettingsFeatures(hasEnterprisePlan)
)
) {
notFound()
redirectToGeneralSettings(workspaceId)
}
}
}