diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 17846d5d4e..6872bcab22 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -60,6 +60,14 @@ export const devSeeding = async ( password: 'abcdef', }), }); + await prismaClient.userAccounts.create({ + data: await userFactory({ + email: 'public-user@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + password: 'abcdef', + }), + }); await prismaClient.userAccounts.create({ data: await userFactory({ roles: { isJurisdictionalAdmin: true }, diff --git a/api/prisma/seed-helpers/user-factory.ts b/api/prisma/seed-helpers/user-factory.ts index 81e3be86cb..e23c831584 100644 --- a/api/prisma/seed-helpers/user-factory.ts +++ b/api/prisma/seed-helpers/user-factory.ts @@ -5,6 +5,7 @@ import { passwordToHash } from '../../src/utilities/password-helpers'; export const userFactory = async (optionalParams?: { roles?: Prisma.UserRolesUncheckedCreateWithoutUserAccountsInput; firstName?: string; + middleName?: string; lastName?: string; email?: string; singleUseCode?: string; @@ -21,6 +22,7 @@ export const userFactory = async (optionalParams?: { optionalParams?.email?.toLocaleLowerCase() || `${randomNoun().toLowerCase()}${randomNoun().toLowerCase()}@${randomAdjective().toLowerCase()}.com`, firstName: optionalParams?.firstName || 'First', + middleName: optionalParams?.middleName || 'Middle', lastName: optionalParams?.lastName || 'Last', passwordHash: optionalParams?.password ? await passwordToHash(optionalParams?.password) diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 848e1e4735..5627ccddfe 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -95,6 +95,14 @@ export const stagingSeed = async ( singleUseCode: '12345', }), }); + await prismaClient.userAccounts.create({ + data: await userFactory({ + email: 'public-user@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + password: 'abcdef', + }), + }); // add jurisdiction specific translations and default ones await prismaClient.translations.create({ data: translationFactory(jurisdiction.id, jurisdiction.name), diff --git a/sites/partners/src/components/users/FormUserConfirm.tsx b/sites/partners/src/components/users/FormUserConfirm.tsx index 3d05081371..73fd131909 100644 --- a/sites/partners/src/components/users/FormUserConfirm.tsx +++ b/sites/partners/src/components/users/FormUserConfirm.tsx @@ -14,7 +14,7 @@ type FormUserConfirmFields = { agree: boolean } -const MIN_PASSWORD_LENGTH = 8 +const MIN_PASSWORD_LENGTH = 12 const FormUserConfirm = () => { // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/sites/public/cypress/e2e/account/account.spec.ts b/sites/public/cypress/e2e/account/account.spec.ts new file mode 100644 index 0000000000..afcf94b4ce --- /dev/null +++ b/sites/public/cypress/e2e/account/account.spec.ts @@ -0,0 +1,63 @@ +import { publicUser, updatedPublicUser } from "../../mockData/userData" + +describe("User accounts", () => { + it("should allow users to update their account information", () => { + cy.visit("/sign-in") + cy.signIn(publicUser.email, publicUser.password) + + // Assert that a user's data shows up on their settings page + cy.getByID("account-dashboard-settings").click() + cy.getByTestId("loading-overlay").should("not.exist") + cy.getByTestId("account-first-name").should("have.value", publicUser.firstName) + cy.getByTestId("account-middle-name").should("have.value", publicUser.middleName) + cy.getByTestId("account-last-name").should("have.value", publicUser.lastName) + cy.getByTestId("dob-field-month").should("have.value", publicUser.birthMonth) + cy.getByTestId("dob-field-day").should("have.value", publicUser.birthDay) + cy.getByTestId("dob-field-year").should("have.value", publicUser.birthYear) + cy.getByTestId("account-email").should("have.value", publicUser.email) + + // Change the name fields + cy.getByTestId("account-first-name").clear().type(updatedPublicUser.firstName) + cy.getByTestId("account-middle-name").clear().type(updatedPublicUser.middleName) + cy.getByTestId("account-last-name").clear().type(updatedPublicUser.lastName) + cy.getByID("account-submit-name").click() + cy.getByTestId("alert-box").contains("Name update successful") + cy.get("[aria-label='close alert']").click() + cy.getByTestId("alert-box").should("not.exist") + + // Change the birthday field + cy.getByTestId("dob-field-month").clear().type(updatedPublicUser.birthMonth) + cy.getByTestId("dob-field-day").clear().type(updatedPublicUser.birthDay) + cy.getByTestId("dob-field-year").clear().type(updatedPublicUser.birthYear) + cy.getByID("account-submit-dob").click() + cy.getByTestId("alert-box").contains("Birthdate update successful") + cy.get("[aria-label='close alert']").click() + cy.getByTestId("alert-box").should("not.exist") + + // Change the password + cy.getByTestId("account-current-password").type(publicUser.password) + cy.getByTestId("account-password").type(updatedPublicUser.password) + cy.getByTestId("account-password-confirmation").type(updatedPublicUser.passwordConfirmation) + cy.getByID("account-submit-password").click() + cy.getByTestId("alert-box").contains("Password update successful") + cy.get("[aria-label='close alert']").click() + cy.getByTestId("alert-box").should("not.exist") + + cy.visit("/account/edit") + + // Confirm that the data actually updated + cy.getByTestId("loading-overlay").should("not.exist") + cy.getByTestId("account-first-name").should("have.value", updatedPublicUser.firstName) + cy.getByTestId("account-middle-name").should("have.value", updatedPublicUser.middleName) + cy.getByTestId("account-last-name").should("have.value", updatedPublicUser.lastName) + cy.getByTestId("dob-field-month").should("have.value", updatedPublicUser.birthMonth) + cy.getByTestId("dob-field-day").should("have.value", updatedPublicUser.birthDay) + cy.getByTestId("dob-field-year").should("have.value", updatedPublicUser.birthYear) + + cy.signOut() + + // Confirm that the new password works + cy.visit("/sign-in") + cy.signIn(updatedPublicUser.email, updatedPublicUser.password) + }) +}) diff --git a/sites/public/cypress/mockData/applicationData.ts b/sites/public/cypress/mockData/applicationData.ts index ac6deda635..0cb45fc91e 100644 --- a/sites/public/cypress/mockData/applicationData.ts +++ b/sites/public/cypress/mockData/applicationData.ts @@ -7,6 +7,8 @@ import { YesNoEnum, UnitTypeEnum, ApplicationMultiselectQuestion, + AlternateContactRelationship, + HouseholdMemberRelationship, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" const idDefaults = { @@ -133,7 +135,7 @@ export const ElmVillageApplication: Application = { }, alternateContact: { ...idDefaults, - type: "other", + type: AlternateContactRelationship.other, firstName: "Alternate Name", lastName: "Alternate Last Name", agency: "Agency Name", @@ -184,7 +186,7 @@ export const ElmVillageApplication: Application = { zipCode: "90224", }, sameAddress: YesNoEnum.no, - relationship: "spouse", + relationship: HouseholdMemberRelationship.spouse, workInRegion: YesNoEnum.yes, }, ], @@ -370,7 +372,7 @@ export const minimalDataApplication: Application = { }, alternateContact: { ...idDefaults, - type: "dontHave", + type: AlternateContactRelationship.noContact, firstName: "", lastName: "", agency: "", diff --git a/sites/public/cypress/mockData/userData.ts b/sites/public/cypress/mockData/userData.ts new file mode 100644 index 0000000000..dd802afd1d --- /dev/null +++ b/sites/public/cypress/mockData/userData.ts @@ -0,0 +1,22 @@ +export const publicUser = { + email: "public-user@example.com", + password: "abcdef", + firstName: "First", + middleName: "Middle", + lastName: "Last", + birthDay: "01", + birthMonth: "01", + birthYear: "1970", +} + +export const updatedPublicUser = { + email: "public-user@example.com", + password: "Abcdefghijk1!", + passwordConfirmation: "Abcdefghijk1!", + firstName: "First Updated", + middleName: "Middle Updated", + lastName: "Last Updated", + birthDay: "20", + birthMonth: "12", + birthYear: "2000", +} diff --git a/sites/public/cypress/support/commands.js b/sites/public/cypress/support/commands.js index ad2f353a30..2cc36a7dc7 100644 --- a/sites/public/cypress/support/commands.js +++ b/sites/public/cypress/support/commands.js @@ -9,9 +9,9 @@ import { raceCheckboxesOrder, } from "./../mockData/applicationData" -Cypress.Commands.add("signIn", () => { - cy.get(`[data-testid="sign-in-email-field"]`).type("admin@example.com") - cy.get(`[data-testid="sign-in-password-field"]`).type("abcdef") +Cypress.Commands.add("signIn", (email, password) => { + cy.get(`[data-testid="sign-in-email-field"]`).type(email ?? "admin@example.com") + cy.get(`[data-testid="sign-in-password-field"]`).type(password ?? "abcdef") cy.getByID("sign-in-button").click() }) @@ -578,7 +578,7 @@ Cypress.Commands.add("step18Summary", (application, verify) => { fields.push({ id: val, fieldValue: val }) } - if (application.alternateContact.type !== "dontHave") { + if (application.alternateContact.type !== "noContact") { fields.push({ id: "app-summary-alternate-name", fieldValue: `${application.alternateContact.firstName} ${application.alternateContact.lastName}`, @@ -682,7 +682,7 @@ Cypress.Commands.add("submitApplication", (listingName, application, signedIn, v cy.step1PrimaryApplicantName(application) cy.step2PrimaryApplicantAddresses(application) cy.step3AlternateContactType(application) - if (application.alternateContact.type !== "dontHave") { + if (application.alternateContact.type !== "noContact") { cy.step4AlternateContactName(application) cy.step5AlternateContactInfo(application) } diff --git a/sites/public/cypress/support/helpers.ts b/sites/public/cypress/support/helpers.ts index da8b40343b..e0c5c14239 100644 --- a/sites/public/cypress/support/helpers.ts +++ b/sites/public/cypress/support/helpers.ts @@ -70,7 +70,7 @@ export const getListingIncome = (): GetIncomeReturn => { } export const updatePreferredUnits = ({ config, listing }: UpdatePreferredUnitsProps) => { - const firstUnitType = listing.units[0].unitType + const firstUnitType = listing.units[0].unitTypes config.preferredUnit = [{ id: firstUnitType?.id }] return config diff --git a/sites/public/cypress/support/index.d.ts b/sites/public/cypress/support/index.d.ts index 8eeaaf44c0..0f188f656a 100644 --- a/sites/public/cypress/support/index.d.ts +++ b/sites/public/cypress/support/index.d.ts @@ -11,7 +11,7 @@ declare namespace Cypress { getPhoneFieldByTestId(testId: string): Chainable goNext(): Chainable isNextRouteValid(currentStep: string, skip?: number): Chainable - signIn(): Chainable + signIn(email?: string, password?: string): Chainable signOut(): Chainable step1PrimaryApplicantName(application: Application): Chainable step2PrimaryApplicantAddresses(application: Application): Chainable diff --git a/sites/public/src/components/shared/FormSummaryDetails.tsx b/sites/public/src/components/shared/FormSummaryDetails.tsx index 30174b49c5..a42d015d5b 100644 --- a/sites/public/src/components/shared/FormSummaryDetails.tsx +++ b/sites/public/src/components/shared/FormSummaryDetails.tsx @@ -78,8 +78,6 @@ const FormSummaryDetails = ({ return application.alternateContact.otherType case "caseManager": return application.alternateContact.agency - case "": - return "" default: return t(`application.alternateContact.type.options.${application.alternateContact.type}`) } @@ -291,69 +289,66 @@ const FormSummaryDetails = ({ )} - {application.alternateContact.type !== "" && - application.alternateContact.type !== "noContact" && ( - <> - - - {t("application.alternateContact.type.label")} - - {editMode && !validationError && ( - {t("t.edit")} - )} - - - -

- {t(`application.alternateContact.type.description`)} -

+ {application.alternateContact.type && application.alternateContact.type !== "noContact" && ( + <> + + + {t("application.alternateContact.type.label")} + + {editMode && !validationError && ( + {t("t.edit")} + )} + + + +

+ {t(`application.alternateContact.type.description`)} +

+ + {application.alternateContact.firstName} {application.alternateContact.lastName} + + + {application.alternateContact.emailAddress && ( - {application.alternateContact.firstName} {application.alternateContact.lastName} + {application.alternateContact.emailAddress} + )} - {application.alternateContact.emailAddress && ( - - {application.alternateContact.emailAddress} - - )} - - {application.alternateContact.phoneNumber && ( - - {application.alternateContact.phoneNumber} - - )} + {application.alternateContact.phoneNumber && ( + + {application.alternateContact.phoneNumber} + + )} - {Object.values(application.alternateContact.address).some( - (value) => value !== "" - ) && ( - - - - )} -
- - )} + {Object.values(application.alternateContact.address).some((value) => value !== "") && ( + + + + )} +
+ + )} {application.householdSize > 1 && ( <> diff --git a/sites/public/src/pages/account/dashboard.tsx b/sites/public/src/pages/account/dashboard.tsx index a6489fa9ad..c21467c615 100644 --- a/sites/public/src/pages/account/dashboard.tsx +++ b/sites/public/src/pages/account/dashboard.tsx @@ -92,7 +92,12 @@ function Dashboard(props: DashboardProps) { headingPriority={2} > - diff --git a/sites/public/src/pages/account/edit.tsx b/sites/public/src/pages/account/edit.tsx index 2504bdfd69..b448ccd9da 100644 --- a/sites/public/src/pages/account/edit.tsx +++ b/sites/public/src/pages/account/edit.tsx @@ -14,6 +14,7 @@ import { AlertTypes, DOBField, DOBFieldValues, + LoadingOverlay, } from "@bloom-housing/ui-components" import { Button, Card } from "@bloom-housing/ui-seeds" import Link from "next/link" @@ -36,9 +37,36 @@ type AlertMessage = { } const Edit = () => { - /* Form Handler */ // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, errors, watch } = useForm() + const { + register: nameRegister, + formState: { errors: nameErrors }, + handleSubmit: nameHandleSubmit, + } = useForm() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { + register: dobRegister, + formState: { errors: dobErrors }, + handleSubmit: dobHandleSubmit, + watch: dobWatch, + } = useForm() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { + register: emailRegister, + formState: { errors: emailErrors }, + handleSubmit: emailHandleSubmit, + } = useForm() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { + register: pwdRegister, + formState: { errors: pwdErrors }, + handleSubmit: pwdHandleSubmit, + watch: pwdWatch, + } = useForm() + const { profile, userService } = useContext(AuthContext) const [passwordAlert, setPasswordAlert] = useState() const [nameAlert, setNameAlert] = useState() @@ -48,10 +76,12 @@ const Edit = () => { const [birthdateLoading, setBirthdateLoading] = useState(false) const [emailLoading, setEmailLoading] = useState(false) const [passwordLoading, setPasswordLoading] = useState(false) + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) - const MIN_PASSWORD_LENGTH = 8 + const MIN_PASSWORD_LENGTH = 12 const password = useRef({}) - password.current = watch("password", "") + password.current = pwdWatch("password", "") useEffect(() => { if (profile) { @@ -60,8 +90,17 @@ const Edit = () => { pageTitle: "Account Settings", status: UserStatus.LoggedIn, }) + const getUser = async () => { + const user = await userService.retrieve({ id: profile.id }).catch((err) => { + console.error(`Error fetching user`) + throw err + }) + setUser(user) + setLoading(false) + } + void getUser() } - }, [profile]) + }, [profile, userService]) const onNameSubmit = async (data: { firstName: string @@ -72,9 +111,10 @@ const Edit = () => { const { firstName, middleName, lastName } = data setNameAlert(null) try { - await userService.update({ - body: { ...profile, firstName, middleName, lastName }, + const newUser = await userService.update({ + body: { ...user, firstName, middleName, lastName }, }) + setUser(newUser) setNameAlert({ type: "success", message: `${t("account.settings.alerts.nameSuccess")}` }) setNameLoading(false) } catch (err) { @@ -89,14 +129,15 @@ const Edit = () => { const { dateOfBirth } = data setDobAlert(null) try { - await userService.update({ + const newUser = await userService.update({ body: { - ...profile, + ...user, dob: dayjs( `${dateOfBirth.birthYear}-${dateOfBirth.birthMonth}-${dateOfBirth.birthDay}` ).toDate(), }, }) + setUser(newUser) setDobAlert({ type: "success", message: `${t("account.settings.alerts.dobSuccess")}` }) setBirthdateLoading(false) } catch (err) { @@ -111,13 +152,14 @@ const Edit = () => { const { email } = data setEmailAlert(null) try { - await userService.update({ + const newUser = await userService.update({ body: { - ...profile, + ...user, appUrl: window.location.origin, newEmail: email, }, }) + setUser(newUser) setEmailAlert({ type: "success", message: `${t("account.settings.alerts.emailSuccess")}` }) setEmailLoading(false) } catch (err) { @@ -147,9 +189,10 @@ const Edit = () => { return } try { - await userService.update({ - body: { ...profile, password, currentPassword }, + const newUser = await userService.update({ + body: { ...user, password, currentPassword }, }) + setUser(newUser) setPasswordAlert({ type: "success", message: `${t("account.settings.alerts.passwordSuccess")}`, @@ -179,234 +222,247 @@ const Edit = () => { subtitle={t("account.accountSettingsSubtitle")} headingPriority={1} > - <> - - {nameAlert && ( - setNameAlert(null)} - className="mb-4" - inverted - closeable - > - {nameAlert.message} - - )} -
- - - - - - - - -
- - - {dobAlert && ( - setDobAlert(null)} - className="mb-4" - inverted - closeable - > - {dobAlert.message} - - )} -
- -

{t("application.name.dobHelper")}

- - -
- - - {emailAlert && ( - setEmailAlert(null)} - inverted - closeable - className={"mb-4"} - > - {emailAlert.message} - - )} -
- - - - -
- - - {passwordAlert && ( - setPasswordAlert(null)} - className="mb-4" - inverted - closeable - > - {passwordAlert.message} - - )} -
-
- - {t("authentication.createAccount.password")} - -

{t("account.settings.passwordRemember")}

-
- - - - {t("authentication.signIn.forgotPassword")} - - -
+ + <> + + {nameAlert && ( + setNameAlert(null)} + className="mb-4" + inverted + closeable + > + {nameAlert.message} + + )} + + + - value === password.current || - t("authentication.createAccount.errors.passwordMismatch"), + name="lastName" + placeholder={t("application.name.lastName")} + className="mb-6" + error={nameErrors.lastName} + register={nameRegister} + defaultValue={user ? user.lastName : null} + label={t("application.contact.familyName")} + validation={{ maxLength: 64 }} + errorMessage={ + nameErrors.lastName?.type === "maxLength" + ? t("errors.maxLength") + : t("errors.lastNameError") + } + dataTestId={"account-last-name"} + /> + + + + + + {dobAlert && ( + setDobAlert(null)} + className="mb-4" + inverted + closeable + > + {dobAlert.message} + + )} +
+ +

{t("application.name.dobHelper")}

+ + +
+ + {emailAlert && ( + setEmailAlert(null)} + inverted + closeable + className={"mb-4"} + > + {emailAlert.message} + + )} +
+ + -
- -
- + + + + + {passwordAlert && ( + setPasswordAlert(null)} + className="mb-4" + inverted + closeable + > + {passwordAlert.message} + + )} +
+
+ + {t("authentication.createAccount.password")} + +

{t("account.settings.passwordRemember")}

+
+ + + + {t("authentication.signIn.forgotPassword")} + + +
+ + + + + value === password.current || + t("authentication.createAccount.errors.passwordMismatch"), + }} + error={pwdErrors.passwordConfirmation} + errorMessage={t("authentication.createAccount.errors.passwordMismatch")} + register={pwdRegister} + dataTestId={"account-password-confirmation"} + /> + + +
+
+
+ +