Repository: Couchers-org/couchers (language: python)

Issue:
Update frontend for new password reset flow
In Couchers-org/couchers#4076, I introduce a new API call for resetting the password in a better way. The frontend should be updated to make use of this new functionality.

## Background

* Initially we had email tokens instead of passwords: you could sign up without ever setting a password, and every time you logged in, it would just send you a token by email
* We have a "jail" system, where a user gets locked out unless they fix up something in their profile/account. We've used this e.g. for when we update ToS or similar where users have to accept before they can continue using it.

The current password reset works like this:

1. You click "reset password", web calls `ResetPassword`
2. Backend sends the right email a link to reset password
3. User clicks on link, takes you to the web app which calls `CompletePasswordReset` with **nothing but the token**
4. The backend clears the users' password
5. The user goes to log in again, this time it falls back to password-less login.
6. We email the user a login link
7. User clicks on link and logs in
8. User gets jailed
9. User has to reset password through `Jail/SetPassword`.
10. User finally gets in

Now with the new system we reduce it to 3 steps:

1. You click "reset password", web calls `ResetPassword`
2. Backend sends the right email a link to reset password
3. User clicks on link, takes you to the web app which calls `CompletePasswordResetV2` with the **reset token and a new password**
4. Backend sets the user's password, **and** logs the user in



Discussion / hints:
@bakeiro 

Fix patch:
diff --git a/.gitignore b/.gitignore
index 48e831d18b..067103a6f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,11 @@
+# general
 .vscode
 .idea
 *.code-workspace
 .DS_Store
+._.DS_Store
 
 # helps Lennart configure his vim
 pyrightconfig.json
 
-.DS_Store
-._.DS_Store
 /private/
diff --git a/app/web/features/auth/jail/Jail.tsx b/app/web/features/auth/jail/Jail.tsx
index aa528683cf..8c0a14252c 100644
--- a/app/web/features/auth/jail/Jail.tsx
+++ b/app/web/features/auth/jail/Jail.tsx
@@ -80,6 +80,7 @@ export default function Jail() {
           className={classes.section}
         />
       )}
+      {/* pontentially dead code */}
       {jailInfo?.hasNotSetPassword && (
         <PasswordSection
           updateJailed={updateJailed}
diff --git a/app/web/features/auth/jail/PasswordSection.tsx b/app/web/features/auth/jail/PasswordSection.tsx
index 914d34ff8e..d5440dc890 100644
--- a/app/web/features/auth/jail/PasswordSection.tsx
+++ b/app/web/features/auth/jail/PasswordSection.tsx
@@ -79,7 +79,7 @@ export default function PasswordSection({
         <Alert severity="success">
           {setPasswordVariables?.password
             ? t("auth:change_password_form.password_changed_success")
-            : t("auth:reset_password_success")}
+            : t("auth:change_password_form.reset_password_success")}
         </Alert>
       )}
       <form className={classes.form} onSubmit={onSubmit}>
diff --git a/app/web/features/auth/locales/en.json b/app/web/features/auth/locales/en.json
index 866a9ab2db..5b2fb875c7 100644
--- a/app/web/features/auth/locales/en.json
+++ b/app/web/features/auth/locales/en.json
@@ -16,11 +16,16 @@
   },
   "change_password_form": {
     "title": "Change password",
+    "subtitle": "Use this form to reset your login password.",
     "confirm_password": "Confirm password",
     "password_changed_success": "Your new password has been set and a notification was sent to your email.",
     "old_password": "Old password",
     "new_password": "New password",
-    "password_mismatch_error": "The passwords don't match."
+    "password_mismatch_error": "The passwords don't match.",
+    "user_logged_error": "You cannot reset your password if you are logged in. You can change your password in settings or log out to reset it.",
+    "token_error": "There was a error processing the password reset token, please try opening the link from the email again.",
+    "reset_password_success": "Your password has been changed successfully!",
+    "reset_password_error": "Error resetting password: {{message}}"
   },
   "jail_set_password_form": {
     "title": "Set a password",
@@ -128,13 +133,11 @@
   "sign_up_completed_prompt": "We have sent an email with a verification link to your email address. Please click the link to activate your account.",
   "sign_up_confirmed_prompt": "You're all done! If you are not redirected, try logging in.",
   "unhandled_sign_up_state": "Error: unhandled signup flow state.",
-  "reset_password_success": "Your password has been reset successfully! You will now be emailed a magic link when you log in with your username or email.",
   "login_prompt": "Click here to log in",
   "about_us": "About us",
   "introduction_subtitle": "Free forever. Community-led. Non-profit. Modern.",
   "introduction_title": "Join and help grow the new alternative to Couchsurfing™",
   "reset_password": "Reset password",
-  "reset_password_error": "Error resetting password: {{message}}",
   "account_settings_page": {
     "title": "Account settings",
     "gender_section": {
diff --git a/app/web/features/auth/password/ChangePassword.tsx b/app/web/features/auth/password/ChangePassword.tsx
index c7716ddbad..844db2b50d 100644
--- a/app/web/features/auth/password/ChangePassword.tsx
+++ b/app/web/features/auth/password/ChangePassword.tsx
@@ -78,7 +78,7 @@ export default function ChangePassword({
         <Alert severity="success">
           {changePasswordVariables?.newPassword
             ? t("auth:change_password_form.password_changed_success")
-            : t("auth:reset_password_success")}
+            : t("auth:change_password_form.reset_password_success")}
         </Alert>
       )}
       <form className={classes.form} onSubmit={onSubmit}>
diff --git a/app/web/features/auth/password/CompleteResetPassword.stories.tsx b/app/web/features/auth/password/CompleteResetPassword.stories.tsx
index a0197c4deb..2810ce3868 100644
--- a/app/web/features/auth/password/CompleteResetPassword.stories.tsx
+++ b/app/web/features/auth/password/CompleteResetPassword.stories.tsx
@@ -1,7 +1,7 @@
-import { Meta, Story } from "@storybook/react";
 import { Empty } from "google-protobuf/google/protobuf/empty_pb";
-import React from "react";
+import { Meta, Story } from "@storybook/react";
 import { mockedService } from "stories/serviceMocks";
+import React from "react";
 
 import CompleteResetPassword from "./CompleteResetPassword";
 
@@ -11,23 +11,16 @@ export default {
 } as Meta;
 
 interface CompleteResetPasswordArgs {
-  isLoading?: boolean;
   shouldSucceed?: boolean;
 }
 
 const Template: Story<CompleteResetPasswordArgs> = ({
-  isLoading = false,
   shouldSucceed = true,
 } = {}) => {
-  setMocks({ isLoading, shouldSucceed });
+  setMocks({ shouldSucceed });
   return <CompleteResetPassword />;
 };
 
-export const Loading = Template.bind({});
-Loading.args = {
-  isLoading: true,
-};
-
 export const Success = Template.bind({});
 
 export const Failed = Template.bind({});
@@ -35,14 +28,9 @@ Failed.args = {
   shouldSucceed: false,
 };
 
-function setMocks({
-  isLoading,
-  shouldSucceed,
-}: Required<CompleteResetPasswordArgs>) {
-  mockedService.account.completePasswordReset = () =>
-    isLoading
-      ? new Promise(() => void 0)
-      : shouldSucceed
+function setMocks({ shouldSucceed }: Required<CompleteResetPasswordArgs>) {
+  mockedService.account.resetPassword = () =>
+    shouldSucceed
       ? Promise.resolve(new Empty())
-      : Promise.reject(new Error("Invalid token"));
+      : Promise.reject(new Error("API error"));
 }
diff --git a/app/web/features/auth/password/CompleteResetPassword.tsx b/app/web/features/auth/password/CompleteResetPassword.tsx
index e502130352..32cb775c91 100644
--- a/app/web/features/auth/password/CompleteResetPassword.tsx
+++ b/app/web/features/auth/password/CompleteResetPassword.tsx
@@ -1,58 +1,152 @@
-import { CircularProgress, Container } from "@material-ui/core";
-import Alert from "components/Alert";
-import { useAppRouteStyles } from "components/AppRoute";
-import HtmlMeta from "components/HtmlMeta";
-import StyledLink from "components/StyledLink";
 import { Empty } from "google-protobuf/google/protobuf/empty_pb";
-import { RpcError } from "grpc-web";
-import { useTranslation } from "i18n";
-import { AUTH } from "i18n/namespaces";
-import { useRouter } from "next/router";
-import { useEffect } from "react";
+import stringOrFirstString from "utils/stringOrFirstString";
+import { useAuthContext } from "features/auth/AuthProvider";
+import { Typography, Container } from "@material-ui/core";
+import StyledLink from "components/StyledLink";
+import TextField from "components/TextField";
+import HtmlMeta from "components/HtmlMeta";
 import { useMutation } from "react-query";
+import { useForm } from "react-hook-form";
+import makeStyles from "utils/makeStyles";
+import { useRouter } from "next/router";
+import { AUTH, GLOBAL } from "i18n/namespaces";
+import Button from "components/Button";
+import { useTranslation } from "i18n";
+import Alert from "components/Alert";
 import { loginRoute } from "routes";
+import { RpcError } from "grpc-web";
 import { service } from "service";
-import stringOrFirstString from "utils/stringOrFirstString";
+
+const useStyles = makeStyles((theme) => ({
+  form: {
+    "& > * + *": {
+      marginBlockStart: theme.spacing(1),
+    },
+  },
+  standardContainer: {
+    marginTop: theme.spacing(2),
+    paddingLeft: theme.spacing(2),
+    paddingRight: theme.spacing(2),
+    paddingBottom: theme.spacing(2),
+    flex: 1,
+  },
+  main: {
+    padding: theme.spacing(0, 3),
+  },
+  textField: {
+    "& > div": {
+      width: "100%",
+      marginBottom: theme.spacing(2),
+      [theme.breakpoints.up("md")]: {
+        width: theme.typography.pxToRem(400),
+      },
+    },
+  },
+}));
 
 export default function CompleteResetPassword() {
-  const { t } = useTranslation(AUTH);
-  const classes = useAppRouteStyles();
+  const { authState } = useAuthContext();
+  const { t } = useTranslation([AUTH, GLOBAL]);
+  const formClass = useStyles();
+  const { handleSubmit, register } =
+    useForm<{ newPassword: string; newPasswordCheck: string }>();
 
   const router = useRouter();
   const resetToken = stringOrFirstString(router.query.token);
+  const isResetTokenOk =
+    !!resetToken && typeof resetToken === "string" && resetToken !== "";
 
-  const {
-    error,
-    isLoading,
-    isSuccess,
-    mutate: completePasswordReset,
-  } = useMutation<Empty, RpcError, string>((resetToken) =>
-    service.account.completePasswordReset(resetToken)
+  const { error, isLoading, isSuccess, mutate } = useMutation<
+    Empty,
+    RpcError,
+    string
+  >((newPassword) =>
+    service.account.CompletePasswordResetV2(resetToken as string, newPassword)
   );
 
-  useEffect(() => {
-    if (resetToken) {
-      completePasswordReset(resetToken);
+  const onSubmit = handleSubmit(({ newPassword, newPasswordCheck }) => {
+    if (newPassword !== newPasswordCheck) {
+      alert(t("auth:change_password_form.password_mismatch_error"));
+      return;
     }
-  }, [completePasswordReset, resetToken]);
+
+    mutate(newPassword);
+  });
+
+  if (authState.authenticated) {
+    return (
+      <Container className={formClass.standardContainer}>
+        <Alert severity="error">
+          {t("auth:change_password_form.user_logged_error")}
+        </Alert>
+      </Container>
+    );
+  }
 
   return (
-    <Container className={classes.standardContainer}>
-      <HtmlMeta title={t("reset_password")} />
-      {isLoading ? (
-        <CircularProgress />
-      ) : isSuccess ? (
-        <>
-          <Alert severity="success">{t("reset_password_success")}</Alert>
-          <StyledLink href={loginRoute}>{t("login_prompt")}</StyledLink>
-        </>
-      ) : error ? (
+    <Container className={formClass.standardContainer}>
+      <HtmlMeta title={t("auth:change_password_form.title")} />
+
+      {!isResetTokenOk && (
         <Alert severity="error">
-          {t("reset_password_error", { message: error.message })}
+          {t("auth:change_password_form.token_error")}
         </Alert>
-      ) : (
-        ""
       )}
+
+      {error && (
+        <Alert severity="error">
+          {t("auth:change_password_form.reset_password_error", {
+            message: error.message,
+          })}
+        </Alert>
+      )}
+
+      {isSuccess && (
+        <>
+          <Alert severity="success">
+            {t("auth:change_password_form.reset_password_success")}
+          </Alert>
+          <StyledLink href={loginRoute}>{t("auth:login_prompt")}</StyledLink>
+        </>
+      )}
+
+      <Typography variant="h1" gutterBottom>
+        {t("auth:change_password_form.title")}
+      </Typography>
+
+      <Typography variant="body1" gutterBottom>
+        {t("auth:change_password_form.subtitle")}
+      </Typography>
+
+      <form className={formClass.form} onSubmit={onSubmit}>
+        <TextField
+          className={formClass.textField}
+          id="newPassword"
+          inputRef={register({ required: true })}
+          label={t("auth:change_password_form.new_password")}
+          name="newPassword"
+          type="password"
+          variant="outlined"
+        />
+
+        <TextField
+          className={formClass.textField}
+          id="newPasswordCheck"
+          inputRef={register({ required: true })}
+          label={t("auth:change_password_form.confirm_password")}
+          name="newPasswordCheck"
+          type="password"
+          variant="outlined"
+        />
+
+        <Button
+          loading={isLoading}
+          type="submit"
+          disabled={isLoading || !isResetTokenOk || authState.authenticated}
+        >
+          {t("global:submit")}
+        </Button>
+      </form>
     </Container>
   );
 }
diff --git a/app/web/features/auth/password/index.ts b/app/web/features/auth/password/index.ts
index a3bf5a9e75..cc047f619c 100644
--- a/app/web/features/auth/password/index.ts
+++ b/app/web/features/auth/password/index.ts
@@ -1,3 +1,3 @@
 export { default as ChangePassword } from "./ChangePassword";
-export { default as CompleteResetPassword } from "./CompleteResetPassword";
 export { default as ResetPassword } from "./ResetPassword";
+export { default as CompleteResetPassword } from "./CompleteResetPassword";
diff --git a/app/web/service/account.ts b/app/web/service/account.ts
index e08a3cacf6..de40ddf3a5 100644
--- a/app/web/service/account.ts
+++ b/app/web/service/account.ts
@@ -10,6 +10,7 @@ import {
 } from "proto/account_pb";
 import {
   CompletePasswordResetReq,
+  CompletePasswordResetV2Req,
   ConfirmChangeEmailReq,
   ContributorForm as ContributorFormPb,
   ResetPasswordReq,
@@ -35,6 +36,17 @@ export function completePasswordReset(resetToken: string) {
   return client.auth.completePasswordReset(req);
 }
 
+export function CompletePasswordResetV2(
+  resetToken: string,
+  newPassword: string
+) {
+  const req = new CompletePasswordResetV2Req();
+  req.setPasswordResetToken(resetToken);
+  req.setNewPassword(newPassword);
+
+  return client.auth.completePasswordResetV2(req);
+}
+
 export function changePassword(oldPassword?: string, newPassword?: string) {
   const req = new ChangePasswordReq();
   if (oldPassword) {


Test patch:
diff --git a/app/web/features/auth/password/ChangePassword.test.tsx b/app/web/features/auth/password/ChangePassword.test.tsx
index e14fda81c9..d166683a69 100644
--- a/app/web/features/auth/password/ChangePassword.test.tsx
+++ b/app/web/features/auth/password/ChangePassword.test.tsx
@@ -151,7 +151,9 @@ describe("ChangePassword", () => {
 
       const successAlert = await screen.findByRole("alert");
       expect(successAlert).toBeVisible();
-      expect(successAlert).toHaveTextContent(t("auth:reset_password_success"));
+      expect(successAlert).toHaveTextContent(
+        t("auth:change_password_form.reset_password_success")
+      );
       expect(changePasswordMock).toHaveBeenCalledTimes(1);
       expect(changePasswordMock).toHaveBeenCalledWith("old_password", "");
 
diff --git a/app/web/features/auth/password/CompleteResetPassword.test.tsx b/app/web/features/auth/password/CompleteResetPassword.test.tsx
index 49bd34a069..1d15f308da 100644
--- a/app/web/features/auth/password/CompleteResetPassword.test.tsx
+++ b/app/web/features/auth/password/CompleteResetPassword.test.tsx
@@ -1,68 +1,154 @@
-import { render, screen } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
-import { Empty } from "google-protobuf/google/protobuf/empty_pb";
-import mockRouter from "next-router-mock";
-import { loginRoute } from "routes";
-import { service } from "service";
-import wrapper from "test/hookWrapper";
 import { MockedService, t } from "test/utils";
+import { AuthRes } from "proto/auth_pb";
+import { useRouter } from "next/router";
+import wrapper from "test/hookWrapper";
+import { service } from "service";
 
-import CompleteResetPassword from "./CompleteResetPassword";
+import CompletePasswordReset from "./CompleteResetPassword";
 
-const completePasswordResetMock = service.account
-  .completePasswordReset as MockedService<
-  typeof service.account.completePasswordReset
+const CompletePasswordResetMock = service.account
+  .CompletePasswordResetV2 as MockedService<
+  typeof service.account.CompletePasswordResetV2
 >;
 
-function renderPage() {
-  mockRouter.setCurrentUrl("?token=P4w0rdR3seTtok3n");
-  render(<CompleteResetPassword />, { wrapper });
-}
+jest.mock("next/router", () => ({
+  useRouter: jest.fn(),
+}));
 
-describe("CompleteResetPassword", () => {
-  it("shows the loading state on initial load", async () => {
-    completePasswordResetMock.mockImplementation(
-      () => new Promise(() => void 0)
-    );
-    renderPage();
+const mockUseRouter = useRouter as jest.Mock;
+
+describe("CompletePasswordReset page", () => {
+  beforeEach(() => {
+    CompletePasswordResetMock.mockResolvedValue(new AuthRes());
+
+    mockUseRouter.mockReturnValue({
+      query: { token: "aaa123" },
+    });
+  });
+
+  it("shows the set new password form correctly", async () => {
+    render(<CompletePasswordReset />, { wrapper });
+
+    expect(
+      screen.getByRole("heading", {
+        level: 1,
+        name: t("auth:change_password_form.title"),
+      })
+    ).toBeVisible();
 
-    expect(await screen.findByRole("progressbar")).toBeVisible();
+    expect(
+      screen.getByText(t("auth:change_password_form.subtitle"))
+    ).toBeVisible();
+
+    expect(
+      screen.getByRole("button", { name: t("global:submit") })
+    ).toBeVisible();
   });
 
-  describe("when the reset password completes successfully", () => {
-    beforeEach(async () => {
-      completePasswordResetMock.mockResolvedValue(new Empty());
-      renderPage();
+  it("shows a warning when empty token", () => {
+    mockUseRouter.mockReturnValue({
+      query: { token: "" },
     });
 
-    it("shows the success alert", async () => {
-      const successAlert = await screen.findByRole("alert");
-      expect(successAlert).toBeVisible();
-      expect(successAlert).toHaveTextContent(t("auth:reset_password_success"));
-      expect(completePasswordResetMock).toHaveBeenCalledTimes(1);
-      expect(completePasswordResetMock).toHaveBeenLastCalledWith(
-        "P4w0rdR3seTtok3n"
-      );
+    render(<CompletePasswordReset />, { wrapper });
+
+    expect(
+      screen.queryByText(t("auth:change_password_form.token_error"))
+    ).toBeInTheDocument();
+  });
+
+  it("don't show a warning when valid token", () => {
+    render(<CompletePasswordReset />, { wrapper });
+
+    expect(
+      screen.queryByText(t("auth:change_password_form.token_error"))
+    ).not.toBeInTheDocument();
+  });
+
+  it("does not submit if empty form", async () => {
+    render(<CompletePasswordReset />, { wrapper });
+
+    userEvent.click(screen.getByRole("button", { name: t("global:submit") }));
+
+    await waitFor(() => {
+      expect(CompletePasswordResetMock).not.toHaveBeenCalled();
     });
+  });
+
+  it("does not submit if password don't match", async () => {
+    render(<CompletePasswordReset />, { wrapper });
 
-    it("shows a link that takes you to the login page when clicked", async () => {
-      userEvent.click(
-        await screen.findByRole("link", { name: t("auth:login_prompt") })
-      );
+    userEvent.type(
+      screen.getByLabelText(t("auth:change_password_form.new_password")),
+      "1111"
+    );
+
+    userEvent.type(
+      screen.getByLabelText(t("auth:change_password_form.confirm_password")),
+      "2222"
+    );
 
-      expect(mockRouter.pathname).toBe(loginRoute);
+    userEvent.click(screen.getByRole("button", { name: t("global:submit") }));
+
+    await waitFor(() => {
+      expect(CompletePasswordResetMock).not.toHaveBeenCalled();
     });
   });
 
-  it("shows an error alert if the reset password process failed to complete", async () => {
+  it("submits the reset password request successfully", async () => {
+    mockUseRouter.mockReturnValue({
+      query: { token: "aaa123" },
+    });
+
+    render(<CompletePasswordReset />, { wrapper });
+
+    userEvent.type(
+      screen.getByLabelText(t("auth:change_password_form.new_password")),
+      "1111"
+    );
+
+    userEvent.type(
+      screen.getByLabelText(t("auth:change_password_form.confirm_password")),
+      "1111"
+    );
+
+    userEvent.click(screen.getByRole("button", { name: t("global:submit") }));
+
+    expect(
+      await screen.findByText(
+        t("auth:change_password_form.reset_password_success")
+      )
+    ).toBeVisible();
+
+    expect(CompletePasswordResetMock).toHaveBeenCalledTimes(1);
+  });
+
+  it("shows an error alert if the reset password request failed", async () => {
     jest.spyOn(console, "error").mockReturnValue(undefined);
-    completePasswordResetMock.mockRejectedValue(new Error("Invalid token"));
-    renderPage();
+    CompletePasswordResetMock.mockRejectedValue(new Error("GRPC error"));
+
+    mockUseRouter.mockReturnValue({
+      query: { token: "aaa123" },
+    });
 
-    const errorAlert = await screen.findByRole("alert");
-    expect(errorAlert).toBeVisible();
-    expect(errorAlert).toHaveTextContent(
-      t("auth:reset_password_error", { message: "Invalid token" })
+    render(<CompletePasswordReset />, { wrapper });
+
+    userEvent.type(
+      screen.getByLabelText(t("auth:change_password_form.new_password")),
+      "1111"
+    );
+
+    userEvent.type(
+      screen.getByLabelText(t("auth:change_password_form.confirm_password")),
+      "1111"
     );
+
+    userEvent.click(screen.getByRole("button", { name: t("global:submit") }));
+
+    expect(
+      screen.queryByText(t("auth:change_password_form.reset_password_success"))
+    ).not.toBeInTheDocument();
   });
 });