Enforce password strength (#1268)

Closes #1229

Signed-off-by: Sergio Castaño Arteaga <tegioz@icloud.com>
Signed-off-by: Cintia Sanchez Garcia <cynthiasg@icloud.com>
Co-authored-by: Sergio Castaño Arteaga <tegioz@icloud.com>
Co-authored-by: Cintia Sanchez Garcia <cynthiasg@icloud.com>
This commit is contained in:
Sergio C. Arteaga 2021-04-23 19:49:42 +02:00 committed by GitHub
parent 61f9123fd5
commit fd76477367
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 496 additions and 188 deletions

1
go.mod
View File

@ -39,6 +39,7 @@ require (
github.com/tektoncd/pipeline v0.23.0
github.com/unrolled/secure v1.0.8
github.com/vincent-petithory/dataurl v0.0.0-20191104211930-d1553a71de50
github.com/wagslane/go-password-validator v0.3.0
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb // indirect
golang.org/x/oauth2 v0.0.0-20210413134643-5e61552d6c78

2
go.sum
View File

@ -1114,6 +1114,8 @@ github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv
github.com/vincent-petithory/dataurl v0.0.0-20191104211930-d1553a71de50 h1:uxE3GYdXIOfhMv3unJKETJEhw78gvzuQqRX/rVirc2A=
github.com/vincent-petithory/dataurl v0.0.0-20191104211930-d1553a71de50/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
github.com/vmware/govmomi v0.20.3/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59bHWk6aFU=
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=

View File

@ -170,6 +170,7 @@ func (h *Handlers) setupRouter() {
// Users
r.Route("/users", func(r chi.Router) {
r.Post("/", h.Users.RegisterUser)
r.Post("/check-password-strength", h.Users.CheckPasswordStrength)
r.Post("/login", h.Users.Login)
r.Post("/password-reset-code", h.Users.RegisterPasswordResetCode)
r.Put("/reset-password", h.Users.ResetPassword)

View File

@ -27,6 +27,7 @@ import (
"github.com/rs/zerolog/log"
"github.com/satori/uuid"
"github.com/spf13/viper"
pwvalidator "github.com/wagslane/go-password-validator"
"golang.org/x/oauth2"
oagithub "golang.org/x/oauth2/github"
"golang.org/x/oauth2/google"
@ -142,6 +143,23 @@ func (h *Handlers) BasicAuth(next http.Handler) http.Handler {
})
}
// CheckPasswordStrength is an http handler that checks the strength of the
// password provided
func (h *Handlers) CheckPasswordStrength(w http.ResponseWriter, r *http.Request) {
var input map[string]string
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
h.logger.Error().Err(err).Str("method", "CheckPasswordStrength").Msg(hub.ErrInvalidInput.Error())
helpers.RenderErrorJSON(w, hub.ErrInvalidInput)
return
}
if err := pwvalidator.Validate(input["password"], user.PasswordMinEntropyBits); err != nil {
h.logger.Error().Err(err).Str("method", "CheckPasswordStrength").Msg(hub.ErrInvalidInput.Error())
helpers.RenderErrorWithCodeJSON(w, err, http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
// CheckAvailability is an http handler that checks the availability of a given
// value for the provided resource kind.
func (h *Handlers) CheckAvailability(w http.ResponseWriter, r *http.Request) {

View File

@ -60,6 +60,71 @@ func TestBasicAuth(t *testing.T) {
})
}
func TestCheckPasswordStrength(t *testing.T) {
t.Run("invalid input", func(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
body := strings.NewReader(`{..`)
r, _ := http.NewRequest("POST", "/", body)
hw := newHandlersWrapper()
hw.h.CheckPasswordStrength(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("invalid passwords", func(t *testing.T) {
passwords := []string{
"invalid",
"123",
"weak12",
}
for _, pw := range passwords {
pw := pw
t.Run(pw, func(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
body := strings.NewReader(fmt.Sprintf(`{"password": "%s"}`, pw))
r, _ := http.NewRequest("POST", "/", body)
hw := newHandlersWrapper()
hw.h.CheckPasswordStrength(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
}
})
t.Run("valid passwords", func(t *testing.T) {
passwords := []string{
"12uuYbaT.",
"this password should be valid too",
"19s-8*s.Y",
"yet123-another-ONE",
}
for _, pw := range passwords {
pw := pw
t.Run(pw, func(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
body := strings.NewReader(fmt.Sprintf(`{"password": "%s"}`, pw))
r, _ := http.NewRequest("POST", "/", body)
hw := newHandlersWrapper()
hw.h.CheckPasswordStrength(w, r)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
})
}
})
}
func TestCheckAvailability(t *testing.T) {
t.Run("invalid input", func(t *testing.T) {
t.Parallel()

View File

@ -16,6 +16,7 @@ import (
"github.com/artifacthub/hub/internal/hub"
"github.com/jackc/pgx/v4"
"github.com/satori/uuid"
pwvalidator "github.com/wagslane/go-password-validator"
"golang.org/x/crypto/bcrypt"
)
@ -39,6 +40,12 @@ const (
verifyPasswordResetCodeDBQ = `select verify_password_reset_code($1::bytea)`
)
const (
// PasswordMinEntropyBits represents the minimum amount of entropy bits
// required for a password.
PasswordMinEntropyBits = 50
)
var (
// ErrInvalidPassword indicates that the password provided is not valid.
ErrInvalidPassword = errors.New("invalid password")
@ -353,6 +360,11 @@ func (m *Manager) RegisterUser(ctx context.Context, user *hub.User, baseURL stri
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid profile image id")
}
}
if !user.EmailVerified {
if err := pwvalidator.Validate(user.Password, PasswordMinEntropyBits); err != nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, err.Error())
}
}
// Hash password
if user.Password != "" {
@ -402,6 +414,9 @@ func (m *Manager) ResetPassword(ctx context.Context, codeB64, newPassword, baseU
if newPassword == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "new password not provided")
}
if err := pwvalidator.Validate(newPassword, PasswordMinEntropyBits); err != nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, err.Error())
}
if m.es != nil {
u, err := url.Parse(baseURL)
if err != nil || u.Scheme == "" || u.Host == "" {
@ -462,6 +477,9 @@ func (m *Manager) UpdatePassword(ctx context.Context, old, new string) error {
if new == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "new password not provided")
}
if err := pwvalidator.Validate(new, PasswordMinEntropyBits); err != nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, err.Error())
}
// Validate old password
var oldHashed string

View File

@ -685,6 +685,7 @@ func TestRegisterPasswordResetCode(t *testing.T) {
func TestRegisterUser(t *testing.T) {
ctx := context.Background()
password := "a66bV.Xp2" // #nosec
t.Run("invalid input", func(t *testing.T) {
testCases := []struct {
@ -712,6 +713,11 @@ func TestRegisterUser(t *testing.T) {
&hub.User{Alias: "user1", Email: "email", ProfileImageID: "invalid"},
"http://baseurl.com",
},
{
"insecure password",
&hub.User{Alias: "user1", Email: "email", Password: "hello"},
"http://baseurl.com",
},
}
for _, tc := range testCases {
tc := tc
@ -757,12 +763,11 @@ func TestRegisterUser(t *testing.T) {
FirstName: "first_name",
LastName: "last_name",
Email: "email@email.com",
Password: "password",
Password: password,
ProfileImageID: "00000000-0000-0000-0000-000000000001",
}
err := m.RegisterUser(ctx, u, "http://baseurl.com")
assert.Equal(t, tc.emailSenderResponse, err)
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(u.Password), []byte("password")))
db.AssertExpectations(t)
es.AssertExpectations(t)
})
@ -777,8 +782,9 @@ func TestRegisterUser(t *testing.T) {
m := NewManager(db, nil)
u := &hub.User{
Alias: "alias",
Email: "email@email.com",
Alias: "alias",
Email: "email@email.com",
Password: password,
}
err := m.RegisterUser(ctx, u, "http://baseurl.com")
assert.Equal(t, tests.ErrFakeDB, err)
@ -790,6 +796,8 @@ func TestResetPassword(t *testing.T) {
ctx := context.Background()
code := []byte("code")
codeB64 := base64.URLEncoding.EncodeToString(code)
newPassword := "a66bV.Xp2" // #nosec
baseURL := "http://baseurl.com"
t.Run("invalid input", func(t *testing.T) {
testCases := []struct {
@ -801,21 +809,27 @@ func TestResetPassword(t *testing.T) {
{
"code not provided",
"",
"newPassword",
"http://baseurl.com",
newPassword,
baseURL,
},
{
"new password not provided",
"code",
"",
"http://baseurl.com",
baseURL,
},
{
"invalid base url",
"code",
"newPassword",
newPassword,
"invalid",
},
{
"insecure password",
"code",
"password",
baseURL,
},
}
for _, tc := range testCases {
tc := tc
@ -852,7 +866,7 @@ func TestResetPassword(t *testing.T) {
db.On("QueryRow", ctx, resetUserPasswordDBQ, code, mock.Anything).Return("", tc.dbErr)
m := NewManager(db, nil)
err := m.ResetPassword(ctx, codeB64, "newPassword", "http://baseurl.com")
err := m.ResetPassword(ctx, codeB64, newPassword, baseURL)
assert.Equal(t, tc.expectedErr, err)
db.AssertExpectations(t)
})
@ -883,7 +897,7 @@ func TestResetPassword(t *testing.T) {
es.On("SendEmail", mock.Anything).Return(tc.emailSenderResponse)
m := NewManager(db, es)
err := m.ResetPassword(ctx, codeB64, "newPassword", "http://baseurl.com")
err := m.ResetPassword(ctx, codeB64, newPassword, baseURL)
assert.Equal(t, tc.emailSenderResponse, err)
db.AssertExpectations(t)
es.AssertExpectations(t)
@ -895,6 +909,7 @@ func TestResetPassword(t *testing.T) {
func TestUpdatePassword(t *testing.T) {
ctx := context.WithValue(context.Background(), hub.UserIDKey, "userID")
oldHashed, _ := bcrypt.GenerateFromPassword([]byte("old"), bcrypt.DefaultCost)
new := "a66bV.Xp2"
t.Run("user id not found in ctx", func(t *testing.T) {
t.Parallel()
@ -920,6 +935,11 @@ func TestUpdatePassword(t *testing.T) {
"old",
"",
},
{
"insecure password",
"old",
"new",
},
}
for _, tc := range testCases {
tc := tc
@ -939,7 +959,7 @@ func TestUpdatePassword(t *testing.T) {
db.On("QueryRow", ctx, getUserPasswordDBQ, "userID").Return("", tests.ErrFakeDB)
m := NewManager(db, nil)
err := m.UpdatePassword(ctx, "old", "new")
err := m.UpdatePassword(ctx, "old", new)
assert.Equal(t, tests.ErrFakeDB, err)
db.AssertExpectations(t)
})
@ -950,7 +970,7 @@ func TestUpdatePassword(t *testing.T) {
db.On("QueryRow", ctx, getUserPasswordDBQ, "userID").Return(string(oldHashed), nil)
m := NewManager(db, nil)
err := m.UpdatePassword(ctx, "old2", "new")
err := m.UpdatePassword(ctx, "old2", new)
assert.Error(t, err)
db.AssertExpectations(t)
})
@ -963,7 +983,7 @@ func TestUpdatePassword(t *testing.T) {
Return(tests.ErrFakeDB)
m := NewManager(db, nil)
err := m.UpdatePassword(ctx, "old", "new")
err := m.UpdatePassword(ctx, "old", new)
assert.Equal(t, tests.ErrFakeDB, err)
db.AssertExpectations(t)
})
@ -975,7 +995,7 @@ func TestUpdatePassword(t *testing.T) {
db.On("Exec", ctx, updateUserPasswordDBQ, "userID", mock.Anything, mock.Anything).Return(nil)
m := NewManager(db, nil)
err := m.UpdatePassword(ctx, "old", "new")
err := m.UpdatePassword(ctx, "old", new)
assert.NoError(t, err)
db.AssertExpectations(t)
})

View File

@ -11,6 +11,7 @@ export const API = {
login: jest.fn(),
logout: jest.fn(),
getUserProfile: jest.fn(),
checkPasswordStrength: jest.fn(),
getAllRepositories: jest.fn(),
getRepositories: jest.fn(),
addRepository: jest.fn(),

View File

@ -350,6 +350,23 @@ describe('index API', () => {
});
});
describe('checkPasswordStrength', () => {
it('success', async () => {
fetchMock.mockResponse('', {
headers: {
'content-type': 'text/plain; charset=utf-8',
},
status: 204,
});
const response = await methods.API.checkPasswordStrength('testTest.12');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toEqual('/api/v1/users/check-password-strength');
expect(response).toBe('');
});
});
describe('getRepositories', () => {
it('success from user', async () => {
const repositories: Repository[] = getData('11') as Repository[];

View File

@ -350,6 +350,18 @@ export const API = {
return apiFetch(`${API_BASE_URL}/users/profile`);
},
checkPasswordStrength: (pwd: string): Promise<boolean> => {
return apiFetch(`${API_BASE_URL}/users/check-password-strength`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
password: pwd,
}),
});
},
getAllRepositories: (): Promise<Repository[]> => {
return apiFetch(`${API_BASE_URL}/repositories`);
},

View File

@ -16,4 +16,8 @@
.inputFeedback {
position: absolute;
}
.inputPwdStrengthError {
margin-bottom: -1rem;
}
}

View File

@ -1,9 +1,9 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { mocked } from 'ts-jest/utils';
import { API } from '../../api';
import { ResourceKind } from '../../types';
import { ErrorKind, ResourceKind } from '../../types';
import InputField from './InputField';
jest.mock('../../api');
@ -81,6 +81,47 @@ describe('InputField', () => {
});
});
describe('validates input on change', () => {
it('valid field', () => {
jest.useFakeTimers();
const { getByTestId } = render(
<InputField {...defaultProps} type="password" validateOnChange minLength={6} value="" autoFocus />
);
const input = getByTestId(`${defaultProps.name}Input`) as HTMLInputElement;
expect(input.minLength).toBe(6);
fireEvent.change(input, { target: { value: '1qa2ws' } });
act(() => {
jest.runTimersToTime(300);
});
expect(onSetValidationStatusMock).toHaveBeenCalledTimes(1);
expect(input).toBeValid();
jest.useRealTimers();
});
it('invalid field', () => {
jest.useFakeTimers();
const { getByTestId } = render(
<InputField {...defaultProps} type="text" value="" validateOnChange excludedValues={['user1']} autoFocus />
);
const input = getByTestId(`${defaultProps.name}Input`) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'user1' } });
act(() => {
jest.runTimersToTime(300);
});
expect(onSetValidationStatusMock).toHaveBeenCalledTimes(1);
expect(input).toBeInvalid();
jest.useRealTimers();
});
});
describe('calls checkAvailability', () => {
it('value is available', async () => {
mocked(API).checkAvailability.mockResolvedValue(true);
@ -230,4 +271,43 @@ describe('InputField', () => {
expect(input).toBeInvalid();
});
});
describe('calls checkPasswordStrength', () => {
it('Password is strength', async () => {
mocked(API).checkPasswordStrength.mockResolvedValue(true);
const { getByTestId } = render(
<InputField {...defaultProps} type="password" value="abc123" checkPasswordStrength validateOnBlur autoFocus />
);
const input = getByTestId(`${defaultProps.name}Input`) as HTMLInputElement;
input.blur();
expect(API.checkPasswordStrength).toBeCalledTimes(1);
expect(API.checkPasswordStrength).toHaveBeenCalledWith('abc123');
await waitFor(() => {
expect(input).toBeValid();
});
});
it('Password is weak', async () => {
mocked(API).checkPasswordStrength.mockRejectedValue({
kind: ErrorKind.Other,
message: 'Insecure password...',
});
const { getByTestId, getByText } = render(
<InputField {...defaultProps} type="password" value="abc123" checkPasswordStrength validateOnBlur autoFocus />
);
const input = getByTestId(`${defaultProps.name}Input`) as HTMLInputElement;
input.blur();
expect(API.checkPasswordStrength).toBeCalledTimes(1);
expect(API.checkPasswordStrength).toHaveBeenCalledWith('abc123');
const invalidText = await waitFor(() => getByText('Insecure password...'));
expect(invalidText).toBeInTheDocument();
expect(input).toBeInvalid();
});
});
});

View File

@ -1,11 +1,12 @@
import classnames from 'classnames';
import isNull from 'lodash/isNull';
import isUndefined from 'lodash/isUndefined';
import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { FaEye, FaEyeSlash } from 'react-icons/fa';
import { API } from '../../api';
import { AvailabilityInfo, RefInputField } from '../../types';
import capitalizeFirstLetter from '../../utils/capitalizeFirstLetter';
import styles from './InputField.module.css';
export interface Props {
@ -31,7 +32,9 @@ export interface Props {
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
validateOnBlur?: boolean;
validateOnChange?: boolean;
checkAvailability?: AvailabilityInfo;
checkPasswordStrength?: boolean;
autoComplete?: string;
readOnly?: boolean;
additionalInfo?: string | JSX.Element;
@ -42,13 +45,18 @@ export interface Props {
excludedValues?: string[];
}
const VALIDATION_DELAY = 3 * 100; // 300ms
const InputField = forwardRef((props: Props, ref: React.Ref<RefInputField>) => {
const input = useRef<HTMLInputElement>(null);
const [isValid, setIsValid] = useState<boolean | null>(null);
const [inputValue, setInputValue] = useState(props.value || '');
const [invalidText, setInvalidText] = useState(!isUndefined(props.invalidText) ? props.invalidText.default : '');
const [isCheckingAvailability, setIsCheckingAvailability] = useState(false);
const [isCheckingPwdStrength, setIsCheckingPwdStrength] = useState(false);
const [pwdStrengthError, setPwdStrengthError] = useState<string | null>(null);
const [activeType, setActiveType] = useState<string>(props.type);
const [validateTimeout, setValidateTimeout] = useState<NodeJS.Timeout | null>(null);
useImperativeHandle(ref, () => ({
checkIsValid(): Promise<boolean> {
@ -61,7 +69,7 @@ const InputField = forwardRef((props: Props, ref: React.Ref<RefInputField>) => {
return inputValue;
},
checkValidity(): boolean {
return input.current!.checkValidity();
return input.current ? input.current!.checkValidity() : true;
},
updateValue(newValue: string): void {
setInputValue(newValue);
@ -69,77 +77,97 @@ const InputField = forwardRef((props: Props, ref: React.Ref<RefInputField>) => {
}));
const checkValidity = (): boolean => {
const isValid = input.current!.checkValidity();
if (!isValid && !isUndefined(props.invalidText)) {
let errorTxt = props.invalidText.default;
const validityState: ValidityState | undefined = input.current?.validity;
if (!isUndefined(validityState)) {
if (validityState.typeMismatch && !isUndefined(props.invalidText.typeMismatch)) {
errorTxt = props.invalidText.typeMismatch;
} else if (validityState.tooShort && !isUndefined(props.invalidText.tooShort)) {
errorTxt = props.invalidText.tooShort;
} else if (validityState.patternMismatch && !isUndefined(props.invalidText.patternMismatch)) {
errorTxt = props.invalidText.patternMismatch;
} else if (validityState.typeMismatch && !isUndefined(props.invalidText.typeMismatch)) {
errorTxt = props.invalidText.typeMismatch;
} else if (validityState.rangeUnderflow && !isUndefined(props.invalidText.rangeUnderflow)) {
errorTxt = props.invalidText.rangeUnderflow;
} else if (validityState.rangeOverflow && !isUndefined(props.invalidText.rangeOverflow)) {
errorTxt = props.invalidText.rangeOverflow;
} else if (validityState.customError && !isUndefined(props.invalidText.customError)) {
if (!isUndefined(props.excludedValues) && props.excludedValues.includes(input.current!.value)) {
errorTxt = props.invalidText.excluded;
} else {
errorTxt = props.invalidText.customError;
let isValid = true;
if (input.current) {
isValid = input.current!.checkValidity();
if (!isValid && !isUndefined(props.invalidText)) {
let errorTxt = props.invalidText.default;
const validityState: ValidityState | undefined = input.current.validity;
if (!isUndefined(validityState)) {
if (validityState.typeMismatch && !isUndefined(props.invalidText.typeMismatch)) {
errorTxt = props.invalidText.typeMismatch;
} else if (validityState.tooShort && !isUndefined(props.invalidText.tooShort)) {
errorTxt = props.invalidText.tooShort;
} else if (validityState.patternMismatch && !isUndefined(props.invalidText.patternMismatch)) {
errorTxt = props.invalidText.patternMismatch;
} else if (validityState.typeMismatch && !isUndefined(props.invalidText.typeMismatch)) {
errorTxt = props.invalidText.typeMismatch;
} else if (validityState.rangeUnderflow && !isUndefined(props.invalidText.rangeUnderflow)) {
errorTxt = props.invalidText.rangeUnderflow;
} else if (validityState.rangeOverflow && !isUndefined(props.invalidText.rangeOverflow)) {
errorTxt = props.invalidText.rangeOverflow;
} else if (validityState.customError && !isUndefined(props.invalidText.customError)) {
if (!isUndefined(props.excludedValues) && props.excludedValues.includes(input.current.value)) {
errorTxt = props.invalidText.excluded;
} else {
errorTxt = props.invalidText.customError;
}
}
}
setInvalidText(errorTxt);
}
setIsValid(isValid);
if (!isUndefined(props.setValidationStatus)) {
props.setValidationStatus(false);
}
setInvalidText(errorTxt);
}
setIsValid(isValid);
if (!isUndefined(props.setValidationStatus)) {
props.setValidationStatus(false);
}
return isValid;
};
const isValidField = async (): Promise<boolean> => {
const value = input.current!.value;
if (value !== '') {
if (!isUndefined(props.excludedValues) && props.excludedValues.includes(value)) {
input.current!.setCustomValidity('Value is excluded');
} else if (!isUndefined(props.checkAvailability) && !props.checkAvailability.excluded.includes(value)) {
setIsCheckingAvailability(true);
try {
const isAvailable = await API.checkAvailability({
resourceKind: props.checkAvailability.resourceKind,
value: value,
});
if (!isNull(input.current)) {
if (isAvailable) {
if (input.current) {
const value = input.current!.value;
if (value !== '') {
if (!isUndefined(props.excludedValues) && props.excludedValues.includes(value)) {
input.current!.setCustomValidity('Value is excluded');
} else if (!isUndefined(props.checkAvailability) && !props.checkAvailability.excluded.includes(value)) {
setIsCheckingAvailability(true);
try {
const isAvailable = await API.checkAvailability({
resourceKind: props.checkAvailability.resourceKind,
value: value,
});
if (!isNull(input.current)) {
if (isAvailable) {
input.current!.setCustomValidity(props.checkAvailability!.isAvailable ? 'Already taken' : '');
} else {
input.current!.setCustomValidity(props.checkAvailability!.isAvailable ? '' : 'Resource is not valid');
}
}
} catch {
if (!isNull(input.current)) {
input.current!.setCustomValidity(props.checkAvailability!.isAvailable ? 'Already taken' : '');
} else {
input.current!.setCustomValidity(props.checkAvailability!.isAvailable ? '' : 'Resource is not valid');
}
}
} catch {
if (!isNull(input.current)) {
input.current!.setCustomValidity(props.checkAvailability!.isAvailable ? 'Already taken' : '');
setIsCheckingAvailability(false);
} else if (props.checkPasswordStrength) {
setIsCheckingPwdStrength(true);
try {
await API.checkPasswordStrength(value);
if (!isNull(input.current)) {
input.current!.setCustomValidity('');
setPwdStrengthError(null);
}
} catch (e) {
if (!isNull(input.current) && e.message) {
setPwdStrengthError(e.message);
input.current!.setCustomValidity(e.message);
}
}
setIsCheckingPwdStrength(false);
} else {
if (!isNull(input.current)) {
input.current!.setCustomValidity('');
}
}
setIsCheckingAvailability(false);
} else {
if (!isNull(input.current)) {
input.current!.setCustomValidity('');
}
}
}
return checkValidity();
};
const handleOnBlur = (): void => {
if (!isUndefined(props.validateOnBlur) && props.validateOnBlur) {
if (!isUndefined(props.validateOnBlur) && props.validateOnBlur && input.current) {
cleanTimeout(); // On blur we clean timeout if it's necessary
isValidField();
}
};
@ -151,6 +179,31 @@ const InputField = forwardRef((props: Props, ref: React.Ref<RefInputField>) => {
}
};
const cleanTimeout = () => {
if (!isNull(validateTimeout)) {
clearTimeout(validateTimeout);
setValidateTimeout(null);
}
};
useEffect(() => {
const isInputFocused = input.current === document.activeElement;
if (isInputFocused && !isUndefined(props.validateOnChange) && props.validateOnChange) {
cleanTimeout();
setValidateTimeout(
setTimeout(() => {
isValidField();
}, VALIDATION_DELAY)
);
}
return () => {
if (validateTimeout) {
clearTimeout(validateTimeout);
}
};
}, [inputValue]); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<div className={`form-group mb-4 position-relative ${props.className}`}>
{!isUndefined(props.label) && (
@ -197,7 +250,7 @@ const InputField = forwardRef((props: Props, ref: React.Ref<RefInputField>) => {
</button>
)}
{isCheckingAvailability && (
{(isCheckingAvailability || isCheckingPwdStrength) && (
<div className={`position-absolute ${styles.spinner}`}>
<span className="spinner-border spinner-border-sm text-primary" />
</div>
@ -207,10 +260,16 @@ const InputField = forwardRef((props: Props, ref: React.Ref<RefInputField>) => {
<div className={`valid-feedback mt-0 ${styles.inputFeedback}`}>{props.validText}</div>
)}
{!isUndefined(invalidText) && (
{!isUndefined(invalidText) && isNull(pwdStrengthError) && (
<div className={`invalid-feedback mt-0 ${styles.inputFeedback}`}>{invalidText}</div>
)}
{!isNull(pwdStrengthError) && (
<div className={`invalid-feedback mt-0 ${styles.inputPwdStrengthError}`}>
{capitalizeFirstLetter(pwdStrengthError)}
</div>
)}
{!isUndefined(props.additionalInfo) && <div className="alert p-0 mt-4">{props.additionalInfo}</div>}
</div>
);

View File

@ -28,7 +28,7 @@ interface Props {
isSearching: boolean;
}
const SEARCH_DELAY = 5 * 100; // 300ms
const SEARCH_DELAY = 5 * 100; // 500ms
const MIN_CHARACTERS_SEARCH = 3;
const SearchBar = (props: Props) => {

View File

@ -128,15 +128,16 @@ const UpdatePassword = () => {
ref={passwordInput}
type="password"
label="New password"
labelLegend={<small className="ml-1 font-italic">(6 characters min.)</small>}
name="password"
minLength={6}
invalidText={{
default: 'This field is required',
tooShort: 'Passwords must be at least 6 characters long',
customError: 'Insecure password',
}}
onChange={onPasswordChange}
autoComplete="new-password"
checkPasswordStrength
validateOnChange
validateOnBlur
required
/>

View File

@ -49,11 +49,6 @@ exports[`Update password - user settings creates snapshot 1`] = `
>
New password
</span>
<small
class="ml-1 font-italic"
>
(6 characters min.)
</small>
</label>
<input
autocomplete="new-password"

View File

@ -279,11 +279,6 @@ exports[`User settings index creates snapshot 1`] = `
>
New password
</span>
<small
class="ml-1 font-italic"
>
(6 characters min.)
</small>
</label>
<input
autocomplete="new-password"

View File

@ -28,7 +28,7 @@ const ProfileSection = (props: Props) => {
}
}
fetchProfile();
}, [props]);
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
return (
<main role="main" className="p-0">

View File

@ -206,15 +206,16 @@ const ResetPasswordModal = (props: Props) => {
ref={passwordInput}
type="password"
label="Password"
labelLegend={<small className="ml-1 font-italic">(6 characters min.)</small>}
name="password"
minLength={6}
invalidText={{
default: 'This field is required',
tooShort: 'Passwords must be at least 6 characters long',
customError: 'Insecure password',
}}
onChange={onPasswordChange}
autoComplete="new-password"
checkPasswordStrength
validateOnChange
validateOnBlur
required
/>

View File

@ -193,42 +193,38 @@ const CreateAnAccount = React.forwardRef<HTMLFormElement, Props>((props, ref) =>
<InputField type="text" label="Last Name" name="lastName" autoComplete="family-name" />
<div className="form-row">
<InputField
ref={passwordInput}
className="col-sm-12 col-md-6"
type="password"
label="Password"
labelLegend={<small className="ml-1 font-italic">(6 characters min.)</small>}
name="password"
minLength={6}
invalidText={{
default: 'This field is required',
tooShort: 'Passwords must be at least 6 characters long',
}}
onChange={onPasswordChange}
autoComplete="new-password"
validateOnBlur
required
/>
<InputField
ref={passwordInput}
type="password"
label="Password"
name="password"
invalidText={{
default: 'This field is required',
customError: 'Insecure password',
}}
onChange={onPasswordChange}
autoComplete="new-password"
checkPasswordStrength
validateOnChange
validateOnBlur
required
/>
<InputField
ref={repeatPasswordInput}
className="col-sm-12 col-md-6"
type="password"
label="Confirm password"
labelLegend={<small className="ml-1 font-italic">(Required)</small>}
name="confirmPassword"
pattern={password.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}
invalidText={{
default: 'This field is required',
patternMismatch: "Passwords don't match",
}}
autoComplete="new-password"
validateOnBlur={password.isValid}
required
/>
</div>
<InputField
ref={repeatPasswordInput}
type="password"
label="Confirm password"
labelLegend={<small className="ml-1 font-italic">(Required)</small>}
name="confirmPassword"
pattern={password.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}
invalidText={{
default: 'This field is required',
patternMismatch: "Passwords don't match",
}}
autoComplete="new-password"
validateOnBlur={password.isValid}
required
/>
</form>
</>
)}

View File

@ -25,5 +25,5 @@
.modal {
max-width: 90%;
width: 620px !important;
height: 685px;
height: 795px;
}

View File

@ -129,77 +129,67 @@ exports[`CreateAnAccount creates snapshot 1`] = `
/>
</div>
<div
class="form-row"
class="form-group mb-4 position-relative undefined"
>
<div
class="form-group mb-4 position-relative col-sm-12 col-md-6"
<label
class="font-weight-bold label"
for="password"
>
<label
class="font-weight-bold label"
for="password"
<span
class="font-weight-bold"
>
<span
class="font-weight-bold"
>
Password
</span>
<small
class="ml-1 font-italic"
>
(6 characters min.)
</small>
</label>
<input
autocomplete="new-password"
class="form-control"
data-testid="passwordInput"
minlength="6"
name="password"
required=""
spellcheck="false"
type="password"
value=""
/>
<div
class="invalid-feedback mt-0 inputFeedback"
>
This field is required
</div>
Password
</span>
</label>
<input
autocomplete="new-password"
class="form-control"
data-testid="passwordInput"
name="password"
required=""
spellcheck="false"
type="password"
value=""
/>
<div
class="invalid-feedback mt-0 inputFeedback"
>
This field is required
</div>
<div
class="form-group mb-4 position-relative col-sm-12 col-md-6"
</div>
<div
class="form-group mb-4 position-relative undefined"
>
<label
class="font-weight-bold label"
for="confirmPassword"
>
<label
class="font-weight-bold label"
for="confirmPassword"
<span
class="font-weight-bold"
>
<span
class="font-weight-bold"
>
Confirm password
</span>
<small
class="ml-1 font-italic"
>
(Required)
</small>
</label>
<input
autocomplete="new-password"
class="form-control"
data-testid="confirmPasswordInput"
name="confirmPassword"
pattern=""
required=""
spellcheck="false"
type="password"
value=""
/>
<div
class="invalid-feedback mt-0 inputFeedback"
Confirm password
</span>
<small
class="ml-1 font-italic"
>
This field is required
</div>
(Required)
</small>
</label>
<input
autocomplete="new-password"
class="form-control"
data-testid="confirmPasswordInput"
name="confirmPassword"
pattern=""
required=""
spellcheck="false"
type="password"
value=""
/>
<div
class="invalid-feedback mt-0 inputFeedback"
>
This field is required
</div>
</div>
</form>

View File

@ -0,0 +1,24 @@
import capitalizeFirstLetter from './capitalizeFirstLetter';
interface Test {
input: string;
output: string;
}
const tests: Test[] = [
{ input: '', output: '' },
{ input: 'this is a sample', output: 'This is a sample' },
{ input: 'this is not going to change', output: 'This is not going to change' },
{ input: 'ALL IN CAPITAL', output: 'ALL IN CAPITAL' },
{ input: 'A Different Case', output: 'A Different Case' },
{ input: ' another case ', output: 'Another case' },
];
describe('capitalizeFirstLetter', () => {
for (let i = 0; i < tests.length; i++) {
it('returns correct string', () => {
const actual = capitalizeFirstLetter(tests[i].input);
expect(actual).toEqual(tests[i].output);
});
}
});

View File

@ -0,0 +1,8 @@
import { isUndefined } from 'lodash';
export default (str: string): string => {
if (isUndefined(str)) return '';
const trimmedStr = str.trim();
const firstLetter = trimmedStr[0] || trimmedStr.charAt(0);
return firstLetter ? `${firstLetter.toUpperCase()}${trimmedStr.substring(1)}` : '';
};