fix: improve password validation flow (#15132)

Refers to #14984 

Currently, password validation is done backend side and is not explicit
enough so it can be painful to create first users.
We'd like to make this validation easier - but also duplicate it
frontend side to make it smoother.

Flows involved : 
- First user set password
- New user set password
- Change password

---------

Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>
This commit is contained in:
Vincent Vielle
2024-11-05 17:22:32 +01:00
committed by GitHub
co-authored by BrunoQuaresma
parent 8b5a18cade
commit 4fe2c5f09a
21 changed files with 528 additions and 72 deletions
+9
View File
@@ -1322,6 +1322,15 @@ class ApiMethods {
await this.axios.put(`/api/v2/users/${userId}/password`, updatePassword);
};
validateUserPassword = async (
password: string,
): Promise<TypesGen.ValidateUserPasswordResponse> => {
const response = await this.axios.post("/api/v2/users/validate-password", {
password,
});
return response.data;
};
getRoles = async (): Promise<Array<TypesGen.AssignableRoles>> => {
const response = await this.axios.get<TypesGen.AssignableRoles[]>(
"/api/v2/users/roles",
+1
View File
@@ -9,6 +9,7 @@ import type {
UpdateUserProfileRequest,
User,
UsersRequest,
ValidateUserPasswordRequest,
} from "api/typesGenerated";
import {
type MetadataState,
+11
View File
@@ -1779,6 +1779,17 @@ export interface UsersRequest extends Pagination {
readonly q?: string;
}
// From codersdk/users.go
export interface ValidateUserPasswordRequest {
readonly password: string;
}
// From codersdk/users.go
export interface ValidateUserPasswordResponse {
readonly valid: boolean;
readonly details: string;
}
// From codersdk/client.go
export interface ValidationError {
readonly field: string;
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test";
import { API } from "api/api";
import { useState } from "react";
import { PasswordField } from "./PasswordField";
const meta: Meta<typeof PasswordField> = {
title: "components/PasswordField",
component: PasswordField,
args: {
label: "Password",
},
render: function StatefulPasswordField(args) {
const [value, setValue] = useState("");
return (
<PasswordField
{...args}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
);
},
};
export default meta;
type Story = StoryObj<typeof PasswordField>;
export const Idle: Story = {};
const securePassword = "s3curePa$$w0rd";
export const Valid: Story = {
play: async ({ canvasElement }) => {
const validatePasswordSpy = spyOn(
API,
"validateUserPassword",
).mockResolvedValueOnce({ valid: true, details: "" });
const user = userEvent.setup();
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Password");
await user.type(input, securePassword);
await waitFor(() =>
expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword),
);
expect(validatePasswordSpy).toHaveBeenCalledTimes(1);
},
};
export const Invalid: Story = {
play: async ({ canvasElement }) => {
const validatePasswordSpy = spyOn(
API,
"validateUserPassword",
).mockResolvedValueOnce({
valid: false,
details: "Password is too short.",
});
const user = userEvent.setup();
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Password");
await user.type(input, securePassword);
await waitFor(() =>
expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword),
);
expect(validatePasswordSpy).toHaveBeenCalledTimes(1);
},
};
@@ -0,0 +1,37 @@
import TextField, { type TextFieldProps } from "@mui/material/TextField";
import { API } from "api/api";
import { useDebouncedValue } from "hooks/debounce";
import type { FC } from "react";
import { useQuery } from "react-query";
// TODO: @BrunoQuaresma: Unable to integrate Yup + Formik for validation. The
// validation was triggering on the onChange event, but the form.errors were not
// updating accordingly. Tried various combinations of validateOnBlur and
// validateOnChange without success. Further investigation is needed.
/**
* A password field component that validates the password against the API with
* debounced calls. It uses a debounced value to minimize the number of API
* calls and displays validation errors.
*/
export const PasswordField: FC<TextFieldProps> = (props) => {
const debouncedValue = useDebouncedValue(`${props.value}`, 500);
const validatePasswordQuery = useQuery({
queryKey: ["validatePassword", debouncedValue],
queryFn: () => API.validateUserPassword(debouncedValue),
keepPreviousData: true,
enabled: debouncedValue.length > 0,
});
const valid = validatePasswordQuery.data?.valid ?? true;
return (
<TextField
{...props}
type="password"
error={!valid || props.error}
helperText={
!valid ? validatePasswordQuery.data?.details : props.helperText
}
/>
);
};
@@ -6,9 +6,10 @@ import type * as TypesGen from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { FormFooter } from "components/FormFooter/FormFooter";
import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { PasswordField } from "components/PasswordField/PasswordField";
import { Stack } from "components/Stack/Stack";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { type FC, useEffect } from "react";
import {
displayNameValidator,
getFormHelpers,
@@ -186,7 +187,7 @@ export const CreateUserForm: FC<
);
})}
</TextField>
<TextField
<PasswordField
{...getFieldHelpers("password", {
helperText:
form.values.login_type !== "password" &&
@@ -198,7 +199,6 @@ export const CreateUserForm: FC<
data-testid="password-input"
disabled={form.values.login_type !== "password"}
label={Language.passwordLabel}
type="password"
/>
</Stack>
<FormFooter
@@ -1,7 +1,8 @@
import { authMethods, createUser } from "api/queries/users";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Margins } from "components/Margins/Margins";
import type { FC } from "react";
import { useDebouncedFunction } from "hooks/debounce";
import { type FC, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
@@ -49,6 +49,32 @@ describe("Setup Page", () => {
);
});
it("renders the password validation error", async () => {
server.use(
http.post("/api/v2/users/validate-password", () => {
return HttpResponse.json({
valid: false,
details: "Password is too short",
});
}),
);
renderWithRouter(
createMemoryRouter(
[
{
path: "/setup",
element: <SetupPage />,
},
],
{ initialEntries: ["/setup"] },
),
);
await waitForLoaderToBeRemoved();
await fillForm({ password: "short" });
await waitFor(() => screen.findByText("Password is too short"));
});
it("redirects to the app when setup is successful", async () => {
let userHasBeenCreated = false;
@@ -99,6 +125,7 @@ describe("Setup Page", () => {
await fillForm();
await waitFor(() => screen.findByText("Templates"));
});
it("calls sendBeacon with telemetry", async () => {
const sendBeacon = jest.fn();
Object.defineProperty(window.navigator, "sendBeacon", {
+2 -1
View File
@@ -3,7 +3,7 @@ import { createFirstUser } from "api/queries/users";
import { Loader } from "components/Loader/Loader";
import { useAuthContext } from "contexts/auth/AuthProvider";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { type FC, useEffect } from "react";
import { type FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery } from "react-query";
import { Navigate, useNavigate } from "react-router-dom";
@@ -24,6 +24,7 @@ export const SetupPage: FC = () => {
const { metadata } = useEmbeddedMetadata();
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
const navigate = useNavigate();
useEffect(() => {
if (!buildInfoQuery.data) {
return;
+3 -4
View File
@@ -10,10 +10,12 @@ import { isAxiosError } from "axios";
import { Alert, AlertDetail } from "components/Alert/Alert";
import { FormFields, VerticalForm } from "components/Form/Form";
import { CoderIcon } from "components/Icons/CoderIcon";
import { PasswordField } from "components/PasswordField/PasswordField";
import { SignInLayout } from "components/SignInLayout/SignInLayout";
import { Stack } from "components/Stack/Stack";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { useEffect } from "react";
import { docs } from "utils/docs";
import {
getFormHelpers,
@@ -33,7 +35,6 @@ export const Language = {
passwordRequired: "Please enter a password.",
create: "Create account",
welcomeMessage: <>Welcome to Coder</>,
firstNameLabel: "First name",
lastNameLabel: "Last name",
companyLabel: "Company",
@@ -167,13 +168,11 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
fullWidth
label={Language.emailLabel}
/>
<TextField
<PasswordField
{...getFieldHelpers("password")}
autoComplete="current-password"
fullWidth
id="password"
label={Language.passwordLabel}
type="password"
/>
<label
htmlFor="trial"
@@ -1,10 +1,13 @@
import LoadingButton from "@mui/lab/LoadingButton";
import TextField from "@mui/material/TextField";
import type * as TypesGen from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Form, FormFields } from "components/Form/Form";
import { PasswordField } from "components/PasswordField/PasswordField";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { useEffect } from "react";
import { getFormHelpers } from "utils/formUtils";
import * as Yup from "yup";
@@ -29,11 +32,7 @@ export const Language = {
const validationSchema = Yup.object({
old_password: Yup.string().trim().required(Language.oldPasswordRequired),
password: Yup.string()
.trim()
.min(8, Language.passwordMinLength)
.max(64, Language.passwordMaxLength)
.required(Language.newPasswordRequired),
password: Yup.string().trim().required(Language.newPasswordRequired),
confirm_password: Yup.string()
.trim()
.test("passwords-match", Language.confirmPasswordMatch, function (value) {
@@ -86,12 +85,11 @@ export const SecurityForm: FC<SecurityFormProps> = ({
label={Language.oldPasswordLabel}
type="password"
/>
<TextField
<PasswordField
{...getFieldHelpers("password")}
autoComplete="password"
fullWidth
label={Language.newPasswordLabel}
type="password"
/>
<TextField
{...getFieldHelpers("confirm_password")}
@@ -76,24 +76,6 @@ test("update password with incorrect old password", async () => {
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues);
});
test("update password with invalid password", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce(
mockApiError({
message: "Invalid password.",
validations: [{ detail: "Invalid password.", field: "password" }],
}),
);
const { user } = await renderPage();
fillAndSubmitSecurityForm();
const errorMessage = await screen.findAllByText("Invalid password.");
expect(errorMessage).toBeDefined();
expect(errorMessage).toHaveLength(2);
expect(API.updateUserPassword).toBeCalledTimes(1);
expect(API.updateUserPassword).toBeCalledWith(user.id, newSecurityFormValues);
});
test("update password when submit returns an unknown error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",