Skip to content

Commit

Permalink
[Issue #3043] add client side user context (#3215)
Browse files Browse the repository at this point in the history
* adds a context provider and hook to allow client components to access up to date logged in user information
* adds a NextJS route to return user data decrypted from the session cookie passed up from the client
* makes some temporary updates to the feature flags table in order for that page
to act as a proof of concept for the functionality
* moves some feature flags and loading spinner code to a more suitable location.
  • Loading branch information
doug-s-nava authored Dec 18, 2024
1 parent eaf62a3 commit 06deaf3
Show file tree
Hide file tree
Showing 21 changed files with 328 additions and 56 deletions.
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);
};
File renamed without changes.
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
File renamed without changes.
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

0 comments on commit 06deaf3

Please sign in to comment.