From 97836f062ee3ceb3de8d821668710cde03178398 Mon Sep 17 00:00:00 2001 From: Jeevan Pillay <169354619+jeevanpillay@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:07:08 +1000 Subject: [PATCH] feat: gate developer connections behind Vercel flag --- api/app/package.json | 6 ++ .../developer-connections-router.test.ts | 16 ++++ .../developer-connections-service.test.ts | 19 ++++- api/app/src/feature-flags.ts | 73 +++++++++++++++++ .../developer-connections.ts | 39 +++++++-- .../developer-connections/auth-box.ts | 12 ++- .../services/developer-connections/catalog.ts | 4 + .../developer-connections-page.test.tsx | 42 +++++++++- .../[slug]/workspace-layout.test.tsx | 80 ++++++++++++------ .../__tests__/components/app-sidebar.test.tsx | 48 +++++++---- .../developer-connections-client.tsx | 82 ++++++++++--------- .../developer-connections/page.tsx | 6 ++ .../[slug]/(workspace)/layout.tsx | 7 +- apps/app/src/components/app-sidebar.tsx | 33 ++++++-- apps/app/turbo.json | 2 + pnpm-lock.yaml | 6 ++ 16 files changed, 370 insertions(+), 105 deletions(-) create mode 100644 api/app/src/feature-flags.ts diff --git a/api/app/package.json b/api/app/package.json index 28abf4198b..3a4e229783 100644 --- a/api/app/package.json +++ b/api/app/package.json @@ -17,6 +17,10 @@ "types": "./src/env.ts", "default": "./src/env.ts" }, + "./feature-flags": { + "types": "./src/feature-flags.ts", + "default": "./src/feature-flags.ts" + }, "./services/github": { "types": "./src/services/github/index.ts", "default": "./src/services/github/index.ts" @@ -92,6 +96,8 @@ "@vendor/observability": "workspace:*", "@vendor/unkey": "workspace:*", "@vendor/upstash": "workspace:*", + "@vendor/vercel-flags": "workspace:*", + "@vercel/flags-core": "^1.1.0", "@vercel/related-projects": "catalog:", "drizzle-orm": "catalog:", "inngest": "catalog:", diff --git a/api/app/src/__tests__/developer-connections-router.test.ts b/api/app/src/__tests__/developer-connections-router.test.ts index b3d843dac8..3537c43ac8 100644 --- a/api/app/src/__tests__/developer-connections-router.test.ts +++ b/api/app/src/__tests__/developer-connections-router.test.ts @@ -8,6 +8,7 @@ const connectDeveloperConnectionMock = vi.fn(); const completeSentryDeveloperConnectionAuthMock = vi.fn(); const setDeveloperConnectionSandboxEnabledMock = vi.fn(); const disconnectDeveloperConnectionMock = vi.fn(); +const isDeveloperConnectionsEnabledMock = vi.fn(); const startSentryDeveloperConnectionAuthMock = vi.fn(); vi.mock("@db/app/client", () => ({ db: {} })); @@ -34,6 +35,10 @@ vi.mock("../services/developer-connections", () => ({ startSentryDeveloperConnectionAuth: startSentryDeveloperConnectionAuthMock, })); +vi.mock("../feature-flags", () => ({ + isDeveloperConnectionsEnabled: isDeveloperConnectionsEnabledMock, +})); + const { createCallerFactory, createTRPCRouter } = await import("../trpc"); const { developerConnectionsRouter } = await import( "../router/(pending-not-allowed)/developer-connections" @@ -75,6 +80,7 @@ function caller(access = adminAccess()) { describe("developerConnectionsRouter", () => { beforeEach(() => { vi.clearAllMocks(); + isDeveloperConnectionsEnabledMock.mockResolvedValue(true); listDeveloperConnectionsForOrgMock.mockResolvedValue([ { provider: "sentry", canManage: false, connection: null }, ]); @@ -104,6 +110,16 @@ describe("developerConnectionsRouter", () => { ).resolves.toEqual([expect.objectContaining({ provider: "sentry" })]); }); + it("hides developer connections procedures when the feature flag is disabled", async () => { + isDeveloperConnectionsEnabledMock.mockResolvedValue(false); + + await expect(caller().developerConnections.list()).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + + expect(listDeveloperConnectionsForOrgMock).not.toHaveBeenCalled(); + }); + it("does not expose public lease materialization on the workspace router", () => { expect("issueLease" in developerConnectionsRouter._def.procedures).toBe( false diff --git a/api/app/src/__tests__/developer-connections-service.test.ts b/api/app/src/__tests__/developer-connections-service.test.ts index 871fadd7c6..fdc7e5a3a4 100644 --- a/api/app/src/__tests__/developer-connections-service.test.ts +++ b/api/app/src/__tests__/developer-connections-service.test.ts @@ -12,6 +12,7 @@ const replaceCurrentDeveloperConnectionMock = vi.fn(); const setCurrentDeveloperConnectionSandboxEnabledMock = vi.fn(); const revokeCurrentDeveloperConnectionMock = vi.fn(); const issueDeveloperConnectionLeaseMock = vi.fn(); +const isDeveloperAuthBoxConfiguredMock = vi.fn(() => true); const sentryAuthBoxStartMock = vi.fn(); const sentryAuthBoxCompleteMock = vi.fn(); @@ -43,6 +44,7 @@ vi.mock("@db/app", () => ({ })); vi.mock("../services/developer-connections/auth-box", () => ({ + isDeveloperAuthBoxConfigured: isDeveloperAuthBoxConfiguredMock, sentryAuthBoxClient: { start: sentryAuthBoxStartMock, complete: sentryAuthBoxCompleteMock, @@ -89,6 +91,7 @@ function ctx(input: { isAdmin?: boolean } = {}) { describe("developer connection services", () => { beforeEach(() => { vi.clearAllMocks(); + isDeveloperAuthBoxConfiguredMock.mockReturnValue(true); listCurrentDeveloperConnectionsMock.mockResolvedValue([]); listDeveloperConnectionLeasesForSandboxRunMock.mockResolvedValue([]); replaceCurrentDeveloperConnectionMock.mockImplementation( @@ -156,11 +159,25 @@ describe("developer connection services", () => { await expect(listDeveloperConnectionsForOrg(ctx())).resolves.toEqual([ expect.objectContaining({ provider: "pscale", canManage: true }), expect.objectContaining({ provider: "upstash", canManage: true }), - expect.objectContaining({ provider: "sentry", canManage: true }), + expect.objectContaining({ + provider: "sentry", + canManage: true, + sentryBrowserOAuthAvailable: true, + }), expect.objectContaining({ provider: "clerk", canManage: true }), ]); }); + it("marks Sentry browser OAuth unavailable when the auth box is not configured", async () => { + isDeveloperAuthBoxConfiguredMock.mockReturnValue(false); + + const rows = await listDeveloperConnectionsForOrg(ctx()); + + expect(rows.find((row) => row.provider === "sentry")).toEqual( + expect.objectContaining({ sentryBrowserOAuthAvailable: false }) + ); + }); + it("encrypts manual PlanetScale credentials and stores a current org connection", async () => { await connectDeveloperConnection(ctx(), { provider: "pscale", diff --git a/api/app/src/feature-flags.ts b/api/app/src/feature-flags.ts new file mode 100644 index 0000000000..f1a4a58a12 --- /dev/null +++ b/api/app/src/feature-flags.ts @@ -0,0 +1,73 @@ +import { flagsEnv } from "@vendor/vercel-flags/env"; +import { createClient } from "@vercel/flags-core"; + +interface BooleanFlagDefinition { + defaultValue: boolean; + key: string; +} + +export const featureFlags = { + developerConnections: { + defaultValue: false, + key: "developer-connections", + }, +} as const satisfies Record; + +export const DEVELOPER_CONNECTIONS_FLAG_KEY = + featureFlags.developerConnections.key; + +type FlagsClient = ReturnType; + +const clientOptions = { + stream: { initTimeoutMs: 2000 }, + polling: { intervalMs: 30_000, initTimeoutMs: 5000 }, +}; + +let client: FlagsClient | null = null; +let initPromise: Promise | null = null; + +function getFlagsClient(): FlagsClient | null { + if (client) { + return client; + } + const sdkKey = flagsEnv.FLAGS; + if (!sdkKey) { + return null; + } + client = createClient(sdkKey, clientOptions); + return client; +} + +async function ensureInitialized() { + const flagsClient = getFlagsClient(); + if (!flagsClient) { + return null; + } + if (!initPromise) { + const result = flagsClient.initialize(); + initPromise = result instanceof Promise ? result : Promise.resolve(); + } + try { + await initPromise; + } catch { + initPromise = null; + return null; + } + return flagsClient; +} + +async function evaluateBooleanFlag(definition: BooleanFlagDefinition) { + const flagsClient = await ensureInitialized(); + if (!flagsClient) { + return definition.defaultValue; + } + const result = await flagsClient.evaluate( + definition.key, + definition.defaultValue + ); + return result.value ?? definition.defaultValue; +} + +export function isDeveloperConnectionsEnabled() { + return evaluateBooleanFlag(featureFlags.developerConnections); +} diff --git a/api/app/src/router/(pending-not-allowed)/developer-connections.ts b/api/app/src/router/(pending-not-allowed)/developer-connections.ts index 66dcc901d3..7cb9083c85 100644 --- a/api/app/src/router/(pending-not-allowed)/developer-connections.ts +++ b/api/app/src/router/(pending-not-allowed)/developer-connections.ts @@ -5,6 +5,8 @@ import { developerConnectionSetSandboxEnabledInputSchema, developerConnectionStartAuthInputSchema, } from "@repo/developer-connection-contract"; +import { TRPCError } from "@trpc/server"; +import { isDeveloperConnectionsEnabled } from "../../feature-flags"; import { completeSentryDeveloperConnectionAuth, connectDeveloperConnection, @@ -19,29 +21,54 @@ import { createTRPCRouter, } from "../../trpc"; +function developerConnectionsNotFoundError() { + return new TRPCError({ + code: "NOT_FOUND", + message: "Developer connections not found", + }); +} + +const developerConnectionsProcedure = boundOrgProcedure.use( + async ({ next }) => { + if (!(await isDeveloperConnectionsEnabled())) { + throw developerConnectionsNotFoundError(); + } + return next(); + } +); + +const developerConnectionsAdminProcedure = boundOrgAdminProcedure.use( + async ({ next }) => { + if (!(await isDeveloperConnectionsEnabled())) { + throw developerConnectionsNotFoundError(); + } + return next(); + } +); + export const developerConnectionsRouter = createTRPCRouter({ - list: boundOrgProcedure.query(async ({ ctx }) => + list: developerConnectionsProcedure.query(async ({ ctx }) => listDeveloperConnectionsForOrg(ctx) ), - connect: boundOrgAdminProcedure + connect: developerConnectionsAdminProcedure .input(developerConnectionConnectInputSchema) .mutation(async ({ ctx, input }) => connectDeveloperConnection(ctx, input)), - startSentryAuth: boundOrgAdminProcedure + startSentryAuth: developerConnectionsAdminProcedure .input(developerConnectionStartAuthInputSchema) .mutation(async ({ ctx, input }) => startSentryDeveloperConnectionAuth(ctx, input) ), - completeSentryAuth: boundOrgAdminProcedure + completeSentryAuth: developerConnectionsAdminProcedure .input(developerConnectionCompleteAuthInputSchema) .mutation(async ({ ctx, input }) => completeSentryDeveloperConnectionAuth(ctx, input) ), - setSandboxEnabled: boundOrgAdminProcedure + setSandboxEnabled: developerConnectionsAdminProcedure .input(developerConnectionSetSandboxEnabledInputSchema) .mutation(async ({ ctx, input }) => setDeveloperConnectionSandboxEnabled(ctx, input) ), - disconnect: boundOrgAdminProcedure + disconnect: developerConnectionsAdminProcedure .input(developerConnectionProviderInputSchema) .mutation(async ({ ctx, input }) => disconnectDeveloperConnection(ctx, input) diff --git a/api/app/src/services/developer-connections/auth-box.ts b/api/app/src/services/developer-connections/auth-box.ts index 5487d917f9..bcba219def 100644 --- a/api/app/src/services/developer-connections/auth-box.ts +++ b/api/app/src/services/developer-connections/auth-box.ts @@ -29,16 +29,22 @@ export interface SentryAuthBoxClient { }): Promise; } +export function isDeveloperAuthBoxConfigured() { + return Boolean(env.DEVELOPER_AUTH_BOX_ORIGIN && env.DEVELOPER_AUTH_BOX_TOKEN); +} + function authBoxConfig() { - if (!(env.DEVELOPER_AUTH_BOX_ORIGIN && env.DEVELOPER_AUTH_BOX_TOKEN)) { + const origin = env.DEVELOPER_AUTH_BOX_ORIGIN; + const token = env.DEVELOPER_AUTH_BOX_TOKEN; + if (!(origin && token)) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Developer auth box is not configured.", }); } return { - origin: env.DEVELOPER_AUTH_BOX_ORIGIN.replace(/\/$/, ""), - token: env.DEVELOPER_AUTH_BOX_TOKEN, + origin: origin.replace(/\/$/, ""), + token, }; } diff --git a/api/app/src/services/developer-connections/catalog.ts b/api/app/src/services/developer-connections/catalog.ts index 8700b83021..091e0cda04 100644 --- a/api/app/src/services/developer-connections/catalog.ts +++ b/api/app/src/services/developer-connections/catalog.ts @@ -5,6 +5,7 @@ import { type DeveloperConnectionProvider, } from "@repo/developer-connection-contract"; import type { AuthContext } from "../../trpc"; +import { isDeveloperAuthBoxConfigured } from "./auth-box"; interface DeveloperConnectionServiceContext { auth: AuthContext; @@ -33,6 +34,7 @@ export interface DeveloperConnectionCatalogRow { description: string; displayName: string; provider: DeveloperConnectionProvider; + sentryBrowserOAuthAvailable: boolean; } export function canManageDeveloperConnections( @@ -92,5 +94,7 @@ export async function listDeveloperConnectionsForOrg( canManage, connectAvailability: availabilityFor(canManage), connection: shapeConnection(byProvider.get(catalogItem.provider)), + sentryBrowserOAuthAvailable: + catalogItem.provider === "sentry" && isDeveloperAuthBoxConfigured(), })); } diff --git a/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/developer-connections-page.test.tsx b/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/developer-connections-page.test.tsx index 144830d739..3a0b113240 100644 --- a/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/developer-connections-page.test.tsx +++ b/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/developer-connections-page.test.tsx @@ -22,6 +22,7 @@ interface DeveloperConnectionRow { description: string; displayName: string; provider: "pscale" | "upstash" | "sentry" | "clerk"; + sentryBrowserOAuthAvailable: boolean; } const completeSentryAuthMutateMock = vi.fn(); @@ -36,6 +37,10 @@ const listQueryOptions = { queryKey: ["org", "workspace", "developerConnections", "list"], }; const listQueryOptionsMock = vi.fn(() => listQueryOptions); +const isDeveloperConnectionsEnabledMock = vi.fn(); +const notFoundMock = vi.fn(() => { + throw new Error("NEXT_NOT_FOUND"); +}); const setSandboxEnabledMutateMock = vi.fn(); const startSentryAuthMutateMock = vi.fn(); const useMutationMock = vi.fn(); @@ -73,6 +78,14 @@ vi.mock("~/trpc/server", () => ({ }, })); +vi.mock("@api/app/feature-flags", () => ({ + isDeveloperConnectionsEnabled: isDeveloperConnectionsEnabledMock, +})); + +vi.mock("next/navigation", () => ({ + notFound: notFoundMock, +})); + vi.mock("~/trpc/react", () => ({ useTRPC: () => ({ org: { @@ -277,6 +290,7 @@ function baseRow( description: "Inspect Sentry issues and manage release artifacts.", displayName: "Sentry", provider: "sentry", + sentryBrowserOAuthAvailable: false, ...overrides, }; } @@ -298,8 +312,8 @@ function connectedSentry( }); } -function availableSentry() { - return baseRow(); +function availableSentry(overrides: Partial = {}) { + return baseRow({ sentryBrowserOAuthAvailable: true, ...overrides }); } function availablePscale() { @@ -324,6 +338,8 @@ beforeEach(() => { delete capturedMutationOptions[key]; } fetchQueryMock.mockResolvedValue([]); + isDeveloperConnectionsEnabledMock.mockResolvedValue(true); + notFoundMock.mockClear(); useSuspenseQueryMock.mockReturnValue({ data: [] }); useMutationMock.mockImplementation( (options: { @@ -352,6 +368,19 @@ beforeEach(() => { }); describe("DeveloperConnectionsPage", () => { + it("renders not found when the developer connections flag is disabled", async () => { + isDeveloperConnectionsEnabledMock.mockResolvedValue(false); + + await expect( + DeveloperConnectionsPage({ + searchParams: Promise.resolve({}), + }) + ).rejects.toThrow("NEXT_NOT_FOUND"); + + expect(notFoundMock).toHaveBeenCalledOnce(); + expect(fetchQueryMock).not.toHaveBeenCalled(); + }); + it("fetches developer connections before rendering hydrated client UI", async () => { fetchQueryMock.mockResolvedValue([connectedSentry()]); useSuspenseQueryMock.mockReturnValue({ data: [connectedSentry()] }); @@ -444,6 +473,15 @@ describe("DeveloperConnectionsPage", () => { }); }); + it("hides Sentry browser OAuth when the auth box is unavailable", () => { + renderClient([availableSentry({ sentryBrowserOAuthAvailable: false })]); + + fireEvent.click(screen.getByRole("button", { name: /^connect$/i })); + + expect(screen.queryByRole("button", { name: /browser oauth/i })).toBeNull(); + expect(screen.getByLabelText(/sentry token/i)).toBeVisible(); + }); + it("disables management controls for non-admin members", () => { renderClient([ connectedSentry({ diff --git a/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/workspace-layout.test.tsx b/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/workspace-layout.test.tsx index aa24a652f0..407fc28d9f 100644 --- a/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/workspace-layout.test.tsx +++ b/apps/app/src/__tests__/app/(app)/(pending-not-allowed)/[slug]/workspace-layout.test.tsx @@ -6,6 +6,7 @@ const sidebarProviderSpy = vi.fn(); const appSidebarSpy = vi.fn(); const authenticatedTopbarSpy = vi.fn(); const commandMenuSpy = vi.fn(); +const isDeveloperConnectionsEnabledMock = vi.fn(); vi.mock("@repo/ui/components/ui/sidebar", () => ({ SidebarProvider: (props: { children: ReactNode }) => { @@ -19,12 +20,16 @@ vi.mock("@repo/ui/components/ui/sidebar", () => ({ })); vi.mock("~/components/app-sidebar", () => ({ - AppSidebar: () => { - appSidebarSpy(); + AppSidebar: (props: { developerConnectionsEnabled?: boolean }) => { + appSidebarSpy(props); return
; }, })); +vi.mock("@api/app/feature-flags", () => ({ + isDeveloperConnectionsEnabled: () => isDeveloperConnectionsEnabledMock(), +})); + vi.mock("~/components/authenticated-topbar", () => ({ AuthenticatedTopbar: (props: { actions?: ReactNode; left?: ReactNode }) => { authenticatedTopbarSpy(props); @@ -51,15 +56,27 @@ beforeEach(() => { appSidebarSpy.mockClear(); authenticatedTopbarSpy.mockClear(); commandMenuSpy.mockClear(); + isDeveloperConnectionsEnabledMock.mockReset(); + isDeveloperConnectionsEnabledMock.mockResolvedValue(false); }); +async function renderWorkspaceLayout({ + actions, + children, +}: { + actions: ReactNode; + children: ReactNode; +}) { + const element = await WorkspaceLayout({ actions, children }); + render(element); +} + describe("WorkspaceLayout", () => { - it("wraps content with sidebar chrome and authenticated topbar", () => { - render( - -
workspace content
-
- ); + it("wraps content with sidebar chrome and authenticated topbar", async () => { + await renderWorkspaceLayout({ + actions: null, + children:
workspace content
, + }); expect(sidebarProviderSpy).toHaveBeenCalledTimes(1); expect(appSidebarSpy).toHaveBeenCalledTimes(1); @@ -67,37 +84,48 @@ describe("WorkspaceLayout", () => { expect(commandMenuSpy).toHaveBeenCalledTimes(1); }); - it("passes the sidebar trigger into the topbar left slot", () => { - render( - -
workspace content
-
- ); + it("passes the sidebar trigger into the topbar left slot", async () => { + await renderWorkspaceLayout({ + actions: null, + children:
workspace content
, + }); const topbarProps = authenticatedTopbarSpy.mock.calls[0]?.[0]; expect(topbarProps?.left).toBeTruthy(); }); - it("forwards the actions slot into the topbar", () => { - render( - view switcher
}> -
workspace content
- - ); + it("forwards the actions slot into the topbar", async () => { + await renderWorkspaceLayout({ + actions:
view switcher
, + children:
workspace content
, + }); const topbarProps = authenticatedTopbarSpy.mock.calls[0]?.[0]; expect(topbarProps?.actions).toBeTruthy(); }); - it("renders children inside the command menu", () => { - render( - -
workspace content
-
- ); + it("renders children inside the command menu", async () => { + await renderWorkspaceLayout({ + actions: null, + children:
workspace content
, + }); const commandMenuProps = commandMenuSpy.mock.calls[0]?.[0]; expect(commandMenuProps?.children).toBeTruthy(); expect(commandMenuProps).toBeTruthy(); }); + + it("passes the developer connections flag state to the sidebar", async () => { + isDeveloperConnectionsEnabledMock.mockResolvedValue(true); + + await renderWorkspaceLayout({ + actions: null, + children:
workspace content
, + }); + + expect(isDeveloperConnectionsEnabledMock).toHaveBeenCalledOnce(); + expect(appSidebarSpy).toHaveBeenCalledWith({ + developerConnectionsEnabled: true, + }); + }); }); diff --git a/apps/app/src/__tests__/components/app-sidebar.test.tsx b/apps/app/src/__tests__/components/app-sidebar.test.tsx index a638caea4c..26d12c56ec 100644 --- a/apps/app/src/__tests__/components/app-sidebar.test.tsx +++ b/apps/app/src/__tests__/components/app-sidebar.test.tsx @@ -160,6 +160,14 @@ vi.mock("@repo/ui/components/ui/popover", () => ({ const { AppSidebar } = await import("~/components/app-sidebar"); +function renderSidebar(input: { developerConnectionsEnabled?: boolean } = {}) { + render( + + ); +} + beforeEach(() => { pathname = "/acme/signals"; isMobile = false; @@ -184,7 +192,7 @@ beforeEach(() => { describe("AppSidebar", () => { it("renders workspace links separately from manage links", () => { - render(); + renderSidebar(); expect(screen.getByRole("link", { name: /signals/i })).toHaveAttribute( "href", @@ -211,8 +219,8 @@ describe("AppSidebar", () => { "/acme/connectors" ); expect( - screen.getByRole("link", { name: /developer connections/i }) - ).toHaveAttribute("href", "/acme/developer-connections"); + screen.queryByRole("link", { name: /developer connections/i }) + ).not.toBeInTheDocument(); expect( screen.getByRole("region", { name: "Workspace" }) ).toBeInTheDocument(); @@ -221,7 +229,7 @@ describe("AppSidebar", () => { it("marks connectors active by route section", () => { pathname = "/acme/connectors"; - render(); + renderSidebar(); const connectorsLink = screen.getByRole("link", { name: /connectors/i }); expect(connectorsLink.closest("[data-active]")).toHaveAttribute( @@ -232,7 +240,7 @@ describe("AppSidebar", () => { it("marks developer connections active by route section", () => { pathname = "/acme/developer-connections"; - render(); + renderSidebar({ developerConnectionsEnabled: true }); const link = screen.getByRole("link", { name: /developer connections/i, @@ -243,8 +251,16 @@ describe("AppSidebar", () => { ); }); + it("shows developer connections when the feature flag is enabled", () => { + renderSidebar({ developerConnectionsEnabled: true }); + + expect( + screen.getByRole("link", { name: /developer connections/i }) + ).toHaveAttribute("href", "/acme/developer-connections"); + }); + it("exposes workspace and manage navigation landmarks", () => { - render(); + renderSidebar(); expect( screen.getByRole("navigation", { name: "Workspace" }) @@ -255,7 +271,7 @@ describe("AppSidebar", () => { }); it("renders the workspace navigation links in order", () => { - render(); + renderSidebar(); const workspaceLinks = screen .getByRole("region", { name: "Workspace" }) @@ -269,7 +285,7 @@ describe("AppSidebar", () => { it("marks the active nav link with aria-current", () => { pathname = "/acme/people"; - render(); + renderSidebar(); expect(screen.getByRole("link", { current: "page" })).toHaveAccessibleName( "People" @@ -277,7 +293,7 @@ describe("AppSidebar", () => { }); it("renders existing chats in a sidebar container below settings", () => { - render(); + renderSidebar(); const manageLinks = screen .getByRole("region", { name: "Manage" }) @@ -307,7 +323,7 @@ describe("AppSidebar", () => { }); it("renders a new chat button in the header", () => { - render(); + renderSidebar(); expect(screen.getByRole("link", { name: "New chat" })).toHaveAttribute( "href", @@ -317,7 +333,7 @@ describe("AppSidebar", () => { it("hides the chats group when there are no conversations", () => { conversationsData = { items: [], nextCursor: null }; - render(); + renderSidebar(); expect( screen.queryByRole("region", { name: "Chats" }) @@ -326,7 +342,7 @@ describe("AppSidebar", () => { it("marks the active existing chat in the chat history", () => { pathname = "/acme/chat/conv_recent"; - render(); + renderSidebar(); expect( screen @@ -337,7 +353,7 @@ describe("AppSidebar", () => { it("does not mark settings active for similar path prefixes", () => { pathname = "/acme/settings-archive"; - render(); + renderSidebar(); const settingsLink = screen.getByRole("link", { name: /settings/i }); expect(settingsLink.closest("[data-active]")).toHaveAttribute( @@ -349,7 +365,7 @@ describe("AppSidebar", () => { it("closes the mobile sidebar when navigating", () => { isMobile = true; - render(); + renderSidebar(); fireEvent.click(screen.getByRole("link", { name: /signals/i })); @@ -358,7 +374,7 @@ describe("AppSidebar", () => { it("shows a mobile close control", () => { isMobile = true; - render(); + renderSidebar(); fireEvent.click(screen.getByRole("button", { name: /close sidebar/i })); @@ -367,7 +383,7 @@ describe("AppSidebar", () => { it("marks people active by route section", () => { pathname = "/acme/people"; - render(); + renderSidebar(); const peopleLink = screen.getByRole("link", { name: /people/i }); expect(peopleLink.closest("[data-active]")).toHaveAttribute( diff --git a/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/developer-connections/_components/developer-connections-client.tsx b/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/developer-connections/_components/developer-connections-client.tsx index b764e7289a..93503e722d 100644 --- a/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/developer-connections/_components/developer-connections-client.tsx +++ b/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/developer-connections/_components/developer-connections-client.tsx @@ -536,52 +536,54 @@ function DeveloperConnectionConnectDialog({ {row.provider === "sentry" ? ( <> -
-
-
-

Browser OAuth

-

- Preferred Sentry auth path for admin setup. -

-
- -
- {sentryAuthAttempt ? ( -
-

User code

-

- {sentryAuthAttempt.userCode} -

- - Open Sentry authorization - + {row.sentryBrowserOAuthAvailable ? ( +
+
+
+

Browser OAuth

+

+ Preferred Sentry auth path for admin setup. +

+
- ) : null} -
+ {sentryAuthAttempt ? ( +
+

User code

+

+ {sentryAuthAttempt.userCode} +

+ + Open Sentry authorization + + +
+ ) : null} +
+ ) : null} ; }) { + if (!(await isDeveloperConnectionsEnabled())) { + notFound(); + } + const params = await searchParams; await getQueryClient().fetchQuery( diff --git a/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/layout.tsx b/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/layout.tsx index 32d533a894..9634d03fd1 100644 --- a/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/layout.tsx +++ b/apps/app/src/app/(app)/(pending-not-allowed)/[slug]/(workspace)/layout.tsx @@ -1,3 +1,4 @@ +import { isDeveloperConnectionsEnabled } from "@api/app/feature-flags"; import { SidebarInset, SidebarProvider, @@ -9,16 +10,18 @@ import { AppSidebar } from "~/components/app-sidebar"; import { AuthenticatedTopbar } from "~/components/authenticated-topbar"; import { WorkspaceCommandMenu } from "~/components/workspace-command-menu"; -export default function WorkspaceLayout({ +export default async function WorkspaceLayout({ actions, children, }: { actions: React.ReactNode; children: React.ReactNode; }) { + const developerConnectionsEnabled = await isDeveloperConnectionsEnabled(); + return ( - + diff --git a/apps/app/turbo.json b/apps/app/turbo.json index 5137968745..7066ede161 100644 --- a/apps/app/turbo.json +++ b/apps/app/turbo.json @@ -23,6 +23,7 @@ "SENTRY_AUTH_TOKEN", "SENTRY_ORG", "SENTRY_PROJECT", + "FLAGS", "BRAINTRUST_API_KEY", "CLERK_SECRET_KEY", "CLERK_CLI_OAUTH_CLIENT_ID", @@ -68,6 +69,7 @@ "dev:next": { "persistent": true, "passThroughEnv": [ + "FLAGS", "MFE_DISABLE_LOCAL_PROXY_REWRITE", "TURBO_TASK_HAS_MFE_PROXY" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6d456a637..f3eaf36fef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -579,6 +579,12 @@ importers: '@vendor/upstash': specifier: workspace:* version: link:../../vendor/upstash + '@vendor/vercel-flags': + specifier: workspace:* + version: link:../../vendor/vercel-flags + '@vercel/flags-core': + specifier: ^1.1.0 + version: 1.1.0(flags@4.0.3(@opentelemetry/api@1.9.1)(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@vercel/related-projects': specifier: 'catalog:' version: 1.1.0