From 3c0194dab0dc7fcd3b49c2d773249ce441a6975f Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Wed, 8 Jan 2025 20:34:24 -0500 Subject: [PATCH 1/3] route is redirecting, wip on setting correct status codes --- frontend/src/app/[locale]/error.tsx | 14 +++++ frontend/src/app/[locale]/error/page.tsx | 14 +++++ .../src/app/[locale]/unauthorized/page.tsx | 14 +++++ frontend/src/app/api/auth/callback/route.ts | 51 +++++-------------- frontend/src/middleware.ts | 18 ++++++- 5 files changed, 72 insertions(+), 39 deletions(-) create mode 100644 frontend/src/app/[locale]/error.tsx create mode 100644 frontend/src/app/[locale]/error/page.tsx create mode 100644 frontend/src/app/[locale]/unauthorized/page.tsx diff --git a/frontend/src/app/[locale]/error.tsx b/frontend/src/app/[locale]/error.tsx new file mode 100644 index 000000000..56996be15 --- /dev/null +++ b/frontend/src/app/[locale]/error.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { GridContainer } from "@trussworks/react-uswds"; + +const TopLevelError = ({ error }: { error: Error }) => { + return ( + +

Error

+
{error.message}
+
+ ); +}; + +export default TopLevelError; diff --git a/frontend/src/app/[locale]/error/page.tsx b/frontend/src/app/[locale]/error/page.tsx new file mode 100644 index 000000000..ba6212b31 --- /dev/null +++ b/frontend/src/app/[locale]/error/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { GridContainer } from "@trussworks/react-uswds"; + +const Unauthorized = () => { + return ( + +

Error

+
Oops, something went wrong. Please try again.
+
+ ); +}; + +export default Unauthorized; diff --git a/frontend/src/app/[locale]/unauthorized/page.tsx b/frontend/src/app/[locale]/unauthorized/page.tsx new file mode 100644 index 000000000..11e0c7dc1 --- /dev/null +++ b/frontend/src/app/[locale]/unauthorized/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { GridContainer } from "@trussworks/react-uswds"; + +const Unauthorized = () => { + return ( + +

Unauthorized

+
Unable to log in
+
+ ); +}; + +export default Unauthorized; diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index d874ed17d..383416ab3 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -1,47 +1,22 @@ -import { createSession, getSession } from "src/services/auth/session"; +import { createSession } from "src/services/auth/session"; import { redirect } from "next/navigation"; import { NextRequest } from "next/server"; -const createSessionAndSetStatus = async ( - token: string, - successStatus: string, -): Promise => { - try { - await createSession(token); - return successStatus; - } catch (error) { - console.error("error in creating session", error); - return "error!"; - } -}; - -/* - For now, we'll send them to generic success and error pages with cookie set on success - - message: str ("success" or "error") - token: str | None - is_user_new: bool | None - error_description: str | None - - TODOS: - - - translating messages? - - ... -*/ +// in Next 15 there is an experimantal `unauthorized` function that will send a 401 +// code to the client and display an unauthorized page +// see https://nextjs.org/docs/app/api-reference/functions/unauthorized +// For now there is no way that I can see to get a 400 code to the user in these cases, +// and have the client render the app export async function GET(request: NextRequest) { - const currentSession = await getSession(); - if (currentSession && currentSession.token) { - const status = await createSessionAndSetStatus( - currentSession.token, - "already logged in", - ); - return redirect(`/user?message=${status}`); - } const token = request.nextUrl.searchParams.get("token"); if (!token) { - return redirect("/user?message=no token provided"); + return redirect("/unauthorized"); + } + try { + await createSession(token); + } catch (_e) { + return redirect("/error"); } - const status = await createSessionAndSetStatus(token, "created session"); - return redirect(`/user?message=${status}`); + return redirect("/"); } diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index b14cb2b53..6b77c5250 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -40,5 +40,21 @@ const i18nMiddleware = createIntlMiddleware({ }); export default function middleware(request: NextRequest): NextResponse { - return featureFlagsManager.middleware(request, i18nMiddleware(request)); + const response = featureFlagsManager.middleware( + request, + i18nMiddleware(request), + ); + if (request.url.includes("/error")) { + return new NextResponse(response.body, { + status: 500, + headers: response.headers, + }); + } + if (request.url.includes("/unauthorized")) { + return new NextResponse(response.body, { + status: 401, + headers: response.headers, + }); + } + return response; } From a6df34eb9ce3b61fd0a7aa411f7f65d868662261 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Thu, 9 Jan 2025 09:51:16 -0500 Subject: [PATCH 2/3] fix tests --- frontend/src/app/api/auth/callback/route.ts | 5 ---- frontend/src/middleware.ts | 4 +++ .../tests/api/auth/callback/route.test.ts | 27 ++++--------------- frontend/tests/utils/getRoutes.test.ts | 2 ++ 4 files changed, 11 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index 383416ab3..f8b4c1f2b 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -3,11 +3,6 @@ import { createSession } from "src/services/auth/session"; import { redirect } from "next/navigation"; import { NextRequest } from "next/server"; -// in Next 15 there is an experimantal `unauthorized` function that will send a 401 -// code to the client and display an unauthorized page -// see https://nextjs.org/docs/app/api-reference/functions/unauthorized -// For now there is no way that I can see to get a 400 code to the user in these cases, -// and have the client render the app export async function GET(request: NextRequest) { const token = request.nextUrl.searchParams.get("token"); if (!token) { diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 6b77c5250..30b27ce1b 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -44,6 +44,10 @@ export default function middleware(request: NextRequest): NextResponse { request, i18nMiddleware(request), ); + // in Next 15 there is an experimantal `unauthorized` function that will send a 401 + // code to the client and display an unauthorized page + // see https://nextjs.org/docs/app/api-reference/functions/unauthorized + // For now we can set status codes on auth redirect errors here if (request.url.includes("/error")) { return new NextResponse(response.body, { status: 500, diff --git a/frontend/tests/api/auth/callback/route.test.ts b/frontend/tests/api/auth/callback/route.test.ts index 3beeac414..68d1da171 100644 --- a/frontend/tests/api/auth/callback/route.test.ts +++ b/frontend/tests/api/auth/callback/route.test.ts @@ -8,47 +8,30 @@ import { wrapForExpectedError } from "src/utils/testing/commonTestUtils"; import { NextRequest } from "next/server"; const createSessionMock = jest.fn(); -const getSessionMock = jest.fn(); jest.mock("src/services/auth/session", () => ({ createSession: (token: string): unknown => createSessionMock(token), - getSession: (): unknown => getSessionMock(), })); // note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect // is to throw an error describe("/api/auth/callback GET handler", () => { afterEach(() => jest.clearAllMocks()); - it("calls createSession with token set in header", async () => { - getSessionMock.mockImplementation(() => ({ - token: "fakeToken", - })); + it("calls createSession on request with token in query params", async () => { const redirectError = await wrapForExpectedError<{ digest: string }>(() => - GET(new NextRequest("https://simpler.grants.gov")), + GET(new NextRequest("https://simpler.grants.gov/?token=fakeToken")), ); expect(createSessionMock).toHaveBeenCalledTimes(1); expect(createSessionMock).toHaveBeenCalledWith("fakeToken"); - expect(redirectError.digest).toContain("message=already logged in"); - }); - - it("if no token exists on current session, calls createSession with token set in query param", async () => { - getSessionMock.mockImplementation(() => ({})); - const redirectError = await wrapForExpectedError<{ digest: string }>(() => - GET(new NextRequest("https://simpler.grants.gov?token=queryFakeToken")), - ); - - expect(createSessionMock).toHaveBeenCalledTimes(1); - expect(createSessionMock).toHaveBeenCalledWith("queryFakeToken"); - expect(redirectError.digest).toContain("message=created session"); + expect(redirectError.digest).toContain(";/;"); }); - it("if no token exists on current session or query param, does not call createSession", async () => { - getSessionMock.mockImplementation(() => ({})); + it("if no token exists on query param, does not call createSession and redirects to unauthorized page", async () => { const redirectError = await wrapForExpectedError<{ digest: string }>(() => GET(new NextRequest("https://simpler.grants.gov")), ); expect(createSessionMock).toHaveBeenCalledTimes(0); - expect(redirectError.digest).toContain("message=no token provided"); + expect(redirectError.digest).toContain(";/unauthorized;"); }); }); diff --git a/frontend/tests/utils/getRoutes.test.ts b/frontend/tests/utils/getRoutes.test.ts index 34b128c33..71fd2b6c1 100644 --- a/frontend/tests/utils/getRoutes.test.ts +++ b/frontend/tests/utils/getRoutes.test.ts @@ -27,6 +27,7 @@ describe("getNextRoutes", () => { expect(result).toEqual([ "/dev/feature-flags", + "/error", "/health", "/maintenance", "/opportunity/1", @@ -37,6 +38,7 @@ describe("getNextRoutes", () => { "/subscribe/confirmation", "/subscribe", "/subscribe/unsubscribe", + "/unauthorized", "/user", ]); }); From ac898465358b98b8cc995717cd85aa8110f8a85c Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Thu, 9 Jan 2025 10:27:24 -0500 Subject: [PATCH 3/3] fix up new pages with translations and meta --- frontend/src/app/[locale]/error.tsx | 14 ----------- frontend/src/app/[locale]/error/page.tsx | 25 +++++++++++++++---- frontend/src/app/[locale]/not-found.tsx | 2 +- .../src/app/[locale]/unauthorized/page.tsx | 21 +++++++++++++--- frontend/src/i18n/messages/en/index.ts | 11 +++++++- frontend/src/middleware.ts | 2 +- 6 files changed, 49 insertions(+), 26 deletions(-) delete mode 100644 frontend/src/app/[locale]/error.tsx diff --git a/frontend/src/app/[locale]/error.tsx b/frontend/src/app/[locale]/error.tsx deleted file mode 100644 index 56996be15..000000000 --- a/frontend/src/app/[locale]/error.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import { GridContainer } from "@trussworks/react-uswds"; - -const TopLevelError = ({ error }: { error: Error }) => { - return ( - -

Error

-
{error.message}
-
- ); -}; - -export default TopLevelError; diff --git a/frontend/src/app/[locale]/error/page.tsx b/frontend/src/app/[locale]/error/page.tsx index ba6212b31..925b16626 100644 --- a/frontend/src/app/[locale]/error/page.tsx +++ b/frontend/src/app/[locale]/error/page.tsx @@ -1,14 +1,29 @@ -"use client"; +import { Metadata } from "next"; +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import { GridContainer } from "@trussworks/react-uswds"; -const Unauthorized = () => { +import ServerErrorAlert from "src/components/ServerErrorAlert"; + +export async function generateMetadata() { + const t = await getTranslations(); + const meta: Metadata = { + title: t("ErrorPages.generic_error.page_title"), + description: t("Index.meta_description"), + }; + return meta; +} + +// not a NextJS error page - this is here to be redirected to manually in cases +// where Next's error handling situation doesn't quite do what we need. +const TopLevelError = () => { + const t = useTranslations("Errors"); return ( -

Error

-
Oops, something went wrong. Please try again.
+
); }; -export default Unauthorized; +export default TopLevelError; diff --git a/frontend/src/app/[locale]/not-found.tsx b/frontend/src/app/[locale]/not-found.tsx index ce0e2e360..d280024aa 100644 --- a/frontend/src/app/[locale]/not-found.tsx +++ b/frontend/src/app/[locale]/not-found.tsx @@ -10,7 +10,7 @@ import BetaAlert from "src/components/BetaAlert"; export async function generateMetadata() { const t = await getTranslations(); const meta: Metadata = { - title: t("ErrorPages.page_not_found.title"), + title: t("ErrorPages.page_not_found.page_title"), description: t("Index.meta_description"), }; return meta; diff --git a/frontend/src/app/[locale]/unauthorized/page.tsx b/frontend/src/app/[locale]/unauthorized/page.tsx index 11e0c7dc1..e0813b2c4 100644 --- a/frontend/src/app/[locale]/unauthorized/page.tsx +++ b/frontend/src/app/[locale]/unauthorized/page.tsx @@ -1,12 +1,25 @@ -"use client"; +import { Metadata } from "next"; -import { GridContainer } from "@trussworks/react-uswds"; +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { Alert, GridContainer } from "@trussworks/react-uswds"; + +export async function generateMetadata() { + const t = await getTranslations(); + const meta: Metadata = { + title: t("ErrorPages.unauthorized.page_title"), + description: t("Index.meta_description"), + }; + return meta; +} const Unauthorized = () => { + const t = useTranslations("Errors"); return ( -

Unauthorized

-
Unable to log in
+ + {t("authorization_fail")} +
); }; diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts index d06c45fac..b98fac57c 100644 --- a/frontend/src/i18n/messages/en/index.ts +++ b/frontend/src/i18n/messages/en/index.ts @@ -463,8 +463,14 @@ export const messages = { "The Simpler.Grants.gov email subscriptions are powered by the Sendy data service. Personal information is not stored within Simpler.Grants.gov.", }, ErrorPages: { - page_title: "Page Not Found | Simpler.Grants.gov", + generic_error: { + page_title: "Error | Simpler.Grants.gov", + }, + unauthorized: { + page_title: "Unauthorized | Simpler.Grants.gov", + }, page_not_found: { + page_title: "Page Not Found | Simpler.Grants.gov", title: "Oops! Page Not Found", message_content_1: "The page you have requested cannot be displayed because it does not exist, has been moved, or the server has been instructed not to let you view it. There is nothing to see here.", @@ -530,6 +536,9 @@ export const messages = { Errors: { heading: "We're sorry.", generic_message: "There seems to have been an error.", + try_again: "Please try again.", + unauthorized: "Unauthorized", + authorization_fail: "Login or user authorization failed. Please try again.", }, Search: { title: "Search Funding Opportunities | Simpler.Grants.gov", diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 30b27ce1b..039e8cac6 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -44,7 +44,7 @@ export default function middleware(request: NextRequest): NextResponse { request, i18nMiddleware(request), ); - // in Next 15 there is an experimantal `unauthorized` function that will send a 401 + // in Next 15 there is an experimental `unauthorized` function that will send a 401 // code to the client and display an unauthorized page // see https://nextjs.org/docs/app/api-reference/functions/unauthorized // For now we can set status codes on auth redirect errors here