-
-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[PasswordResetRequest] Eliminate info leak (#2410)
- Loading branch information
1 parent
dcefdb1
commit d9d764a
Showing
5 changed files
with
161 additions
and
69 deletions.
There are no files selected for viewing
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
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,92 +1,103 @@ | ||
import { Card, Grid, TextField, Typography } from "@mui/material"; | ||
import { Button, Card, Grid, TextField, Typography } from "@mui/material"; | ||
import { FormEvent, ReactElement, useState } from "react"; | ||
import { useTranslation } from "react-i18next"; | ||
import { useNavigate } from "react-router-dom"; | ||
|
||
import { isEmailTaken, isUsernameTaken, resetPasswordRequest } from "backend"; | ||
import { resetPasswordRequest } from "backend"; | ||
import { LoadingDoneButton } from "components/Buttons"; | ||
import { useAppDispatch } from "types/hooks"; | ||
import { Path } from "types/path"; | ||
|
||
export enum PasswordRequestIds { | ||
ButtonLogin = "password-request-login", | ||
ButtonSubmit = "password-request-submit", | ||
FieldEmailOrUsername = "password-request-text", | ||
} | ||
|
||
export default function ResetRequest(): ReactElement { | ||
const dispatch = useAppDispatch(); | ||
const [emailOrUsername, setEmailOrUsername] = useState(""); | ||
const [isDone, setIsDone] = useState(false); | ||
const [isError, setIsError] = useState(false); | ||
const [isLoading, setIsLoading] = useState(false); | ||
const [isNotFound, setIsNotFound] = useState(false); | ||
|
||
const { t } = useTranslation(); | ||
const navigate = useNavigate(); | ||
|
||
const onSubmit = (event: FormEvent<HTMLElement>): void => { | ||
event.preventDefault(); | ||
setIsError(false); | ||
setIsLoading(true); | ||
setTimeout(() => tryResetRequest(), 1000); | ||
}; | ||
|
||
const tryResetRequest = async (): Promise<void> => { | ||
setIsLoading(true); | ||
const exists = | ||
(await isEmailTaken(emailOrUsername)) || | ||
(await isUsernameTaken(emailOrUsername)); | ||
if (exists) { | ||
await resetPasswordRequest(emailOrUsername); | ||
setIsDone(true); | ||
setTimeout(() => navigate(Path.Login), 1000); | ||
} else { | ||
setIsNotFound(true); | ||
} | ||
setIsLoading(false); | ||
}; | ||
|
||
const setTextField = (text: string): void => { | ||
setEmailOrUsername(text); | ||
setIsNotFound(false); | ||
resetPasswordRequest(emailOrUsername) | ||
.then(() => { | ||
setIsDone(true); | ||
}) | ||
.catch(() => { | ||
setIsError(true); | ||
setIsLoading(false); | ||
}); | ||
}; | ||
|
||
return ( | ||
<div> | ||
<Grid container justifyContent="center"> | ||
<Card style={{ padding: 10, width: 450 }}> | ||
<Typography variant="h5" align="center"> | ||
{t("passwordReset.resetRequestTitle")} | ||
</Typography> | ||
<Typography variant="subtitle1" align="center"> | ||
{t("passwordReset.resetRequestInstructions")} | ||
</Typography> | ||
<form onSubmit={onSubmit}> | ||
<Grid container justifyContent="center"> | ||
<Card style={{ padding: 10, width: 450 }}> | ||
<Typography align="center" variant="h5"> | ||
{t("passwordReset.resetRequestTitle")} | ||
</Typography> | ||
{isDone ? ( | ||
<> | ||
<Typography>{t("passwordReset.resetDone")}</Typography> | ||
<Grid item> | ||
<TextField | ||
id="password-reset-request-text" | ||
required | ||
type="text" | ||
<Button | ||
data-testid={PasswordRequestIds.ButtonLogin} | ||
id={PasswordRequestIds.ButtonLogin} | ||
onClick={() => navigate(Path.Login)} | ||
type="button" | ||
variant="outlined" | ||
label={t("passwordReset.emailOrUsername")} | ||
value={emailOrUsername} | ||
style={{ width: "100%" }} | ||
error={isNotFound} | ||
helperText={isNotFound && t("passwordReset.notFoundError")} | ||
margin="normal" | ||
onChange={(e) => setTextField(e.target.value)} | ||
/> | ||
</Grid> | ||
<Grid item> | ||
<LoadingDoneButton | ||
disabled={!emailOrUsername} | ||
loading={isLoading} | ||
done={isDone} | ||
buttonProps={{ | ||
onClick: () => onSubmit, | ||
variant: "contained", | ||
color: "primary", | ||
id: "password-reset-request", | ||
}} | ||
> | ||
{t("passwordReset.submit")} | ||
</LoadingDoneButton> | ||
{t("login.backToLogin")} | ||
</Button> | ||
</Grid> | ||
</form> | ||
</Card> | ||
</Grid> | ||
</div> | ||
</> | ||
) : ( | ||
<> | ||
<Typography align="center" variant="subtitle1"> | ||
{t("passwordReset.resetRequestInstructions")} | ||
</Typography> | ||
<form onSubmit={onSubmit}> | ||
<Grid item> | ||
<TextField | ||
helperText={isError && t("passwordReset.resetFail")} | ||
id={PasswordRequestIds.FieldEmailOrUsername} | ||
inputProps={{ | ||
"data-testid": PasswordRequestIds.FieldEmailOrUsername, | ||
}} | ||
label={t("passwordReset.emailOrUsername")} | ||
margin="normal" | ||
onChange={(e) => setEmailOrUsername(e.target.value)} | ||
required | ||
type="text" | ||
style={{ width: "100%" }} | ||
value={emailOrUsername} | ||
variant="outlined" | ||
/> | ||
</Grid> | ||
<Grid item> | ||
<LoadingDoneButton | ||
buttonProps={{ | ||
color: "primary", | ||
id: PasswordRequestIds.ButtonSubmit, | ||
onClick: () => onSubmit, | ||
variant: "contained", | ||
}} | ||
disabled={!emailOrUsername} | ||
loading={isLoading} | ||
> | ||
{t("passwordReset.submit")} | ||
</LoadingDoneButton> | ||
</Grid> | ||
</form> | ||
</> | ||
)} | ||
</Card> | ||
</Grid> | ||
); | ||
} |
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,79 @@ | ||
import "@testing-library/jest-dom"; | ||
import { act, cleanup, render, screen } from "@testing-library/react"; | ||
import userEvent from "@testing-library/user-event"; | ||
|
||
import "tests/reactI18nextMock"; | ||
import ResetRequest, { | ||
PasswordRequestIds, | ||
} from "components/PasswordReset/Request"; | ||
|
||
jest.mock("react-router-dom", () => ({ | ||
useNavigate: jest.fn(), | ||
})); | ||
|
||
jest.mock("backend", () => ({ | ||
resetPasswordRequest: (...args: any[]) => mockResetPasswordRequest(...args), | ||
})); | ||
|
||
const mockResetPasswordRequest = jest.fn(); | ||
|
||
const setupMocks = (): void => { | ||
mockResetPasswordRequest.mockResolvedValue(true); | ||
}; | ||
|
||
beforeEach(() => { | ||
jest.clearAllMocks(); | ||
setupMocks(); | ||
}); | ||
|
||
afterEach(cleanup); | ||
|
||
const renderUserSettings = async (): Promise<void> => { | ||
await act(async () => { | ||
render(<ResetRequest />); | ||
}); | ||
}; | ||
|
||
describe("ResetRequest", () => { | ||
it("has disabled submit button until something is typed", async () => { | ||
// Setup | ||
const agent = userEvent.setup(); | ||
await renderUserSettings(); | ||
|
||
// Before | ||
const button = screen.getByRole("button"); | ||
expect(button).toBeDisabled(); | ||
|
||
// Act | ||
const field = screen.getByTestId(PasswordRequestIds.FieldEmailOrUsername); | ||
await act(async () => { | ||
await agent.type(field, "a"); | ||
}); | ||
|
||
// After | ||
expect(button).toBeEnabled(); | ||
}); | ||
|
||
it("after submit, removes text field and submit button and reveals login button", async () => { | ||
// Setup | ||
const agent = userEvent.setup(); | ||
await renderUserSettings(); | ||
|
||
// Before | ||
expect(screen.queryAllByRole("textbox")).toHaveLength(1); | ||
expect(screen.queryAllByRole("button")).toHaveLength(1); | ||
expect(screen.queryByTestId(PasswordRequestIds.ButtonLogin)).toBeNull(); | ||
|
||
// Act | ||
const field = screen.getByTestId(PasswordRequestIds.FieldEmailOrUsername); | ||
await act(async () => { | ||
await agent.type(field, "a"); | ||
await agent.click(screen.getByRole("button")); | ||
}); | ||
|
||
// After | ||
expect(screen.queryAllByRole("textbox")).toHaveLength(0); | ||
expect(screen.queryAllByRole("button")).toHaveLength(1); | ||
expect(screen.queryByTestId(PasswordRequestIds.ButtonLogin)).toBeTruthy(); | ||
}); | ||
}); |