From c33a872042e52f580d75b9533264beaae2204b84 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 22 May 2026 15:24:44 -0400 Subject: [PATCH 1/2] add contract admin view link for B2B managers --- .../mitxonline/hooks/organizations/index.ts | 23 ++- .../mitxonline/hooks/organizations/queries.ts | 62 ++++-- .../ContractAdminPage/ContractAdminPage.tsx | 182 ++++++++++++++++++ .../DashboardPage/ContractContent.tsx | 32 ++- .../contract/[contractSlug]/admin/layout.tsx | 20 ++ .../contract/[contractSlug]/admin/page.tsx | 16 ++ frontends/main/src/common/feature_flags.ts | 1 + frontends/main/src/common/urls.ts | 4 + frontends/main/src/common/utils.ts | 2 +- 9 files changed, 317 insertions(+), 25 deletions(-) create mode 100644 frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx create mode 100644 frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/layout.tsx create mode 100644 frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/page.tsx diff --git a/frontends/api/src/mitxonline/hooks/organizations/index.ts b/frontends/api/src/mitxonline/hooks/organizations/index.ts index e042776f5e..69a4e85792 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/index.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/index.ts @@ -1,3 +1,22 @@ -import { organizationQueries, useB2BAttachMutation } from "./queries" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { b2bApi } from "../../clients" +import { B2bApiB2bAttachCreateRequest } from "@mitodl/mitxonline-api-axios/v2" +import { organizationQueries, managerOrganizationQueries } from "./queries" -export { organizationQueries, useB2BAttachMutation } +const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => { + const response = await b2bApi.b2bAttachCreate(opts) + // 200 (already attached) indicates user already attached to all contracts + // 201 (successfully attached) is success + // 404 (invalid or expired code) will be thrown as error by axios + return response + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["mitxonline"] }) + }, + }) +} + +export { organizationQueries, managerOrganizationQueries, useB2BAttachMutation } diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts index 01862ed6c2..44d8eba05a 100644 --- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts +++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts @@ -1,13 +1,10 @@ -import { - queryOptions, - useMutation, - useQueryClient, -} from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query" import { b2bApi } from "../../clients" import { OrganizationPage, - B2bApiB2bAttachCreateRequest, + ManagerContractDetail, B2bApiB2bOrganizationsRetrieveRequest, + B2bApiB2bManagerOrganizationsContractsRetrieveRequest, } from "@mitodl/mitxonline-api-axios/v2" const organizationKeys = { @@ -29,20 +26,43 @@ const organizationQueries = { }), } -const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async () => { - const response = await b2bApi.b2bAttachCreate(opts) - // 200 (already attached) indicates user already attached to all contracts - // 201 (successfully attached) is success - // 404 (invalid or expired code) will be thrown as error by axios - return response - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["mitxonline"] }) - }, - }) +const managerOrganizationKeys = { + list: () => ["mitxonline", "manager", "organizations", "list"] as const, + contractDetail: ( + opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest, + ) => + [ + "mitxonline", + "manager", + "organizations", + "contracts", + "detail", + opts, + ] as const, +} + +const managerOrganizationQueries = { + managerOrganizationsList: () => + queryOptions({ + queryKey: managerOrganizationKeys.list(), + queryFn: async (): Promise => + b2bApi.b2bManagerOrganizationsList().then((res) => res.data), + }), + managerContractDetail: ( + opts: B2bApiB2bManagerOrganizationsContractsRetrieveRequest, + ) => + queryOptions({ + queryKey: managerOrganizationKeys.contractDetail(opts), + queryFn: async (): Promise => + b2bApi + .b2bManagerOrganizationsContractsRetrieve(opts) + .then((res) => res.data), + }), } -export { organizationQueries, organizationKeys, useB2BAttachMutation } +export { + organizationQueries, + organizationKeys, + managerOrganizationQueries, + managerOrganizationKeys, +} diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx new file mode 100644 index 0000000000..3fea0f868a --- /dev/null +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -0,0 +1,182 @@ +"use client" + +import React from "react" +import Image from "next/image" +import { useQuery } from "@tanstack/react-query" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { Skeleton, Stack, Typography, styled } from "ol-components" +import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" +import { matchOrganizationBySlug } from "@/common/utils" +import { ForbiddenError } from "@/common/errors" +import { FeatureFlags } from "@/common/feature_flags" +import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" +import graduateLogo from "@/public/images/dashboard/graduate.png" + +const PageRoot = styled.div(({ theme }) => ({ + maxWidth: "1200px", + margin: "0 auto", + padding: "40px 24px", + [theme.breakpoints.down("sm")]: { + padding: "24px 16px", + }, +})) + +const HeaderSection = styled.div(({ theme }) => ({ + display: "flex", + padding: "24px", + alignItems: "center", + gap: "24px", + borderRadius: "8px", + backgroundColor: theme.custom.colors.white, + boxShadow: "0 1px 3px 0 rgba(120, 147, 172, 0.40)", + [theme.breakpoints.down("sm")]: { + padding: "16px", + gap: "16px", + }, +})) + +const ImageContainer = styled.div(({ theme }) => ({ + display: "flex", + width: "80px", + height: "80px", + padding: "16px", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + borderRadius: "8px", + backgroundColor: theme.custom.colors.white, + boxShadow: "0px 1px 3px 0px rgba(120, 147, 172, 0.40)", + "> img": { + width: "100%", + height: "auto", + }, + [theme.breakpoints.down("sm")]: { + width: "56px", + height: "56px", + padding: "8px", + }, +})) + +const HeaderTextContainer = styled.div({ + display: "flex", + flexDirection: "column", + gap: "4px", + flex: 1, +}) + +const OrgName = styled(Typography)(({ theme }) => ({ + ...theme.typography.h3, + color: theme.custom.colors.darkGray2, + [theme.breakpoints.down("sm")]: { + ...theme.typography.h5, + }, +})) as typeof Typography + +const ContractName = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const SeatCount = styled(Typography)(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +type ContractAdminPageInternalProps = { + orgSlug: string + contractSlug: string +} + +const ContractAdminPageInternal: React.FC = ({ + orgSlug, + contractSlug, +}) => { + const { data: managerOrgs, isLoading: isLoadingOrgs } = useQuery( + managerOrganizationQueries.managerOrganizationsList(), + ) + + const org = managerOrgs?.find(matchOrganizationBySlug(orgSlug)) + const contract = org?.contracts.find((c) => c.slug === contractSlug) + + const { data: contractDetail, isLoading: isLoadingDetail } = useQuery({ + ...managerOrganizationQueries.managerContractDetail({ + id: contract?.id ?? 0, + parent_lookup_organization: org?.id ?? 0, + }), + enabled: !!org && !!contract, + }) + + if (isLoadingOrgs) { + return ( + + + + ) + } + + if (!org) { + return + } + + if (!contract) { + return + } + + return ( + + + + + + + + {org.name} + {contract.name} + {isLoadingDetail ? ( + + ) : ( + contractDetail?.total_codes !== undefined && ( + {contractDetail.total_codes} seats + ) + )} + + + + + ) +} + +type ContractAdminPageProps = { + orgSlug: string + contractSlug: string +} + +const ContractAdminPage: React.FC = ({ + orgSlug, + contractSlug, +}) => { + const flagEnabled = useFeatureFlagEnabled( + FeatureFlags.B2BContractManagerDashboard, + ) + + if (flagEnabled === false) { + throw new ForbiddenError("Not enabled.") + } + + if (!flagEnabled) { + return null + } + + return ( + + ) +} + +export default ContractAdminPage + +export type { ContractAdminPageProps } diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx index e78fac96da..187824bef3 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.tsx @@ -23,10 +23,14 @@ import { V3UserProgramEnrollment, } from "@mitodl/mitxonline-api-axios/v2" import { mitxUserQueries } from "api/mitxonline-hooks/user" +import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" import { ButtonLink } from "@mitodl/smoot-design" import { RiAwardFill } from "@remixicon/react" +import { useFeatureFlagEnabled } from "posthog-js/react" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import { matchOrganizationBySlug } from "@/common/utils" +import { FeatureFlags } from "@/common/feature_flags" +import { contractAdminView } from "@/common/urls" import { ResourceType, getKey } from "./CoursewareDisplay/helpers" import type { DashboardCourseEntry } from "./CoursewareDisplay/model/dashboardViewModel" import { useContractDashboardData } from "./CoursewareDisplay/hooks/useContractDashboardData" @@ -382,10 +386,14 @@ const ContractHeaderSection = styled.div(({ theme }) => ({ type ContractContentInternalProps = { org: OrganizationPage contract: ContractPage + orgSlug: string + contractSlug: string } const ContractContentInternal: React.FC = ({ org, contract, + orgSlug, + contractSlug, }) => { const { isLoading, @@ -397,6 +405,15 @@ const ContractContentInternal: React.FC = ({ collections, } = useContractDashboardData(org, contract) + const managerDashboardFlag = useFeatureFlagEnabled( + FeatureFlags.B2BContractManagerDashboard, + ) + const { data: managerOrgs } = useQuery({ + ...managerOrganizationQueries.managerOrganizationsList(), + enabled: managerDashboardFlag === true, + }) + const isManager = managerOrgs?.some(matchOrganizationBySlug(orgSlug)) ?? false + const skeleton = ( {Array.from({ length: 2 }).map((_, index) => ( @@ -436,6 +453,14 @@ const ContractContentInternal: React.FC = ({ + {managerDashboardFlag && isManager && ( + + Manage + + )} {languageOptions.length > 1 && ( = ({ } return ( - + ) } diff --git a/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/layout.tsx b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/layout.tsx new file mode 100644 index 0000000000..34762eb825 --- /dev/null +++ b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/layout.tsx @@ -0,0 +1,20 @@ +import React from "react" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" +import { Permission } from "api/hooks/user" +import { standardizeMetadata } from "@/common/metadata" +import type { Metadata } from "next" + +export const metadata: Metadata = standardizeMetadata({ + title: "Contract Management", + social: false, +}) + +const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ( + + {children} + + ) +} + +export default Layout diff --git a/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/page.tsx b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/page.tsx new file mode 100644 index 0000000000..77aa16042b --- /dev/null +++ b/frontends/main/src/app/(site)/organization/[orgSlug]/contract/[contractSlug]/admin/page.tsx @@ -0,0 +1,16 @@ +import React from "react" +import ContractAdminPage from "@/app-pages/ContractAdminPage/ContractAdminPage" + +const Page: React.FC< + PageProps<"/organization/[orgSlug]/contract/[contractSlug]/admin"> +> = async ({ params }) => { + const resolved = await params + return ( + + ) +} + +export default Page diff --git a/frontends/main/src/common/feature_flags.ts b/frontends/main/src/common/feature_flags.ts index 2fddd7acc3..2ff30e705f 100644 --- a/frontends/main/src/common/feature_flags.ts +++ b/frontends/main/src/common/feature_flags.ts @@ -15,6 +15,7 @@ export enum FeatureFlags { OcwProductPages = "ocw-product-pages", VideoPlaylistPage = "video-playlist-page", PodcastDetailPage = "podcast-detail-page", + B2BContractManagerDashboard = "b2b-contract-manager-dashboard", } /** diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts index f697fd5927..8f167df65e 100644 --- a/frontends/main/src/common/urls.ts +++ b/frontends/main/src/common/urls.ts @@ -89,6 +89,10 @@ export const CONTRACT_VIEW = "/dashboard/organization/[orgSlug]/contract/[contractSlug]" export const contractView = (orgSlug: string, contractSlug: string) => generatePath(CONTRACT_VIEW, { orgSlug: orgSlug, contractSlug: contractSlug }) +export const CONTRACT_ADMIN_VIEW = + "/organization/[orgSlug]/contract/[contractSlug]/admin" +export const contractAdminView = (orgSlug: string, contractSlug: string) => + generatePath(CONTRACT_ADMIN_VIEW, { orgSlug, contractSlug }) export const PROGRAM_VIEW = "/dashboard/program/[id]" export const programView = (id: number) => generatePath(PROGRAM_VIEW, { id: String(id) }) diff --git a/frontends/main/src/common/utils.ts b/frontends/main/src/common/utils.ts index 2652f5946c..a949f11090 100644 --- a/frontends/main/src/common/utils.ts +++ b/frontends/main/src/common/utils.ts @@ -10,7 +10,7 @@ const isInEnum = ( const matchOrganizationBySlug = (orgSlug: string) => (organization: OrganizationPage) => { - return organization.slug.replace("org-", "") === orgSlug + return organization.slug.replace(/^org-/, "") === orgSlug } // Utility function to collapse whitespace From d71fce3b6d89b9c660449696784d29c288ea8d44 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Fri, 22 May 2026 16:42:55 -0400 Subject: [PATCH 2/2] add tests for Manage button gating and ContractAdminPage branches --- .../ContractAdminPage.test.tsx | 120 ++++++++++++++++++ .../DashboardPage/ContractContent.test.tsx | 73 +++++++++++ 2 files changed, 193 insertions(+) create mode 100644 frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx new file mode 100644 index 0000000000..bde5c6d3be --- /dev/null +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.test.tsx @@ -0,0 +1,120 @@ +import React from "react" +import { renderWithProviders, screen } from "@/test-utils" +import { setMockResponse } from "api/test-utils" +import { factories } from "api/mitxonline-test-utils" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { allowConsoleErrors } from "ol-test-utilities" +import { ForbiddenError } from "@/common/errors" +import ContractAdminPage from "./ContractAdminPage" + +jest.mock("posthog-js/react", () => ({ + ...jest.requireActual("posthog-js/react"), + useFeatureFlagEnabled: jest.fn(), +})) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) + +const API_BASE_URL = process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL +const managerOrgsUrl = `${API_BASE_URL}/api/v0/b2b/manager/organizations/` +const managerContractDetailUrl = (orgId: number, contractId: number) => + `${API_BASE_URL}/api/v0/b2b/manager/organizations/${orgId}/contracts/${contractId}/` + +const makeOrgWithContract = () => { + const contract = factories.contracts.contract() + const org = factories.organizations.organization({ contracts: [contract] }) + return { org, contract } +} + +describe("ContractAdminPage", () => { + beforeEach(() => { + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) + }) + + test("throws ForbiddenError when feature flag is explicitly false", () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + allowConsoleErrors() + + expect(() => + renderWithProviders( + , + ), + ).toThrow(ForbiddenError) + }) + + test("renders nothing while feature flag is loading (undefined)", () => { + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) + + const { view } = renderWithProviders( + , + ) + + expect(view.container.firstChild).toBeNull() + }) + + test("shows 'Organization not found' when user is not a manager for the requested org", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const otherOrg = factories.organizations.organization({}) + setMockResponse.get(managerOrgsUrl, [otherOrg]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Organization not found" }) + }) + + test("shows 'Contract not found' when org is found but contract slug does not match", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const { org } = makeOrgWithContract() + setMockResponse.get(managerOrgsUrl, [org]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Contract not found" }) + }) + + test("renders org name and contract name when flag is on and user is a manager", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const { org, contract } = makeOrgWithContract() + setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerContractDetailUrl(org.id, contract.id), { + ...contract, + attachment_percentage: null, + total_enrollments: 0, + total_codes: 50, + }) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: org.name }) + expect(screen.getByText(contract.name)).toBeInTheDocument() + }) + + test("renders seat count from contract detail", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const { org, contract } = makeOrgWithContract() + setMockResponse.get(managerOrgsUrl, [org]) + setMockResponse.get(managerContractDetailUrl(org.id, contract.id), { + ...contract, + attachment_percentage: null, + total_enrollments: 12, + total_codes: 75, + }) + + renderWithProviders( + , + ) + + await screen.findByText("75 seats") + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 08876330da..e5c2b712e1 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -19,6 +19,18 @@ import { import { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import invariant from "tiny-invariant" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import { contractAdminView } from "@/common/urls" + +jest.mock("posthog-js/react", () => ({ + ...jest.requireActual("posthog-js/react"), + useFeatureFlagEnabled: jest.fn(), +})) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) + +const API_BASE_URL = process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL +const managerOrganizationsUrl = `${API_BASE_URL}/api/v0/b2b/manager/organizations/` const makeCourseEnrollment = factories.enrollment.courseEnrollment const makeGrade = factories.enrollment.grade @@ -56,6 +68,7 @@ describe("ContractContent", () => { setMockResponse.get(urls.enrollment.enrollmentsListV3(), []) setMockResponse.get(urls.programEnrollments.enrollmentsListV3(), []) setMockResponse.get(urls.contracts.contractsList(), []) + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) }) it("displays a header for each program returned and cards for courses in program", async () => { @@ -1394,6 +1407,66 @@ describe("ContractContent", () => { await screen.findByRole("heading", { name: "Contract not found" }) }) + test("does not render the Manage button when the feature flag is off", async () => { + mockedUseFeatureFlagEnabled.mockImplementation(() => false) + const { orgX } = setupProgramsAndCourses() + + setMockResponse.get(managerOrganizationsUrl, [orgX]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: orgX.name }) + expect(screen.queryByRole("link", { name: "Manage" })).not.toBeInTheDocument() + }) + + test("does not render the Manage button when flag is on but user is not a manager for this org", async () => { + mockedUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.B2BContractManagerDashboard, + ) + const { orgX } = setupProgramsAndCourses() + + // Return a different org — user is a manager elsewhere, not for orgX + const otherOrg = factories.organizations.organization({}) + setMockResponse.get(managerOrganizationsUrl, [otherOrg]) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: orgX.name }) + expect(screen.queryByRole("link", { name: "Manage" })).not.toBeInTheDocument() + }) + + test("renders the Manage button with correct href when flag is on and user is a manager", async () => { + mockedUseFeatureFlagEnabled.mockImplementation( + (flag) => flag === FeatureFlags.B2BContractManagerDashboard, + ) + const { orgX } = setupProgramsAndCourses() + + setMockResponse.get(managerOrganizationsUrl, [orgX]) + + renderWithProviders( + , + ) + + const manageButton = await screen.findByRole("link", { name: "Manage" }) + expect(manageButton).toHaveAttribute( + "href", + contractAdminView(orgX.slug, orgX.contracts[0].slug), + ) + }) + test("sanitizes HTML content in welcome_message_extra", async () => { const { orgX } = setupProgramsAndCourses()