diff --git a/shared/src/business/entities/ChangePassword.ts b/shared/src/business/entities/ChangePassword.ts new file mode 100644 index 00000000000..8c4ec904ee3 --- /dev/null +++ b/shared/src/business/entities/ChangePassword.ts @@ -0,0 +1,129 @@ +import { JoiValidationConstants } from './JoiValidationConstants'; +import { JoiValidationEntity } from './JoiValidationEntity'; +import joi from 'joi'; + +type PasswordValidation = { + message: string; + valid: boolean; +}; + +export type ChangePasswordValidations = { + hasNoLeadingOrTrailingSpace: PasswordValidation; + hasOneLowercase: PasswordValidation; + hasOneNumber: PasswordValidation; + hasOneUppercase: PasswordValidation; + hasSpecialCharacterOrSpace: PasswordValidation; + isProperLength: PasswordValidation; +}; + +const ChangePasswordValidationErrorMessages = { + hasNoLeadingOrTrailingSpace: 'Must not contain leading or trailing space', + hasOneLowercase: 'Must contain lower case letter', + hasOneNumber: 'Must contain number', + hasOneUppercase: 'Must contain upper case letter', + hasSpecialCharacterOrSpace: 'Must contain special character or space', + isProperLength: 'Must be between 8-99 characters long', +}; + +export function getDefaultPasswordErrors(): ChangePasswordValidations { + return { + hasNoLeadingOrTrailingSpace: { + message: + ChangePasswordValidationErrorMessages.hasNoLeadingOrTrailingSpace, + valid: true, + }, + hasOneLowercase: { + message: ChangePasswordValidationErrorMessages.hasOneLowercase, + valid: true, + }, + hasOneNumber: { + message: ChangePasswordValidationErrorMessages.hasOneNumber, + valid: true, + }, + hasOneUppercase: { + message: ChangePasswordValidationErrorMessages.hasOneUppercase, + valid: true, + }, + hasSpecialCharacterOrSpace: { + message: ChangePasswordValidationErrorMessages.hasSpecialCharacterOrSpace, + valid: true, + }, + isProperLength: { + message: ChangePasswordValidationErrorMessages.isProperLength, + valid: true, + }, + }; +} + +export class ChangePasswordForm extends JoiValidationEntity { + public password: string; + public confirmPassword: string; + + constructor(rawProps) { + super('ChangePasswordForm'); + this.password = rawProps.password; + this.confirmPassword = rawProps.confirmPassword; + } + + static VALIDATION_RULES = joi.object().keys({ + confirmPassword: joi + .valid(joi.ref('password')) + .required() + .messages({ '*': 'Passwords must match' }), + entityName: + JoiValidationConstants.STRING.valid('ChangePasswordForm').required(), + password: JoiValidationConstants.STRING.custom((value, helper) => { + const errors = getDefaultPasswordErrors(); + + if (value.length < 8 || value.length > 99) { + errors.isProperLength.valid = false; + } + + if (!/[a-z]/.test(value)) { + errors.hasOneLowercase.valid = false; + } + + if (!/[A-Z]/.test(value)) { + errors.hasOneUppercase.valid = false; + } + + if (!/[\^$*.[\]{}()?\-“!@#%&/,><’:;|_~`]/.test(value)) { + errors.hasSpecialCharacterOrSpace.valid = false; + } + + if (!/[0-9]/.test(value)) { + errors.hasOneNumber.valid = false; + } + + if (/^\s/.test(value) || /\s$/.test(value)) { + errors.hasNoLeadingOrTrailingSpace.valid = false; + } + + const noErrors = Object.values(errors).reduce( + (accumulator, currentValue) => { + return accumulator && currentValue.valid; + }, + true, + ); + + if (noErrors) { + return value; + } else { + return helper.message( + Object.entries(errors) + .filter(([, curValue]) => !curValue.valid) + .map(([key]) => key) + .join('|') as any, + ); + } + }).description( + 'Password for the account. Contains a custom validation because we want to construct a string with all the keys that failed which later we parse out to an object', + ), + }); + + getValidationRules() { + return ChangePasswordForm.VALIDATION_RULES; + } +} + +export type RawChangePasswordForm = ExcludeMethods; diff --git a/shared/src/business/entities/NewPetitionerUser.ts b/shared/src/business/entities/NewPetitionerUser.ts index 8a3da38cb5b..4290bb13614 100644 --- a/shared/src/business/entities/NewPetitionerUser.ts +++ b/shared/src/business/entities/NewPetitionerUser.ts @@ -7,7 +7,7 @@ type PasswordValidation = { valid: boolean; }; -export type NewPetitionerUserPasswordValidations = { +export type PasswordValidations = { hasNoLeadingOrTrailingSpace: PasswordValidation; hasOneLowercase: PasswordValidation; hasOneNumber: PasswordValidation; @@ -24,7 +24,7 @@ const NewPetitionerUserPasswordValidationErrorMessages = { isProperLength: 'Must be between 8-99 characters long', }; -export function getDefaultPasswordErrors(): NewPetitionerUserPasswordValidations { +export function getDefaultPasswordErrors(): PasswordValidations { return { hasNoLeadingOrTrailingSpace: { message: diff --git a/web-api/src/business/useCases/auth/changePasswordInteractor.ts b/web-api/src/business/useCases/auth/changePasswordInteractor.ts new file mode 100644 index 00000000000..9d368652fda --- /dev/null +++ b/web-api/src/business/useCases/auth/changePasswordInteractor.ts @@ -0,0 +1,25 @@ +export const changePasswordInteractor = async ( + applicationContext: IApplicationContext, + { + newPassword, + sessionId, + userEmail, + }: { newPassword: string; sessionId: string; userEmail: string }, +) => { + const params = { + ChallengeName: 'NEW_PASSWORD_REQUIRED', + ChallengeResponses: { + NEW_PASSWORD: newPassword, + USERNAME: userEmail, + }, + ClientId: process.env.COGNITO_CLIENT_ID, + Session: sessionId, + }; + + const result = await applicationContext + .getCognito() + .respondToAuthChallenge(params) + .promise(); + + return result; +}; diff --git a/web-client/src/presenter/actions/Login/redirectToChangePasswordAction.ts b/web-client/src/presenter/actions/Login/redirectToChangePasswordAction.ts index fdeff6433b0..a3966d6aeb0 100644 --- a/web-client/src/presenter/actions/Login/redirectToChangePasswordAction.ts +++ b/web-client/src/presenter/actions/Login/redirectToChangePasswordAction.ts @@ -1,8 +1,3 @@ -import { state } from '@web-client/presenter/app.cerebral'; - -export const redirectToChangePasswordAction = ({ get, router }) => { - const a = get(state.cognitoPasswordChange); - console.log(`get(state.cognitoPasswordChange)[${a}]`); - - router.externalRoute(get(state.cognitoPasswordChange)); +export const redirectToChangePasswordAction = async ({ router }) => { + await router.route('/change-password'); }; diff --git a/web-client/src/presenter/computeds/CreatePetitionerAccount/createAccountHelper.ts b/web-client/src/presenter/computeds/CreatePetitionerAccount/createAccountHelper.ts index 60016e37a5c..97aa76be609 100644 --- a/web-client/src/presenter/computeds/CreatePetitionerAccount/createAccountHelper.ts +++ b/web-client/src/presenter/computeds/CreatePetitionerAccount/createAccountHelper.ts @@ -1,7 +1,7 @@ import { Get } from 'cerebral'; import { NewPetitionerUser, - NewPetitionerUserPasswordValidations, + PasswordValidations, getDefaultPasswordErrors, } from '@shared/business/entities/NewPetitionerUser'; import { state } from '@web-client/presenter/app.cerebral'; @@ -11,12 +11,12 @@ export type CreateAccountHelperResults = { email?: string; formIsValid: boolean; name?: string; - passwordErrors?: NewPetitionerUserPasswordValidations; + passwordErrors?: PasswordValidations; }; -const convertErrorMessageToPasswordValidationObject = ( +export const convertErrorMessageToPasswordValidationObject = ( stringToParse: string | undefined, -): NewPetitionerUserPasswordValidations => { +): PasswordValidations => { const errorObjects = getDefaultPasswordErrors(); if (!stringToParse) return errorObjects; @@ -33,7 +33,7 @@ export const createAccountHelper = (get: Get): CreateAccountHelperResults => { const formEntity = new NewPetitionerUser(form); const errors = formEntity.getFormattedValidationErrors(); - const passwordErrors: NewPetitionerUserPasswordValidations = + const passwordErrors: PasswordValidations = convertErrorMessageToPasswordValidationObject(errors?.password); return { diff --git a/web-client/src/presenter/computeds/Login/changePasswordHelper.ts b/web-client/src/presenter/computeds/Login/changePasswordHelper.ts new file mode 100644 index 00000000000..dcb49f2b30e --- /dev/null +++ b/web-client/src/presenter/computeds/Login/changePasswordHelper.ts @@ -0,0 +1,26 @@ +import { ChangePasswordForm } from '@shared/business/entities/ChangePassword'; +import { Get } from 'cerebral'; +import { PasswordValidations } from '@shared/business/entities/NewPetitionerUser'; +import { convertErrorMessageToPasswordValidationObject } from '@web-client/presenter/computeds/CreatePetitionerAccount/createAccountHelper'; +import { state } from '@web-client/presenter/app.cerebral'; + +export type ChangePasswordHelperResults = { + confirmPassword: boolean; + formIsValid: boolean; + passwordErrors?: PasswordValidations; +}; + +export const changePasswordHelper = (get: Get): ChangePasswordHelperResults => { + const form = get(state.form); + const formEntity = new ChangePasswordForm(form); + const errors = formEntity.getFormattedValidationErrors(); + + const passwordErrors: PasswordValidations = + convertErrorMessageToPasswordValidationObject(errors?.password); + + return { + confirmPassword: !errors?.confirmPassword, + formIsValid: formEntity.isValid(), + passwordErrors, + }; +}; diff --git a/web-client/src/presenter/presenter.ts b/web-client/src/presenter/presenter.ts index 9a6229a70df..de3a0b1a102 100644 --- a/web-client/src/presenter/presenter.ts +++ b/web-client/src/presenter/presenter.ts @@ -122,6 +122,7 @@ import { getCaseInventoryReportSequence } from './sequences/getCaseInventoryRepo import { getCustomCaseReportSequence } from './sequences/getCustomCaseReportSequence'; import { getUsersInSectionSequence } from './sequences/getUsersInSectionSequence'; import { goToApplyStampSequence } from './sequences/gotoApplyStampSequence'; +import { goToChangePasswordSequence } from '@web-client/presenter/sequences/Login/goToChangePasswordSequence'; import { goToCreatePetitionerAccountSequence } from '@web-client/presenter/sequences/Public/goToCreatePetitionerAccountSequence'; import { goToVerificationSentSequence } from '@web-client/presenter/sequences/goToVerificationSentSequence'; import { gotoAccessibilityStatementSequence } from './sequences/gotoAccessibilityStatementSequence'; @@ -748,6 +749,7 @@ export const presenterSequences = { getCustomCaseReportSequence, getUsersInSectionSequence: getUsersInSectionSequence as unknown as Function, goToApplyStampSequence: goToApplyStampSequence as unknown as Function, + goToChangePasswordSequence, goToCreatePetitionerAccountSequence, goToVerificationSentSequence: goToVerificationSentSequence as unknown as Function, diff --git a/web-client/src/presenter/sequences/Login/goToChangePasswordSequence.ts b/web-client/src/presenter/sequences/Login/goToChangePasswordSequence.ts new file mode 100644 index 00000000000..799398ce37a --- /dev/null +++ b/web-client/src/presenter/sequences/Login/goToChangePasswordSequence.ts @@ -0,0 +1,5 @@ +import { setupCurrentPageAction } from '../../actions/setupCurrentPageAction'; + +export const goToChangePasswordSequence = [ + setupCurrentPageAction('ChangePassword'), +] as unknown as () => void; diff --git a/web-client/src/presenter/state.ts b/web-client/src/presenter/state.ts index b21ed11fc92..5a5819e43fb 100644 --- a/web-client/src/presenter/state.ts +++ b/web-client/src/presenter/state.ts @@ -37,6 +37,7 @@ import { caseSearchNoMatchesHelper } from './computeds/caseSearchNoMatchesHelper import { caseStatusHistoryHelper } from './computeds/caseStatusHistoryHelper'; import { caseTypeDescriptionHelper } from './computeds/caseTypeDescriptionHelper'; import { caseWorksheetsHelper } from '@web-client/presenter/computeds/CaseWorksheets/caseWorksheetsHelper'; +import { changePasswordHelper } from '@web-client/presenter/computeds/Login/changePasswordHelper'; import { cloneDeep } from 'lodash'; import { completeDocumentTypeSectionHelper } from './computeds/completeDocumentTypeSectionHelper'; import { confirmInitiateServiceModalHelper } from './computeds/confirmInitiateServiceModalHelper'; @@ -241,6 +242,9 @@ export const computeds = { caseWorksheetsHelper: caseWorksheetsHelper as unknown as ReturnType< typeof caseWorksheetsHelper >, + changePasswordHelper: changePasswordHelper as unknown as ReturnType< + typeof changePasswordHelper + >, completeDocumentTypeSectionHelper: completeDocumentTypeSectionHelper as unknown as ReturnType< typeof completeDocumentTypeSectionHelper diff --git a/web-client/src/router.ts b/web-client/src/router.ts index eecc990935f..31ef759f7d4 100644 --- a/web-client/src/router.ts +++ b/web-client/src/router.ts @@ -1117,6 +1117,11 @@ const router = { app.getSequence('gotoLoginSequence')(); }); + registerRoute('/change-password', () => { + setPageTitle('Change Password'); + app.getSequence('goToChangePasswordSequence')(); + }); + registerRoute('/create-account/petitioner', () => { setPageTitle('Account Registration'); app.getSequence('goToCreatePetitionerAccountSequence')(); diff --git a/web-client/src/views/AppComponent.tsx b/web-client/src/views/AppComponent.tsx index 7bfd0b000b9..f76f503fa19 100644 --- a/web-client/src/views/AppComponent.tsx +++ b/web-client/src/views/AppComponent.tsx @@ -19,6 +19,7 @@ import { CaseInventoryReport } from './CaseInventoryReport/CaseInventoryReport'; import { CaseInventoryReportModal } from './CaseInventoryReport/CaseInventoryReportModal'; import { CaseSearchNoMatches } from './CaseSearchNoMatches'; import { ChangeLoginAndServiceEmail } from './ChangeLoginAndServiceEmail'; +import { ChangePassword } from '@web-client/views/Login/ChangePassword'; import { Contact } from './Contact'; import { ContactEdit } from './ContactEdit'; import { CourtIssuedDocketEntry } from './CourtIssuedDocketEntry/CourtIssuedDocketEntry'; @@ -119,6 +120,7 @@ const pages = { CaseInventoryReport, CaseSearchNoMatches, ChangeLoginAndServiceEmail, + ChangePassword, Contact, ContactEdit, CourtIssuedDocketEntry, @@ -190,6 +192,7 @@ const pages = { }; const pagesWithBlueBackground = { + ChangePassword, CreatePetitionerAccount, Login, VerificationSent, diff --git a/web-client/src/views/Login/ChangePassword.tsx b/web-client/src/views/Login/ChangePassword.tsx new file mode 100644 index 00000000000..b12a5a6df73 --- /dev/null +++ b/web-client/src/views/Login/ChangePassword.tsx @@ -0,0 +1,218 @@ +import { Button } from '@web-client/ustc-ui/Button/Button'; +import { MessageAlert } from '@web-client/ustc-ui/MessageAlert/MessageAlert'; +import { RequirementsText } from '@web-client/views/CreatePetitionerAccount/RequirementsText'; +import { SuccessNotification } from '@web-client/views/SuccessNotification'; +import { connect } from '@web-client/presenter/shared.cerebral'; +import { sequences, state } from '@web-client/presenter/app.cerebral'; +import React from 'react'; + +export const ChangePassword = connect( + { + alertError: state.alertError, + changePasswordHelper: state.changePasswordHelper, + cognitoRequestPasswordResetUrl: state.cognitoRequestPasswordResetUrl, + confirmPassword: state.form.confirmPassword, + password: state.form.password, + showConfirmPassword: state.showConfirmPassword, + showPassword: state.showPassword, + submitLoginSequence: sequences.submitLoginSequence, + toggleShowPasswordSequence: sequences.toggleShowPasswordSequence, + updateFormValueSequence: sequences.updateFormValueSequence, + }, + ({ + alertError, + changePasswordHelper, + confirmPassword, + password, + showConfirmPassword, + showPassword, + submitLoginSequence, + toggleShowPasswordSequence, + updateFormValueSequence, + }) => { + return ( + <> +
+
+
+ + {alertError && ( + + )} +
+
+
+ {/* TODO: Update this with UX? */} +

Reset Password

+
+ + { + updateFormValueSequence({ + key: 'password', + value: e.target.value, + }); + }} + /> + + + + + { + updateFormValueSequence({ + key: 'confirmPassword', + value: e.target.value, + }); + }} + /> + +
+ + +
+
+
+
+
+
+
+
+ + ); + }, +);