diff --git a/frontends/api/src/mitxonline/hooks/organizations/queries.ts b/frontends/api/src/mitxonline/hooks/organizations/queries.ts
index b6173bcdbe..01862ed6c2 100644
--- a/frontends/api/src/mitxonline/hooks/organizations/queries.ts
+++ b/frontends/api/src/mitxonline/hooks/organizations/queries.ts
@@ -32,7 +32,13 @@ const organizationQueries = {
const useB2BAttachMutation = (opts: B2bApiB2bAttachCreateRequest) => {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: () => b2bApi.b2bAttachCreate(opts),
+ 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"] })
},
diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx
index a81e290173..ec9bf9df29 100644
--- a/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.test.tsx
@@ -18,8 +18,10 @@ import * as mitxonline from "api/mitxonline-test-utils"
import { useFeatureFlagEnabled } from "posthog-js/react"
import HomeContent from "./HomeContent"
import invariant from "tiny-invariant"
+import * as NextProgressBar from "next-nprogress-bar"
jest.mock("posthog-js/react")
+jest.mock("next-nprogress-bar")
const mockedUseFeatureFlagEnabled = jest
.mocked(useFeatureFlagEnabled)
.mockImplementation(() => false)
@@ -239,4 +241,66 @@ describe("HomeContent", () => {
}
},
)
+
+ test("Does not display enrollment error alert when query param is not present", async () => {
+ setupAPIs()
+ renderWithProviders()
+
+ await screen.findByRole("heading", {
+ name: "Your MIT Learning Journey",
+ })
+
+ expect(screen.queryByText("Enrollment Error")).not.toBeInTheDocument()
+ })
+
+ test("Displays enrollment error alert when query param is present and then clears it", async () => {
+ setupAPIs()
+ const mockReplace = jest.fn()
+ jest.spyOn(NextProgressBar, "useRouter").mockReturnValue({
+ replace: mockReplace,
+ } as Partial> as ReturnType<
+ typeof NextProgressBar.useRouter
+ >)
+
+ renderWithProviders(, {
+ url: "/dashboard?enrollment_error=1",
+ })
+
+ await screen.findByRole("heading", {
+ name: "Your MIT Learning Journey",
+ })
+
+ // Verify the alert was shown
+ expect(screen.getByText("Enrollment Error")).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ /The Enrollment Code is incorrect or no longer available/,
+ ),
+ ).toBeInTheDocument()
+ expect(screen.getByText("Contact Support")).toBeInTheDocument()
+
+ // Verify the query param is cleared
+ expect(mockReplace).toHaveBeenCalledWith("/dashboard")
+ })
+
+ test("Does not clear query param when it is not present", async () => {
+ setupAPIs()
+ const mockReplace = jest.fn()
+ jest.spyOn(NextProgressBar, "useRouter").mockReturnValue({
+ replace: mockReplace,
+ } as Partial> as ReturnType<
+ typeof NextProgressBar.useRouter
+ >)
+
+ renderWithProviders(, {
+ url: "/dashboard",
+ })
+
+ await screen.findByRole("heading", {
+ name: "Your MIT Learning Journey",
+ })
+
+ // Verify router.replace was not called
+ expect(mockReplace).not.toHaveBeenCalled()
+ })
})
diff --git a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx
index 377d1eb7e9..8b4e022d08 100644
--- a/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx
+++ b/frontends/main/src/app-pages/DashboardPage/HomeContent.tsx
@@ -1,9 +1,9 @@
"use client"
import React, { Suspense } from "react"
-import { ButtonLink } from "@mitodl/smoot-design"
+import { Alert, ButtonLink } from "@mitodl/smoot-design"
import { ResourceTypeEnum } from "api"
-import { styled, Typography } from "ol-components"
-import { PROFILE } from "@/common/urls"
+import { Link, styled, Typography } from "ol-components"
+import { PROFILE, ENROLLMENT_ERROR_QUERY_PARAM } from "@/common/urls"
import {
TopPicksCarouselConfig,
TopicCarouselConfig,
@@ -18,6 +18,8 @@ import { useFeatureFlagEnabled } from "posthog-js/react"
import { FeatureFlags } from "@/common/feature_flags"
import { useUserMe } from "api/hooks/user"
import { OrganizationCards } from "./CoursewareDisplay/OrganizationCards"
+import { useSearchParams } from "next/navigation"
+import { useRouter } from "next-nprogress-bar"
const SubTitleText = styled(Typography)(({ theme }) => ({
color: theme.custom.colors.darkGray2,
@@ -66,13 +68,36 @@ const TitleText = styled(Typography)(({ theme }) => ({
},
})) as typeof Typography
+const AlertBanner = styled(Alert)({
+ marginTop: "32px",
+})
+
const HomeContent: React.FC = () => {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+ const enrollmentError = searchParams.get(ENROLLMENT_ERROR_QUERY_PARAM)
+ const [showEnrollmentError, setShowEnrollmentError] = React.useState(false)
const { isLoading: isLoadingProfile, data: user } = useUserMe()
const topics = user?.profile?.preference_search_filters.topic
const certification = user?.profile?.preference_search_filters.certification
const showEnrollments = useFeatureFlagEnabled(
FeatureFlags.EnrollmentDashboard,
)
+ const supportEmail = process.env.NEXT_PUBLIC_MITOL_SUPPORT_EMAIL || ""
+
+ // Show error and clear the query param
+ React.useEffect(() => {
+ if (enrollmentError) {
+ setShowEnrollmentError(true)
+ const newParams = new URLSearchParams(searchParams.toString())
+ newParams.delete(ENROLLMENT_ERROR_QUERY_PARAM)
+ const newUrl = newParams.toString()
+ ? `${window.location.pathname}?${newParams.toString()}`
+ : window.location.pathname
+ router.replace(newUrl)
+ }
+ }, [enrollmentError, searchParams, router])
+
return (
<>
@@ -88,6 +113,21 @@ const HomeContent: React.FC = () => {
+ {showEnrollmentError && (
+
+
+ Enrollment Error
+
+
+ {" - "}
+ The Enrollment Code is incorrect or no longer available.{" "}
+
+ Contact Support
+ {" "}
+ for assistance.
+
+
+ )}
{showEnrollments ? : null}
diff --git a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx
index af357581de..df03c49885 100644
--- a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx
+++ b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.test.tsx
@@ -1,7 +1,7 @@
import React from "react"
import { renderWithProviders, setMockResponse, waitFor } from "@/test-utils"
import { makeRequest, urls } from "api/test-utils"
-import { urls as b2bUrls } from "api/mitxonline-test-utils"
+import { urls as b2bUrls, factories } from "api/mitxonline-test-utils"
import * as commonUrls from "@/common/urls"
import { Permission } from "api/hooks/user"
import EnrollmentCodePage from "./EnrollmentCodePage"
@@ -25,6 +25,9 @@ describe("EnrollmentCodePage", () => {
[Permission.Authenticated]: false,
})
+ const mitxUser = factories.user.user()
+ setMockResponse.get(b2bUrls.userMe.get(), mitxUser)
+
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [], {
code: 403,
})
@@ -54,6 +57,9 @@ describe("EnrollmentCodePage", () => {
[Permission.Authenticated]: true,
})
+ const mitxUser = factories.user.user()
+ setMockResponse.get(b2bUrls.userMe.get(), mitxUser)
+
setMockResponse.post(b2bUrls.b2bAttach.b2bAttachView("test-code"), [])
renderWithProviders(, {
@@ -61,13 +67,14 @@ describe("EnrollmentCodePage", () => {
})
})
- test("Redirects to dashboard on successful attachment", async () => {
+ test("Redirects to dashboard on successful attachment (201 status)", async () => {
setMockResponse.get(urls.userMe.get(), {
[Permission.Authenticated]: true,
})
const attachUrl = b2bUrls.b2bAttach.b2bAttachView("test-code")
- setMockResponse.post(attachUrl, [])
+ // 201 status indicates successful attachment to new contract(s)
+ setMockResponse.post(attachUrl, {}, { code: 201 })
renderWithProviders(, {
url: commonUrls.B2B_ATTACH_VIEW,
@@ -77,6 +84,54 @@ describe("EnrollmentCodePage", () => {
expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
})
- expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME)
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME)
+ })
+ })
+
+ test("Redirects to dashboard when user already attached to all contracts (200 status)", async () => {
+ setMockResponse.get(urls.userMe.get(), {
+ [Permission.Authenticated]: true,
+ })
+
+ const attachUrl = b2bUrls.b2bAttach.b2bAttachView("already-used-code")
+ // 200 status indicates user already attached to all contracts - still redirect to dashboard without error
+ setMockResponse.post(attachUrl, {}, { code: 200 })
+
+ renderWithProviders(, {
+ url: commonUrls.B2B_ATTACH_VIEW,
+ })
+
+ await waitFor(() => {
+ expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
+ })
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(commonUrls.DASHBOARD_HOME)
+ })
+ })
+
+ test("Redirects to dashboard with error for invalid code (404 status)", async () => {
+ setMockResponse.get(urls.userMe.get(), {
+ [Permission.Authenticated]: true,
+ })
+
+ const attachUrl = b2bUrls.b2bAttach.b2bAttachView("invalid-code")
+ // 404 status indicates invalid or expired enrollment code
+ setMockResponse.post(attachUrl, {}, { code: 404 })
+
+ renderWithProviders(, {
+ url: commonUrls.B2B_ATTACH_VIEW,
+ })
+
+ await waitFor(() => {
+ expect(makeRequest).toHaveBeenCalledWith("post", attachUrl, undefined)
+ })
+
+ await waitFor(() => {
+ expect(mockPush).toHaveBeenCalledWith(
+ commonUrls.DASHBOARD_HOME_ENROLLMENT_ERROR,
+ )
+ })
})
})
diff --git a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx
index ade2e949f4..2d87f9e5c4 100644
--- a/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx
+++ b/frontends/main/src/app-pages/EnrollmentCodePage/EnrollmentCodePage.tsx
@@ -17,22 +17,38 @@ const InterstitialMessage = styled(Typography)(({ theme }) => ({
}))
const EnrollmentCodePage: React.FC = ({ code }) => {
+ const router = useRouter()
+
const enrollment = useB2BAttachMutation({
enrollment_code: code,
})
- const router = useRouter()
const { isLoading: userLoading, data: user } = useQuery({
...userQueries.me(),
staleTime: 0,
})
- const enrollAsync = enrollment.mutateAsync
React.useEffect(() => {
- if (user?.is_authenticated) {
- enrollAsync().then(() => router.push(urls.DASHBOARD_HOME))
+ if (
+ user?.is_authenticated &&
+ !enrollment.isPending &&
+ !enrollment.isSuccess
+ ) {
+ enrollment.mutate()
+ }
+ }, [user?.is_authenticated, enrollment])
+
+ // Handle redirect based on response status code
+ // 201: Successfully attached to new contract(s) -> redirect to dashboard
+ // 200: Already attached to all contracts -> redirect to dashboard
+ // 404: Invalid or expired code -> show error
+ React.useEffect(() => {
+ if (enrollment.isSuccess) {
+ router.push(urls.DASHBOARD_HOME)
+ } else if (enrollment.isError) {
+ router.push(urls.DASHBOARD_HOME_ENROLLMENT_ERROR)
}
- }, [user?.is_authenticated, enrollAsync, router])
+ }, [enrollment.isSuccess, enrollment.isError, router])
React.useEffect(() => {
if (userLoading) {
diff --git a/frontends/main/src/common/urls.ts b/frontends/main/src/common/urls.ts
index 8843084378..b70edf1181 100644
--- a/frontends/main/src/common/urls.ts
+++ b/frontends/main/src/common/urls.ts
@@ -60,6 +60,8 @@ export const DASHBOARD_VIEW = "/dashboard/[tab]"
const dashboardView = (tab: string) => generatePath(DASHBOARD_VIEW, { tab })
export const DASHBOARD_HOME = "/dashboard"
+export const ENROLLMENT_ERROR_QUERY_PARAM = "enrollment_error"
+export const DASHBOARD_HOME_ENROLLMENT_ERROR = `/dashboard?${ENROLLMENT_ERROR_QUERY_PARAM}=1`
export const MY_LISTS = dashboardView("my-lists")
export const PROFILE = dashboardView("profile")
export const SETTINGS = dashboardView("settings")