mirror of https://github.com/artifacthub/hub.git
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:
parent
61f9123fd5
commit
fd76477367
1
go.mod
1
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -16,4 +16,8 @@
|
|||
.inputFeedback {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.inputPwdStrengthError {
|
||||
margin-bottom: -1rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const ProfileSection = (props: Props) => {
|
|||
}
|
||||
}
|
||||
fetchProfile();
|
||||
}, [props]);
|
||||
}, []); /* eslint-disable-line react-hooks/exhaustive-deps */
|
||||
|
||||
return (
|
||||
<main role="main" className="p-0">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -25,5 +25,5 @@
|
|||
.modal {
|
||||
max-width: 90%;
|
||||
width: 620px !important;
|
||||
height: 685px;
|
||||
height: 795px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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)}` : '';
|
||||
};
|
||||
Loading…
Reference in New Issue