diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel
index e3ddd8962ab4..77149f9abf70 100644
--- a/client/web/BUILD.bazel
+++ b/client/web/BUILD.bazel
@@ -241,7 +241,7 @@ ts_project(
"src/cody/management/UseCodyInEditorSection.tsx",
"src/cody/management/api/client.ts",
"src/cody/management/api/components/CodyProApiClient.ts",
- "src/cody/management/api/react-query/QueryClientProvider.tsx",
+ "src/cody/management/api/react-query/CodyProApiProvider.tsx",
"src/cody/management/api/react-query/callCodyProApi.ts",
"src/cody/management/api/react-query/invites.ts",
"src/cody/management/api/react-query/queryKeys.ts",
@@ -281,6 +281,7 @@ ts_project(
"src/cody/upsell/vs-code.tsx",
"src/cody/useCodyChat.tsx",
"src/cody/useCodyIgnore.tsx",
+ "src/cody/useCodyProNavLinks.ts",
"src/cody/util.ts",
"src/cody/widgets/CodyRecipesWidget.tsx",
"src/cody/widgets/components/Recipe.tsx",
@@ -1887,6 +1888,7 @@ ts_project(
"src/codeintel/ReferencesPanel.test.tsx",
"src/cody/team/TeamMemberList.test.ts",
"src/cody/useCodyIgnore.test.ts",
+ "src/cody/useCodyProNavLinks.test.tsx",
"src/components/ErrorBoundary.test.tsx",
"src/components/FilteredConnection/FilteredConnection.test.tsx",
"src/components/FilteredConnection/hooks/usePageSwitcherPagination.test.tsx",
diff --git a/client/web/src/LegacySourcegraphWebApp.tsx b/client/web/src/LegacySourcegraphWebApp.tsx
index 030638f9bea3..2587d1e54eb9 100644
--- a/client/web/src/LegacySourcegraphWebApp.tsx
+++ b/client/web/src/LegacySourcegraphWebApp.tsx
@@ -41,6 +41,7 @@ import { WildcardThemeContext, type WildcardTheme } from '@sourcegraph/wildcard'
import { authenticatedUser as authenticatedUserSubject, authenticatedUserValue, type AuthenticatedUser } from './auth'
import { getWebGraphQLClient } from './backend/graphql'
import { isBatchChangesExecutionEnabled } from './batches'
+import { CodyProApiProvider } from './cody/management/api/react-query/CodyProApiProvider'
import { ComponentsComposer } from './components/ComponentsComposer'
import { ErrorBoundary } from './components/ErrorBoundary'
import { FeatureFlagsLocalOverrideAgent } from './featureFlags/FeatureFlagsProvider'
@@ -258,6 +259,7 @@ export class LegacySourcegraphWebApp extends React.Component,
,
,
+ ,
/* eslint-enable react/no-children-prop, react/jsx-key */
]}
>
diff --git a/client/web/src/SourcegraphWebApp.tsx b/client/web/src/SourcegraphWebApp.tsx
index 11795aa0794f..1d9fc2bc5d6a 100644
--- a/client/web/src/SourcegraphWebApp.tsx
+++ b/client/web/src/SourcegraphWebApp.tsx
@@ -29,6 +29,7 @@ import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { WildcardThemeContext, type WildcardTheme } from '@sourcegraph/wildcard'
import { authenticatedUser as authenticatedUserSubject, type AuthenticatedUser, authenticatedUserValue } from './auth'
+import { CodyProApiProvider } from './cody/management/api/react-query/CodyProApiProvider'
import { ComponentsComposer } from './components/ComponentsComposer'
import { ErrorBoundary, RouteError } from './components/ErrorBoundary'
import { FeatureFlagsLocalOverrideAgent } from './featureFlags/FeatureFlagsProvider'
@@ -283,6 +284,7 @@ export const SourcegraphWebApp: FC = props => {
...props,
}}
/>,
+ ,
/* eslint-enable react/no-children-prop, react/jsx-key */
]}
>
diff --git a/client/web/src/cody/codyProRoutes.tsx b/client/web/src/cody/codyProRoutes.tsx
index 5d7f6005e5ac..bda86e234b21 100644
--- a/client/web/src/cody/codyProRoutes.tsx
+++ b/client/web/src/cody/codyProRoutes.tsx
@@ -4,7 +4,6 @@ import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent'
import { LegacyRoute, type LegacyLayoutRouteContext } from '../LegacyRouteContext'
-import { QueryClientProvider } from './management/api/react-query/QueryClientProvider'
import { isEmbeddedCodyProUIEnabled } from './util'
export enum CodyProRoutes {
@@ -78,9 +77,5 @@ interface CodyProPageProps extends Pick = props => {
const Component = routeComponents[props.path]
- return (
-
-
-
- )
+ return
}
diff --git a/client/web/src/cody/management/api/react-query/QueryClientProvider.tsx b/client/web/src/cody/management/api/react-query/CodyProApiProvider.tsx
similarity index 56%
rename from client/web/src/cody/management/api/react-query/QueryClientProvider.tsx
rename to client/web/src/cody/management/api/react-query/CodyProApiProvider.tsx
index 98f0d38386a1..18e4192ce838 100644
--- a/client/web/src/cody/management/api/react-query/QueryClientProvider.tsx
+++ b/client/web/src/cody/management/api/react-query/CodyProApiProvider.tsx
@@ -1,4 +1,4 @@
-import { QueryClient, QueryClientProvider as ReactQueryClientProvider } from '@tanstack/react-query'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Tweak the default queries and mutations behavior.
// See defaults here: https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults
@@ -15,6 +15,10 @@ const queryClient = new QueryClient({
},
})
-export const QueryClientProvider: React.FC> = ({ children }) => (
- {children}
+/**
+ * CodyProApiProvider wraps its children with the react-query QueryClientProvider.
+ * It is used to access the Cody Pro API and is only utilized on dotcom.
+ */
+export const CodyProApiProvider: React.FC> = ({ children }) => (
+ {children}
)
diff --git a/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx b/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx
index 357bf0db8ef5..9310665f0f74 100644
--- a/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx
+++ b/client/web/src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx
@@ -20,7 +20,7 @@ import type { LegacyLayoutRouteContext } from '../../../../LegacyRouteContext'
import { CodyProRoutes } from '../../../codyProRoutes'
import { PageHeaderIcon } from '../../../components/PageHeaderIcon'
import { USER_CODY_PLAN } from '../../../subscription/queries'
-import { useCurrentSubscription } from '../../api/react-query/subscriptions'
+import { useCurrentSubscription, useSubscriptionSummary } from '../../api/react-query/subscriptions'
import { InvoiceHistory } from './InvoiceHistory'
import { PaymentDetails } from './PaymentDetails'
@@ -38,6 +38,7 @@ const AuthenticatedCodySubscriptionManagePage: React.FC = ({ telemetryRec
error: useCodyPlanError,
data: userCodyPlanData,
} = useQuery(USER_CODY_PLAN, {})
+ const subscriptionSummaryQuery = useSubscriptionSummary()
useEffect(
function recordViewEvent() {
@@ -46,7 +47,7 @@ const AuthenticatedCodySubscriptionManagePage: React.FC = ({ telemetryRec
[telemetryRecorder]
)
- if (userCodyPlanLoading) {
+ if (userCodyPlanLoading || subscriptionSummaryQuery.isLoading) {
return
}
@@ -55,18 +56,32 @@ const AuthenticatedCodySubscriptionManagePage: React.FC = ({ telemetryRec
return null
}
+ if (subscriptionSummaryQuery.isError) {
+ logger.error('Failed to fetch Cody subscription summary', subscriptionSummaryQuery.error)
+ return null
+ }
+
const subscriptionData = userCodyPlanData?.currentUser?.codySubscription
if (!subscriptionData) {
logger.error('Cody subscription data is not available.')
return null
}
+ if (!subscriptionSummaryQuery.data) {
+ logger.error('Cody subscription summary is not available.')
+ return null
+ }
+
// This page only applies to users who have a Cody Pro subscription to manage.
// Otherwise, direct them to the ./new page to sign up.
if (subscriptionData.plan !== CodySubscriptionPlan.PRO) {
return
}
+ if (subscriptionSummaryQuery.data.userRole !== 'admin') {
+ return
+ }
+
return (
diff --git a/client/web/src/cody/useCodyProNavLinks.test.tsx b/client/web/src/cody/useCodyProNavLinks.test.tsx
new file mode 100644
index 000000000000..f7d27a589b61
--- /dev/null
+++ b/client/web/src/cody/useCodyProNavLinks.test.tsx
@@ -0,0 +1,85 @@
+import { renderHook } from '@testing-library/react'
+import { vi, describe, afterEach, test, expect, beforeEach } from 'vitest'
+
+import { CodyProRoutes } from './codyProRoutes'
+import * as subscriptionQueries from './management/api/react-query/subscriptions'
+import type { SubscriptionSummary } from './management/api/teamSubscriptions'
+import { useCodyProNavLinks } from './useCodyProNavLinks'
+
+describe('useCodyProNavLinks', () => {
+ const useSubscriptionSummaryMock = vi.spyOn(subscriptionQueries, 'useSubscriptionSummary')
+
+ afterEach(() => {
+ useSubscriptionSummaryMock.mockReset()
+ })
+
+ const mockSubscriptionSummary = (summary?: SubscriptionSummary): void => {
+ useSubscriptionSummaryMock.mockReturnValue({ data: summary } as ReturnType<
+ typeof subscriptionQueries.useSubscriptionSummary
+ >)
+ }
+
+ test('returns empty array if subscription summary is undefined', () => {
+ mockSubscriptionSummary()
+ const { result } = renderHook(() => useCodyProNavLinks())
+ expect(result.current).toHaveLength(0)
+ })
+
+ test('returns empty array user is not admin', () => {
+ const summary: SubscriptionSummary = {
+ teamId: '018ff1b3-118c-7789-82e4-ab9106eed204',
+ userRole: 'member',
+ teamCurrentMembers: 2,
+ teamMaxMembers: 6,
+ subscriptionStatus: 'active',
+ cancelAtPeriodEnd: false,
+ }
+ mockSubscriptionSummary(summary)
+ const { result } = renderHook(() => useCodyProNavLinks())
+ expect(result.current).toHaveLength(0)
+ })
+
+ describe('user is admin', () => {
+ const summary: SubscriptionSummary = {
+ teamId: '018ff1b3-118c-7789-82e4-ab9106eed204',
+ userRole: 'admin',
+ teamCurrentMembers: 2,
+ teamMaxMembers: 6,
+ subscriptionStatus: 'active',
+ cancelAtPeriodEnd: false,
+ }
+
+ const setUseEmbeddedUI = (useEmbeddedUI: boolean) => {
+ vi.stubGlobal('context', {
+ frontendCodyProConfig: {
+ stripePublishableKey: 'pk_test_123',
+ sscBaseUrl: '',
+ useEmbeddedUI,
+ },
+ })
+ }
+
+ beforeEach(() => {
+ vi.stubGlobal('context', {})
+ mockSubscriptionSummary(summary)
+ })
+
+ test.skip('returns links to subscription and team management pages if embedded UI is enabled', () => {
+ setUseEmbeddedUI(true)
+ const { result } = renderHook(() => useCodyProNavLinks())
+ expect(result.current).toHaveLength(2)
+ expect(result.current[0].label).toBe('Manage subscription')
+ expect(result.current[0].to).toBe(CodyProRoutes.SubscriptionManage)
+ expect(result.current[1].label).toBe('Manage team')
+ expect(result.current[1].to).toBe(CodyProRoutes.ManageTeam)
+ })
+
+ test('returns link to subscription management page if embedded UI is disabled', () => {
+ setUseEmbeddedUI(false)
+ const { result } = renderHook(() => useCodyProNavLinks())
+ expect(result.current).toHaveLength(1)
+ expect(result.current[0].label).toBe('Manage subscription')
+ expect(result.current[0].to).toBe('https://accounts.sourcegraph.com/cody/subscription')
+ })
+ })
+})
diff --git a/client/web/src/cody/useCodyProNavLinks.ts b/client/web/src/cody/useCodyProNavLinks.ts
new file mode 100644
index 000000000000..fe79e853a3e2
--- /dev/null
+++ b/client/web/src/cody/useCodyProNavLinks.ts
@@ -0,0 +1,23 @@
+import { useMemo } from 'react'
+
+import { CodyProRoutes } from './codyProRoutes'
+import { useSubscriptionSummary } from './management/api/react-query/subscriptions'
+import { getManageSubscriptionPageURL, isEmbeddedCodyProUIEnabled } from './util'
+
+export const useCodyProNavLinks = (): { to: string; label: string }[] => {
+ const { data } = useSubscriptionSummary()
+
+ return useMemo(() => {
+ if (!data || data.userRole !== 'admin') {
+ return []
+ }
+
+ const items = [{ to: getManageSubscriptionPageURL(), label: 'Manage subscription' }]
+
+ if (isEmbeddedCodyProUIEnabled()) {
+ items.push({ to: CodyProRoutes.ManageTeam, label: 'Manage team' })
+ }
+
+ return items
+ }, [data])
+}
diff --git a/client/web/src/nav/UserNavItem.test.tsx b/client/web/src/nav/UserNavItem.test.tsx
index 79dbe461a957..415dc135c672 100644
--- a/client/web/src/nav/UserNavItem.test.tsx
+++ b/client/web/src/nav/UserNavItem.test.tsx
@@ -2,13 +2,15 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import sinon from 'sinon'
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest'
+import { afterAll, beforeAll, afterEach, describe, expect, test, vi, beforeEach } from 'vitest'
import { NOOP_TELEMETRY_SERVICE } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { AnchorLink, RouterLink, setLinkComponent } from '@sourcegraph/wildcard'
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
+import * as codyProHooks from '../cody/useCodyProNavLinks'
+
import { UserNavItem, type UserNavItemProps } from './UserNavItem'
describe('UserNavItem', () => {
@@ -30,6 +32,14 @@ describe('UserNavItem', () => {
setLinkComponent(AnchorLink)
})
+ const useCodyProNavLinksMock = vi.spyOn(codyProHooks, 'useCodyProNavLinks')
+ beforeEach(() => {
+ useCodyProNavLinksMock.mockReturnValue([])
+ })
+ afterEach(() => {
+ useCodyProNavLinksMock.mockReset()
+ })
+
const USER: UserNavItemProps['authenticatedUser'] = {
username: 'alice',
displayName: 'alice doe',
@@ -100,4 +110,54 @@ describe('UserNavItem', () => {
expect(result.locationRef.entries.length).toBe(1)
expect(result.locationRef.entries.find(({ pathname }) => pathname.includes('sign-out'))).toBe(undefined)
})
+
+ describe('Cody Pro section', () => {
+ const setup = (isSourcegraphDotCom: boolean) => {
+ renderWithBrandedContext(
+
+ undefined}
+ authenticatedUser={USER}
+ isSourcegraphDotCom={isSourcegraphDotCom}
+ showFeedbackModal={() => undefined}
+ telemetryService={NOOP_TELEMETRY_SERVICE}
+ />
+
+ )
+ userEvent.click(screen.getByRole('button'))
+ }
+
+ describe('dotcom', () => {
+ test('renders provided links', () => {
+ const links = [
+ { to: '/foo', label: 'Foo' },
+ { to: '/bar', label: 'Bar' },
+ ]
+ useCodyProNavLinksMock.mockReturnValue(links)
+ setup(true)
+
+ for (const link of links) {
+ const el = screen.getByText(link.label)
+ expect(el).toHaveAttribute('href', link.to)
+ }
+ })
+
+ test('is not rendered if no links provided', () => {
+ useCodyProNavLinksMock.mockReturnValue([])
+ setup(true)
+
+ expect(useCodyProNavLinksMock).toHaveBeenCalled()
+ expect(screen.queryByText('Cody Pro')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('enterprise', () => {
+ test('is not rendered', () => {
+ setup(false)
+
+ // Cody Pro section is not rendered thus useCodyProNavLinks hook is not called
+ expect(useCodyProNavLinksMock).not.toHaveBeenCalled()
+ })
+ })
+ })
})
diff --git a/client/web/src/nav/UserNavItem.tsx b/client/web/src/nav/UserNavItem.tsx
index 88c8d30f3d57..81cf3ae6d0a2 100644
--- a/client/web/src/nav/UserNavItem.tsx
+++ b/client/web/src/nav/UserNavItem.tsx
@@ -27,6 +27,7 @@ import {
import type { AuthenticatedUser } from '../auth'
import { CodyProRoutes } from '../cody/codyProRoutes'
+import { useCodyProNavLinks } from '../cody/useCodyProNavLinks'
import { PageRoutes } from '../routes.constants'
import { enableDevSettings, isSourcegraphDev, useDeveloperSettings } from '../stores'
@@ -132,6 +133,7 @@ export const UserNavItem: FC = props => {
Signed in as @{authenticatedUser.username}
+ {isSourcegraphDotCom && }
Settings
@@ -251,3 +253,24 @@ export const UserNavItem: FC = props => {
>
)
}
+
+const CodyProSection: React.FC = () => {
+ const links = useCodyProNavLinks()
+
+ if (!links.length) {
+ return null
+ }
+
+ return (
+ <>
+
+ Cody PRO
+
+ {links.map(({ to, label }) => (
+
+ {label}
+
+ ))}
+ >
+ )
+}