Skip to content

Commit 93249e5

Browse files
authored
fix(projects): gate the Project list on the projects rollout flag (#9052)
* fix(projects): gate the Project list on the projects flag and answer 403 when it is off * fix(projects): hide cached Projects once the projects flag is off * chore(projects): retrigger CI * fix(projects): keep the disabled-flag 503 that realtime treats as transient
1 parent ce1612a commit 93249e5

7 files changed

Lines changed: 122 additions & 3 deletions

File tree

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag'
1212
import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags'
1313
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
1414
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
15+
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1516
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
1617
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1718
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
@@ -73,6 +74,7 @@ export default async function OrganizationLayout({
7374
computerUseEnabled,
7475
workflowTestsEnabled,
7576
changelogEnabled,
77+
projectsEnabled,
7678
] = await Promise.all([
7779
prefetchOrganizationSidebar(
7880
queryClient,
@@ -89,6 +91,7 @@ export default async function OrganizationLayout({
8991
isComputerUseAvailable(),
9092
isWorkflowTestsEnabled(organizationId),
9193
isChangelogEnabled(organizationId),
94+
isProjectsEnabled(),
9295
])
9396
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
9497

@@ -103,6 +106,7 @@ export default async function OrganizationLayout({
103106
'mothership-computer-use': computerUseEnabled,
104107
'mothership-plan-mode': benchmarkEnabled,
105108
'mothership-memory-spaces': benchmarkEnabled,
109+
projects: projectsEnabled,
106110
}}
107111
>
108112
<OrganizationProvider context={context}>

‎apps/sim/app/workspace/[workspaceId]/layout.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@/lib/desktop/executor/availability'
1616
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
1717
import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry'
18+
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
1819
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
1920
import { ApplicationLoading } from '@/app/_shell/application-loading'
2021
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
@@ -131,6 +132,7 @@ async function WorkspaceContent({
131132
changelogEnabled,
132133
desktopExecutorRegistered,
133134
computerUseEnabled,
135+
projectsEnabled,
134136
] = await Promise.all([
135137
cookies(),
136138
orgSettings,
@@ -149,6 +151,7 @@ async function WorkspaceContent({
149151
isChangelogEnabled(hostContext.hostOrganizationId),
150152
hasDesktopBackgroundExecutor(session.user.id),
151153
isComputerUseAvailable(),
154+
isProjectsEnabled(),
152155
prefetchWorkspaceAccess(queryClient, workspaceId, principal),
153156
prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext),
154157
])
@@ -164,6 +167,7 @@ async function WorkspaceContent({
164167
'mothership-model-selector': modelSelectorEnabled,
165168
'mothership-computer-use': computerUseEnabled,
166169
'mothership-plan-mode': planModeEnabled,
170+
projects: projectsEnabled,
167171
}}
168172
>
169173
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>

‎apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface WorkspaceFeatureFlags {
1010
'mothership-plan-mode': boolean
1111
'mothership-computer-use'?: boolean
1212
'mothership-memory-spaces'?: boolean
13+
projects?: boolean
1314
}
1415

1516
const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/** @vitest-environment jsdom */
2+
3+
import { act } from 'react'
4+
import { flushMacrotask } from '@sim/testing/helpers'
5+
import { jsonResponse } from '@sim/testing/helpers/http'
6+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7+
import { createRoot, type Root } from 'react-dom/client'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
10+
import { ProjectIssueRestrictions } from '@/ee/access-control/components/project-issue-restrictions'
11+
12+
let root: Root
13+
let container: HTMLDivElement
14+
let client: QueryClient
15+
let requests: string[]
16+
17+
beforeEach(() => {
18+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
19+
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
20+
requests = []
21+
vi.stubGlobal(
22+
'fetch',
23+
vi.fn(async (url: string) => {
24+
requests.push(url)
25+
return jsonResponse({
26+
projects: [
27+
{
28+
id: 'project-1',
29+
name: 'Payments',
30+
organizationId: 'org-1',
31+
ownerId: 'user-1',
32+
archivedAt: null,
33+
createdAt: '2026-01-01T00:00:00Z',
34+
updatedAt: '2026-01-01T00:00:00Z',
35+
environments: [],
36+
capabilities: { administer: true, issues: true },
37+
},
38+
],
39+
nextCursor: null,
40+
})
41+
})
42+
)
43+
container = document.createElement('div')
44+
document.body.appendChild(container)
45+
root = createRoot(container)
46+
})
47+
48+
afterEach(() => {
49+
act(() => root.unmount())
50+
client.clear()
51+
container.remove()
52+
})
53+
54+
async function render(projects: boolean) {
55+
await act(async () => {
56+
root.render(
57+
<QueryClientProvider client={client}>
58+
<FeatureFlagsProvider
59+
flags={{
60+
dashboards: false,
61+
'workflow-tests': false,
62+
changelog: false,
63+
'mothership-model-selector': false,
64+
'mothership-plan-mode': false,
65+
projects,
66+
}}
67+
>
68+
<ProjectIssueRestrictions organizationId='org-1' value={[]} onChange={() => {}} />
69+
</FeatureFlagsProvider>
70+
</QueryClientProvider>
71+
)
72+
})
73+
await act(() => flushMacrotask())
74+
}
75+
76+
describe('ProjectIssueRestrictions', () => {
77+
it('never reads the Project APIs while the projects flag is off', async () => {
78+
await render(false)
79+
expect(requests).toEqual([])
80+
expect(container.querySelector('#project-issues-project-1')).toBeNull()
81+
})
82+
83+
it('offers each authorized Project once the projects flag is on', async () => {
84+
await render(true)
85+
expect(requests.some((url) => url.startsWith('/api/projects?'))).toBe(true)
86+
expect(container.querySelector('#project-issues-project-1')).not.toBeNull()
87+
})
88+
89+
it('hides Projects loaded before the projects flag turned off', async () => {
90+
await render(true)
91+
act(() => root.unmount())
92+
root = createRoot(container)
93+
requests = []
94+
await render(false)
95+
expect(requests).toEqual([])
96+
expect(container.querySelector('#project-issues-project-1')).toBeNull()
97+
})
98+
})

‎apps/sim/ee/access-control/components/project-issue-restrictions.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ export function ProjectIssueRestrictions({
2121
onChange,
2222
}: ProjectIssueRestrictionsProps) {
2323
const projects = useProjects(organizationId)
24-
if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) {
24+
if (
25+
!projects.isEnabled ||
26+
projects.isPending ||
27+
(isApiClientError(projects.error) && projects.error.status === 503)
28+
) {
2529
return null
2630
}
2731
const choices = projects.data?.pages.flatMap((page) => page.projects) ?? []

‎apps/sim/hooks/queries/projects.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'
22
import { isApiClientError } from '@/lib/api/client/errors'
33
import { requestJson } from '@/lib/api/client/request'
44
import { listProjectsContract } from '@/lib/api/contracts/projects'
5+
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
56

67
const PROJECT_LIST_STALE_TIME = 30_000
78
const projectKeys = {
@@ -10,7 +11,9 @@ const projectKeys = {
1011
list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const,
1112
}
1213

14+
/** Lists the caller's Projects; never requests while the `projects` rollout flag is off. */
1315
export function useProjects(organizationId: string) {
16+
const projectsEnabled = useFeatureFlag('projects')
1417
return useInfiniteQuery({
1518
queryKey: projectKeys.list(organizationId),
1619
initialPageParam: null as string | null,
@@ -27,6 +30,6 @@ export function useProjects(organizationId: string) {
2730
error.status === 408 ||
2831
error.status === 429 ||
2932
(error.status >= 500 && error.status !== 503)),
30-
enabled: Boolean(organizationId),
33+
enabled: projectsEnabled && Boolean(organizationId),
3134
})
3235
}

‎apps/sim/lib/projects/rollout.server.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ class ProjectUnavailableError extends HttpError {
88
}
99
}
1010

11+
/** Project rollout is a global switch; it never varies by organization or user. */
12+
export function isProjectsEnabled(): Promise<boolean> {
13+
return isFeatureEnabled('projects')
14+
}
15+
1116
export async function requireProjectApiEnabled(): Promise<void> {
12-
if (!(await isFeatureEnabled('projects'))) throw new ProjectUnavailableError()
17+
if (!(await isProjectsEnabled())) throw new ProjectUnavailableError()
1318
}

0 commit comments

Comments
 (0)