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

feat: DAH-2876 useRedirect for authenticated pages #2488

Closed
wants to merge 62 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
99e823c
feat: DAH-2876 useRedirect for authenticated pages
cade-exygy Jan 7, 2025
b97e876
fix: translations
cade-exygy Jan 8, 2025
8e6a74b
feat: use token for auth check
cade-exygy Jan 23, 2025
caba50e
feat: HOC withAuthentication
cade-exygy Jan 23, 2025
a086a37
clean: remove unused hook
cade-exygy Jan 23, 2025
1137a56
fix: tests
cade-exygy Jan 31, 2025
81eac08
fix: add back banner
cade-exygy Feb 3, 2025
bbb4c2d
fix: tests
cade-exygy Feb 3, 2025
a9c5089
fix: test
cade-exygy Feb 3, 2025
1231935
fix: DAH-3097 FCFS link color contrast accessibility (#2482)
cliu02 Jan 7, 2025
f490077
feat: DAH-2982 directory page empty state (#2462)
tallulahkay Jan 8, 2025
21318a1
fix: DAH-3118 update scroll behavior (#2477)
tallulahkay Jan 8, 2025
2941219
feat: DAH-3041 remove field error on sign in page (#2471)
tallulahkay Jan 8, 2025
e26bf09
feat: DAH-3014 Listing detail event analytics (#2487)
chadbrokaw Jan 8, 2025
bbe88d7
fix: DAH-2738 hide listing details apply in mobile (#2490)
tallulahkay Jan 8, 2025
3cb0467
fix: DAH-3014 Fix undefined lottery preferences bug (#2491)
chadbrokaw Jan 9, 2025
ae3e002
feat: DAH-3109 auto open on button navigation and highlight on moveme…
tallulahkay Jan 10, 2025
8410de8
feat: DAH-3077 DALP hide lottery prefs section and skip validate hous…
tallulahkay Jan 10, 2025
943241d
feat: DAH-2985 Autofill email for forgot password (#2485)
chadbrokaw Jan 14, 2025
f890d85
feat: DAH-3073 DALP priority data (#2493)
tallulahkay Jan 14, 2025
f7c0ffe
feat: DAH-3088 retrieve DALP loan officers (#2495)
tallulahkay Jan 14, 2025
14803de
feat: DAH-3079 Change terms page for DALP listings (#2499)
chadbrokaw Jan 15, 2025
6e90ce7
fix: DAH-3148 Send Listing Type in GTM (#2494)
chadbrokaw Jan 15, 2025
cc87461
feat: DAH-3122 Log Potential Google Translate API Usage (#2486)
jimlin-sfgov Jan 15, 2025
192faf7
feat: DAH-3075 add dalp text to what to expect (#2496)
tallulahkay Jan 15, 2025
5cf702d
feat: DAH-3103 remove work in sf question (#2497)
tallulahkay Jan 15, 2025
1c3757d
feat: DAH-3157 use new document types for dalp (#2500)
tallulahkay Jan 15, 2025
8b5dd5d
feat: DAH-2987 Reset password api (#2461)
chadbrokaw Jan 16, 2025
66019fd
feat: DAH-3093 Update demographics description in application summary…
cliu02 Jan 16, 2025
f9ea085
fix: DAH-3122 Prevent translation rake tasks from triggering page_vie…
jimlin-sfgov Jan 17, 2025
ee76121
urgent: fix e2e fixture (#2507)
tallulahkay Jan 17, 2025
de24308
fix: DAH-3176 hide directory 1.5 change (#2508)
tallulahkay Jan 21, 2025
68a149f
urgent: add checklist item for PA testing (#2506)
tallulahkay Jan 22, 2025
f41e7b3
fix: DAH-3111 Clear error message on Sign In (#2481)
cliu02 Jan 22, 2025
ae2151d
feat: DAH-3083 remove preferences in DALP application summary (#2492)
cliu02 Jan 22, 2025
3f4f122
fix: DAH-3181 Handle invalid listings when logging translation usage …
jimlin-sfgov Jan 22, 2025
4d60e84
feat: DAH-3087 Remove back link on DALP listings (#2502)
chadbrokaw Jan 23, 2025
76af9f8
fix: DAH-2865 translations fixes (#2473)
tallulahkay Jan 23, 2025
413731c
fix: DAH-2867 make height auto on my accounts dashboard (#2475)
tallulahkay Jan 23, 2025
5d4eb8c
fix: DAH-2868 status formatting (#2474)
tallulahkay Jan 24, 2025
8019dbe
fix: DAH-3158 open sections upon navigation (#2504)
tallulahkay Jan 24, 2025
7dcb7ae
feat: DAH-3110 make enter submit (#2479)
tallulahkay Jan 27, 2025
822c2f1
fix: DAH-3193 change naming (#2518)
tallulahkay Jan 28, 2025
048e88b
feat: DAH-3090 DAH-3076 DALP Qualify Checkbox Conditional Language (#…
tallulahkay Jan 28, 2025
fd3a3ca
fix: DAH-3190 Use translations on confirmation modals (#2519)
chadbrokaw Jan 28, 2025
0c92c8d
feat: DAH-3055 Analytics for log in and log out (#2511)
chadbrokaw Jan 29, 2025
5048264
feat: DAH-3080 DALP for My Applications page (#2509)
cliu02 Jan 29, 2025
db30bc8
fix: DAH-3187 update FCFS how to apply link translations (#2513)
cliu02 Jan 29, 2025
8c83653
feat: DAH-3091 Human Translations 2024-12-02 (#2514)
jimlin-sfgov Jan 29, 2025
4bc7026
feat: DAH-3082 add dalp priority to conf page (#2522)
tallulahkay Jan 30, 2025
4543971
fix: DAH-2866 fix formatting (#2476)
tallulahkay Jan 30, 2025
f3176b6
feat: DAH-3084 confirmation page DALP (#2521)
tallulahkay Jan 31, 2025
8bdbd54
feat: DAH-3074 DALP Application Question (#2530)
jimlin-sfgov Feb 1, 2025
43b0289
urgent: upgdate circle config (#2534)
tallulahkay Feb 3, 2025
e6dbd7b
feat: DAH-3169 default to sf gov listing (#2532)
tallulahkay Feb 4, 2025
492e08d
feat: DAH-3132 Update icon (#2536)
tallulahkay Feb 5, 2025
e7b2173
feat: DAH-3085 DALP confirmation email (#2531)
tallulahkay Feb 5, 2025
bba991f
fix: DAH-3191 fix email address for lottery appeals (#2540)
tallulahkay Feb 5, 2025
950eff2
feat: DAH-3201 Application Summary Edit button should take user to DA…
jimlin-sfgov Feb 6, 2025
60dd7dc
feat: DAH-2826 Auto populate user (#2527)
chadbrokaw Feb 6, 2025
af1e40e
fix: DAH-3207 Use NPM Chromedriver for Protractor (#2544)
jimlin-sfgov Feb 6, 2025
051e29f
fix: test conflicts
cade-exygy Feb 6, 2025
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
1 change: 1 addition & 0 deletions app/assets/json/translations/react/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,7 @@
"signIn.fillInFaster": "Fill in applications faster",
"signIn.forgotPassword": "Forgot password?",
"signIn.loggedOutDueToInactivity": "We care about your security. We logged you out due to inactivity. Please sign in to continue.",
"signIn.loginRequired": "You must be signed in to see this page. Sign in to continue.",
"signIn.newAccount.p1": "We sent a link to %{email}.",
"signIn.newAccount.p2": "Follow the link in your email to finish creating your account. It expires after 24 hours.",
"signIn.newAccount.sendEmailAgainButton": "Send email again",
Expand Down
141 changes: 141 additions & 0 deletions app/javascript/__tests__/authentication/withAuthentication.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React from "react"
import { render } from "@testing-library/react"
import { withAuthentication } from "../../authentication/withAuthentication"
import UserContext, { ContextProps } from "../../authentication/context/UserContext"
import { isTokenValid } from "../../authentication/token"
import { getLocalizedPath } from "../../util/routeUtil"
import { getCurrentLanguage } from "../../util/languageUtil"

jest.mock("../../authentication/token", () => ({
isTokenValid: jest.fn(),
}))

jest.mock("../../util/languageUtil", () => ({
getCurrentLanguage: jest.fn(() => "en"),
getPathWithoutLanguagePrefix: jest.fn((path) => path),
}))

jest.mock("../../util/routeUtil", () => ({
getLocalizedPath: jest.fn((path) => path),
}))

describe("withAuthentication", () => {
let originalLocation: Location
let mockContextValue: ContextProps
const TestComponent = () => <div>Protected Component</div>
const WrappedComponent = withAuthentication(TestComponent)

beforeEach(() => {
originalLocation = window.location
mockContextValue = {
profile: {
uid: "123",
email: "[email protected]",
created_at: new Date(),
updated_at: new Date(),
},
signIn: jest.fn(),
signOut: jest.fn(),
timeOut: jest.fn(),
saveProfile: jest.fn(),
loading: false,
initialStateLoaded: true,
}

// Reset all mocks before each test
jest.clearAllMocks()
;(getCurrentLanguage as jest.Mock).mockReturnValue("en")

// Mock window.location
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).location
window.location = {
...originalLocation,
href: "http://test.com",
assign: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
toString: jest.fn(),
}
})

afterEach(() => {
jest.clearAllMocks()
window.location = originalLocation
})

it("renders the wrapped component when authenticated", () => {
;(isTokenValid as jest.Mock).mockReturnValue(true)

const { getByText } = render(
<UserContext.Provider value={mockContextValue}>
<WrappedComponent />
</UserContext.Provider>
)

expect(getByText("Protected Component")).toBeInTheDocument()
})

it("redirects to sign-in when token is invalid", () => {
;(isTokenValid as jest.Mock).mockReturnValue(false)
;(getLocalizedPath as jest.Mock).mockReturnValue("/sign-in")

render(
<UserContext.Provider value={mockContextValue}>
<WrappedComponent />
</UserContext.Provider>
)

expect(getLocalizedPath).toHaveBeenCalledWith("/sign-in", "en", "")
expect(window.location.href).toBe("/sign-in")
})

it("redirects to sign-in with redirect param when specified", () => {
;(isTokenValid as jest.Mock).mockReturnValue(false)
;(getLocalizedPath as jest.Mock).mockReturnValue("/sign-in?redirect=test-path")
const WrappedWithRedirect = withAuthentication(TestComponent, { redirectPath: "test-path" })

render(
<UserContext.Provider value={mockContextValue}>
<WrappedWithRedirect />
</UserContext.Provider>
)

expect(getLocalizedPath).toHaveBeenCalledWith("/sign-in", "en", "?redirect=test-path")
expect(window.location.href).toBe("/sign-in?redirect=test-path")
})

it("returns null while loading", () => {
;(isTokenValid as jest.Mock).mockReturnValue(true)
mockContextValue.loading = true

const { container } = render(
<UserContext.Provider value={mockContextValue}>
<WrappedComponent />
</UserContext.Provider>
)

expect(container.firstChild).toBeNull()
})

it("returns null when profile is undefined", () => {
;(isTokenValid as jest.Mock).mockReturnValue(true)
mockContextValue.profile = undefined

const { container } = render(
<UserContext.Provider value={mockContextValue}>
<WrappedComponent />
</UserContext.Provider>
)

expect(container.firstChild).toBeNull()
})

it("sets display name for debugging", () => {
const TestComponentWithName = () => <div>Named Component</div>
TestComponentWithName.displayName = "TestComponentWithName"
const WrappedWithName = withAuthentication(TestComponentWithName)

expect(WrappedWithName.displayName).toBe("WithAuthentication(TestComponentWithName)")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ exports[`<AccountSettingsPage /> when the user is signed in resize events 1`] =
name="google-site-verification"
/>
<meta
content="http://localhost/"
content="/sign-in?redirect=settings"
property="og:url"
/>
<meta
Expand Down Expand Up @@ -897,7 +897,7 @@ exports[`<AccountSettingsPage /> when the user is signed in resize events 1`] =
name="google-site-verification"
/>
<meta
content="http://localhost/"
content="/sign-in?redirect=settings"
property="og:url"
/>
<meta
Expand Down Expand Up @@ -1819,7 +1819,7 @@ exports[`<AccountSettingsPage /> when the user is signed in resize events 2`] =
name="google-site-verification"
/>
<meta
content="http://localhost/"
content="/sign-in?redirect=settings"
property="og:url"
/>
<meta
Expand Down Expand Up @@ -2660,7 +2660,7 @@ exports[`<AccountSettingsPage /> when the user is signed in resize events 2`] =
name="google-site-verification"
/>
<meta
content="http://localhost/"
content="/sign-in?redirect=settings"
property="og:url"
/>
<meta
Expand Down
16 changes: 15 additions & 1 deletion app/javascript/__tests__/pages/account/account-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ describe("<AccountSettingsPage />", () => {
let originalUseContext
let promise
let renderResult
let originalLocation: Location

beforeEach(async () => {
document.documentElement.lang = "en"
originalUseContext = React.useContext
originalLocation = window.location

const mockContextValue: ContextProps = {
profile: mockProfile,
signIn: jest.fn(),
Expand All @@ -45,6 +48,17 @@ describe("<AccountSettingsPage />", () => {
initialStateLoaded: true,
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any)?.location
;(window as Window).location = {
...originalLocation,
href: "http://dahlia.com",
assign: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
toString: jest.fn(),
}

jest.spyOn(React, "useContext").mockImplementation((context) => {
if (context === UserContext) {
return mockContextValue
Expand Down Expand Up @@ -772,7 +786,7 @@ describe("<AccountSettingsPage />", () => {
it("redirects to the sign in page", async () => {
const { queryByText } = await renderAndLoadAsync(<AccountSettingsPage assetPaths={{}} />)

expect(window.location.href).toBe("/sign-in")
expect(window.location.href).toBe("/sign-in?redirect=settings")
expect(queryByText("Account Settings")).toBeNull()
})
})
Expand Down
24 changes: 13 additions & 11 deletions app/javascript/__tests__/pages/account/my-account.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ describe("<MyAccount />", () => {
describe("when the user is signed in", () => {
let getByTestId
let originalUseContext
let originalLocation: Location

beforeEach(async () => {
originalUseContext = React.useContext
originalLocation = window.location
const mockContextValue: ContextProps = {
profile: {
uid: "abc123",
Expand All @@ -38,6 +40,17 @@ describe("<MyAccount />", () => {
return originalUseContext(context)
})

// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any)?.location
;(window as Window).location = {
...originalLocation,
href: "http://dahlia.com",
assign: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
toString: jest.fn(),
}

const renderResult = await renderAndLoadAsync(<MyAccount assetPaths={{}} />)
getByTestId = renderResult.getByTestId
})
Expand Down Expand Up @@ -94,17 +107,6 @@ describe("<MyAccount />", () => {
return originalUseContext(context)
})

// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any)?.location
;(window as Window).location = {
...originalLocation,
href: "http://dahlia.com",
assign: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
toString: jest.fn(),
}

await renderAndLoadAsync(<MyAccount assetPaths={{}} />)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe("<MyApplications />", () => {
it("redirects to the sign in page", async () => {
const { queryByText } = await renderAndLoadAsync(<MyApplications assetPaths={{}} />)

expect(window.location.href).toBe("http://dahlia.com/sign-in")
expect(window.location.href).toBe("http://dahlia.com/sign-in?redirect=applications")
expect(queryByText("My applications")).toBeNull()
})
})
Expand Down
17 changes: 15 additions & 2 deletions app/javascript/authentication/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import {
Icon,
AlertBox,
LinkButton,
NavigationContext,
} from "@bloom-housing/ui-components"
import { Link, Heading } from "@bloom-housing/ui-seeds"
import { FieldValues, SubmitHandler, useForm } from "react-hook-form"

import { getForgotPasswordPath, getMyAccountPath } from "../util/routeUtil"
import { getForgotPasswordPath, getSignInRedirectUrl } from "../util/routeUtil"
import EmailFieldset from "../pages/account/components/EmailFieldset"
import PasswordFieldset from "../pages/account/components/PasswordFieldset"
import "../pages/account/styles/account.scss"
Expand All @@ -31,6 +32,11 @@ const getExpiredConfirmedEmail = () => {
return expiredUnconfirmedEmail
}

const getRedirectFromUrl = () => {
const urlParams = new URLSearchParams(window.location.search)
return urlParams.get("redirect")
}

const SignInFormCard = ({
onSubmit,
requestError,
Expand Down Expand Up @@ -111,6 +117,8 @@ const SignInForm = () => {

const { signIn } = useContext(UserContext)

const { router } = useContext(NavigationContext)

const handleRequestError = (error: AxiosError<{ error: string; email: string }>) => {
if (error.response.data.error === "not_confirmed") {
setNewAccountNotConfirmedModal(error.response.data.email)
Expand All @@ -130,7 +138,8 @@ const SignInForm = () => {

signIn(email, password)
.then(() => {
window.location.href = getMyAccountPath()
const redirectUrl = getRedirectFromUrl()
router.push(getSignInRedirectUrl(redirectUrl || ""))
window.scrollTo(0, 0)
})
.catch((error: AxiosError<{ error: string; email: string }>) => {
Expand All @@ -139,6 +148,10 @@ const SignInForm = () => {
}

useEffect(() => {
const redirectUrl = getRedirectFromUrl()
if (redirectUrl) {
setRequestError(t("signIn.loginRequired"))
}
const newAccountEmail: string | null = window.sessionStorage.getItem("newAccount")
const expiredConfirmedEmail = getExpiredConfirmedEmail()
const expiredUnconfirmedEmail = getExpiredUnconfirmedEmail()
Expand Down
55 changes: 55 additions & 0 deletions app/javascript/authentication/withAuthentication.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from "react"
import { isTokenValid } from "./token"
import UserContext from "./context/UserContext"
import { getLocalizedPath } from "../util/routeUtil"
import { getCurrentLanguage } from "../util/languageUtil"

interface WithAuthenticationProps {
redirectPath?: string
}

/**
* Higher-order component that handles authentication for protected routes.
* It will:
* 1. Check for a valid token on mount
* 2. Show nothing while profile is loading
* 3. Redirect to sign-in if no valid token or no profile
* 4. Render the wrapped component only when authenticated
*
* @param WrappedComponent The component to protect
* @param redirectPath Optional path to redirect back to after sign-in
*/
export const withAuthentication = <P extends object>(
WrappedComponent: React.ComponentType<P>,
{ redirectPath }: WithAuthenticationProps = {}
) => {
const WithAuthenticationComponent = (props: P) => {
const { profile, loading } = React.useContext(UserContext)

React.useEffect(() => {
if (!isTokenValid()) {
const redirectParam = redirectPath ? `?redirect=${redirectPath}` : ""
const language = getCurrentLanguage()
const signInPath = getLocalizedPath("/sign-in", language, redirectParam)
Copy link
Collaborator

Choose a reason for hiding this comment

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

thought: Storing redirect information in the URL is a departure from the previous iterations of this PR, and from the Angular version of the website. I'd love to get a breakdown of this decision. CC @chadbrokaw since I believe he is also quite involved with the authentication work.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah for sure! In short, handling the redirect with url params allows us to avoid using session storage to keep track of the redirect state across page reloads. I feel like this is a more "React like" way of handling this, since the SignIn component now fully controls when to show the banner and adjust the redirect after submit, instead of relying on a session storage value being set (and then cleared appropriately). Since the banner is toggled only on the presence of the redirect flag, it is more clear why and when that happens imo. The withAuthentication HOC also provides us a way to handle adding this param for any page that we want to be gated behind an authentication state.

window.location.href = signInPath
}
}, [])

if (loading) {
return null
}

if (!profile) {
return null
}

return <WrappedComponent {...props} />
}

// Set display name for easier debugging
WithAuthenticationComponent.displayName = `WithAuthentication(${
WrappedComponent.displayName || WrappedComponent.name || "Component"
})`

return WithAuthenticationComponent
}
Loading
Loading