diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index b7525c40adb..9b11599685c 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -12,6 +12,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { isProjectsEnabled } from '@/lib/projects/rollout.server' import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -73,6 +74,7 @@ export default async function OrganizationLayout({ computerUseEnabled, workflowTestsEnabled, changelogEnabled, + projectsEnabled, ] = await Promise.all([ prefetchOrganizationSidebar( queryClient, @@ -89,6 +91,7 @@ export default async function OrganizationLayout({ isComputerUseAvailable(), isWorkflowTestsEnabled(organizationId), isChangelogEnabled(organizationId), + isProjectsEnabled(), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' @@ -103,6 +106,7 @@ export default async function OrganizationLayout({ 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': benchmarkEnabled, 'mothership-memory-spaces': benchmarkEnabled, + projects: projectsEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 8d6945d408f..f98e57ff015 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -15,6 +15,7 @@ import { } from '@/lib/desktop/executor/availability' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' +import { isProjectsEnabled } from '@/lib/projects/rollout.server' import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag' import { ApplicationLoading } from '@/app/_shell/application-loading' import { getQueryClient } from '@/app/_shell/providers/get-query-client' @@ -131,6 +132,7 @@ async function WorkspaceContent({ changelogEnabled, desktopExecutorRegistered, computerUseEnabled, + projectsEnabled, ] = await Promise.all([ cookies(), orgSettings, @@ -149,6 +151,7 @@ async function WorkspaceContent({ isChangelogEnabled(hostContext.hostOrganizationId), hasDesktopBackgroundExecutor(session.user.id), isComputerUseAvailable(), + isProjectsEnabled(), prefetchWorkspaceAccess(queryClient, workspaceId, principal), prefetchWorkspaceForkAvailability(queryClient, workspaceId, principal, hostContext), ]) @@ -164,6 +167,7 @@ async function WorkspaceContent({ 'mothership-model-selector': modelSelectorEnabled, 'mothership-computer-use': computerUseEnabled, 'mothership-plan-mode': planModeEnabled, + projects: projectsEnabled, }} > diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index c42c1e7ef08..f33fd8f230f 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -10,6 +10,7 @@ export interface WorkspaceFeatureFlags { 'mothership-plan-mode': boolean 'mothership-computer-use'?: boolean 'mothership-memory-spaces'?: boolean + projects?: boolean } const FeatureFlagsContext = createContext(null) diff --git a/apps/sim/ee/access-control/components/project-issue-restrictions.test.tsx b/apps/sim/ee/access-control/components/project-issue-restrictions.test.tsx new file mode 100644 index 00000000000..ec4dd421983 --- /dev/null +++ b/apps/sim/ee/access-control/components/project-issue-restrictions.test.tsx @@ -0,0 +1,98 @@ +/** @vitest-environment jsdom */ + +import { act } from 'react' +import { flushMacrotask } from '@sim/testing/helpers' +import { jsonResponse } from '@sim/testing/helpers/http' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { ProjectIssueRestrictions } from '@/ee/access-control/components/project-issue-restrictions' + +let root: Root +let container: HTMLDivElement +let client: QueryClient +let requests: string[] + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + requests = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + requests.push(url) + return jsonResponse({ + projects: [ + { + id: 'project-1', + name: 'Payments', + organizationId: 'org-1', + ownerId: 'user-1', + archivedAt: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + environments: [], + capabilities: { administer: true, issues: true }, + }, + ], + nextCursor: null, + }) + }) + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + client.clear() + container.remove() +}) + +async function render(projects: boolean) { + await act(async () => { + root.render( + + + {}} /> + + + ) + }) + await act(() => flushMacrotask()) +} + +describe('ProjectIssueRestrictions', () => { + it('never reads the Project APIs while the projects flag is off', async () => { + await render(false) + expect(requests).toEqual([]) + expect(container.querySelector('#project-issues-project-1')).toBeNull() + }) + + it('offers each authorized Project once the projects flag is on', async () => { + await render(true) + expect(requests.some((url) => url.startsWith('/api/projects?'))).toBe(true) + expect(container.querySelector('#project-issues-project-1')).not.toBeNull() + }) + + it('hides Projects loaded before the projects flag turned off', async () => { + await render(true) + act(() => root.unmount()) + root = createRoot(container) + requests = [] + await render(false) + expect(requests).toEqual([]) + expect(container.querySelector('#project-issues-project-1')).toBeNull() + }) +}) diff --git a/apps/sim/ee/access-control/components/project-issue-restrictions.tsx b/apps/sim/ee/access-control/components/project-issue-restrictions.tsx index 48babc2df8e..0b029a18d86 100644 --- a/apps/sim/ee/access-control/components/project-issue-restrictions.tsx +++ b/apps/sim/ee/access-control/components/project-issue-restrictions.tsx @@ -21,7 +21,11 @@ export function ProjectIssueRestrictions({ onChange, }: ProjectIssueRestrictionsProps) { const projects = useProjects(organizationId) - if (projects.isPending || (isApiClientError(projects.error) && projects.error.status === 503)) { + if ( + !projects.isEnabled || + projects.isPending || + (isApiClientError(projects.error) && projects.error.status === 503) + ) { return null } const choices = projects.data?.pages.flatMap((page) => page.projects) ?? [] diff --git a/apps/sim/hooks/queries/projects.ts b/apps/sim/hooks/queries/projects.ts index 97397ee4c04..0a40e4fe65b 100644 --- a/apps/sim/hooks/queries/projects.ts +++ b/apps/sim/hooks/queries/projects.ts @@ -2,6 +2,7 @@ import { useInfiniteQuery } from '@tanstack/react-query' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { listProjectsContract } from '@/lib/api/contracts/projects' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' const PROJECT_LIST_STALE_TIME = 30_000 const projectKeys = { @@ -10,7 +11,9 @@ const projectKeys = { list: (organizationId: string) => [...projectKeys.lists(), organizationId] as const, } +/** Lists the caller's Projects; never requests while the `projects` rollout flag is off. */ export function useProjects(organizationId: string) { + const projectsEnabled = useFeatureFlag('projects') return useInfiniteQuery({ queryKey: projectKeys.list(organizationId), initialPageParam: null as string | null, @@ -27,6 +30,6 @@ export function useProjects(organizationId: string) { error.status === 408 || error.status === 429 || (error.status >= 500 && error.status !== 503)), - enabled: Boolean(organizationId), + enabled: projectsEnabled && Boolean(organizationId), }) } diff --git a/apps/sim/lib/projects/rollout.server.ts b/apps/sim/lib/projects/rollout.server.ts index 758f5c0b963..b2c7ffe5d9d 100644 --- a/apps/sim/lib/projects/rollout.server.ts +++ b/apps/sim/lib/projects/rollout.server.ts @@ -8,6 +8,11 @@ class ProjectUnavailableError extends HttpError { } } +/** Project rollout is a global switch; it never varies by organization or user. */ +export function isProjectsEnabled(): Promise { + return isFeatureEnabled('projects') +} + export async function requireProjectApiEnabled(): Promise { - if (!(await isFeatureEnabled('projects'))) throw new ProjectUnavailableError() + if (!(await isProjectsEnabled())) throw new ProjectUnavailableError() }