mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
BrunoQuaresma
parent
8b5a18cade
commit
4fe2c5f09a
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
UpdateUserProfileRequest,
|
||||
User,
|
||||
UsersRequest,
|
||||
ValidateUserPasswordRequest,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
type MetadataState,
|
||||
|
||||
Generated
+11
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user