Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Issue #3043] Next login flow client #3215

Merged
merged 6 commits into from
Dec 18, 2024
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
105 changes: 64 additions & 41 deletions frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,85 @@
"use client";

import { useFeatureFlags } from "src/hooks/useFeatureFlags";
import { useUser } from "src/services/auth/useUser";

import React from "react";
import { Button, Table } from "@trussworks/react-uswds";

import Loading from "src/components/Loading";

/**
* View for managing feature flags
*/
export default function FeatureFlagsTable() {
const { featureFlagsManager, mounted, setFeatureFlag } = useFeatureFlags();
const { user, isLoading, error } = useUser();

if (!mounted) {
return null;
}

if (isLoading) {
return <Loading />;
}

if (error) {
// there's no error page within this tree, should we make a top level error?
return (
<>
<h1>Error</h1>
{error.message}
</>
);
}

return (
<Table>
<thead>
<tr>
<th scope="col">Status</th>
<th scope="col">Feature Flag</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{Object.entries(featureFlagsManager.featureFlags).map(
([featureName, enabled]) => (
<tr key={featureName}>
<td
data-testid={`${featureName}-status`}
style={{ background: enabled ? "#81cc81" : "#fc6a6a" }}
>
{enabled ? "Enabled" : "Disabled"}
</td>
<th scope="row">{featureName}</th>
<td>
<Button
data-testid={`enable-${featureName}`}
disabled={enabled}
onClick={() => setFeatureFlag(featureName, true)}
type="button"
>
Enable
</Button>
<Button
data-testid={`disable-${featureName}`}
disabled={!enabled}
onClick={() => setFeatureFlag(featureName, false)}
type="button"
<>
<h2>
{user?.token ? `Logged in with token: ${user.token}` : "Not logged in"}
</h2>
<Table>
<thead>
<tr>
<th scope="col">Status</th>
<th scope="col">Feature Flag</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{Object.entries(featureFlagsManager.featureFlags).map(
([featureName, enabled]) => (
<tr key={featureName}>
<td
data-testid={`${featureName}-status`}
style={{ background: enabled ? "#81cc81" : "#fc6a6a" }}
>
Disable
</Button>
</td>
</tr>
),
)}
</tbody>
</Table>
{enabled ? "Enabled" : "Disabled"}
</td>
<th scope="row">{featureName}</th>
<td>
<Button
data-testid={`enable-${featureName}`}
disabled={enabled}
onClick={() => setFeatureFlag(featureName, true)}
type="button"
>
Enable
</Button>
<Button
data-testid={`disable-${featureName}`}
disabled={!enabled}
onClick={() => setFeatureFlag(featureName, false)}
type="button"
>
Disable
</Button>
</td>
</tr>
),
)}
</tbody>
</Table>
</>
);
}
3 changes: 1 addition & 2 deletions frontend/src/app/[locale]/dev/feature-flags/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Metadata } from "next";
import FeatureFlagsTable from "src/app/[locale]/dev/feature-flags/FeatureFlagsTable";

import Head from "next/head";
import React from "react";

import FeatureFlagsTable from "./FeatureFlagsTable";

export function generateMetadata() {
const meta: Metadata = {
title: "Feature flag manager",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/app/[locale]/dev/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import UserProvider from "src/services/auth/UserProvider";

import React from "react";

export default function Layout({ children }: { children: React.ReactNode }) {
return <UserProvider>{children}</UserProvider>;
}
2 changes: 1 addition & 1 deletion frontend/src/app/[locale]/opportunity/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import NotFound from "src/app/[locale]/not-found";
import { fetchOpportunity } from "src/app/api/fetchers";
import { OPPORTUNITY_CRUMBS } from "src/constants/breadcrumbs";
import { ApiRequestError, parseErrorStatus } from "src/errors";
import withFeatureFlag from "src/hoc/search/withFeatureFlag";
import withFeatureFlag from "src/hoc/withFeatureFlag";
import { Opportunity } from "src/types/opportunity/opportunityResponseTypes";
import { WithFeatureFlagProps } from "src/types/uiTypes";

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/[locale]/search/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Metadata } from "next";
import QueryProvider from "src/app/[locale]/search/QueryProvider";
import withFeatureFlag from "src/hoc/search/withFeatureFlag";
import withFeatureFlag from "src/hoc/withFeatureFlag";
import { LocalizedPageProps } from "src/types/intl";
import { SearchParamsTypes } from "src/types/search/searchRequestTypes";
import { Breakpoints } from "src/types/uiTypes";
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getSession } from "src/services/auth/session";

import { NextResponse } from "next/server";

export async function GET() {
const currentSession = await getSession();
if (currentSession) {
return NextResponse.json({
token: currentSession.token,
});
} else {
return NextResponse.json({ token: "" });
}
}
19 changes: 19 additions & 0 deletions frontend/src/app/api/userFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { ApiRequestError } from "src/errors";
import { SessionPayload, UserFetcher } from "src/services/auth/types";

// this fetcher is a one off for now, since the request is made from the client to the
// NextJS Node server. We will need to build out a fetcher pattern to accomodate this usage in the future
export const userFetcher: UserFetcher = async (url) => {
let response;
try {
response = await fetch(url);
} catch (e) {
console.error("User session fetch network error", e);
throw new ApiRequestError(0); // Network error
}
if (response.status === 204) return undefined;
if (response.ok) return (await response.json()) as SessionPayload;
throw new ApiRequestError(response.status);
};
2 changes: 1 addition & 1 deletion frontend/src/components/search/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import Loading from "src/app/[locale]/search/loading";
import { searchForOpportunities } from "src/app/api/searchFetcher";
import { QueryParamData } from "src/types/search/searchRequestTypes";

import { Suspense } from "react";

import Loading from "src/components/Loading";
import SearchPagination from "src/components/search/SearchPagination";
import SearchPaginationFetch from "src/components/search/SearchPaginationFetch";
import SearchResultsHeader from "src/components/search/SearchResultsHeader";
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/services/auth/UserProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

// note that importing these individually allows us to mock them, otherwise mocks don't work :shrug:
import debounce from "lodash/debounce";
import noop from "lodash/noop";
import { userFetcher } from "src/app/api/userFetcher";
import { UserSession } from "src/services/auth/types";
import { UserContext } from "src/services/auth/useUser";
import { isSessionExpired } from "src/utils/authUtil";

import React, { useCallback, useEffect, useMemo, useState } from "react";

// if we don't debounce this call we get multiple requests going out on page load
const debouncedUserFetcher = debounce(
() => userFetcher("/api/auth/session"),
500,
{
leading: true,
trailing: false,
},
);

export default function UserProvider({
children,
}: {
children: React.ReactNode;
}) {
const [localUser, setLocalUser] = useState<UserSession>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [userFetchError, setUserFetchError] = useState<Error | undefined>();

const getUserSession = useCallback(async (): Promise<void> => {
try {
setIsLoading(true);
const fetchedUser = await debouncedUserFetcher();
if (fetchedUser) {
setLocalUser(fetchedUser);
setUserFetchError(undefined);
setIsLoading(false);
return;
}
throw new Error("received empty user session");
} catch (error) {
setIsLoading(false);
setUserFetchError(error as Error);
}
}, []);

useEffect(() => {
if (localUser && !isSessionExpired(localUser)) return;
getUserSession().then(noop).catch(noop);
}, [localUser, getUserSession]);

const value = useMemo(
() => ({ user: localUser, error: userFetchError, isLoading }),
[localUser, userFetchError, isLoading],
);

return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
2 changes: 1 addition & 1 deletion frontend/src/services/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function encrypt({
const jwt = await new SignJWT({ token })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(expiresAt)
.setExpirationTime(expiresAt || "")
.sign(encodedKey);
return jwt;
}
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/services/auth/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,7 @@ export type SessionPayload = {
/**
* Fetches the user from the profile API route to fill the useUser hook with the
* UserProfile object.
*
* If needed, you can pass a custom fetcher to the UserProvider component via the
* UserProviderProps.fetcher prop.
*
* @throws {@link RequestError}

*/
export type UserFetcher = (url: string) => Promise<SessionPayload | undefined>;

Expand Down
35 changes: 35 additions & 0 deletions frontend/src/services/auth/useUser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { UserProviderState } from "src/services/auth/types";

import { createContext, useContext } from "react";

export const UserContext = createContext({} as UserProviderState);

/**
* @ignore
*/
export type UserContextHook = () => UserProviderState;

/**
* The `useUser` hook, which will get you the {@link UserProfile} object from the server-side session by fetching it
* from the {@link HandleProfile} API route.
*
* ```js
* import Link from 'next/link';
* import { useUser } from 'src/services/auth/useUser';
*
* export default function Profile() {
* const { user, error, isLoading } = useUser();
*
* if (isLoading) return <div>Loading...</div>;
* if (error) return <div>{error.message}</div>;
* if (!user) return <Link href="/api/auth/login"><a>Login</a></Link>;
* return <div>Hello {user.name}, <Link href="/api/auth/logout"><a>Logout</a></Link></div>;
* }
* ```
*
* @category Client
*/
export const useUser: UserContextHook = () =>
useContext<UserProviderState>(UserContext);
10 changes: 10 additions & 0 deletions frontend/src/utils/authUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { UserSession } from "src/services/auth/types";

export const isSessionExpired = (userSession: UserSession): boolean => {
// if we haven't implemented expiration yet
// TODO: remove this once expiration is implemented in the token
if (!userSession?.expiresAt) {
return false;
}
return userSession.expiresAt > new Date(Date.now());
};
3 changes: 2 additions & 1 deletion frontend/stories/pages/loading.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Meta } from "@storybook/react";
import Loading from "src/app/[locale]/search/loading";

import Loading from "src/components/Loading";

const meta: Meta<typeof Loading> = {
component: Loading,
Expand Down
44 changes: 44 additions & 0 deletions frontend/tests/api/auth/session/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* @jest-environment node
*/

import { GET } from "src/app/api/auth/session/route";

const getSessionMock = jest.fn();
const responseJsonMock = jest.fn((something: unknown) => something);

jest.mock("src/services/auth/session", () => ({
getSession: (): unknown => getSessionMock(),
}));

jest.mock("next/server", () => ({
NextResponse: {
json: (any: object) => responseJsonMock(any),
},
}));

// 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("GET request", () => {
afterEach(() => jest.clearAllMocks());
it("returns the current session token when one exists", async () => {
getSessionMock.mockImplementation(() => ({
token: "fakeToken",
}));

await GET();

expect(getSessionMock).toHaveBeenCalledTimes(1);
expect(responseJsonMock).toHaveBeenCalledTimes(1);
expect(responseJsonMock).toHaveBeenCalledWith({ token: "fakeToken" });
});

it("returns a resopnse with an empty token if no session token exists", async () => {
getSessionMock.mockImplementation(() => null);
await GET();

expect(getSessionMock).toHaveBeenCalledTimes(1);
expect(responseJsonMock).toHaveBeenCalledTimes(1);
expect(responseJsonMock).toHaveBeenCalledWith({ token: "" });
});
});
Loading