Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from "react"
import { waitFor } from "@testing-library/react"
import { renderWithProviders, screen } from "../../test-utils"
import { HOME, login } from "@/common/urls"
import { HOME } from "@/common/urls"
import ForbiddenPage from "./ForbiddenPage"
import { setMockResponse, urls } from "api/test-utils"
import { Permissions } from "@/common/permissions"
Expand All @@ -25,19 +24,6 @@ afterAll(() => {
window.location = oldWindowLocation
})

test("The ForbiddenPage loads with meta", async () => {
setMockResponse.get(urls.userMe.get(), {
[Permissions.Authenticated]: true,
})
renderWithProviders(<ForbiddenPage />)
await waitFor(() => {
expect(document.title).toBe("Not Allowed | MIT Learn")
})

const meta = document.head.querySelector('meta[name="robots"]')
expect(meta).toHaveProperty("content", "noindex,noarchive")
})

test("The ForbiddenPage loads with Correct Title", () => {
setMockResponse.get(urls.userMe.get(), {
[Permissions.Authenticated]: true,
Expand All @@ -54,17 +40,3 @@ test("The ForbiddenPage loads with a link that directs to HomePage", () => {
const homeLink = screen.getByRole("link", { name: "Home" })
expect(homeLink).toHaveAttribute("href", HOME)
})

test("Redirects unauthenticated users to login", async () => {
setMockResponse.get(urls.userMe.get(), {
[Permissions.Authenticated]: false,
})
renderWithProviders(<ForbiddenPage />, { url: "/some/url?foo=bar#baz" })

const expectedUrl = login({
pathname: "/some/url",
search: "?foo=bar",
hash: "#baz",
})
expect(window.location.assign).toHaveBeenCalledWith(expectedUrl)
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import React, { useEffect } from "react"
import ErrorPageTemplate from "./ErrorPageTemplate"
import { useUserMe } from "api/hooks/user"
import { Typography } from "ol-components"
import { login } from "@/common/urls"
import { useLocation } from "react-router"
import { redirect } from "next/navigation"
import * as urls from "@/common/urls"

const ForbiddenPage: React.FC = () => {
const location = useLocation()
const { data: user } = useUserMe()

useEffect(() => {
if (!user?.is_authenticated) {
window.location.assign(login(location))
const loginUrl = urls.login()
redirect(loginUrl)
}
})
}, [user])
return (
<ErrorPageTemplate title="Not Allowed">
<Typography variant="h3" component="h1">
Expand Down
9 changes: 0 additions & 9 deletions frontends/main/src/app-pages/ErrorPage/NotFoundPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import React from "react"
import { waitFor } from "@testing-library/react"
import { renderWithProviders, screen } from "@/test-utils"
import { HOME } from "@/common/urls"
import NotFoundPage from "./NotFoundPage"

test.skip("The NotFoundPage loads with meta", async () => {
renderWithProviders(<NotFoundPage />, {})
await waitFor(() => {
const meta = document.head.querySelector('meta[name="robots"]')
expect(meta).toHaveProperty("content", "noindex,noarchive")
})
})

test("The NotFoundPage loads with Correct Title", () => {
renderWithProviders(<NotFoundPage />, {})
screen.getByRole("heading", { name: "404 Not Found Error" })
Expand Down
8 changes: 7 additions & 1 deletion frontends/main/src/app/dashboard/[tab]/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ import DashboardPage from "@/app-pages/DashboardPage/DashboardPage"

import { Metadata } from "next"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Your MIT Learning Journey",
social: false,
})

const Page: React.FC = () => {
return <DashboardPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<DashboardPage />
</RestrictedRoute>
)
}

export default Page
8 changes: 7 additions & 1 deletion frontends/main/src/app/dashboard/[tab]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import DashboardPage from "@/app-pages/DashboardPage/DashboardPage"

import { Metadata } from "next"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Your MIT Learning Journey",
social: false,
})
const Page: React.FC = () => {
return <DashboardPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<DashboardPage />
</RestrictedRoute>
)
}

export default Page
9 changes: 8 additions & 1 deletion frontends/main/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import React from "react"
import { Metadata } from "next"
import DashboardPage from "@/app-pages/DashboardPage/DashboardPage"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Your MIT Learning Journey",
social: false,
})

const Page: React.FC = () => {
return <DashboardPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<DashboardPage />
</RestrictedRoute>
)
}

export default Page
9 changes: 9 additions & 0 deletions frontends/main/src/app/getQueryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,22 @@ type MaybeHasStatus = {

const RETRY_STATUS_CODES = [408, 429, 502, 503, 504]
const MAX_RETRIES = 3
const THROW_ERROR_CODES: (number | undefined)[] = [404, 403, 401]

const makeQueryClient = (): QueryClient => {
return new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: Infinity,
// Throw runtime errors instead of marking query as errored.
// The runtime error will be caught by an error boundary.
// For now, only do this for 404s, 403s, and 401s. Other errors should
// be handled locally by components.
useErrorBoundary: (error) => {
const status = (error as MaybeHasStatus)?.response?.status
return THROW_ERROR_CODES.includes(status)
},
retry: (failureCount, error) => {
const status = (error as MaybeHasStatus)?.response?.status
/**
Expand Down
5 changes: 4 additions & 1 deletion frontends/main/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PageWrapper, PageWrapperInner } from "./styled"
import Providers from "./providers"
import { MITLearnGlobalStyles } from "ol-components"
import Script from "next/script"
import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary"

import "./GlobalStyles"

Expand All @@ -22,7 +23,9 @@ export default function RootLayout({
<MITLearnGlobalStyles />
<PageWrapper>
<Header />
<PageWrapperInner>{children}</PageWrapperInner>
<PageWrapperInner>
<ErrorBoundary>{children}</ErrorBoundary>
</PageWrapperInner>
<Footer />
</PageWrapper>
</Providers>
Expand Down
8 changes: 7 additions & 1 deletion frontends/main/src/app/learningpaths/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import React from "react"
import LearningPathDetailsPage from "@/app-pages/LearningPathDetailsPage/LearningPathDetailsPage"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

const Page: React.FC = () => {
return <LearningPathDetailsPage />
return (
<RestrictedRoute requires={Permissions.LearningPathEditor}>
<LearningPathDetailsPage />
</RestrictedRoute>
)
}

export default Page
9 changes: 8 additions & 1 deletion frontends/main/src/app/learningpaths/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ import LearningPathListingPage from "@/app-pages/LearningPathListingPage/Learnin

import { Metadata } from "next"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Learning Paths",
})

const Page: React.FC = () => {
return <LearningPathListingPage />
return (
<RestrictedRoute requires={Permissions.LearningPathEditor}>
<LearningPathListingPage />
</RestrictedRoute>
)
}

export default Page
8 changes: 7 additions & 1 deletion frontends/main/src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import React from "react"
import { Metadata } from "next"
import OnboardingPage from "@/app-pages/OnboardingPage/OnboardingPage"
import { standardizeMetadata } from "@/common/metadata"
import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
import { Permissions } from "@/common/permissions"

export const metadata: Metadata = standardizeMetadata({
title: "Onboarding",
social: false,
})

const Page: React.FC = () => {
return <OnboardingPage />
return (
<RestrictedRoute requires={Permissions.Authenticated}>
<OnboardingPage />
</RestrictedRoute>
)
}

export default Page
81 changes: 81 additions & 0 deletions frontends/main/src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client"

import React, { Component } from "react"
import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage"
import ForbiddenPage from "@/app-pages/ErrorPage/ForbiddenPage"
import { ForbiddenError } from "@/common/permissions"
import { usePathname } from "next/navigation"

interface ErrorBoundaryProps {
children: React.ReactNode
}

interface ErrorBoundaryHandlerProps extends ErrorBoundaryProps {
pathname: string
}

interface ErrorBoundaryHandlerState {
hasError: boolean
error: unknown
previousPathname: string
}
const isForbiddenError = (error: unknown) => error instanceof ForbiddenError

class ErrorBoundaryHandler extends Component<
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was interested to see a class component here and realize it's so we can use the error boundary lifecycle methods. It says here there's no equivalent for function components, https://legacy.reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks, though that doc is legacy... and these do appear in the up to date docs: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary (on the same page as a bunch of warnings "We recommend defining components as functions instead of classes")!

Looks like we can assume this is the recommended approach until they maybe make a hook available in future versions. The latest v19-beta does still recommend a single class component error boundary "if you’d like to avoid creating class components" - https://19.react.dev/reference/react/Component#componentdidcatch.

ErrorBoundaryHandlerProps,
ErrorBoundaryHandlerState
> {
constructor(props: ErrorBoundaryHandlerProps) {
super(props)
this.state = {
hasError: false,
error: null,
previousPathname: this.props.pathname,
}
}

static getDerivedStateFromError(error: unknown) {
return { hasError: true, error: error }
}

static getDerivedStateFromProps(
props: ErrorBoundaryHandlerProps,
state: ErrorBoundaryHandlerState,
): ErrorBoundaryHandlerState | null {
if (props.pathname !== state.previousPathname && state.error) {
return {
error: null,
hasError: false,
previousPathname: props.pathname,
}
}
return {
error: state.error,
hasError: state.hasError,
previousPathname: props.pathname,
}
}

render() {
if (this.state.hasError) {
if (isForbiddenError(this.state.error)) {
return <ForbiddenPage />
} else {
return <NotFoundPage />
}
}

return this.props.children
}
}

export function ErrorBoundary({
children,
}: ErrorBoundaryProps & { children: React.ReactNode }): JSX.Element {
const pathname = usePathname()
return (
<ErrorBoundaryHandler pathname={pathname}>{children}</ErrorBoundaryHandler>
)
}

export default ErrorBoundary
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from "react"
import { renderWithProviders, screen } from "../../test-utils"
import RestrictedRoute from "./RestrictedRoute"
import { Permissions } from "@/common/permissions"
import { allowConsoleErrors } from "ol-test-utilities"

test("Renders children if permission check satisfied", () => {
const errors: unknown[] = []

renderWithProviders(
<RestrictedRoute requires={Permissions.Authenticated}>
Hello, world!
</RestrictedRoute>,

{
user: { [Permissions.Authenticated]: true },
},
)

screen.getByText("Hello, world!")
expect(!errors.length).toBe(true)
})

test.each(Object.values(Permissions))(
"Throws error if and only if lacking required permission",
async (permission) => {
// if a user is not authenticated they are redirected to login before an error is thrown
if (permission === Permissions.Authenticated) {
return
}
allowConsoleErrors()

expect(() =>
renderWithProviders(
<RestrictedRoute requires={permission}>Hello, world!</RestrictedRoute>,
{
user: { [permission]: false },
},
),
).toThrow("Not allowed.")
},
)
Loading
Loading