-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
eaf62a3
commit 06deaf3
Showing
21 changed files
with
328 additions
and
56 deletions.
There are no files selected for viewing
105 changes: 64 additions & 41 deletions
105
frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: "" }); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: "" }); | ||
}); | ||
}); |
Oops, something went wrong.