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
Generated
+61
@@ -5373,6 +5373,45 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/validate-password": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Authorization"
|
||||
],
|
||||
"summary": "Validate user password",
|
||||
"operationId": "validate-user-password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Validate user password request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ValidateUserPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ValidateUserPasswordResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -14096,6 +14135,28 @@ const docTemplate = `{
|
||||
"UserStatusSuspended"
|
||||
]
|
||||
},
|
||||
"codersdk.ValidateUserPasswordRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ValidateUserPasswordResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"details": {
|
||||
"type": "string"
|
||||
},
|
||||
"valid": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ValidationError": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
Generated
+53
@@ -4737,6 +4737,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/validate-password": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Authorization"],
|
||||
"summary": "Validate user password",
|
||||
"operationId": "validate-user-password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Validate user password request",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ValidateUserPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.ValidateUserPasswordResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{user}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -12817,6 +12850,26 @@
|
||||
"UserStatusSuspended"
|
||||
]
|
||||
},
|
||||
"codersdk.ValidateUserPasswordRequest": {
|
||||
"type": "object",
|
||||
"required": ["password"],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ValidateUserPasswordResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"details": {
|
||||
"type": "string"
|
||||
},
|
||||
"valid": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ValidationError": {
|
||||
"type": "object",
|
||||
"required": ["detail", "field"],
|
||||
|
||||
@@ -1047,6 +1047,7 @@ func New(options *Options) *API {
|
||||
r.Use(httpmw.RateLimit(options.LoginRateLimit, time.Minute))
|
||||
r.Post("/login", api.postLogin)
|
||||
r.Post("/otp/request", api.postRequestOneTimePasscode)
|
||||
r.Post("/validate-password", api.validateUserPassword)
|
||||
r.Post("/otp/change-password", api.postChangePasswordWithOneTimePasscode)
|
||||
r.Route("/oauth2", func(r chi.Router) {
|
||||
r.Route("/github", func(r chi.Router) {
|
||||
|
||||
@@ -447,6 +447,41 @@ func (api *API) postChangePasswordWithOneTimePasscode(rw http.ResponseWriter, r
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
|
||||
//
|
||||
// @Summary Validate user password
|
||||
// @ID validate-user-password
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Accept json
|
||||
// @Tags Authorization
|
||||
// @Param request body codersdk.ValidateUserPasswordRequest true "Validate user password request"
|
||||
// @Success 200 {object} codersdk.ValidateUserPasswordResponse
|
||||
// @Router /users/validate-password [post]
|
||||
func (*API) validateUserPassword(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
valid = true
|
||||
details = ""
|
||||
)
|
||||
|
||||
var req codersdk.ValidateUserPasswordRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
err := userpassword.Validate(req.Password)
|
||||
if err != nil {
|
||||
valid = false
|
||||
details = err.Error()
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ValidateUserPasswordResponse{
|
||||
Valid: valid,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
// Authenticates the user with an email and password.
|
||||
//
|
||||
// @Summary Log in user
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package userpassword_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -12,46 +13,101 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
)
|
||||
|
||||
func TestUserPassword(t *testing.T) {
|
||||
func TestUserPasswordValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("Legacy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Ensures legacy v1 passwords function for v2.
|
||||
// This has is manually generated using a print statement from v1 code.
|
||||
equal, err := userpassword.Compare("$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg", "tomato")
|
||||
require.NoError(t, err)
|
||||
require.True(t, equal)
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "Invalid - Too short password", password: "pass", wantErr: true},
|
||||
{name: "Invalid - Too long password", password: strings.Repeat("a", 65), wantErr: true},
|
||||
{name: "Invalid - easy password", password: "password", wantErr: true},
|
||||
{name: "Ok", password: "PasswordSecured123!", wantErr: false},
|
||||
}
|
||||
|
||||
t.Run("Same", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hash, err := userpassword.Hash("password")
|
||||
require.NoError(t, err)
|
||||
equal, err := userpassword.Compare(hash, "password")
|
||||
require.NoError(t, err)
|
||||
require.True(t, equal)
|
||||
})
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := userpassword.Validate(tt.password)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Different", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hash, err := userpassword.Hash("password")
|
||||
require.NoError(t, err)
|
||||
equal, err := userpassword.Compare(hash, "notpassword")
|
||||
require.NoError(t, err)
|
||||
require.False(t, equal)
|
||||
})
|
||||
func TestUserPasswordCompare(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
passwordToValidate string
|
||||
password string
|
||||
shouldHash bool
|
||||
wantErr bool
|
||||
wantEqual bool
|
||||
}{
|
||||
{
|
||||
name: "Legacy",
|
||||
passwordToValidate: "$pbkdf2-sha256$65535$z8c1p1C2ru9EImBP1I+ZNA$pNjE3Yk0oG0PmJ0Je+y7ENOVlSkn/b0BEqqdKsq6Y97wQBq0xT+lD5bWJpyIKJqQICuPZcEaGDKrXJn8+SIHRg",
|
||||
password: "tomato",
|
||||
shouldHash: false,
|
||||
wantErr: false,
|
||||
wantEqual: true,
|
||||
},
|
||||
{
|
||||
name: "Same",
|
||||
passwordToValidate: "password",
|
||||
password: "password",
|
||||
shouldHash: true,
|
||||
wantErr: false,
|
||||
wantEqual: true,
|
||||
},
|
||||
{
|
||||
name: "Different",
|
||||
passwordToValidate: "password",
|
||||
password: "notpassword",
|
||||
shouldHash: true,
|
||||
wantErr: false,
|
||||
wantEqual: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid",
|
||||
passwordToValidate: "invalidhash",
|
||||
password: "password",
|
||||
shouldHash: false,
|
||||
wantErr: true,
|
||||
wantEqual: false,
|
||||
},
|
||||
{
|
||||
name: "InvalidParts",
|
||||
passwordToValidate: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||
password: "test",
|
||||
shouldHash: false,
|
||||
wantErr: true,
|
||||
wantEqual: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
equal, err := userpassword.Compare("invalidhash", "password")
|
||||
require.False(t, equal)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("InvalidParts", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
equal, err := userpassword.Compare("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", "test")
|
||||
require.False(t, equal)
|
||||
require.Error(t, err)
|
||||
})
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if tt.shouldHash {
|
||||
hash, err := userpassword.Hash(tt.passwordToValidate)
|
||||
require.NoError(t, err)
|
||||
tt.passwordToValidate = hash
|
||||
}
|
||||
equal, err := userpassword.Compare(tt.passwordToValidate, tt.password)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tt.wantEqual, equal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1219,6 +1219,24 @@ func TestUpdateUserPassword(t *testing.T) {
|
||||
require.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[numLogs-1].Action)
|
||||
})
|
||||
|
||||
t.Run("ValidateUserPassword", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.ValidateUserPassword(ctx, codersdk.ValidateUserPasswordRequest{
|
||||
Password: "MySecurePassword!",
|
||||
})
|
||||
|
||||
require.NoError(t, err, "users shoud be able to validate complexity of a potential new password")
|
||||
require.True(t, resp.Valid)
|
||||
})
|
||||
|
||||
t.Run("ChangingPasswordDeletesKeys", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -178,6 +178,15 @@ type UpdateUserProfileRequest struct {
|
||||
Name string `json:"name" validate:"user_real_name"`
|
||||
}
|
||||
|
||||
type ValidateUserPasswordRequest struct {
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type ValidateUserPasswordResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Details string `json:"details"`
|
||||
}
|
||||
|
||||
type UpdateUserAppearanceSettingsRequest struct {
|
||||
ThemePreference string `json:"theme_preference" validate:"required"`
|
||||
}
|
||||
@@ -407,6 +416,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// ValidateUserPassword validates the complexity of a user password and that it is secured enough.
|
||||
func (c *Client) ValidateUserPassword(ctx context.Context, req ValidateUserPasswordRequest) (ValidateUserPasswordResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/validate-password", req)
|
||||
if err != nil {
|
||||
return ValidateUserPasswordResponse{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ValidateUserPasswordResponse{}, ReadBodyAsError(res)
|
||||
}
|
||||
var resp ValidateUserPasswordResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// UpdateUserStatus sets the user status to the given status
|
||||
func (c *Client) UpdateUserStatus(ctx context.Context, user string, status UserStatus) (User, error) {
|
||||
path := fmt.Sprintf("/api/v2/users/%s/status/", user)
|
||||
|
||||
Generated
+47
@@ -178,6 +178,53 @@ curl -X POST http://coder-server:8080/api/v2/users/otp/request \
|
||||
| ------ | --------------------------------------------------------------- | ----------- | ------ |
|
||||
| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | |
|
||||
|
||||
## Validate user password
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X POST http://coder-server:8080/api/v2/users/validate-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`POST /users/validate-password`
|
||||
|
||||
> Body parameter
|
||||
|
||||
```json
|
||||
{
|
||||
"password": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ |
|
||||
| `body` | body | [codersdk.ValidateUserPasswordRequest](schemas.md#codersdkvalidateuserpasswordrequest) | true | Validate user password request |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"details": "string",
|
||||
"valid": true
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------- |
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ValidateUserPasswordResponse](schemas.md#codersdkvalidateuserpasswordresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Convert user from password to oauth authentication
|
||||
|
||||
### Code samples
|
||||
|
||||
Generated
+30
@@ -6446,6 +6446,36 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
||||
| `dormant` |
|
||||
| `suspended` |
|
||||
|
||||
## codersdk.ValidateUserPasswordRequest
|
||||
|
||||
```json
|
||||
{
|
||||
"password": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------- | ------ | -------- | ------------ | ----------- |
|
||||
| `password` | string | true | | |
|
||||
|
||||
## codersdk.ValidateUserPasswordResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"details": "string",
|
||||
"valid": true
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| --------- | ------- | -------- | ------------ | ----------- |
|
||||
| `details` | string | false | | |
|
||||
| `valid` | boolean | false | | |
|
||||
|
||||
## codersdk.ValidationError
|
||||
|
||||
```json
|
||||
|
||||
@@ -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