From 603ee54fd3760e7540ba788bbbe90622bf6dc663 Mon Sep 17 00:00:00 2001 From: Morgan Ludtke <42942267+ludtkemorgan@users.noreply.github.com> Date: Fri, 23 Feb 2024 15:09:52 -0600 Subject: [PATCH 01/35] fix: null check on option links (#3897) --- .../listings/PaperListingForm/sections/SelectAndOrder.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sites/partners/src/components/listings/PaperListingForm/sections/SelectAndOrder.tsx b/sites/partners/src/components/listings/PaperListingForm/sections/SelectAndOrder.tsx index 00fe87a348..129ff1bf65 100644 --- a/sites/partners/src/components/listings/PaperListingForm/sections/SelectAndOrder.tsx +++ b/sites/partners/src/components/listings/PaperListingForm/sections/SelectAndOrder.tsx @@ -220,7 +220,7 @@ const SelectAndOrder = ({ {option.collectAddress && (
0) ? "-mt-4" : "mt-0" + isNotLastItem && (option.description || option.links?.length > 0) ? "-mt-4" : "mt-0" }`} > ({t("listings.providesAdditionalFields.info")}) From 8fea8b4debb7e0aae1d5aadb39f209b9b8278344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Zi=C4=99cina?= Date: Tue, 27 Feb 2024 16:14:25 +0100 Subject: [PATCH 02/35] fix: adjust spacing for preference application checkboxes (#3868) --- shared-helpers/src/views/multiselectQuestions.tsx | 4 ++-- .../ApplicationMultiselectQuestionStep.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/shared-helpers/src/views/multiselectQuestions.tsx b/shared-helpers/src/views/multiselectQuestions.tsx index 5b0c91aa2f..e628e5b721 100644 --- a/shared-helpers/src/views/multiselectQuestions.tsx +++ b/shared-helpers/src/views/multiselectQuestions.tsx @@ -228,7 +228,7 @@ export const getCheckboxOption = ( ) => { const optionFieldName = fieldName(question.text, applicationSection, option.text) return ( -
+
{getCheckboxField( option, @@ -244,7 +244,7 @@ export const getCheckboxOption = ( )}
{option.description && ( -
+

{option.description} diff --git a/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx b/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx index fa9cde23f7..ea9ce8d1b5 100644 --- a/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx +++ b/sites/public/src/components/applications/ApplicationMultiselectQuestionStep.tsx @@ -248,7 +248,7 @@ const ApplicationMultiselectQuestionStep = ({ {question?.text} {applicationSection === MultiselectQuestionsApplicationSectionEnum.preferences && ( -

+

{question?.text}

{question?.description && (

{question?.description}

@@ -266,7 +266,7 @@ const ApplicationMultiselectQuestionStep = ({ ))}
)} -

+

{t("application.household.preferredUnit.optionsLabel")}

{allOptions @@ -280,8 +280,8 @@ const ApplicationMultiselectQuestionStep = ({ )}
- - {verifyAddress && ( + {verifyAddress && ( + - )} - + + )} From c5bc517bed7ab2604b602b54b0f9f89004ad8578 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Tue, 27 Feb 2024 19:22:13 -0700 Subject: [PATCH 03/35] feat: limit user names allowed (#3908) --- api/src/services/user.service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index f6f2719282..599824a520 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -479,6 +479,15 @@ export class UserService { requestingUser: User, jurisdictionName?: string, ): Promise { + if ( + this.containsInvalidCharacters(dto.firstName) || + this.containsInvalidCharacters(dto.lastName) + ) { + throw new ForbiddenException( + `${dto.firstName} ${dto.lastName} was found to be invalid`, + ); + } + if (forPartners) { await this.authorizeAction( requestingUser, @@ -849,4 +858,8 @@ export class UserService { return misMatched; }, []); } + + containsInvalidCharacters(value: string): boolean { + return value.includes('.') || value.includes('http'); + } } From 16772d9a0a3dd6948d06e4f8f8a557972127cef0 Mon Sep 17 00:00:00 2001 From: Jared White Date: Wed, 28 Feb 2024 19:41:58 -0800 Subject: [PATCH 04/35] fix: the Save and Return link should only show after the Review step has been reached (#3901) --- sites/public/src/lib/applications/ApplicationConductor.ts | 2 +- sites/public/src/pages/applications/review/summary.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/sites/public/src/lib/applications/ApplicationConductor.ts b/sites/public/src/lib/applications/ApplicationConductor.ts index 6b9355395b..1c4e99abb9 100644 --- a/sites/public/src/lib/applications/ApplicationConductor.ts +++ b/sites/public/src/lib/applications/ApplicationConductor.ts @@ -187,7 +187,7 @@ export default class ApplicationConductor { } canJumpForwardToReview() { - return this.application.completedSections === this.totalNumberOfSections - 1 + return this.application.reachedReviewStep } sync() { diff --git a/sites/public/src/pages/applications/review/summary.tsx b/sites/public/src/pages/applications/review/summary.tsx index 39d03298a6..5068d2fa8d 100644 --- a/sites/public/src/pages/applications/review/summary.tsx +++ b/sites/public/src/pages/applications/review/summary.tsx @@ -56,6 +56,11 @@ const ApplicationSummary = () => { } }, [listing, router]) + useEffect(() => { + conductor.application.reachedReviewStep = true + conductor.sync() + }, [conductor]) + const onSubmit = () => { applicationsService .submissionValidation({ From c514748cc178ed1b766e69656ca3c6553acae602 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Thu, 29 Feb 2024 09:43:38 -0700 Subject: [PATCH 05/35] fix: 3898/passwordless schema changes (#3902) * feat: mfaCode -> singleUseCode * feat: adding new field to jurisdiction --- .../migration.sql | 7 ++++ .../migration.sql | 2 ++ api/prisma/schema.prisma | 18 +++++----- api/prisma/seed-helpers/user-factory.ts | 6 ++-- api/prisma/seed-staging.ts | 2 +- .../dtos/jurisdictions/jurisdiction.dto.ts | 6 ++++ api/src/passports/mfa.strategy.ts | 33 +++++++++-------- api/src/services/auth.service.ts | 12 +++---- api/src/services/email.service.ts | 4 +-- api/src/services/sms.service.ts | 4 +-- api/test/integration/auth.e2e-spec.ts | 14 ++++---- api/test/integration/jurisdiction.e2e-spec.ts | 3 ++ .../integration/permission-tests/helpers.ts | 2 ++ api/test/unit/passports/mfa.strategy.spec.ts | 36 +++++++++---------- api/test/unit/services/auth.service.spec.ts | 10 +++--- 15 files changed, 92 insertions(+), 67 deletions(-) create mode 100644 api/prisma/migrations/03_mfa_code_to_single_use_code/migration.sql create mode 100644 api/prisma/migrations/04_allow_single_use_code_login_jurisdiction_flag/migration.sql diff --git a/api/prisma/migrations/03_mfa_code_to_single_use_code/migration.sql b/api/prisma/migrations/03_mfa_code_to_single_use_code/migration.sql new file mode 100644 index 0000000000..5d8a35af39 --- /dev/null +++ b/api/prisma/migrations/03_mfa_code_to_single_use_code/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable + +ALTER TABLE "user_accounts" RENAME COLUMN "mfa_code" TO "single_use_code"; + + +ALTER TABLE "user_accounts" RENAME COLUMN "mfa_code_updated_at" TO "single_use_code_updated_at"; + diff --git a/api/prisma/migrations/04_allow_single_use_code_login_jurisdiction_flag/migration.sql b/api/prisma/migrations/04_allow_single_use_code_login_jurisdiction_flag/migration.sql new file mode 100644 index 0000000000..219cd57348 --- /dev/null +++ b/api/prisma/migrations/04_allow_single_use_code_login_jurisdiction_flag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "jurisdictions" ADD COLUMN "allow_single_use_code_login" BOOLEAN NOT NULL DEFAULT false; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 13f6e4231d..bc8f354b17 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -329,6 +329,7 @@ model Jurisdictions { enableAccessibilityFeatures Boolean @default(false) @map("enable_accessibility_features") enableUtilitiesIncluded Boolean @default(false) @map("enable_utilities_included") enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") + allowSingleUseCodeLogin Boolean @default(false) @map("allow_single_use_code_login") amiChart AmiChart[] multiselectQuestions MultiselectQuestions[] listings Listings[] @@ -571,10 +572,10 @@ model Listings { } model MapLayers { - id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid name String - jurisdictionId String @map("jurisdiction_id") - featureCollection Json @map("feature_collection") @default("{}") + jurisdictionId String @map("jurisdiction_id") + featureCollection Json @default("{}") @map("feature_collection") @@map("map_layers") } @@ -650,9 +651,9 @@ model Translations { // Note: [name] formerly max length 256 model UnitAccessibilityPriorityTypes { - id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid - createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) name String units Units[] unitGroup UnitGroup[] @@ -779,8 +780,8 @@ model UserAccounts { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) language LanguagesEnum? mfaEnabled Boolean @default(false) @map("mfa_enabled") - mfaCode String? @map("mfa_code") @db.VarChar - mfaCodeUpdatedAt DateTime? @map("mfa_code_updated_at") @db.Timestamptz(6) + singleUseCode String? @map("single_use_code") @db.VarChar + singleUseCodeUpdatedAt DateTime? @map("single_use_code_updated_at") @db.Timestamptz(6) lastLoginAt DateTime @default(now()) @map("last_login_at") @db.Timestamp(6) failedLoginAttemptsCount Int @default(0) @map("failed_login_attempts_count") phoneNumberVerified Boolean? @default(false) @map("phone_number_verified") @@ -809,7 +810,6 @@ model UserRoles { @@map("user_roles") } - // START DETROIT SPECIFIC model ListingNeighborhoodAmenities { id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid diff --git a/api/prisma/seed-helpers/user-factory.ts b/api/prisma/seed-helpers/user-factory.ts index f081035d24..b25a3ef279 100644 --- a/api/prisma/seed-helpers/user-factory.ts +++ b/api/prisma/seed-helpers/user-factory.ts @@ -7,7 +7,7 @@ export const userFactory = async (optionalParams?: { firstName?: string; lastName?: string; email?: string; - mfaCode?: string; + singleUseCode?: string; mfaEnabled?: boolean; confirmedAt?: Date; phoneNumber?: string; @@ -30,10 +30,10 @@ export const userFactory = async (optionalParams?: { isPartner: optionalParams?.roles?.isPartner || false, }, }, - mfaCode: optionalParams?.mfaCode || null, + singleUseCode: optionalParams?.singleUseCode || null, mfaEnabled: optionalParams?.mfaEnabled || false, confirmedAt: optionalParams?.confirmedAt || null, - mfaCodeUpdatedAt: optionalParams?.mfaEnabled ? new Date() : undefined, + singleUseCodeUpdatedAt: optionalParams?.mfaEnabled ? new Date() : undefined, phoneNumber: optionalParams?.phoneNumber || null, phoneNumberVerified: optionalParams?.phoneNumberVerified || null, agreedToTermsOfService: optionalParams?.acceptedTerms || false, diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 7062a3bb9b..d9c3247476 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -85,7 +85,7 @@ export const stagingSeed = async ( jurisdictionIds: [jurisdiction.id], acceptedTerms: true, mfaEnabled: true, - mfaCode: '12345', + singleUseCode: '12345', }), }); // add jurisdiction specific translations and default ones diff --git a/api/src/dtos/jurisdictions/jurisdiction.dto.ts b/api/src/dtos/jurisdictions/jurisdiction.dto.ts index a298872f3b..34c77b84e1 100644 --- a/api/src/dtos/jurisdictions/jurisdiction.dto.ts +++ b/api/src/dtos/jurisdictions/jurisdiction.dto.ts @@ -95,6 +95,12 @@ export class Jurisdiction extends AbstractDTO { @ApiProperty() enableUtilitiesIncluded: boolean; + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + allowSingleUseCodeLogin: boolean; + @Expose() @IsArray({ groups: [ValidationsGroupsEnum.default] }) @IsEnum(UserRoleEnum, { diff --git a/api/src/passports/mfa.strategy.ts b/api/src/passports/mfa.strategy.ts index ee37adebaf..2101e289bc 100644 --- a/api/src/passports/mfa.strategy.ts +++ b/api/src/passports/mfa.strategy.ts @@ -113,8 +113,12 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { } let authSuccess = true; - if (!dto.mfaCode || !rawUser.mfaCode || !rawUser.mfaCodeUpdatedAt) { - // if an mfaCode was not sent, and an mfaCode wasn't stored in the db for the user + if ( + !dto.mfaCode || + !rawUser.singleUseCode || + !rawUser.singleUseCodeUpdatedAt + ) { + // if an mfaCode was not sent, and a singleUseCode wasn't stored in the db for the user // signal to the front end to request an mfa code await this.updateFailedLoginCount(0, rawUser.id); throw new UnauthorizedException({ @@ -122,24 +126,25 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { }); } else if ( new Date( - rawUser.mfaCodeUpdatedAt.getTime() + Number(process.env.MFA_CODE_VALID), + rawUser.singleUseCodeUpdatedAt.getTime() + + Number(process.env.MFA_CODE_VALID), ) < new Date() || - rawUser.mfaCode !== dto.mfaCode + rawUser.singleUseCode !== dto.mfaCode ) { // if mfaCode TTL has expired, or if the mfa code input was incorrect authSuccess = false; } else { // if mfaCode login was a success - rawUser.mfaCode = null; - rawUser.mfaCodeUpdatedAt = new Date(); + rawUser.singleUseCode = null; + rawUser.singleUseCodeUpdatedAt = new Date(); } if (!authSuccess) { // if we failed login validation rawUser.failedLoginAttemptsCount += 1; await this.updateStoredUser( - rawUser.mfaCode, - rawUser.mfaCodeUpdatedAt, + rawUser.singleUseCode, + rawUser.singleUseCodeUpdatedAt, rawUser.phoneNumberVerified, rawUser.failedLoginAttemptsCount, rawUser.id, @@ -161,8 +166,8 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { } await this.updateStoredUser( - rawUser.mfaCode, - rawUser.mfaCodeUpdatedAt, + rawUser.singleUseCode, + rawUser.singleUseCodeUpdatedAt, rawUser.phoneNumberVerified, rawUser.failedLoginAttemptsCount, rawUser.id, @@ -188,16 +193,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { } async updateStoredUser( - mfaCode: string, - mfaCodeUpdatedAt: Date, + singleUseCode: string, + singleUseCodeUpdatedAt: Date, phoneNumberVerified: boolean, failedLoginAttemptsCount: number, userId: string, ): Promise { await this.prisma.userAccounts.update({ data: { - mfaCode, - mfaCodeUpdatedAt, + singleUseCode, + singleUseCodeUpdatedAt, phoneNumberVerified, failedLoginAttemptsCount, lastLoginAt: new Date(), diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index b00a7103df..f0c2591042 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -219,11 +219,11 @@ export class AuthService { } } - const mfaCode = this.generateMfaCode(); + const singleUseCode = this.generateSingleUseCode(); await this.prisma.userAccounts.update({ data: { - mfaCode, - mfaCodeUpdatedAt: new Date(), + singleUseCode, + singleUseCodeUpdatedAt: new Date(), phoneNumber: user.phoneNumber, }, where: { @@ -232,9 +232,9 @@ export class AuthService { }); if (dto.mfaType === MfaType.email) { - await this.emailsService.sendMfaCode(mapTo(User, user), mfaCode); + await this.emailsService.sendMfaCode(mapTo(User, user), singleUseCode); } else if (dto.mfaType === MfaType.sms) { - await this.smsService.sendMfaCode(user.phoneNumber, mfaCode); + await this.smsService.sendMfaCode(user.phoneNumber, singleUseCode); } return dto.mfaType === MfaType.email @@ -328,7 +328,7 @@ export class AuthService { /* generates a numeric mfa code */ - generateMfaCode() { + generateSingleUseCode() { let out = ''; const characters = '0123456789'; for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) { diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts index e1c58c9721..02901edabc 100644 --- a/api/src/services/email.service.ts +++ b/api/src/services/email.service.ts @@ -299,7 +299,7 @@ export class EmailService { ); } - public async sendMfaCode(user: User, mfaCode: string) { + public async sendMfaCode(user: User, singleUseCode: string) { const jurisdiction = await this.getJurisdiction(user.jurisdictions); void (await this.loadTranslations(jurisdiction, user.language)); const emailFromAddress = await this.getEmailToSendFrom( @@ -312,7 +312,7 @@ export class EmailService { 'Partners Portal account access token', this.template('mfa-code')({ user: user, - mfaCodeOptions: { mfaCode }, + mfaCodeOptions: { singleUseCode }, }), ); } diff --git a/api/src/services/sms.service.ts b/api/src/services/sms.service.ts index 2325a73d73..5a1f0a5e35 100644 --- a/api/src/services/sms.service.ts +++ b/api/src/services/sms.service.ts @@ -15,13 +15,13 @@ export class SmsService { } public async sendMfaCode( phoneNumber: string, - mfaCode: string, + singleUseCode: string, ): Promise { if (!this.client) { return; } await this.client.messages.create({ - body: `Your Partners Portal account access token: ${mfaCode}`, + body: `Your Partners Portal account access token: ${singleUseCode}`, from: process.env.TWILIO_PHONE_NUMBER, to: phoneNumber, }); diff --git a/api/test/integration/auth.e2e-spec.ts b/api/test/integration/auth.e2e-spec.ts index 96bb70321b..07bccd767a 100644 --- a/api/test/integration/auth.e2e-spec.ts +++ b/api/test/integration/auth.e2e-spec.ts @@ -44,7 +44,7 @@ describe('Auth Controller Tests', () => { const storedUser = await prisma.userAccounts.create({ data: await userFactory({ roles: { isAdmin: true }, - mfaCode: 'abcdef', + singleUseCode: 'abcdef', mfaEnabled: true, confirmedAt: new Date(), }), @@ -54,7 +54,7 @@ describe('Auth Controller Tests', () => { .send({ email: storedUser.email, password: 'abcdef', - mfaCode: storedUser.mfaCode, + mfaCode: storedUser.singleUseCode, mfaType: MfaType.email, } as Login) .expect(201); @@ -78,7 +78,7 @@ describe('Auth Controller Tests', () => { }); expect(loggedInUser.lastLoginAt).not.toBeNull(); - expect(loggedInUser.mfaCode).toBeNull(); + expect(loggedInUser.singleUseCode).toBeNull(); expect(loggedInUser.activeAccessToken).not.toBeNull(); expect(loggedInUser.activeRefreshToken).not.toBeNull(); }); @@ -118,7 +118,7 @@ describe('Auth Controller Tests', () => { }); expect(loggedInUser.lastLoginAt).not.toBeNull(); - expect(loggedInUser.mfaCode).toBeNull(); + expect(loggedInUser.singleUseCode).toBeNull(); expect(loggedInUser.activeAccessToken).not.toBeNull(); expect(loggedInUser.activeRefreshToken).not.toBeNull(); }); @@ -163,7 +163,7 @@ describe('Auth Controller Tests', () => { }); expect(loggedInUser.lastLoginAt).not.toBeNull(); - expect(loggedInUser.mfaCode).toBeNull(); + expect(loggedInUser.singleUseCode).toBeNull(); expect(loggedInUser.activeAccessToken).toBeNull(); expect(loggedInUser.activeRefreshToken).toBeNull(); }); @@ -208,8 +208,8 @@ describe('Auth Controller Tests', () => { }, }); - expect(user.mfaCode).not.toBeNull(); - expect(user.mfaCodeUpdatedAt).not.toBeNull(); + expect(user.singleUseCode).not.toBeNull(); + expect(user.singleUseCodeUpdatedAt).not.toBeNull(); }); it('should update password', async () => { diff --git a/api/test/integration/jurisdiction.e2e-spec.ts b/api/test/integration/jurisdiction.e2e-spec.ts index 999f3ac1dd..83f8d0b47f 100644 --- a/api/test/integration/jurisdiction.e2e-spec.ts +++ b/api/test/integration/jurisdiction.e2e-spec.ts @@ -124,6 +124,7 @@ describe('Jurisdiction Controller Tests', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: true, listingApprovalPermissions: [], }; const res = await request(app.getHttpServer()) @@ -149,6 +150,7 @@ describe('Jurisdiction Controller Tests', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: true, listingApprovalPermissions: [], }; const res = await request(app.getHttpServer()) @@ -178,6 +180,7 @@ describe('Jurisdiction Controller Tests', () => { enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: true, listingApprovalPermissions: [], }; diff --git a/api/test/integration/permission-tests/helpers.ts b/api/test/integration/permission-tests/helpers.ts index dd34c20f65..df1c36bf62 100644 --- a/api/test/integration/permission-tests/helpers.ts +++ b/api/test/integration/permission-tests/helpers.ts @@ -107,6 +107,7 @@ export const buildJurisdictionCreateMock = ( enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: true, listingApprovalPermissions: [], }; }; @@ -127,6 +128,7 @@ export const buildJurisdictionUpdateMock = ( enablePartnerSettings: true, enableAccessibilityFeatures: true, enableUtilitiesIncluded: true, + allowSingleUseCodeLogin: true, listingApprovalPermissions: [], }; }; diff --git a/api/test/unit/passports/mfa.strategy.spec.ts b/api/test/unit/passports/mfa.strategy.spec.ts index 3a4f505299..2d5f684623 100644 --- a/api/test/unit/passports/mfa.strategy.spec.ts +++ b/api/test/unit/passports/mfa.strategy.spec.ts @@ -247,8 +247,8 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { - mfaCode: null, - mfaCodeUpdatedAt: null, + singleUseCode: null, + singleUseCodeUpdatedAt: null, phoneNumberVerified: null, lastLoginAt: expect.anything(), failedLoginAttemptsCount: 0, @@ -428,8 +428,8 @@ describe('Testing mfa strategy', () => { passwordHash: await passwordToHash('abcdef'), mfaEnabled: true, phoneNumberVerified: false, - mfaCode: 'zyxwv', - mfaCodeUpdatedAt: new Date(), + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(), }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); @@ -460,8 +460,8 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { - mfaCode: 'zyxwv', - mfaCodeUpdatedAt: expect.anything(), + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: expect.anything(), phoneNumberVerified: false, lastLoginAt: expect.anything(), failedLoginAttemptsCount: 1, @@ -485,8 +485,8 @@ describe('Testing mfa strategy', () => { passwordHash: await passwordToHash('abcdef'), mfaEnabled: true, phoneNumberVerified: false, - mfaCode: 'zyxwv', - mfaCodeUpdatedAt: new Date(0), + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); @@ -517,8 +517,8 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { - mfaCode: 'zyxwv', - mfaCodeUpdatedAt: expect.anything(), + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: expect.anything(), phoneNumberVerified: false, lastLoginAt: expect.anything(), failedLoginAttemptsCount: 1, @@ -542,8 +542,8 @@ describe('Testing mfa strategy', () => { passwordHash: await passwordToHash('abcdef'), mfaEnabled: true, phoneNumberVerified: false, - mfaCode: 'zyxwv', - mfaCodeUpdatedAt: new Date(), + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(), }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); @@ -572,8 +572,8 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { - mfaCode: null, - mfaCodeUpdatedAt: expect.anything(), + singleUseCode: null, + singleUseCodeUpdatedAt: expect.anything(), phoneNumberVerified: true, lastLoginAt: expect.anything(), failedLoginAttemptsCount: 0, @@ -597,8 +597,8 @@ describe('Testing mfa strategy', () => { passwordHash: await passwordToHash('abcdef'), mfaEnabled: true, phoneNumberVerified: false, - mfaCode: 'zyxwv', - mfaCodeUpdatedAt: new Date(), + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(), }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); @@ -627,8 +627,8 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { - mfaCode: null, - mfaCodeUpdatedAt: expect.anything(), + singleUseCode: null, + singleUseCodeUpdatedAt: expect.anything(), phoneNumberVerified: false, lastLoginAt: expect.anything(), failedLoginAttemptsCount: 0, diff --git a/api/test/unit/services/auth.service.spec.ts b/api/test/unit/services/auth.service.spec.ts index 666eb4835f..54b53f7bfb 100644 --- a/api/test/unit/services/auth.service.spec.ts +++ b/api/test/unit/services/auth.service.spec.ts @@ -483,8 +483,8 @@ describe('Testing auth service', () => { }); expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { - mfaCode: expect.anything(), - mfaCodeUpdatedAt: expect.anything(), + singleUseCode: expect.anything(), + singleUseCodeUpdatedAt: expect.anything(), }, where: { id, @@ -532,8 +532,8 @@ describe('Testing auth service', () => { }); expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { - mfaCode: expect.anything(), - mfaCodeUpdatedAt: expect.anything(), + singleUseCode: expect.anything(), + singleUseCodeUpdatedAt: expect.anything(), phoneNumber: '520-781-8711', }, where: { @@ -609,7 +609,7 @@ describe('Testing auth service', () => { }); it('should generate mfa code', () => { - expect(authService.generateMfaCode().length).toEqual( + expect(authService.generateSingleUseCode().length).toEqual( Number(process.env.MFA_CODE_LENGTH), ); }); From ff479abe34dde4333e48c4c04a90e114c2e265e2 Mon Sep 17 00:00:00 2001 From: cade-exygy <131277283+cade-exygy@users.noreply.github.com> Date: Tue, 5 Mar 2024 12:14:49 -0600 Subject: [PATCH 06/35] fix: call submit directly from onClick (#3907) --- .../components/account/ConfirmationModal.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/sites/public/src/components/account/ConfirmationModal.tsx b/sites/public/src/components/account/ConfirmationModal.tsx index 06bf5b1e2b..1d511ef70e 100644 --- a/sites/public/src/components/account/ConfirmationModal.tsx +++ b/sites/public/src/components/account/ConfirmationModal.tsx @@ -21,11 +21,11 @@ const ConfirmationModal = (props: ConfirmationModalProps) => { // This is causing a linting issue with unbound-method, see open issue as of 10/21/2020: // https://github.com/react-hook-form/react-hook-form/issues/2887 // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, errors, watch } = useForm() + const { register, handleSubmit, errors, watch, getValues } = useForm() const email = useRef({}) email.current = watch("email", "") - const onSubmit = async ({ email }) => { + const onSubmit = async (email) => { try { const listingId = router.query?.listingId as string await resendConfirmation(email, listingId) @@ -39,6 +39,11 @@ const ConfirmationModal = (props: ConfirmationModalProps) => { window.scrollTo(0, 0) } + const onFormSubmit = async () => { + const { email } = getValues() + await onSubmit(email) + } + useEffect(() => { const redirectUrl = router.query?.redirectUrl as string const listingId = router.query?.listingId as string @@ -83,15 +88,7 @@ const ConfirmationModal = (props: ConfirmationModalProps) => { window.scrollTo(0, 0) }} actions={[ - , -
-
+ + +
- - -
- + + + + ) } diff --git a/shared-helpers/src/views/sign-in/FormSignIn.module.scss b/shared-helpers/src/views/sign-in/FormSignIn.module.scss new file mode 100644 index 0000000000..3bf53b4c23 --- /dev/null +++ b/shared-helpers/src/views/sign-in/FormSignIn.module.scss @@ -0,0 +1,19 @@ +.forgot-password { + float: right; + font-size: var(--seeds-font-size-sm); + text-decoration-line: underline; + color: var(--seeds-color-blue-900); +} + +.sign-in-error-container { + margin-inline: var(--seeds-s4); + + @media (min-width: theme("screens.sm")) { + margin-inline: var(--seeds-s12); + } +} + +.sign-in-error { + margin-top: var(--seeds-s6); + width: 100%; +} diff --git a/shared-helpers/src/views/sign-in/FormSignIn.tsx b/shared-helpers/src/views/sign-in/FormSignIn.tsx index 91f60f87f7..b46b05434a 100644 --- a/shared-helpers/src/views/sign-in/FormSignIn.tsx +++ b/shared-helpers/src/views/sign-in/FormSignIn.tsx @@ -1,14 +1,14 @@ import React, { useContext } from "react" +import type { UseFormMethods } from "react-hook-form" import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" import { Button, Heading } from "@bloom-housing/ui-seeds" -import { CardSection, CardFooter } from "@bloom-housing/ui-seeds/src/blocks/Card" +import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" import { FormSignInErrorBox } from "./FormSignInErrorBox" import { NetworkStatus } from "../../auth/catchNetworkError" -import type { UseFormMethods } from "react-hook-form" -import { AccountCard } from "../accounts/AccountCard" -import styles from "../../../../sites/public/styles/sign-in.module.scss" +import { BloomCard } from "../components/BloomCard" import { useRouter } from "next/router" import { getListingRedirectUrl } from "../../utilities/getListingRedirectUrl" +import styles from "./FormSignIn.module.scss" export type FormSignInProps = { control: FormSignInControl @@ -45,27 +45,26 @@ const FormSignIn = ({ const createAccountUrl = getListingRedirectUrl(listingIdRedirect, "/create-account") return ( - + <> - +
- + )} - + ) } diff --git a/shared-helpers/src/views/sign-in/FormSignInErrorBox.tsx b/shared-helpers/src/views/sign-in/FormSignInErrorBox.tsx index f3b4874170..ca8b808271 100644 --- a/shared-helpers/src/views/sign-in/FormSignInErrorBox.tsx +++ b/shared-helpers/src/views/sign-in/FormSignInErrorBox.tsx @@ -2,6 +2,7 @@ import React from "react" import { t, AlertBox, SiteAlert, AlertNotice, ErrorMessage } from "@bloom-housing/ui-components" import type { UseFormMethods } from "react-hook-form" import { NetworkStatus } from "../../auth/catchNetworkError" +import styles from "./FormSignIn.module.scss" export type FormSignInErrorBoxProps = { errors: FormSignInErrorBoxControl["errors"] @@ -21,12 +22,10 @@ const FormSignInErrorBox = ({ errorMessageId, className, }: FormSignInErrorBoxProps) => { - const classNames = ["border-b"] - if (className) classNames.push(className) return ( -
+
{Object.entries(errors).length > 0 && !networkStatus.content && ( - + {errors.authentication ? errors.authentication.message : t("errors.errorsToResolve")} )} @@ -35,7 +34,7 @@ const FormSignInErrorBox = ({ networkStatus.reset()}> {networkStatus.content.title} @@ -49,7 +48,12 @@ const FormSignInErrorBox = ({ {networkStatus.type === "success" && ( <> - networkStatus.reset()}> + networkStatus.reset()} + className={styles["sign-in-error"]} + > {networkStatus.content?.title} @@ -58,8 +62,7 @@ const FormSignInErrorBox = ({ )} - - +
) } diff --git a/shared-helpers/src/views/sign-in/ResendConfirmationModal.tsx b/shared-helpers/src/views/sign-in/ResendConfirmationModal.tsx index fa4c21bf48..7d6de2e2ca 100644 --- a/shared-helpers/src/views/sign-in/ResendConfirmationModal.tsx +++ b/shared-helpers/src/views/sign-in/ResendConfirmationModal.tsx @@ -80,7 +80,6 @@ const ResendConfirmationModal = ({ <>
diff --git a/sites/partners/src/pages/reset-password.tsx b/sites/partners/src/pages/reset-password.tsx index 8e2294bd5b..6c5ccad7f4 100644 --- a/sites/partners/src/pages/reset-password.tsx +++ b/sites/partners/src/pages/reset-password.tsx @@ -64,7 +64,6 @@ const ResetPassword = () => {
{ errorMessage={t("authentication.forgotPassword.enterNewLoginPassword")} register={register} type="password" + labelClassName={"text__caps-spaced"} /> { errorMessage={t("authentication.createAccount.errors.passwordMismatch")} register={register} type="password" + labelClassName={"text__caps-spaced"} />
diff --git a/sites/public/src/components/account/ConfirmationModal.tsx b/sites/public/src/components/account/ConfirmationModal.tsx index 1d511ef70e..097194e393 100644 --- a/sites/public/src/components/account/ConfirmationModal.tsx +++ b/sites/public/src/components/account/ConfirmationModal.tsx @@ -112,7 +112,6 @@ const ConfirmationModal = (props: ConfirmationModalProps) => { )} { error={errors.email} errorMessage={t("authentication.signIn.loginError")} register={register} + labelClassName={"text__caps-spaced"} />

{t("authentication.createAccount.resendEmailInfo")}

diff --git a/sites/public/src/components/account/SignUpBenefits.module.scss b/sites/public/src/components/account/SignUpBenefits.module.scss new file mode 100644 index 0000000000..38566e4b58 --- /dev/null +++ b/sites/public/src/components/account/SignUpBenefits.module.scss @@ -0,0 +1,28 @@ +.sign-up-benefits-container { + display: flex; + flex-direction: column; + padding-top: var(--seeds-s6); + padding-bottom: var(--seeds-s4); + padding-inline: var(--seeds-s4); + @media (min-width: theme("screens.md")) { + padding: 0; + } +} + +.sign-up-benefits-item { + display: flex; + flex-direction: row; + margin-bottom: var(--seeds-s3); + align-items: center; + + .icon { + border: 1px solid var(--seeds-color-white); + background-color: var(--seeds-color-white); + border-radius: var(--seeds-rounded-full); + padding: var(--seeds-s2_5); + } + + .text { + margin-left: var(--seeds-s3); + } +} diff --git a/sites/public/src/components/account/SignUpBenefits.tsx b/sites/public/src/components/account/SignUpBenefits.tsx index 7b18d551fb..2a4b6c8864 100644 --- a/sites/public/src/components/account/SignUpBenefits.tsx +++ b/sites/public/src/components/account/SignUpBenefits.tsx @@ -1,6 +1,7 @@ import { Icon } from "@bloom-housing/ui-seeds" import { faStopwatch, faEye, faLock } from "@fortawesome/free-solid-svg-icons" import { t } from "@bloom-housing/ui-components" +import styles from "./SignUpBenefits.module.scss" type SignUpBenefitsProps = { className?: string @@ -12,18 +13,14 @@ const SignUpBenefits = (props: SignUpBenefitsProps) => { { icon: faEye, text: t("account.signUpSaveTime.checkStatus") }, { icon: faLock, text: t("account.signUpSaveTime.resetPassword") }, ] - const classNames = ["flex flex-col pt-6 pb-6 pr-4 pl-4 md:p-0"] + const classNames = [styles["sign-up-benefits-container"]] if (props.className) classNames.push(props.className) return (
    {iconListItems.map((item) => ( -
  • - -

    {item.text}

    +
  • + +

    {item.text}

  • ))}
diff --git a/sites/public/src/components/account/SignUpBenefitsHeadingGroup.module.scss b/sites/public/src/components/account/SignUpBenefitsHeadingGroup.module.scss new file mode 100644 index 0000000000..766637f366 --- /dev/null +++ b/sites/public/src/components/account/SignUpBenefitsHeadingGroup.module.scss @@ -0,0 +1,13 @@ +.sign-up-benefits-heading-group { + padding-top: var(--seeds-s6); + padding-inline: var(--seeds-s4); + padding-bottom: var(--seeds-s2); + + @media (min-width: theme("screens.md")) { + padding-bottom: var(--seeds-s6); + } + + @media (min-width: theme("screens.sm")) { + padding-top: 0; + } +} diff --git a/sites/public/src/components/account/SignUpBenefitsHeadingGroup.tsx b/sites/public/src/components/account/SignUpBenefitsHeadingGroup.tsx index f55de20e44..4566a4fcd9 100644 --- a/sites/public/src/components/account/SignUpBenefitsHeadingGroup.tsx +++ b/sites/public/src/components/account/SignUpBenefitsHeadingGroup.tsx @@ -1,14 +1,14 @@ import { t } from "@bloom-housing/ui-components" import { HeadingGroup } from "@bloom-housing/ui-seeds" +import styles from "./SignUpBenefitsHeadingGroup.module.scss" const SignUpBenefitsHeadingGroup = (props: { mobileView: boolean }) => { - const classNames = props.mobileView ? "py-6 px-4" : "" return ( ) } diff --git a/sites/public/src/layouts/application-form.module.scss b/sites/public/src/layouts/application-form.module.scss index 0b0a5218db..47952c7376 100644 --- a/sites/public/src/layouts/application-form.module.scss +++ b/sites/public/src/layouts/application-form.module.scss @@ -1,5 +1,6 @@ .application-form-header { margin-block: var(--seeds-s6); + color: var(--seeds-color-gray-700); @media (max-width: theme("screens.md")) { margin-block-end: 0; @@ -13,11 +14,49 @@ border-top-right-radius: 0; margin-top: 0; } + + .application-form-header-title { + background-color: var(--seeds-color-primary); + padding-block: var(--seeds-s4); + color: var(--seeds-color-white); + --card-content-padding-inline: var(--seeds-s8); + @media (max-width: theme("screens.sm")) { + --card-content-padding-inline: var(--seeds-s4); + } + } + + .application-form-header-heading { + font-size: var(--seeds-type-heading-size-xl); + color: var(--seeds-color-white); + font-weight: var(--seeds-font-weight-bold); + font-family: var(--seeds-font-alt-sans); + } + + .application-form-header-progress { + padding-block: var(--seeds-s4); + --card-content-padding-block: var(--seeds-s4); + --card-content-padding-inline: var(--seeds-s8); + @media (max-width: theme("screens.sm")) { + --card-content-padding-inline: var(--seeds-s4); + } + + .desktop-nav { + display: block; + @media (max-width: theme("screens.sm")) { + display: none; + } + } + + .mobile-nav { + display: none; + @media (max-width: theme("screens.sm")) { + display: block; + } + } + } } .application-form-body { - margin-block-end: var(--seeds-s6); - @media (max-width: theme("screens.md")) { border-top-left-radius: 0; border-top-right-radius: 0; @@ -30,6 +69,24 @@ } } +.application-form-header-no-border { + header { + --card-divider-width: 0; + } +} + +.application-form-action-footer { + background-color: var(--seeds-color-primary-lighter); +} + +.application-form-save-and-return { + margin-top: var(--seeds-s4); +} + +.application-form-back-link { + margin-bottom: var(--seeds-s6); +} + /* overrides for Seeds Message & Alert */ .message-inside-card { --common-message-max-width: auto; diff --git a/sites/public/src/layouts/application-form.tsx b/sites/public/src/layouts/application-form.tsx index 3d5560c203..babd56eb94 100644 --- a/sites/public/src/layouts/application-form.tsx +++ b/sites/public/src/layouts/application-form.tsx @@ -1,10 +1,10 @@ import React from "react" import { faChevronLeft } from "@fortawesome/free-solid-svg-icons" -import { Button, Card, Heading, Icon } from "@bloom-housing/ui-seeds" +import { Button, Heading, Icon } from "@bloom-housing/ui-seeds" +import { BloomCard } from "@bloom-housing/shared-helpers" import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" import { t, ProgressNav, StepHeader } from "@bloom-housing/ui-components" import ApplicationConductor from "../lib/applications/ApplicationConductor" - import styles from "./application-form.module.scss" interface ApplicationFormLayoutProps { @@ -29,7 +29,7 @@ interface ApplicationFormLayoutProps { const ApplicationFormLayout = (props: ApplicationFormLayoutProps) => { const getBackLink = (url?: string, onClickFxn?: () => void) => { return ( -
+
- - {props.conductor.canJumpForwardToReview() && ( -
- -
- )} + + <> + + + {props.listingName} + - )} - + +
+ +
+
+ +
+
+ +
+ + <> + {props.children} + {props.conductor && ( + + + + {props.conductor.canJumpForwardToReview() && ( +
+ +
+ )} +
+ )} + +
) } diff --git a/sites/public/src/layouts/forms.module.scss b/sites/public/src/layouts/forms.module.scss new file mode 100644 index 0000000000..3003bf6195 --- /dev/null +++ b/sites/public/src/layouts/forms.module.scss @@ -0,0 +1,14 @@ +.form-layout-container { + background-color: var(--seeds-bg-color-canvas); + border-top: 1px solid var(--seeds-border-color); +} + +.form-layout { + margin-inline: auto; + max-width: 100%; + @media (min-width: theme("screens.sm")) { + margin-bottom: var(--seeds-s20); + margin-top: var(--seeds-s12); + max-width: var(--seeds-width-lg); + } +} diff --git a/sites/public/src/layouts/forms.tsx b/sites/public/src/layouts/forms.tsx index 526b1cb940..84c885e561 100644 --- a/sites/public/src/layouts/forms.tsx +++ b/sites/public/src/layouts/forms.tsx @@ -1,21 +1,20 @@ import React from "react" import Layout from "./application" import { ApplicationTimeout } from "../components/applications/ApplicationTimeout" +import styles from "./forms.module.scss" interface FormLayoutProps { children?: React.ReactNode className?: string } const FormLayout = (props: FormLayoutProps) => { - const classNames = [ - "md:mb-20 md:mt-12 mx-auto sm:max-w-lg max-w-full print:my-0 print:max-w-full", - ] + const classNames = [styles["form-layout"]] if (props.className) classNames.push(props.className) return ( <> -
+
{props.children}
diff --git a/sites/public/src/pages/account/account.module.scss b/sites/public/src/pages/account/account.module.scss index f1431b1660..b4ed1e3580 100644 --- a/sites/public/src/pages/account/account.module.scss +++ b/sites/public/src/pages/account/account.module.scss @@ -24,6 +24,7 @@ .account-settings-label { font-size: var(--seeds-font-size-sm); font-weight: var(--seeds-font-weight-bold); + color: var(--seeds-input-text-label-color); } .application-no-results { diff --git a/sites/public/src/pages/account/applications.tsx b/sites/public/src/pages/account/applications.tsx index 793c8aa73a..ee5026e41c 100644 --- a/sites/public/src/pages/account/applications.tsx +++ b/sites/public/src/pages/account/applications.tsx @@ -2,12 +2,17 @@ import React, { useEffect, useState, Fragment, useContext } from "react" import Head from "next/head" import { t, LoadingOverlay } from "@bloom-housing/ui-components" import { Button, Card, Heading } from "@bloom-housing/ui-seeds" -import { PageView, pushGtmEvent, AuthContext, RequireLogin } from "@bloom-housing/shared-helpers" +import { + PageView, + pushGtmEvent, + AuthContext, + RequireLogin, + BloomCard, +} from "@bloom-housing/shared-helpers" import Layout from "../../layouts/application" import { StatusItemWrapper, AppWithListing } from "./StatusItemWrapper" import { MetaTags } from "../../components/shared/MetaTags" import { UserStatus } from "../../lib/constants" -import { AccountCard } from "@bloom-housing/shared-helpers/src/views/accounts/AccountCard" import styles from "../../pages/account/account.module.scss" @@ -88,14 +93,12 @@ const Applications = () => {
-
- + <> @@ -108,7 +111,7 @@ const Applications = () => { {!applications && !loading && noApplicationsSection()} - +
diff --git a/sites/public/src/pages/account/dashboard.tsx b/sites/public/src/pages/account/dashboard.tsx index e9087870e0..a1b1273f79 100644 --- a/sites/public/src/pages/account/dashboard.tsx +++ b/sites/public/src/pages/account/dashboard.tsx @@ -2,12 +2,17 @@ import React, { useEffect, useState, useContext } from "react" import Head from "next/head" import { NextRouter, withRouter } from "next/router" import { t, SiteAlert, AlertBox } from "@bloom-housing/ui-components" -import { PageView, pushGtmEvent, AuthContext, RequireLogin } from "@bloom-housing/shared-helpers" +import { + PageView, + pushGtmEvent, + AuthContext, + RequireLogin, + BloomCard, +} from "@bloom-housing/shared-helpers" import Layout from "../../layouts/application" import { MetaTags } from "../../components/shared/MetaTags" import { UserStatus } from "../../lib/constants" import { Button, Card, Grid } from "@bloom-housing/ui-seeds" -import { AccountCard } from "@bloom-housing/shared-helpers/src/views/accounts/AccountCard" import styles from "./account.module.scss" @@ -59,11 +64,12 @@ function Dashboard(props: DashboardProps) { - - + diff --git a/sites/public/src/pages/account/edit.tsx b/sites/public/src/pages/account/edit.tsx index a2831a469c..a247b206cd 100644 --- a/sites/public/src/pages/account/edit.tsx +++ b/sites/public/src/pages/account/edit.tsx @@ -19,10 +19,15 @@ import { } from "@bloom-housing/ui-components" import { Button, Card } from "@bloom-housing/ui-seeds" import Link from "next/link" -import { PageView, pushGtmEvent, AuthContext, RequireLogin } from "@bloom-housing/shared-helpers" +import { + PageView, + pushGtmEvent, + AuthContext, + RequireLogin, + BloomCard, +} from "@bloom-housing/shared-helpers" import { UserStatus } from "../../lib/constants" import FormsLayout from "../../layouts/forms" -import { AccountCard } from "@bloom-housing/shared-helpers/src/views/accounts/AccountCard" import styles from "./account.module.scss" @@ -150,28 +155,27 @@ const Edit = () => { return ( - <> - {nameAlert && ( - setNameAlert(null)} - className="my-0" - inverted - closeable - > - {nameAlert.message} - - )} + + {nameAlert && ( + setNameAlert(null)} + className="mb-4" + inverted + closeable + > + {nameAlert.message} + + )}
- {dobAlert && ( - setDobAlert(null)} - className="my-0" - inverted - closeable - > - {dobAlert.message} - - )} + + {dobAlert && ( + setDobAlert(null)} + className="mb-4" + inverted + closeable + > + {dobAlert.message} + + )}
{
- {emailAlert && ( - setEmailAlert(null)} - inverted - closeable - > - {emailAlert.message} - - )} + + {emailAlert && ( + setEmailAlert(null)} + inverted + closeable + className={"mb-4"} + > + {emailAlert.message} + + )}
- {passwordAlert && ( - setPasswordAlert(null)} - className="my-0" - inverted - closeable - > - {passwordAlert.message} - - )} + + {passwordAlert && ( + setPasswordAlert(null)} + className="mb-4" + inverted + closeable + > + {passwordAlert.message} + + )}
@@ -362,7 +370,7 @@ const Edit = () => { - + ) diff --git a/sites/public/src/pages/applications/financial/income.tsx b/sites/public/src/pages/applications/financial/income.tsx index ea72245968..b056a75241 100644 --- a/sites/public/src/pages/applications/financial/income.tsx +++ b/sites/public/src/pages/applications/financial/income.tsx @@ -177,7 +177,7 @@ const ApplicationIncome = () => { name="income" type="currency" label={t("application.financial.income.prompt")} - caps={true} + labelClassName={"text__caps-spaced"} validation={{ required: true, min: 0.01 }} error={errors.income} register={register} diff --git a/sites/public/src/pages/create-account.tsx b/sites/public/src/pages/create-account.tsx index 2c18f6790e..860eddf428 100644 --- a/sites/public/src/pages/create-account.tsx +++ b/sites/public/src/pages/create-account.tsx @@ -17,10 +17,9 @@ import dayjs from "dayjs" import customParseFormat from "dayjs/plugin/customParseFormat" dayjs.extend(customParseFormat) import { useRouter } from "next/router" -import { PageView, pushGtmEvent, AuthContext } from "@bloom-housing/shared-helpers" +import { PageView, pushGtmEvent, AuthContext, BloomCard } from "@bloom-housing/shared-helpers" import { UserStatus } from "../lib/constants" import FormsLayout from "../layouts/forms" -import { AccountCard } from "@bloom-housing/shared-helpers/src/views/accounts/AccountCard" import accountCardStyles from "./account/account.module.scss" import styles from "../../styles/create-account.module.scss" import signUpBenefitsStyles from "../../styles/sign-up-benefits.module.scss" @@ -91,13 +90,7 @@ export default () => {
)}
- + <> {requestError && ( setRequestError(undefined)} type="alert"> @@ -185,7 +178,6 @@ export default () => { className={accountCardStyles["account-card-settings-section"]} > { errorMessage={t("authentication.signIn.loginError")} register={register} controlClassName={styles["create-account-input"]} + labelClassName={"text__caps-spaced"} /> { className={accountCardStyles["account-card-settings-section"]} > { - +
{signUpCopy && (
diff --git a/sites/public/src/pages/reset-password.tsx b/sites/public/src/pages/reset-password.tsx index 51227c2bc4..fe1c8a95f7 100644 --- a/sites/public/src/pages/reset-password.tsx +++ b/sites/public/src/pages/reset-password.tsx @@ -4,15 +4,14 @@ import { useForm } from "react-hook-form" import { Field, Form, - FormCard, - Icon, t, AlertBox, SiteAlert, setSiteAlertMessage, } from "@bloom-housing/ui-components" import { Button } from "@bloom-housing/ui-seeds" -import { PageView, pushGtmEvent, AuthContext } from "@bloom-housing/shared-helpers" +import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" +import { PageView, pushGtmEvent, AuthContext, BloomCard } from "@bloom-housing/shared-helpers" import { UserStatus } from "../lib/constants" import FormsLayout from "../layouts/forms" @@ -67,53 +66,49 @@ const ResetPassword = () => { return ( - -
- -

{t("authentication.forgotPassword.changePassword")}

-
- {requestError && ( - setRequestError(undefined)} type="alert"> - {requestError} - - )} - -
-
- + + <> + {requestError && ( + setRequestError(undefined)} type="alert"> + {requestError} + + )} + + + + - - value === passwordValue.current || - t("authentication.createAccount.errors.passwordMismatch"), - }} - error={errors.passwordConfirmation} - errorMessage={t("authentication.createAccount.errors.passwordMismatch")} - register={register} - type="password" - /> + + value === passwordValue.current || + t("authentication.createAccount.errors.passwordMismatch"), + }} + error={errors.passwordConfirmation} + errorMessage={t("authentication.createAccount.errors.passwordMismatch")} + register={register} + type="password" + labelClassName={"text__caps-spaced"} + /> -
-
- -
-
+ + + +
) } diff --git a/sites/public/styles/create-account.module.scss b/sites/public/styles/create-account.module.scss index 8eeb43315f..ebfa1a3103 100644 --- a/sites/public/styles/create-account.module.scss +++ b/sites/public/styles/create-account.module.scss @@ -1,26 +1,26 @@ .create-account-header { - color: var(--seeds-color-gray-750); - font-size: var(--seeds-font-size-sm); - font-weight: var(--seeds-font-weight-bold); - display: block; + color: var(--seeds-input-text-label-color); + font-size: var(--seeds-font-size-sm); + font-weight: var(--seeds-font-weight-bold); + display: block; } .create-account-field { - color: var(--seeds-color-gray-750); - font-size: var(--seeds-font-size-sm); - display: block; - margin-top: var(--bloom-s3); + color: var(--seeds-input-text-label-color); + font-size: var(--seeds-font-size-sm); + display: block; + margin-top: var(--bloom-s3); } .create-account-input { - border-radius: var(--bloom-rounded-lg); - margin-top: var(--bloom-s2); + border-radius: var(--bloom-rounded-lg); + margin-top: var(--bloom-s2); } .create-account-gap { - margin-bottom: var(--bloom-s4); + margin-bottom: var(--bloom-s4); } .create-account-label { - margin-bottom: var(--bloom-s1); -} \ No newline at end of file + margin-bottom: var(--bloom-s1); +} diff --git a/sites/public/styles/overrides.scss b/sites/public/styles/overrides.scss index 71842a4cc4..d4664d0f81 100644 --- a/sites/public/styles/overrides.scss +++ b/sites/public/styles/overrides.scss @@ -2,10 +2,11 @@ .seeds-button { -webkit-font-smoothing: antialiased; // restore macOS styling that had been unset } - + --text-caps-spaced-letter-spacing: var(--bloom-letter-spacing-tight); --text-caps-spaced-font-weight: 700; .text__caps-spaced { text-transform: none; + color: var(--seeds-input-text-label-color); } } diff --git a/sites/public/styles/sign-in.module.scss b/sites/public/styles/sign-in.module.scss deleted file mode 100644 index b97924f850..0000000000 --- a/sites/public/styles/sign-in.module.scss +++ /dev/null @@ -1,6 +0,0 @@ -.forgot-password { - float: right; - font-size: var(--seeds-font-size-sm); - text-decoration-line: underline; - color: var(--seeds-color-blue-900); -} \ No newline at end of file diff --git a/sites/public/styles/sign-up-benefits.module.scss b/sites/public/styles/sign-up-benefits.module.scss index 8a46239e97..65f9c60f69 100644 --- a/sites/public/styles/sign-up-benefits.module.scss +++ b/sites/public/styles/sign-up-benefits.module.scss @@ -1,54 +1,53 @@ .benefits-form-layout { - @media (min-width: theme("screens.sm")) { - max-width: var(--seeds-width-lg); - } - @media (min-width: theme("screens.md")) { - max-width: 100%; - } + @media (min-width: theme("screens.sm")) { + max-width: var(--seeds-width-lg); } - .benefits-container { - display: flex; - flex-direction: column; - justify-content: center; - - @media (min-width: theme("screens.md")) { - flex-direction: row; - margin-left: var(--seeds-s20); - } + @media (min-width: theme("screens.md")) { + max-width: 100%; } - - .benefits-display-hide { - display: block; - - @media (min-width: theme("screens.md")) { - display: none; - } +} +.benefits-container { + display: flex; + flex-direction: column; + justify-content: center; + + @media (min-width: theme("screens.md")) { + flex-direction: row; + margin-left: var(--seeds-s20); } - - .benefits-hide-display { +} + +.benefits-display-hide { + display: block; + + @media (min-width: theme("screens.md")) { display: none; - - @media (min-width: theme("screens.md")) { - display: flex; - } } - - .benefits-form-container { +} + +.benefits-hide-display { + display: none; + + @media (min-width: theme("screens.md")) { + display: flex; + } +} + +.benefits-form-container { + width: 100%; + justify-content: center; + + @media (min-width: theme("screens.md")) { + max-width: var(--seeds-width-lg); + } +} + +.benefits-desktop-container { + @media (min-width: theme("screens.md")) { + display: flex; + flex-direction: column; + padding: var(--seeds-s8); + max-width: var(--seeds-width-lg); width: 100%; - justify-content: center; - - @media (min-width: theme("screens.md")) { - max-width: var(--seeds-width-lg); - width: max-content; - } } - - .benefits-desktop-container { - @media (min-width: theme("screens.md")) { - display: flex; - flex-direction: column; - padding: var(--seeds-s8); - max-width: var(--seeds-width-lg); - width: 100%; - } - } \ No newline at end of file +} diff --git a/sites/public/tailwind.config.js b/sites/public/tailwind.config.js index 51aa076671..cf9d186a96 100644 --- a/sites/public/tailwind.config.js +++ b/sites/public/tailwind.config.js @@ -17,6 +17,7 @@ module.exports = { "./src/**/*.tsx", "../../node_modules/@bloom-housing/shared-helpers/src/views/**/*.tsx", "../../node_modules/@bloom-housing/ui-components/src/**/*.tsx", + "../../node_modules/@bloom-housing/shared-helpers/src/views/**/*.tsx", ], safelist: [/grid-cols-/], }, From 42d56e03c1a850b866b2e99032af3da17da7d87c Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Tue, 12 Mar 2024 11:55:35 -0600 Subject: [PATCH 16/35] fix: underscores in translations (#3925) --- shared-helpers/src/locales/es.json | 58 +++++++-------- shared-helpers/src/locales/tl.json | 110 ++++++++++++++--------------- shared-helpers/src/locales/vi.json | 20 +++--- 3 files changed, 94 insertions(+), 94 deletions(-) diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 6a738290d9..18ba7829c8 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -12,10 +12,10 @@ "account.myApplicationsSubtitle": "Vea las fechas de la loterĆ­a y los listados de las propiedades para las que ha presentado solicitudes", "account.myApplications": "Mis Solicitudes", "account.noApplications": "Parece que todavĆ­a no ha realizado una solicitud a ningĆŗn anuncio.", - "account.settings.alerts.currentPassword": "ContraseƱa invĆ”lida. Por favor_ vuelva a intentarlo.", + "account.settings.alerts.currentPassword": "ContraseƱa invĆ”lida. Por favor, vuelva a intentarlo.", "account.settings.alerts.dobSuccess": "La fecha de nacimiento se ha actualizado de manera exitosa", "account.settings.alerts.emailSuccess": "El correo electrĆ³nico se ha actualizado de manera exitosa", - "account.settings.alerts.genericError": "Hubo un error. Por favor_ intĆ©ntelo nuevamente o comunĆ­quese con el servicio de asistencia para obtener ayuda.", + "account.settings.alerts.genericError": "Hubo un error. Por favor, intĆ©ntelo nuevamente o comunĆ­quese con el servicio de asistencia para obtener ayuda.", "account.settings.alerts.nameSuccess": "El nombre se ha actualizado de manera exitosa", "account.settings.alerts.passwordEmpty": "Los campos de contraseƱa no pueden estar vacĆ­os", "account.settings.alerts.passwordMatch": "Los campos de la nueva contraseƱa no coinciden", @@ -23,7 +23,7 @@ "account.settings.confirmNewPassword": "Confirmar nueva contraseƱa", "account.settings.currentPassword": "ContraseƱa actual", "account.settings.newPassword": "Nueva contraseƱa", - "account.settings.passwordRemember": "Le recomendamos que al momento de cambiar su contraseƱa_ asegĆŗrese de anotarla para recordarla en el futuro.", + "account.settings.passwordRemember": "Le recomendamos que al momento de cambiar su contraseƱa, asegĆŗrese de anotarla para recordarla en el futuro.", "account.settings.placeholders.day": "DD", "account.settings.placeholders.month": "MM", "account.settings.placeholders.year": "AAAA", @@ -76,7 +76,7 @@ "application.contact.cityName": "Nombre de la Ciudad", "application.contact.city": "Ciudad", "application.contact.contactPreference": "ĀæCĆ³mo prefiere que nos comuniquemos con usted?", - "application.contact.couldntLocateAddress": "No hemos podido ubicar la direcciĆ³n que ingresĆ³. Por favor_ confirme que sea la direcciĆ³n correcta.", + "application.contact.couldntLocateAddress": "No hemos podido ubicar la direcciĆ³n que ingresĆ³. Por favor, confirme que sea la direcciĆ³n correcta.", "application.contact.doYouWorkInDescription": "Por decidirse", "application.contact.doYouWorkIn": "ĀæTrabaja usted en ?", "application.contact.familyName": "Apellido", @@ -95,7 +95,7 @@ "application.contact.streetAddress": "Domicilio", "application.contact.suggestedAddress": "DirecciĆ³n sugerida:", "application.contact.title": "Gracias, %{firstName}. Ahora necesitamos saber cĆ³mo comunicarnos con usted acerca de su solicitud.", - "application.contact.verifyAddressTitle": "Hemos localizado la siguiente direcciĆ³n. Por favor_ confirme que sea la direcciĆ³n correcta.", + "application.contact.verifyAddressTitle": "Hemos localizado la siguiente direcciĆ³n. Por favor, confirme que sea la direcciĆ³n correcta.", "application.contact.verifyMultipleAddresses": "Dado que existen varias opciones para esta preferencia, deberĆ” verificar varias direcciones.", "application.contact.workAddress": "DirecciĆ³n del trabajo", "application.contact.youEntered": "Ha ingresado los siguientes datos:", @@ -118,7 +118,7 @@ "application.financial.income.validationError.reason.low": "Los ingresos de su hogar son demasiado bajos.", "application.financial.vouchers.housingVouchers.strong": "Cupones de viviendas", "application.financial.vouchers.housingVouchers.text": "como Section 8", - "application.financial.vouchers.legend": "Recibos de vivienda_ ingresos deducibles o subsidios de alquiler", + "application.financial.vouchers.legend": "Recibos de vivienda, ingresos deducibles o subsidios de alquiler", "application.financial.vouchers.nonTaxableIncome.strong": "Ingresos no gravables de impuestos", "application.financial.vouchers.nonTaxableIncome.text": "como SSI, SSDI, pagos de manutenciĆ³n infantil o beneficios de compensaciones del trabajador", "application.financial.vouchers.rentalSubsidies.strong": "Subsidios en el alquiler", @@ -334,7 +334,7 @@ "application.review.takeAMomentToReview": "Dedique un momento a revisar su informaciĆ³n antes de enviar su solicitud.", "application.review.terms.confirmCheckboxText": "Convengo y comprendo que no puedo cambiar nada despuĆ©s de enviar la solicitud.", "application.review.terms.textSubmissionDate": "Esta solicitud debe enviarse antes del %{applicationDueDate}.

", - "application.review.terms.text": "El agente inmobiliario se comunicarĆ” con los solicitantes por sorteo y orden de preferencia o por lista de espera hasta que todas las vacantes estĆ©n completas. Toda la informaciĆ³n serĆ” revisada y se confirmarĆ” su elegibilidad. En caso de haber realizado alguna declaraciĆ³n fraudulenta_ su solicitud y las solicitudes duplicadas del mismo hogar pueden eliminarse de la lista de espera ya que solo se permite una solicitud por hogar. En caso que usted desee que su solicitud sea revisada_ deberĆ” completar una solicitud mĆ”s detallada y brindar los documentos de respaldo que sean requeridos. Para obtener mĆ”s informaciĆ³n_ por favor_ contĆ”ctese con la empresa constructora o el agente de arrendamiento publicado en el anuncio. Puede contactarse directamente directamente con la empresa constructora o con el administrador de la propiedad si hay alguna actualizaciĆ³n en su solicitud.

En caso de no poder verificar alguna preferencia para el sorteo de vivienda que haya reclamado_ no recibirĆ” la preferencia pero tampoco recibirĆ” una penalizaciĆ³n.< br>
El hecho de haber completado la solicitud de vivienda no le da derecho al acceso de la vivienda ni indica que es elegible para vivienda; todos los solicitantes serĆ”n seleccionados tal como se indica en los Criterios de selecciĆ³n de residentes de la propiedad. No ofrecemos garantĆ­as sobre la obtenciĆ³n de vivienda.

Una vez que haya enviado su solicitud en lĆ­nea_ no podrĆ” modificarla.

Por la presente_ declaro que lo anterior es verdadero y exacto_ y reconozco que cualquier declaraciĆ³n errĆ³nea realizada de manera fraudulenta o negligente en esta solicitud puede ser eliminada de la loterĆ­a.

", + "application.review.terms.text": "El agente inmobiliario se comunicarĆ” con los solicitantes por sorteo y orden de preferencia o por lista de espera hasta que todas las vacantes estĆ©n completas. Toda la informaciĆ³n serĆ” revisada y se confirmarĆ” su elegibilidad. En caso de haber realizado alguna declaraciĆ³n fraudulenta, su solicitud y las solicitudes duplicadas del mismo hogar pueden eliminarse de la lista de espera ya que solo se permite una solicitud por hogar. En caso que usted desee que su solicitud sea revisada, deberĆ” completar una solicitud mĆ”s detallada y brindar los documentos de respaldo que sean requeridos. Para obtener mĆ”s informaciĆ³n, por favor, contĆ”ctese con la empresa constructora o el agente de arrendamiento publicado en el anuncio. Puede contactarse directamente directamente con la empresa constructora o con el administrador de la propiedad si hay alguna actualizaciĆ³n en su solicitud.

En caso de no poder verificar alguna preferencia para el sorteo de vivienda que haya reclamado, no recibirĆ” la preferencia pero tampoco recibirĆ” una penalizaciĆ³n.< br>
El hecho de haber completado la solicitud de vivienda no le da derecho al acceso de la vivienda ni indica que es elegible para vivienda; todos los solicitantes serĆ”n seleccionados tal como se indica en los Criterios de selecciĆ³n de residentes de la propiedad. No ofrecemos garantĆ­as sobre la obtenciĆ³n de vivienda.

Una vez que haya enviado su solicitud en lĆ­nea, no podrĆ” modificarla.

Por la presente, declaro que lo anterior es verdadero y exacto, y reconozco que cualquier declaraciĆ³n errĆ³nea realizada de manera fraudulenta o negligente en esta solicitud puede ser eliminada de la loterĆ­a.

", "application.review.terms.title": "TĆ©rminos", "application.review.voucherOrSubsidy": "CupĆ³n de vivienda o subsidio de alquiler", "application.start.whatToExpect.info1": "Primero, le haremos preguntas sobre usted y las personas con las que piensa vivir. Luego, le haremos preguntas sobre sus ingresos. Finalmente, veremos si usted reĆŗne los requisitos de alguna preferencia de loterĆ­a para vivienda de precio accesible.", @@ -355,7 +355,7 @@ "authentication.createAccount.anEmailHasBeenSent": "Se ha enviado un email a %{email}", "authentication.createAccount.confirmationInstruction": "Por favor haga clic en el enlace del email que le hemos enviado para completar la creaciĆ³n de su cuenta.", "authentication.createAccount.confirmationNeeded": "Se necesita confirmaciĆ³n", - "authentication.createAccount.emailSent": "Se ha enviado un correo electrĆ³nico de confirmaciĆ³n. Por favor_ le solicitamos que revise su bandeja de entrada.", + "authentication.createAccount.emailSent": "Se ha enviado un correo electrĆ³nico de confirmaciĆ³n. Por favor, le solicitamos que revise su bandeja de entrada.", "authentication.createAccount.errors.accountConfirmed": "Su cuenta ha sido confirmada.", "authentication.createAccount.errors.emailInUse": "El correo electrĆ³nico ya estĆ” en uso", "authentication.createAccount.errors.emailMismatch": "Los correos electrĆ³nicos no coinciden", @@ -376,32 +376,32 @@ "authentication.createAccount.resendEmailInfo": "PodrĆ” ingresar al enlace que le enviamos por correo electrĆ³nico que caducarĆ” dentro de las prĆ³ximas 24 horas para crear su cuenta.", "authentication.createAccount.resendTheEmail": "Volver a enviar el email", "authentication.forgotPassword.changePassword": "Cambia la contraseƱa", - "authentication.forgotPassword.errors.emailNotFound": "El correo electrĆ³nico no fue encontrado. Por favor_ revise que si ha creado una cuenta con nosotros con ese correo electrĆ³nico y haya sido confirmado confirmado.", + "authentication.forgotPassword.errors.emailNotFound": "El correo electrĆ³nico no fue encontrado. Por favor, revise que si ha creado una cuenta con nosotros con ese correo electrĆ³nico y haya sido confirmado confirmado.", "authentication.forgotPassword.errors.passwordTooWeak": "La contraseƱa es demasiado dĆ©bil. Debe tener al menos 8 caracteres y deberĆ” incluir por lo menos 1 letra y 1 nĆŗmero.", - "authentication.forgotPassword.errors.tokenExpired": "El token de restablecimiento de contraseƱa caducĆ³. Por favor_ solicite uno nuevo.", - "authentication.forgotPassword.errors.tokenMissing": "El token no fue encontrado. Por favor_ solicite uno nuevo.", + "authentication.forgotPassword.errors.tokenExpired": "El token de restablecimiento de contraseƱa caducĆ³. Por favor, solicite uno nuevo.", + "authentication.forgotPassword.errors.tokenMissing": "El token no fue encontrado. Por favor, solicite uno nuevo.", "authentication.forgotPassword.sendEmail": "Enviar correo electrĆ³nico", - "authentication.signIn.accountHasBeenLocked": "Por razones de seguridad_ esta cuenta ha sido bloqueada.", - "authentication.signIn.afterFailedAttempts": "Por razones de seguridad_ despuĆ©s de %{count} intentos fallidos_ deberĆ” esperar 30 minutos antes de volver a intentarlo.", + "authentication.signIn.accountHasBeenLocked": "Por razones de seguridad, esta cuenta ha sido bloqueada.", + "authentication.signIn.afterFailedAttempts": "Por razones de seguridad, despuĆ©s de %{count} intentos fallidos, deberĆ” esperar 30 minutos antes de volver a intentarlo.", "authentication.signIn.changeYourPassword": "Puede cambiar su contraseƱa", - "authentication.signIn.enterLoginEmail": "Por favor_ escriba su correo electrĆ³nico de inicio de sesiĆ³n", - "authentication.signIn.enterLoginPassword": "Por favor_ escriba su contraseƱa de inicio de sesiĆ³n", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Por favo_ escriba un cĆ³digo vĆ”lido.", - "authentication.signIn.enterValidEmailAndPassword": "Por favor_ escriba un correo electrĆ³nico y una contraseƱa vĆ”lidos.", + "authentication.signIn.enterLoginEmail": "Por favor, escriba su correo electrĆ³nico de inicio de sesiĆ³n", + "authentication.signIn.enterLoginPassword": "Por favor, escriba su contraseƱa de inicio de sesiĆ³n", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Por favor, escriba un cĆ³digo vĆ”lido.", + "authentication.signIn.enterValidEmailAndPassword": "Por favor, escriba un correo electrĆ³nico y una contraseƱa vĆ”lidos.", "authentication.signIn.errorGenericMessage": "Por favor intĆ©ntelo de nuevo, o comunĆ­quese con servicio al cliente para recibir asistencia.", "authentication.signIn.error": "Hubo un error cuando usted iniciĆ³ sesiĆ³n", "authentication.signIn.forgotPassword": "OlvidĆ© la contraseƱa", - "authentication.signIn.loginError": "Por favor_ escriba una direcciĆ³n de correo electrĆ³nico vĆ”lida", - "authentication.signIn.passwordError": "Por favor_ escriba una contraseƱa vĆ”lida", - "authentication.signIn.passwordOutdated": "Su contraseƱa ha expirado. Por favor_ elija una nueva contraseƱa.", + "authentication.signIn.loginError": "Por favor, escriba una direcciĆ³n de correo electrĆ³nico vĆ”lida", + "authentication.signIn.passwordError": "Por favor, escriba una contraseƱa vĆ”lida", + "authentication.signIn.passwordOutdated": "Su contraseƱa ha expirado. Por favor, elija una nueva contraseƱa.", "authentication.signIn.success": "Ā”Bienvenido de nuevo, %{name}!", - "authentication.signIn.youHaveToWait": "Antes de volver a intentarlo_ deberĆ” esperar 30 minutos desde el Ćŗltimo intento fallido.", + "authentication.signIn.youHaveToWait": "Antes de volver a intentarlo, deberĆ” esperar 30 minutos desde el Ćŗltimo intento fallido.", "authentication.signIn.yourAccountIsNotConfirmed": "Su cuenta no estĆ” confirmada", "authentication.signOut.success": "La sesiĆ³n de su cuenta ha sido cerrada exitosamente.", "authentication.terms.acceptToc": "Acepto los tĆ©rminos del servicio", - "authentication.terms.reviewToc": "Por favor_ revise los tĆ©rminos de servicio", + "authentication.terms.reviewToc": "Por favor, revise los tĆ©rminos de servicio", "authentication.terms.termsOfService": "TĆ©rminos de servicio", - "authentication.terms.youMustAcceptToc": "Para continuar_ deberĆ” aceptar los TĆ©rminos de Servicio", + "authentication.terms.youMustAcceptToc": "Para continuar, deberĆ” aceptar los TĆ©rminos de Servicio", "authentication.timeout.action": "Permanecer en la sesiĆ³n", "authentication.timeout.signOutMessage": "Su seguridad es importante para nosotros. Concluimos su sesiĆ³n debido a inactividad. SĆ­rvase iniciar sesiĆ³n para continuar.", "authentication.timeout.text": "Para proteger su identidad, su sesiĆ³n concluirĆ” en un minuto debido a inactividad. Si decide no responder, perderĆ” toda la informaciĆ³n que no haya guardado y concluirĆ” su sesiĆ³n.", @@ -429,8 +429,8 @@ "errors.alert.timeoutPleaseTryAgain": "Ā”Oops! Parece que algo saliĆ³ mal. Por favor, intĆ©ntelo de nuevo.", "errors.alert.applicationSubmissionVerificationError": "A su solicitud le faltan campos obligatorios. Vuelva atrĆ”s y corrija esto antes de enviarlo.", "errors.cityError": "Por favor ingrese una ciudad", - "errors.dateError": "Por favor_ deberĆ” introducir una fecha valida", - "errors.dateOfBirthErrorAge": "Ingrese una fecha de nacimiento vĆ”lida. Para poder registrarse_ debe ser mayor de 18 aƱos", + "errors.dateError": "Por favor, deberĆ” introducir una fecha valida", + "errors.dateOfBirthErrorAge": "Ingrese una fecha de nacimiento vĆ”lida. Para poder registrarse, debe ser mayor de 18 aƱos", "errors.dateOfBirthError": "Por favor ingrese una fecha de nacimiento vĆ”lida", "errors.emailAddressError": "Por favor ingrese una direcciĆ³n de email", "errors.errorsToResolve": "Hay errores que tendrĆ” que corregir antes de poder seguir adelante.", @@ -447,7 +447,7 @@ "errors.passwordConfirmationMismatch": "La confirmaciĆ³n de la contraseƱa no coincide", "errors.phoneNumberError": "Por favor ingrese un nĆŗmero telefĆ³nico", "errors.phoneNumberTypeError": "Por favor ingrese un tipo de nĆŗmero telefĆ³nico", - "errors.rateLimitExceeded": "Ha excedido el lĆ­mite de intentos. Por favor_ intĆ©ntelo nuevamente mĆ”s tarde.", + "errors.rateLimitExceeded": "Ha excedido el lĆ­mite de intentos. Por favor, intĆ©ntelo nuevamente mĆ”s tarde.", "errors.requiredFieldError": "Este campo es obligatorio", "errors.requiredFieldsError": "Estos campos son obligatorios", "errors.selectAllThatApply": "Por favor seleccione todas las opciones que correspondan.", @@ -457,7 +457,7 @@ "errors.somethingWentWrong": "Ā”Ups! Parece que algo saliĆ³ mal.", "errors.stateError": "Por favor ingrese un estado", "errors.streetError": "Por favor ingrese una direcciĆ³n", - "errors.timeError": "Por favor_ ingrese una hora vĆ”lida", + "errors.timeError": "Por favor, ingrese una hora vĆ”lida", "errors.zipCodeError": "Por favor ingrese un cĆ³digo postal", "footer.contact": "Contacto", "footer.copyright": "Demonstration Jurisdiction Ā© 2021 ā€¢ Todos los derechos reservados.", @@ -496,10 +496,10 @@ "listings.apply.pickUpAnApplication": "Recoja una solicitud.", "listings.apply.sendByUsMail": "Enviar la Solicitud a travĆ©s del Servicio Postal de los EE.UU.", "listings.apply.submitAPaperApplication": "EnvĆ­e una Solicitud impresa", - "listings.apply.submitPaperDueDateNoPostMark": "Las solicitudes deberĆ”n ser enviadas antes de la fecha lĆ­mite. Si se envĆ­a a travĆ©s del correo postal de los Estados Unidos_ la solicitud deberĆ” incluir el sello de %{applicationDueDate}. %{developer} no se harĆ” responsable en caso de correo perdido o retrasado.", + "listings.apply.submitPaperDueDateNoPostMark": "Las solicitudes deberĆ”n ser enviadas antes de la fecha lĆ­mite. Si se envĆ­a a travĆ©s del correo postal de los Estados Unidos, la solicitud deberĆ” incluir el sello de %{applicationDueDate}. %{developer} no se harĆ” responsable en caso de correo perdido o retrasado.", "listings.apply.submitPaperDueDatePostMark": "Las solicitudes deben recibirse para la fecha lĆ­mite. Si envĆ­a la solicitud a travĆ©s del Servicio Postal de los EE.UU., la solicitud debe llevar el matasellos a mĆ”s tardar del %{applicationDueDate} y ser recibida por correo a mĆ”s tardar el %{postmarkReceivedByDate}. Las solicitudes recibidas por correo despuĆ©s del %{postmarkReceivedByDate} no serĆ”n aceptadas incluso si el matasellos lleva una fecha de a mĆ”s tardar el %{applicationDueDate}. %{developer} no es responsable de correo extraviado o demorado.", "listings.apply.submitPaperNoDueDateNoPostMark": "%{developer} no se harĆ” responsabile si el correo se pierde o se retrasa.", - "listings.apply.submitPaperNoDueDatePostMark": "Las solicitudes deberĆ”n ser recibidas antes de la fecha lĆ­mite. Si se envĆ­a a travĆ©s del correo de los Estados Unidos_ la solicitud deberĆ” ser recibida a mĆ”s tardar el dĆ­a %{postmarkReceivedByDate}. No se aceptarĆ”n las solicitudes recibidas despuĆ©s del %{postmarkReceivedByDate} por correo. %{developer} no se responsabiliza por el correo perdido o retrasado.", + "listings.apply.submitPaperNoDueDatePostMark": "Las solicitudes deberĆ”n ser recibidas antes de la fecha lĆ­mite. Si se envĆ­a a travĆ©s del correo de los Estados Unidos, la solicitud deberĆ” ser recibida a mĆ”s tardar el dĆ­a %{postmarkReceivedByDate}. No se aceptarĆ”n las solicitudes recibidas despuĆ©s del %{postmarkReceivedByDate} por correo. %{developer} no se responsabiliza por el correo perdido o retrasado.", "listings.availableAndWaitlist": "Viviendas disponibles y lista de espera abierta", "listings.availableUnitsAndWaitlistDesc": "Una vez que los solicitantes llenen todas las viviendas disponibles, los solicitantes adicionales serĆ”n colocados en la lista de espera de %{number} viviendas", "listings.availableUnitsAndWaitlist": "Viviendas disponibles y lista de espera", diff --git a/shared-helpers/src/locales/tl.json b/shared-helpers/src/locales/tl.json index 69f89e2fce..f998a3aedf 100644 --- a/shared-helpers/src/locales/tl.json +++ b/shared-helpers/src/locales/tl.json @@ -1,5 +1,5 @@ { - "account.accountSettingsSubtitle": "Account Settings_ email at password", + "account.accountSettingsSubtitle": "Account Settings, email at password", "account.accountSettings": "Settings ng Account", "account.application.confirmation": "Kumpirmasyon", "account.application.error": "Error", @@ -15,7 +15,7 @@ "account.settings.alerts.currentPassword": "Hindi tama ang kasalukuyang password. Pakisubukan muli.", "account.settings.alerts.dobSuccess": "Matagumpay na na-update ang petsa ng kapanganakan", "account.settings.alerts.emailSuccess": "Matagumpay na na-update ang email", - "account.settings.alerts.genericError": "Nagkaproblema. Pakisubukan muli_ o kontakin ang support para sa tulong.", + "account.settings.alerts.genericError": "Nagkaproblema. Pakisubukan muli, o kontakin ang support para sa tulong.", "account.settings.alerts.nameSuccess": "Matagumpay na na-update ang pangalan", "account.settings.alerts.passwordEmpty": "Hindi pwedeng walang laman ang mga puwang", "account.settings.alerts.passwordMatch": "Hindi tugma ang mga puwang para sa bagong password", @@ -32,7 +32,7 @@ "application.ada.hearing": "Para Sa Mga Kapansanan Sa Pandinig", "application.ada.label": "Mga Accessible Unit ng ADA", "application.ada.mobility": "Para Sa Mga Kapansanan Sa Pagkilos", - "application.ada.subTitle": "Kung napili ka para sa isang unit_ ang property ay kikilos upang maibigay ang pangangailangan mo sa abot ng kanilang makakaya. Kapag napili ang application mo_ maghanda para sa pagbibigay ng karagdagang dokumento mula sa iyong doktor.", + "application.ada.subTitle": "Kung napili ka para sa isang unit, ang property ay kikilos upang maibigay ang pangangailangan mo sa abot ng kanilang makakaya. Kapag napili ang application mo, maghanda para sa pagbibigay ng karagdagang dokumento mula sa iyong doktor.", "application.ada.title": "Ikaw ba o ang sinuman sa sambahayan mo ay nangangailangan ng isa sa sumusunod na mga accessibility feature ng ADA?", "application.ada.vision": "Para Sa Mga Kapansanan Sa Paningin", "application.alternateContact.contact.contactMailingAddressHelperText": "Pumili ng address kung saan mo pwedeng matanggap ang mga update at materyal tungkol sa iyong application", @@ -46,7 +46,7 @@ "application.alternateContact.name.caseManagerAgencyFormPlaceHolder": "Ahensiya", "application.alternateContact.name.caseManagerAgencyValidationErrorMessage": "Pakilagay ang ahensiya", "application.alternateContact.name.title": "Sino ang iyong kahaliling contact?", - "application.alternateContact.type.description": "Sa pamamagitan ng pagbibigay ng alternatibong contact_ pinapayagan mo kaming talakayin ang impormasyon sa iyong application sa kanila.", + "application.alternateContact.type.description": "Sa pamamagitan ng pagbibigay ng alternatibong contact, pinapayagan mo kaming talakayin ang impormasyon sa iyong application sa kanila.", "application.alternateContact.type.label": "Kahaliling Contact", "application.alternateContact.type.options.caseManager": "Case manager o tagapayo sa pabahay", "application.alternateContact.type.options.familyMember": "Miyembro ng pamilya", @@ -57,20 +57,20 @@ "application.alternateContact.type.otherTypeValidationErrorMessage": "Pakilagay ang uri ng relasyon", "application.alternateContact.type.title": "May iba pa bang tao na gusto mong pahintulutan na kontakin namin kung hindi ka namin makontak?", "application.alternateContact.type.validationErrorMessage": "Piliin ang kahaliling contact", - "application.autofill.prefillYourApplication": "Sasagutan lamang muna namin ang iyong application kasama ang sumusunod na mga detalye_ at maaari kang gumawa ng mga update.", + "application.autofill.prefillYourApplication": "Sasagutan lamang muna namin ang iyong application kasama ang sumusunod na mga detalye, at maaari kang gumawa ng mga update.", "application.autofill.reset": "I-reset at magsimulang muli", "application.autofill.saveTime": "Makatipid ng oras gamit ang mga detalye mula sa iyong huling application", "application.autofill.start": "Magsimula sa mga detalyeng ito", "application.chooseLanguage.chooseYourLanguage": "Pumili ng Iyong Wika", "application.chooseLanguage.letsGetStarted": "Simulan na natin ang iyong application", - "application.chooseLanguage.signInSaveTime": "Makakatipid ka sa oras sa pag-sign in sa pamamagitan ng pagsisimula sa mga detalye ng iyong huling application_ at payagan kang tingnan ang status ng application na ito anumang oras.", + "application.chooseLanguage.signInSaveTime": "Makakatipid ka sa oras sa pag-sign in sa pamamagitan ng pagsisimula sa mga detalye ng iyong huling application, at payagan kang tingnan ang status ng application na ito anumang oras.", "application.confirmation.informationSubmittedTitle": "Narito ang impormasyon na iyong isinumite.", "application.confirmation.lotteryNumber": "Ang confirmation number mo.", "application.confirmation.printCopy": "I-print ang kopya ng iyong mga record", "application.confirmation.submitted": "Isinumite: ", "application.confirmation.viewOriginalListing": "Tingnan ang orihinal na listahan", "application.contact.additionalPhoneNumber": "Mayroon akong karagdagang numero ng telepono", - "application.contact.addressWhereYouCurrentlyLive": "Kailangan namin ang address kung saan ka kasalukuyang nakatira. Kung wala kang tirahan_ ilagay ang shelter address o address na malapit kung saan ka nakatira.", + "application.contact.addressWhereYouCurrentlyLive": "Kailangan namin ang address kung saan ka kasalukuyang nakatira. Kung wala kang tirahan, ilagay ang shelter address o address na malapit kung saan ka nakatira.", "application.contact.address": "Address", "application.contact.apt": "Apt o Unit #", "application.contact.cityName": "Pangalan ng Lungsod", @@ -106,23 +106,23 @@ "application.contact.zip": "Zip Code", "application.details.adaPriorities": "Napiling ADA Priorities", "application.edited": "Binago", - "application.financial.income.instruction1": "Idagdag ang iyong kabuuang (bago ang buwis) na kita ng sambahayan mula sa sahod_ benepisyo at iba pang pinagkukunan mula sa lahat ng miyembro ng sambahayan.", + "application.financial.income.instruction1": "Idagdag ang iyong kabuuang (bago ang buwis) na kita ng sambahayan mula sa sahod, benepisyo at iba pang pinagkukunan mula sa lahat ng miyembro ng sambahayan.", "application.financial.income.instruction2": "Kailangan mo lamang magbigay ng tinantyang kabuuan ngayon. Ang aktuwal na kabuuan ay kukuwentahin kung ikaw ay napili.", "application.financial.income.legend": " Dalas ng kita", "application.financial.income.placeholder": "Kabuuan ng lahat ng iyong pinagkukunan ng kita", "application.financial.income.prompt": "Ano ang kabuuang kita ng iyong sambahayan bago kaltasan ng buwis?", "application.financial.income.title": "Magtungo tayo sa kita.", - "application.financial.income.validationError.instruction1": "Pakipalitan kung sa tingin mo ay nagkamali ka. Pakitandaan na kapag hindi tama ang alinmang impormasyon sa iyong application_ ikaw ay madidiskwalipika.", - "application.financial.income.validationError.instruction2": "Kung ang impormasyon na iyong inilagay ay tama_ hinihimok namin kayo na tingnan muli sa susunod dahil mas maraming property ang magiging available.", + "application.financial.income.validationError.instruction1": "Pakipalitan kung sa tingin mo ay nagkamali ka. Pakitandaan na kapag hindi tama ang alinmang impormasyon sa iyong application, ikaw ay madidiskwalipika.", + "application.financial.income.validationError.instruction2": "Kung ang impormasyon na iyong inilagay ay tama, hinihimok namin kayo na tingnan muli sa susunod dahil mas maraming property ang magiging available.", "application.financial.income.validationError.reason.high": "Napakataas ng kita ng iyong sambahayan.", "application.financial.income.validationError.reason.low": "Napakababa ng kita ng iyong sambahayan.", "application.financial.vouchers.housingVouchers.strong": "Mga housing voucher", "application.financial.vouchers.housingVouchers.text": "tulad ng Seksyon 8", - "application.financial.vouchers.legend": "Mga voucher sa pabahay_ kita na hindi kinakaltasan ng buwis o mga subsidiya sa pagrenta", + "application.financial.vouchers.legend": "Mga voucher sa pabahay, kita na hindi kinakaltasan ng buwis o mga subsidiya sa pagrenta", "application.financial.vouchers.nonTaxableIncome.strong": "Kita na hindi kinakaltasan ng buwis", - "application.financial.vouchers.nonTaxableIncome.text": "tulad ng SSI_ SSDI_ mga pagbabayad sa sustento sa anak_ o kabayarang benepisyo ng manggagawa", + "application.financial.vouchers.nonTaxableIncome.text": "tulad ng SSI, SSDI, mga pagbabayad sa sustento sa anak, o kabayarang benepisyo ng manggagawa", "application.financial.vouchers.rentalSubsidies.strong": "Mga subsidiya sa pagrent", - "application.financial.vouchers.rentalSubsidies.text": "tulad ng VASH_ HSA_ HOPWA_ Catholic Charities_ AIDS Foundation_ atbp.", + "application.financial.vouchers.rentalSubsidies.text": "tulad ng VASH, HSA, HOPWA, Catholic Charities, AIDS Foundation, atbp.", "application.financial.vouchers.title": "Ikaw ba o sinuman sa application na ito ay tumatanggap ng alinman sa mga sumusunod?", "application.form.general.saveAndFinishLater": "I-save at tapusin pagkatapos", "application.form.general.saveAndReturn": "I-save at ibalik para repasuhin", @@ -147,10 +147,10 @@ "application.household.addMembers.title": "Sabihin sa amin ang tungkol sa iyong sambahayan.", "application.household.assistanceUrl": "https://exygy.com/", "application.household.dontQualifyHeader": "Sa kasamaang palad lumilitaw na hindi ka kwalipikado para sa listing na ito.", - "application.household.dontQualifyInfo": "Baguhin kung naniniwala kang maaaring nagkamali ka. Dapat mong malaman na kung mali ang alinmang impormasyon sa iyong application ikaw ay madidiskwalipika. Kung tumpak ang impormasyong inilagay mo_ hinihikayat ka naming balikan sa susunod kapag mas maraming property ang magiging available.", - "application.household.expectingChanges.question": "Inaasahan mo ba ang anumang mga pagbabago sa iyong sambahayan sa susunod na 12 buwan_ tulad ng bilang ng mga tao?", + "application.household.dontQualifyInfo": "Baguhin kung naniniwala kang maaaring nagkamali ka. Dapat mong malaman na kung mali ang alinmang impormasyon sa iyong application ikaw ay madidiskwalipika. Kung tumpak ang impormasyong inilagay mo, hinihikayat ka naming balikan sa susunod kapag mas maraming property ang magiging available.", + "application.household.expectingChanges.question": "Inaasahan mo ba ang anumang mga pagbabago sa iyong sambahayan sa susunod na 12 buwan, tulad ng bilang ng mga tao?", "application.household.expectingChanges.title": "Inaasahan ang mga Pagbabago ng Sambahayan", - "application.household.genericSubtitle": "Kung mapili ang iyong aplikasyon_ maging handa na magbigay ng mga karagdagang dokumento.", + "application.household.genericSubtitle": "Kung mapili ang iyong aplikasyon, maging handa na magbigay ng mga karagdagang dokumento.", "application.household.householdMember": "Miyembro ng Sambahayan", "application.household.householdMembers": "Mga Miyembro ng Sambahayan", "application.household.householdStudent.question": "Mayroon ba sa iyong sambahayan na isang full time na estudyante o magiging 18 taong gulang sa loob ng 60 araw?", @@ -171,7 +171,7 @@ "application.household.member.whatReletionship": "Ano ang kanilang relasyon sa iyo", "application.household.member.workInRegionNote": "TBD", "application.household.member.workInRegion": "Nagtatrabaho ba sila sa %{county} County?", - "application.household.membersInfo.title": "Bago magdagdag ng ibang tao_ siguraduhing hindi sila pinangalanan sa anumang iba pang application para sa listahang ito.", + "application.household.membersInfo.title": "Bago magdagdag ng ibang tao, siguraduhing hindi sila pinangalanan sa anumang iba pang application para sa listahang ito.", "application.household.preferredUnit.legend": "Napiling uri ng unit", "application.household.preferredUnit.options.SRO": "SRO", "application.household.preferredUnit.options.fiveBdrm": "5 Kwarto", @@ -182,7 +182,7 @@ "application.household.preferredUnit.options.twoBdrm": "2 Kwarto", "application.household.preferredUnit.optionsLabel": "Tingnan ang lahat ng naaangkop", "application.household.preferredUnit.preferredUnitType": "Napiling Uri ng Unit", - "application.household.preferredUnit.subTitle": "Bagama't ang mga sukat ng unit ay karaniwang ibabatay sa nakatira_ mangyaring ibigay ang iyong gustong laki ng unit para sa pagtukoy ng iyong kagustuhan sa pagkakataong ito o pagbuo ng waitlist (para sa pagkakataong ito lamang).", + "application.household.preferredUnit.subTitle": "Bagama't ang mga sukat ng unit ay karaniwang ibabatay sa nakatira, mangyaring ibigay ang iyong gustong laki ng unit para sa pagtukoy ng iyong kagustuhan sa pagkakataong ito o pagbuo ng waitlist (para sa pagkakataong ito lamang).", "application.household.preferredUnit.title": "Sa anong mga sukat ng unit ka interesado?", "application.household.primaryApplicant": "Pangunahing Aplikante", "application.name.dobHelper": "Halimbawa: 01 19 2000", @@ -206,17 +206,17 @@ "application.review.confirmation.applicationsClosed": "Sarado na ang \nmga application", "application.review.confirmation.applicationsRanked": "Nakaranggo na ang \nmga application", "application.review.confirmation.browseMore": "Mag-browse ng mas maraming listahan", - "application.review.confirmation.createAccount": "### Gusto mo bang gumawa ng account?\n\nAng paggawa ng account ay mag-iingat ng iyong impormasyon para sa susunod na mga application_ at maaari mong tingnan ang status ng application kahit anong oras.", - "application.review.confirmation.createAccountParagraph": "Ang paggawa ng account ay mag-iingat ng iyong impormasyon para sa susunod na mga application_ at maaari mong tingnan ang status ng application kahit anong oras.", + "application.review.confirmation.createAccount": "### Gusto mo bang gumawa ng account?\n\nAng paggawa ng account ay mag-iingat ng iyong impormasyon para sa susunod na mga application, at maaari mong tingnan ang status ng application kahit anong oras.", + "application.review.confirmation.createAccountParagraph": "Ang paggawa ng account ay mag-iingat ng iyong impormasyon para sa susunod na mga application, at maaari mong tingnan ang status ng application kahit anong oras.", "application.review.confirmation.createAccountTitle": "Gusto mo bang gumawa ng account?", "application.review.confirmation.doNotSubmitTitle": "Huwag magsumite ng isa pang application para sa listahang ito.", "application.review.confirmation.eligibleApplicants.FCFS": "Ang mga kwalipikadong aplikante ay tatawagan ayon sa batayang first come first serve hanggang sa mapuno ang bakante.", "application.review.confirmation.eligibleApplicants.lottery": "Ang mga karapat-dapat na aplikante ay tatawagan ng ahente sa pagkakasunud-sunod ng ranggo ng lottery hanggang sa mapunan ang mga bakante.", "application.review.confirmation.eligibleApplicants.lotteryDate": "Ang lottery ay gaganapin sa **%{lotteryDate}**.", - "application.review.confirmation.imdone": "Salamat na lang_ tapos na ako.", + "application.review.confirmation.imdone": "Salamat na lang, tapos na ako.", "application.review.confirmation.lotteryNumber": "Narito ang iyong numero ng kumpirmasyon ng application", "application.review.confirmation.needToMakeUpdates": "### Kailangang gumawa ng mga update?\n\nKung kailangan mong i-update ang impormasyon sa iyong application, huwag mag-apply muli. Sa halip, makipag-ugnayan sa ahente para sa listahang ito.\n\n**%{agentName}** \n%{agentPhone} \n%{agentEmail}\n\n**Mga Oras na Bukas ang Opisina** \n%{agentOfficeHours}\n\nTawagan ang ahente kung hindi ka nakatanggap ng email ng kumpirmasyon.", - "application.review.confirmation.needToUpdate": "Kung kailangan mong baguhin ang impormasyon sa iyong application_ huwag mag-apply muli. Makipag-ugnayan sa ahente kung hindi ka nakatanggap ng kumpirmasyon sa email.", + "application.review.confirmation.needToUpdate": "Kung kailangan mong baguhin ang impormasyon sa iyong application, huwag mag-apply muli. Makipag-ugnayan sa ahente kung hindi ka nakatanggap ng kumpirmasyon sa email.", "application.review.confirmation.pleaseWriteNumber": "Mangyaring isulat ang iyong numero ng application at itago ito sa isang ligtas na lugar. Nagpadala rin kami sa iyo ng numerong ito sa email kung nagbigay ka ng email address.", "application.review.confirmation.print": "Tingnan ang isinumiteng application at i-print ang kopya.", "application.review.confirmation.title": "Salamat. Natanggap na namin ang iyong application.", @@ -224,7 +224,7 @@ "application.review.confirmation.whatExpectFirstParagraph.held": "Ang lottery ay gaganapin sa", "application.review.confirmation.whatExpectFirstParagraph.listing": "sa listahan. ", "application.review.confirmation.whatExpectFirstParagraph.refer": "Tingnan ang listahan para sa petsa ng mga resulta ng lottery.", - "application.review.confirmation.whatExpectSecondparagraph": "Ang mga aplikante ay tatawagan hanggang sa mapunan ang bakante. Kung mapili ang iyong application_ maging handa upang sagutan ang isang mas detalyadong application at magbigay ng mga kinakailangang karagdagang dokumento.", + "application.review.confirmation.whatExpectSecondparagraph": "Ang mga aplikante ay tatawagan hanggang sa mapunan ang bakante. Kung mapili ang iyong application, maging handa upang sagutan ang isang mas detalyadong application at magbigay ng mga kinakailangang karagdagang dokumento.", "application.review.confirmation.whatExpectTitle": "Ano ang susunod na aasahan", "application.review.confirmation.whatHappensNext": "### Ano ang susunod na mangyayari?\n\n* Matapos maisumite ang lahat ng mga application, magsisimula ang property manager sa pagproseso ng mga application.\n\n* %{reviewOrder}\n\n* Kapag tinawagan ka para sa interview, kailangan mong sagutan ang mas detalyadong application at magbigay ng karagdagang mga dokumento.", "application.review.demographics.ethnicityLabel": "Alin ang pinakanaglalarawan sa iyong etnisidad?", @@ -287,10 +287,10 @@ "application.review.takeAMomentToReview": "Maglaan ng ilang sandali upang suriin ang iyong impormasyon bago isumite ang iyong application.", "application.review.terms.confirmCheckboxText": "Sumasang-ayon ako at nauunawaan na hindi ko mababago ang anuman pagkatapos kong magsumite.", "application.review.terms.textSubmissionDate": "Ang application na ito ay dapat na isumite bago ang %{applicationDueDate}.

", - "application.review.terms.text": "Makikipag-ugnayan ang mga aplikante ng ahente sa lottery ng pagpaparenta at preference order o waitlist order hanggang sa mapunan ang mga bakante. Ang lahat ng impormasyon na iyong ibinigay ay i-ve-verify ang kukumpirmahin kung kwalipikado ka. Maaaring alisin ang iyong application sa waitlist kung gumawa ka ng anumang mga mapanlinlang na pahayag at maaaring alisin ang mga dobleng application mula sa parehong sambahayan dahil isang application lamang sa bawat sambahayan ang pinahihintulutan. Kung mapili ang iyong application para sa pagsusuri_ maging handa na punan ang isang mas detalyadong application at magbigay ng mga kinakailangang karagdagang dokumento. Para sa higit pang impormasyon_ makipag-ugnayan sa developer o ahente sa pagpapaupa na naka-post sa listahan. Makipag-ugnayan nang direkta sa developer/property manager kung mayroong anumang mga update sa iyong application.

Kung hindi namin ma-verify ang isang kagustuhan sa lottery sa pabahay na iyong nakuha_ hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan.< br>
Ang pagkumpleto ng application sa pabahay na ito ay hindi nagbibigay sa iyo ng karapatan sa pabahay o nagpapahiwatig na ikaw ay karapat-dapat para sa pabahay; lahat ng mga aplikante ay sasalain ayon sa nakabalangkas sa Pamantayan sa Pagpili ng Residente ng property. Hindi kami nag-aalok ng mga garantiya tungkol sa pagkuha ng pabahay.

Hindi mo mababago ang iyong online na application pagkatapos mong isumite.

Ipinapahayag ko na ang nabanggit ay totoo at tumpak_ at kinikilala na ang anumang maling pahayag ay mapanlinlang o kapabayaan na ginawa sa ang application na ito ay maaaring magresulta sa pag-alis mula sa lottery.

", + "application.review.terms.text": "Makikipag-ugnayan ang mga aplikante ng ahente sa lottery ng pagpaparenta at preference order o waitlist order hanggang sa mapunan ang mga bakante. Ang lahat ng impormasyon na iyong ibinigay ay i-ve-verify ang kukumpirmahin kung kwalipikado ka. Maaaring alisin ang iyong application sa waitlist kung gumawa ka ng anumang mga mapanlinlang na pahayag at maaaring alisin ang mga dobleng application mula sa parehong sambahayan dahil isang application lamang sa bawat sambahayan ang pinahihintulutan. Kung mapili ang iyong application para sa pagsusuri, maging handa na punan ang isang mas detalyadong application at magbigay ng mga kinakailangang karagdagang dokumento. Para sa higit pang impormasyon, makipag-ugnayan sa developer o ahente sa pagpapaupa na naka-post sa listahan. Makipag-ugnayan nang direkta sa developer/property manager kung mayroong anumang mga update sa iyong application.

Kung hindi namin ma-verify ang isang kagustuhan sa lottery sa pabahay na iyong nakuha, hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan.< br>
Ang pagkumpleto ng application sa pabahay na ito ay hindi nagbibigay sa iyo ng karapatan sa pabahay o nagpapahiwatig na ikaw ay karapat-dapat para sa pabahay; lahat ng mga aplikante ay sasalain ayon sa nakabalangkas sa Pamantayan sa Pagpili ng Residente ng property. Hindi kami nag-aalok ng mga garantiya tungkol sa pagkuha ng pabahay.

Hindi mo mababago ang iyong online na application pagkatapos mong isumite.

Ipinapahayag ko na ang nabanggit ay totoo at tumpak, at kinikilala na ang anumang maling pahayag ay mapanlinlang o kapabayaan na ginawa sa ang application na ito ay maaaring magresulta sa pag-alis mula sa lottery.

", "application.review.terms.title": "Mga Tuntunin", "application.review.voucherOrSubsidy": "Voucher ng Pabahay o Subsidiya ng Pagrenta", - "application.start.whatToExpect.info1": "Magtatanong muna kami tungkol sa iyo at sa mga taong pinaplano mong makasama. Pagkatapos_ tatanungin namin ang iyong kita. Sa huli_ titingnan natin kung kwalipikado ka para sa anumang pagpili sa lottery ng abot-kayang pabahay.", + "application.start.whatToExpect.info1": "Magtatanong muna kami tungkol sa iyo at sa mga taong pinaplano mong makasama. Pagkatapos, tatanungin namin ang iyong kita. Sa huli, titingnan natin kung kwalipikado ka para sa anumang pagpili sa lottery ng abot-kayang pabahay.", "application.start.whatToExpect.info2": "Tandaan na ang bawat miyembro ng sambahayan ay maaari lamang lumitaw sa isang application para sa bawat listahan.", "application.start.whatToExpect.info3": "Anumang mga mapanlinlang na pahayag ay magiging sanhi ng pagtanggal ng iyong application.", "application.start.whatToExpect.title": "Narito ang aasahan mula sa application na ito.", @@ -300,7 +300,7 @@ "application.statuses.submitted": "Naisumite Na", "application.timeout.action": "Mapatuloy na tinatrabaho", "application.timeout.afterMessage": "Pinapahalagahan namin ang iyong seguridad. Tinapos namin ang iyong sesyon dahil sa kawalan ng aktibidad. Mangyaring magsimula ng bagong application upang magpatuloy.", - "application.timeout.text": "Para maprotektahan ang iyong pagkakakilanlan_ matatapos ang iyong sesyon sa loob ng isang minuto dahil sa kawalan ng aktibidad. Mawawala sa iyo ang anumang hindi na-save na impormasyon kung pipiliin mong hindi tumugon.", + "application.timeout.text": "Para maprotektahan ang iyong pagkakakilanlan, matatapos ang iyong sesyon sa loob ng isang minuto dahil sa kawalan ng aktibidad. Mawawala sa iyo ang anumang hindi na-save na impormasyon kung pipiliin mong hindi tumugon.", "application.viewApplication": "Tingnan ang Application", "application.yourLotteryNumber": "Ang numero ng kumpirmasyon mo ay", "applications.addApplication": "Magdagdag ng Application", @@ -334,30 +334,30 @@ "authentication.forgotPassword.errors.tokenExpired": "Nag-expire na ang token ng pag-reset ng password. Humiling ng bago.", "authentication.forgotPassword.errors.tokenMissing": "Hindi nahanap ang token. Humiling ng bago.", "authentication.forgotPassword.sendEmail": "Magpadala ng email", - "authentication.signIn.accountHasBeenLocked": "Para sa mga kadahilanang pangseguridad_ ang account na ito isinara na.", - "authentication.signIn.afterFailedAttempts": "Para sa mga kadahilanang pangseguridad_ pagkatapos ng %{count} nabigong pagtatangka_ kailangan mong maghintay ng 30 minuto bago subukang muli.", + "authentication.signIn.accountHasBeenLocked": "Para sa mga kadahilanang pangseguridad, ang account na ito isinara na.", + "authentication.signIn.afterFailedAttempts": "Para sa mga kadahilanang pangseguridad, pagkatapos ng %{count} nabigong pagtatangka, kailangan mong maghintay ng 30 minuto bago subukang muli.", "authentication.signIn.changeYourPassword": "Maaari mong palitan ang iyong password", "authentication.signIn.enterLoginEmail": "Pakilagay ang iyong email sa pag-login", "authentication.signIn.enterLoginPassword": "Pakilagay ang iyong password sa pag-log in", "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Pakilagay ang tamang code.", "authentication.signIn.enterValidEmailAndPassword": "Pakilagay ang tamang email at password.", - "authentication.signIn.errorGenericMessage": "Pakisubukang muli_ o makipag-ugnayan sa suporta para humingi ng tulong.", + "authentication.signIn.errorGenericMessage": "Pakisubukang muli, o makipag-ugnayan sa suporta para humingi ng tulong.", "authentication.signIn.error": "Nagkaproblema sa iyong pag-sign in", "authentication.signIn.forgotPassword": "Nakalimutan ang password?", "authentication.signIn.loginError": "Pakilagay ang tamang email address", "authentication.signIn.passwordError": "Pakilagay ang tamang password", "authentication.signIn.passwordOutdated": "Nag-expire na ang password mo. Paki-reset ang password mo.", - "authentication.signIn.success": "Maligayang pagbabalik_ %{name}!", + "authentication.signIn.success": "Maligayang pagbabalik, %{name}!", "authentication.signIn.youHaveToWait": "Kailangan mong maghintay ng 30 minuto mula noong huling nabigong pagtatangka bago subukang muli.", "authentication.signIn.yourAccountIsNotConfirmed": "Hindi nakumpirma ang iyong account", "authentication.signOut.success": "Matagumpay kang nakapag-log out sa iyong account.", "authentication.terms.acceptToc": "Tinatanggap ko ang Mga Tuntunin ng Serbisyo", "authentication.terms.reviewToc": "Repasuhin ang Mga Tuntunin ng Serbisyo", "authentication.terms.termsOfService": "Mga Tuntunin ng Serbisyo", - "authentication.terms.youMustAcceptToc": "Upang magpatuloy_ dapat mong tanggapin ang Mga Tuntunin ng Serbisyo", + "authentication.terms.youMustAcceptToc": "Upang magpatuloy, dapat mong tanggapin ang Mga Tuntunin ng Serbisyo", "authentication.timeout.action": "Manatiling naka-login", "authentication.timeout.signOutMessage": "Pinapahalagahan namin ang iyong seguridad. Ni-log out ka namin dahil sa kawalan ng aktibidad. Mangyaring mag-sign in upang magpatuloy.", - "authentication.timeout.text": "Para protektahan ang iyong pagkakakilanlan_ mag-e-expire ang iyong sesyon sa loob ng isang minuto dahil sa kawalan ng aktibidad. Mawawala sa iyo ang anumang hindi na-save na impormasyon at ila-log out ka kung pipiliin mong hindi tumugon.", + "authentication.timeout.text": "Para protektahan ang iyong pagkakakilanlan, mag-e-expire ang iyong sesyon sa loob ng isang minuto dahil sa kawalan ng aktibidad. Mawawala sa iyo ang anumang hindi na-save na impormasyon at ila-log out ka kung pipiliin mong hindi tumugon.", "config.routePrefix": "tl", "errors.agreeError": "Dapat kang sumang-ayon sa mga tuntunin upang magpatuloy", "errors.alert.badRequest": "Mukhang nagkaproblema. Pakisubukan muli. \n\nMakipag-ugnayan sa iyong departamento ng pabahay kung nakakaranas ka pa rin ng mga isyu.", @@ -365,7 +365,7 @@ "errors.alert.applicationSubmissionVerificationError": "Walang kinakailangang field ang iyong aplikasyon. Mangyaring bumalik at itama ito bago isumite.", "errors.cityError": "Pakilagay ang lungsod", "errors.dateError": "Pakilagay ang tamang petsa", - "errors.dateOfBirthErrorAge": "Pakilagay ang tamang Petsa ng Kapanganakan_ dapat 18 o mas matanda", + "errors.dateOfBirthErrorAge": "Pakilagay ang tamang Petsa ng Kapanganakan, dapat 18 o mas matanda", "errors.dateOfBirthError": "Pakilagay ang tamang Petsa ng Kapanganakan", "errors.emailAddressError": "Pakilagay ang email address", "errors.errorsToResolve": "May mga problema na gusto mong resolbahin bago magpatuloy.", @@ -376,13 +376,13 @@ "errors.householdTooSmall": "Napakaliit ng iyong sambahayan.", "errors.lastNameError": "Pakilagay ang Apelyido", "errors.maxLength": "Hindi dapat higit sa 64 na character.", - "errors.notFound.message": "Sori_ mukhang hindi namin makita ang page na hinahanap mo. Pakisubukang bumalik sa dating page o mag-click sa ibaba para mag-browse ng mga listahan.", + "errors.notFound.message": "Sori, mukhang hindi namin makita ang page na hinahanap mo. Pakisubukang bumalik sa dating page o mag-click sa ibaba para mag-browse ng mga listahan.", "errors.notFound.title": "Hindi Nahanap ang Page", "errors.numberError": "Pakilagay ang tamang numero na mas malaki sa 0.", "errors.passwordConfirmationMismatch": "Hindi tugma ang kumpirmasyon ng password", "errors.phoneNumberError": "Pakilagay ang numero ng telepono", "errors.phoneNumberTypeError": "Pakilagay ang uri ng numero ng telepono", - "errors.rateLimitExceeded": "Lumampas sa limit ang rate_ subukan muli sa ibang pagkakataon.", + "errors.rateLimitExceeded": "Lumampas sa limit ang rate, subukan muli sa ibang pagkakataon.", "errors.requiredFieldError": "Kailangan ang field na ito", "errors.requiredFieldsError": "Kailangan ang mga field na ito", "errors.selectAllThatApply": "Piliin ang lahat ng angkop.", @@ -397,7 +397,7 @@ "footer.contact": "Contact", "footer.copyright": "Demonstration Jurisdiction Ā© 2021 ā€¢ Ang Lahat ng Karapatan ay Nakalaan", "footer.disclaimer": "Pagtatatuwa", - "footer.forGeneralQuestions": "Para sa pangkalahatang katanungan ukol sa programa_ maaari mo kaming tawagan sa 000-000-0000.", + "footer.forGeneralQuestions": "Para sa pangkalahatang katanungan ukol sa programa, maaari mo kaming tawagan sa 000-000-0000.", "footer.giveFeedback": "Magbigay ng Feedback", "housingCounselors.call": "Tumawag sa %{number}", "housingCounselors.languageServices": "Mga Serbisyo ng Lengguwahe: ", @@ -431,19 +431,19 @@ "listings.apply.pickUpAnApplication": "Pick-up-in ang application", "listings.apply.sendByUsMail": "Magpadala Application sa US Mail", "listings.apply.submitAPaperApplication": "Magsumite ng Papel na Application", - "listings.apply.submitPaperDueDateNoPostMark": "Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail_ ang application ay dapat na naka-postmark ng %{applicationDueDate}. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat.", - "listings.apply.submitPaperDueDatePostMark": "Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail_ ang application ay dapat na naka-postmark ng %{applicationDueDate}. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat. Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail_ ang application ay dapat na naka-postmark ng %{applicationDueDate}. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat.", + "listings.apply.submitPaperDueDateNoPostMark": "Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail, ang application ay dapat na naka-postmark ng %{applicationDueDate}. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat.", + "listings.apply.submitPaperDueDatePostMark": "Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail, ang application ay dapat na naka-postmark ng %{applicationDueDate}. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat. Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail, ang application ay dapat na naka-postmark ng %{applicationDueDate}. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat.", "listings.apply.submitPaperNoDueDateNoPostMark": "Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat.", - "listings.apply.submitPaperNoDueDatePostMark": "Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail_ ang pplication ay dapat na matanggap sa pamamagitan ng koreo nang hindi lalampas sa %{postmarkReceivedByDate}. Ang mga aplikasyong natanggap pagkatapos ng %{postmarkReceivedByDate} sa pamamagitan ng koreo ay hindi tatanggapin. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat.", + "listings.apply.submitPaperNoDueDatePostMark": "Ang mga application ay dapat matanggap bago ang huling araw. Kung nagpapadala sa pamamagitan ng U.S. Mail, ang pplication ay dapat na matanggap sa pamamagitan ng koreo nang hindi lalampas sa %{postmarkReceivedByDate}. Ang mga aplikasyong natanggap pagkatapos ng %{postmarkReceivedByDate} sa pamamagitan ng koreo ay hindi tatanggapin. Ang %{developer} ay hindi mananagot para sa nawala o naantalang sulat.", "listings.availableAndWaitlist": "Mga Available na Unit at Bukas na Waitlist", - "listings.availableUnitsAndWaitlistDesc": "Kapag napuno na ng mga aplikante ang lahat ng available na unit_ ilalagay ang mga karagdagang aplikante sa waitlist para sa %{number} na unit", + "listings.availableUnitsAndWaitlistDesc": "Kapag napuno na ng mga aplikante ang lahat ng available na unit, ilalagay ang mga karagdagang aplikante sa waitlist para sa %{number} na unit", "listings.availableUnitsAndWaitlist": "Mga available na unit at waitlist", "listings.bath": "bath", "listings.browseListings": "Mag-browse ng mga listahan", "listings.buildingImageAltText": "Larawan ng gusali", "listings.buildingSelectionCriteria": "Pagpili sa Criteria ng Gusali", - "listings.cc&rDescription": "Ipinapaliwanag ng CC&R ang mga patakaran ng samahan ng mga may-ari ng bahay_ at pinaghihigpitan kung paano mo mababago ang ari-arian.", - "listings.cc&r": "Mga Kasunduan_ Kundisyon at Pagbabawal o Covenants_ Conditions and Restrictions (CC&R's)", + "listings.cc&rDescription": "Ipinapaliwanag ng CC&R ang mga patakaran ng samahan ng mga may-ari ng bahay, at pinaghihigpitan kung paano mo mababago ang ari-arian.", + "listings.cc&r": "Mga Kasunduan, Kundisyon at Pagbabawal o Covenants, Conditions and Restrictions (CC&R's)", "listings.chooseALanguage": "Pumili ng wika", "listings.closedListings": "Sarado nang mga Listahan", "listings.closed": "Sarado na", @@ -453,7 +453,7 @@ "listings.criminalBackground": "Kriminal na Background", "listings.depositMayBeHigherForLowerCredit": "Maaaring mas mataas o mas mababa ang mga credit score", "listings.depositOrMonthsRent": "o isang buwang renta", - "listings.developmentalDisabilitiesDescription": "Ang isang bahaging bilang ng mga unit sa gusaling ito ay nakalaan para sa mga taong may kapansanan sa pag-unlad. Mangyaring bisitahin ang housingchoices.org para sa impormasyon sa pagiging kwalipikado_ mga kinakailangan_ kung paano makakuha ng application at para sa mga sagot sa iba pang mga katanungan maaaring mayroon ka tungkol sa proseso.", + "listings.developmentalDisabilitiesDescription": "Ang isang bahaging bilang ng mga unit sa gusaling ito ay nakalaan para sa mga taong may kapansanan sa pag-unlad. Mangyaring bisitahin ang housingchoices.org para sa impormasyon sa pagiging kwalipikado, mga kinakailangan, kung paano makakuha ng application at para sa mga sagot sa iba pang mga katanungan maaaring mayroon ka tungkol sa proseso.", "listings.developmentalDisabilities": "Mga taong may kapansanan sa pag-unlad", "listings.downloadPdf": "I-download ang PDF", "listings.eligibilityNotebook": "Notebook ng Pagiging Kwalipikado", @@ -461,7 +461,7 @@ "listings.enterLotteryForWaitlist": "Magsumite ng application para sa bukas ng slot ng waitlist para sa %{units} na unit.", "listings.featuresCards": "Mga Feature Card", "listings.forIncomeCalculationsBMR": "Ang mga kalkulasyon ng kita ay batay sa uri ng unit", - "listings.forIncomeCalculations": "Para sa mga kalkulasyon ng kita_ ang laki ng sambahayan ay kinabibilangan ng bawat isa (lahat ng edad) na nakatira sa unit.", + "listings.forIncomeCalculations": "Para sa mga kalkulasyon ng kita, ang laki ng sambahayan ay kinabibilangan ng bawat isa (lahat ng edad) na nakatira sa unit.", "listings.hideClosedListings": "Itago ang mga Nakasara nang Listahan", "listings.householdMaximumIncome": "Pinakamataas na Kita ng Sambahayan", "listings.householdSize": "Sukat ng Sambahayan", @@ -481,7 +481,7 @@ "listings.noOpenListings": "Walang kasalukuyang mga listahan ang bukas para sa mga application.", "listings.occupancyDescriptionAllSro": "Ang naninirahan para sa gusaling ito ay limitado lamang sa 1 tao sa bawat unit.", "listings.occupancyDescriptionNoSro": "Ang mga limit ng paninirahan para sa gusaling ito ay batay sa uri ng unit.", - "listings.occupancyDescriptionSomeSro": "Iba't iba ang naninirahan para sa gusaling ito ayon sa uri ng unit. Ang mga SRO ay limitado sa 1 tao bawat unit_ anuman ang edad. Para sa lahat ng iba pang uri ng unit_ hindi binibilang ng mga limitasyon sa paninirahan ang mga batang wala pang 6 taong gulang.", + "listings.occupancyDescriptionSomeSro": "Iba't iba ang naninirahan para sa gusaling ito ayon sa uri ng unit. Ang mga SRO ay limitado sa 1 tao bawat unit, anuman ang edad. Para sa lahat ng iba pang uri ng unit, hindi binibilang ng mga limitasyon sa paninirahan ang mga batang wala pang 6 taong gulang.", "listings.openHouseEvent.header": "Mga Bukas na Bahay", "listings.openHouseEvent.seeVideo": "Tingnan ang Video", "listings.percentAMIUnit": "%{percent}% AMI Unit", @@ -490,32 +490,32 @@ "listings.processInfo": "Impormasyon ng Proseso", "listings.publicLottery.header": "Pampublikong Lottery", "listings.rePricing": "Pagbabago ng Presyo", - "listings.remainingUnitsAfterPreferenceConsideration": "Pagkatapos na isaalang-alang ang lahat ng preference holder_ ang anumang natitirang mga unit ay magiging available sa iba pang mga kwalipikadong aplikante.", + "listings.remainingUnitsAfterPreferenceConsideration": "Pagkatapos na isaalang-alang ang lahat ng preference holder, ang anumang natitirang mga unit ay magiging available sa iba pang mga kwalipikadong aplikante.", "listings.rentalHistory": "History ng Pag-upa", "listings.requiredDocuments": "Kinakailangang mga Dokumento", - "listings.reservedUnitsDescription": "Upang maging kuwalipikado para sa mga unit na ito_ ang isa sa mga sumusunod ay dapat na angkop sa iyo o sa isang tao sa iyong sambahayan:", + "listings.reservedUnitsDescription": "Upang maging kuwalipikado para sa mga unit na ito, ang isa sa mga sumusunod ay dapat na angkop sa iyo o sa isang tao sa iyong sambahayan:", "listings.reservedUnitsForWhoAre": "Nakareserba para sa %{communityType} na %{reservedType}", "listings.reservedUnits": "Nakareserbang mga Unit", "listings.sections.additionalEligibilitySubtitle": "Ang mga aplikante ay dapat ding maging kwalipikado sa ilalim ng mga patakaran ng gusali.", "listings.sections.additionalEligibilityTitle": "Mga Karagdagang Panuntunan sa Pagiging Kwalipikado", "listings.sections.additionalFees": "Mga Dagdag na Singil", "listings.sections.additionalInformationSubtitle": "Mga kinakailangang dokumento at pamantayan sa pagpili", - "listings.sections.eligibilitySubtitle": "Kita_ paninirahan_ mga kagustuhan_ at mga subsidiya", + "listings.sections.eligibilitySubtitle": "Kita, paninirahan, mga kagustuhan, at mga subsidiya", "listings.sections.eligibilityTitle": "Pagiging kwalipikado", - "listings.sections.featuresSubtitle": "Mga pasilidad_ detalye ng unit at karagdagang bayad", + "listings.sections.featuresSubtitle": "Mga pasilidad, detalye ng unit at karagdagang bayad", "listings.sections.featuresTitle": "Mga feature", "listings.sections.housingPreferencesSubtitle": "Ang mga preference holder ay bibigyan ng pinakamataas na ranggo.", "listings.sections.housingPreferencesTitle": "Mga Pagpipilian ng Pabahay", "listings.sections.neighborhoodSubtitle": "Lokasyon at transportasyon", "listings.sections.processSubtitle": "Mahahalagang petsa at impormasyon ng kontak", "listings.sections.processTitle": "Proseso", - "listings.sections.rentalAssistanceSubtitle": "Isasaalang-alang ang Housing Choice Voucher_ Seksyon 8 at iba pang wastong programa ng tulong sa pagrenta para sa property na ito. Sa kaso ng may bisang subsidiya sa pag-upa_ ang kinakailangang minimum na kita ay ibabatay sa bahagi ng upa na babayaran ng nangungupahan pagkatapos gamitin ang subsidiya.", + "listings.sections.rentalAssistanceSubtitle": "Isasaalang-alang ang Housing Choice Voucher, Seksyon 8 at iba pang wastong programa ng tulong sa pagrenta para sa property na ito. Sa kaso ng may bisang subsidiya sa pag-upa, ang kinakailangang minimum na kita ay ibabatay sa bahagi ng upa na babayaran ng nangungupahan pagkatapos gamitin ang subsidiya.", "listings.sections.rentalAssistanceTitle": "Tulong sa Pagrenta", "listings.seeMaximumIncomeInformation": "Tingnan ang Impormasyon ng Pinakamataas na Kita", "listings.seePreferenceInformation": "Tingnan ang Impormasyon ng Pagpipilian", "listings.seeUnitInformation": "Tingnan ang Impormasyon ng Unit", "listings.showClosedListings": "Ipakita ang Saradong nang mga Listahan", - "listings.singleRoomOccupancyDescription": "Nag-aalok ang property na ito ng isahang mga kwarto para sa isang tao lamang. Ang mga nangungupahan ay maaaring maghati sa mga banyo_ at kung minsan ay mga kagamitan sa kusina.", + "listings.singleRoomOccupancyDescription": "Nag-aalok ang property na ito ng isahang mga kwarto para sa isang tao lamang. Ang mga nangungupahan ay maaaring maghati sa mga banyo, at kung minsan ay mga kagamitan sa kusina.", "listings.singleRoomOccupancy": "SRO", "listings.specialNotes": "Espesyal na Mga Paunawa", "listings.unit.sharedBathroom": "Ibinahagi", @@ -536,7 +536,7 @@ "listings.waitlist.label": "Waitlist", "listings.waitlist.openSlots": "Bukas na mga Slot sa Waitlist", "listings.waitlist.open": "Bukas na Waitlist", - "listings.waitlist.submitAnApplication": "Sa sandaling mapunan ng mga na-rank na aplikante ang lahat ng magagamit na mga yunit_ ang mga natitirang na-rank na mga aplikante ay ilalagay sa isang waitlist para sa parehong mga unit.", + "listings.waitlist.submitAnApplication": "Sa sandaling mapunan ng mga na-rank na aplikante ang lahat ng magagamit na mga yunit, ang mga natitirang na-rank na mga aplikante ay ilalagay sa isang waitlist para sa parehong mga unit.", "listings.waitlist.submitForWaitlist": "Magsumite ng application para sa isang bukas na slot sa waitlist.", "listings.waitlist.unitsAndWaitlist": "Mga Available na Unit at Waitlist", "lottery.applicationsThatQualifyForPreference": "Ang mga application na kwalipikado para sa pagpipilian na ito ay bibigyan ng mas mataas na prayoridad.", @@ -552,7 +552,7 @@ "nav.signOut": "Mag-sign Out", "nav.siteTitle": "Portal ng Pabahay", "pageDescription.additionalResources": "Hinihikayat ka naming mag-browse ng iba pang mapagkukunan ng abot-kayang pabahay.", - "pageDescription.listing": "Mag-apply para sa abot-kayang pabahay sa %{listingName} sa %{regionName}_ na binuo sa pakikipagtulungan ng Exygy.", + "pageDescription.listing": "Mag-apply para sa abot-kayang pabahay sa %{listingName} sa %{regionName}, na binuo sa pakikipagtulungan ng Exygy.", "pageDescription.welcome": "Maghanap at mag-apply para sa abot-kayang pabahay sa Housing Portal ng %{regionName}.", "pageTitle.additionalResources": "Mas Marami Pang Oportunidad ng Pabahay", "pageTitle.disclaimer": "Mga Pagtatatuwa ng Pag-endorso", @@ -673,7 +673,7 @@ "users.accountConfirmed": "Kinumpirma ang account", "users.confirmationSent": "Ipinadala ang kumpirmasyon", "users.inviteSent": "Ipinadala ang imbitasyon", - "welcome.allApplicationClosed": "Ang lahat ng mga application ay kasalukuyang sarado_ ngunit maaari mong tingnan ang mga sarado nang listahan.", + "welcome.allApplicationClosed": "Ang lahat ng mga application ay kasalukuyang sarado, ngunit maaari mong tingnan ang mga sarado nang listahan.", "welcome.seeMoreOpportunitiesTruncated": "Tingnan ang mas maraming oportunidad at mga pagkukunan", "welcome.seeMoreOpportunities": "Tingnan ang mas maraming renta at mga oportunidad ng pag-aari ng pabahay", "welcome.seeRentalListings": "Tingnan ang mga Renta", @@ -682,6 +682,6 @@ "welcome.title": "Mag-apply para sa abot-kayang pabahay sa", "welcome.viewAdditionalHousingTruncated": "Tingnan ang mga oportunidad at mapagkukunan", "welcome.viewAdditionalHousing": "Tingnan ang karagdagang oportunidad at mapagkukunan ng pabahay", - "whatToExpect.default": "Ang mga aplikante ay tatawagan ng ahente ng property sa ayon sa pagkakasunod-sunod ng rank hanggang sa mapunan ang mga bakante. Ang lahat ng impormasyon na iyong ibinigay ay i-ve-verify ang kukumpirmahin ang iyong pagiging kwalipikado. Aalisin ang iyong application sa waitlist kung gumawa ka ng anumang mapanlinlang na pahayag. Kung hindi namin ma-verify ang isang kagustuhan sa pabahay na iyong na-claim_ hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan. Kung mapili ang iyong application_ maging handa upang punan ang isang mas detalyadong application at magbigay ng mga kinakailangang pansuportang dokumento.", + "whatToExpect.default": "Ang mga aplikante ay tatawagan ng ahente ng property sa ayon sa pagkakasunod-sunod ng rank hanggang sa mapunan ang mga bakante. Ang lahat ng impormasyon na iyong ibinigay ay i-ve-verify ang kukumpirmahin ang iyong pagiging kwalipikado. Aalisin ang iyong application sa waitlist kung gumawa ka ng anumang mapanlinlang na pahayag. Kung hindi namin ma-verify ang isang kagustuhan sa pabahay na iyong na-claim, hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan. Kung mapili ang iyong application, maging handa upang punan ang isang mas detalyadong application at magbigay ng mga kinakailangang pansuportang dokumento.", "whatToExpect.label": "Ano ang Aasahan" } diff --git a/shared-helpers/src/locales/vi.json b/shared-helpers/src/locales/vi.json index 28e8aa7a02..5fc73c518a 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -23,7 +23,7 @@ "account.settings.confirmNewPassword": "XĆ”c nhįŗ­n mįŗ­t khįŗ©u mį»›i", "account.settings.currentPassword": "Mįŗ­t khįŗ©u hiį»‡n tįŗ”i", "account.settings.newPassword": "Mįŗ­t khįŗ©u mį»›i", - "account.settings.passwordRemember": "Khi thay đį»•i mįŗ­t khįŗ©u_ hĆ£y nhį»› ghi lįŗ”i mįŗ­t khįŗ©u đį»ƒ cĆ³ thį»ƒ ghi nhį»› trong tĘ°Ę”ng lai.", + "account.settings.passwordRemember": "Khi thay đį»•i mįŗ­t khįŗ©u, hĆ£y nhį»› ghi lįŗ”i mįŗ­t khįŗ©u đį»ƒ cĆ³ thį»ƒ ghi nhį»› trong tĘ°Ę”ng lai.", "account.settings.placeholders.day": "NgĆ y", "account.settings.placeholders.month": "ThĆ”ng", "account.settings.placeholders.year": "Năm", @@ -118,7 +118,7 @@ "application.financial.income.validationError.reason.low": "Thu nhįŗ­p hį»™ gia đƬnh cį»§a quĆ½ vį»‹ quĆ” thįŗ„p.", "application.financial.vouchers.housingVouchers.strong": "Phiįŗæu chį»n NhĆ ", "application.financial.vouchers.housingVouchers.text": "nhĘ° Mį»„c 8 (Section 8)", - "application.financial.vouchers.legend": "Phiįŗæu mua nhĆ _ thu nhįŗ­p đʰį»£c miį»…n thuįŗæ hoįŗ·c trį»£ cįŗ„p tiį»n thuĆŖ nhĆ ", + "application.financial.vouchers.legend": "Phiįŗæu mua nhĆ , thu nhįŗ­p đʰį»£c miį»…n thuįŗæ hoįŗ·c trį»£ cįŗ„p tiį»n thuĆŖ nhĆ ", "application.financial.vouchers.nonTaxableIncome.strong": "Thu nhįŗ­p khĆ“ng chį»‹u thuįŗæ", "application.financial.vouchers.nonTaxableIncome.text": "nhĘ° SSI, SSDI, cĆ”c khoįŗ£n tiį»n trį»£ cįŗ„p nuĆ“i con, hoįŗ·c cĆ”c khoįŗ£n tiį»n quyį»n lį»£i bį»“i thĘ°į»ng cho ngĘ°į»i lao đį»™ng", "application.financial.vouchers.rentalSubsidies.strong": "CĆ”c khoįŗ£n trį»£ cįŗ„p tiį»n thuĆŖ nhĆ ", @@ -334,7 +334,7 @@ "application.review.takeAMomentToReview": "HĆ£y dĆ nh mį»™t chĆŗt thį»i gian đį»ƒ xem lįŗ”i thĆ“ng tin cį»§a quĆ½ vį»‹ trĘ°į»›c khi nį»™p Ä‘Ę”n ghi danh.", "application.review.terms.confirmCheckboxText": "TĆ“i đį»“ng Ć½ vĆ  hiį»ƒu rįŗ±ng tĆ“i khĆ“ng thį»ƒ thay đį»•i bįŗ„t cį»© thĆ“ng tin nĆ o sau khi tĆ“i nį»™p Ä‘Ę”n.", "application.review.terms.textSubmissionDate": "Đʔn đăng kĆ½ nĆ y phįŗ£i đʰį»£c gį»­i trĘ°į»›c %{applicationDueDate}.

", - "application.review.terms.text": "NgĘ°į»i đăng kĆ½ sįŗ½ đʰį»£c đįŗ”i lĆ½ cho thuĆŖ liĆŖn hį»‡ theo thį»© tį»± bį»‘c thăm vĆ  thį»© tį»± Ę°u tiĆŖn hoįŗ·c thį»© tį»± danh sĆ”ch chį» cho đįŗæn khi hįŗæt căn hį»™ trį»‘ng. Tįŗ„t cįŗ£ thĆ“ng tin quĆ½ vį»‹ Ä‘Ć£ cung cįŗ„p sįŗ½ đʰį»£c xĆ”c minh vĆ  xĆ”c nhįŗ­n tĆ­nh đį»§ điį»u kiį»‡n. Đʔn đăng kĆ½ cį»§a quĆ½ vį»‹ cĆ³ thį»ƒ bį»‹ loįŗ”i khį»i danh sĆ”ch chį» nįŗæu quĆ½ vį»‹ cĆ³ bįŗ„t kį»³ tuyĆŖn bį»‘ gian dį»‘i nĆ o_ cĆ”c Ä‘Ę”n đăng kĆ½ trĆ¹ng lįŗ·p tį»« cĆ¹ng mį»™t hį»™ gia đƬnh cĆ³ thį»ƒ bį»‹ loįŗ”i vƬ mį»—i hį»™ gia đƬnh chį»‰ đʰį»£c phĆ©p đăng kĆ½ mį»™t Ä‘Ę”n. Nįŗæu Ä‘Ę”n đăng kĆ½ cį»§a quĆ½ vį»‹ đʰį»£c chį»n đį»ƒ xem xĆ©t_ hĆ£y chuįŗ©n bį»‹ đį»ƒ điį»n vĆ o Ä‘Ę”n đăng kĆ½ chi tiįŗæt hĘ”n vĆ  cung cįŗ„p cĆ”c tĆ i liį»‡u hį»— trį»£ cįŗ§n thiįŗæt. Đį»ƒ biįŗæt thĆŖm thĆ“ng tin_ vui lĆ²ng liĆŖn hį»‡ vį»›i chį»§ đįŗ§u tĘ° hoįŗ·c đįŗ”i lĆ½ cho thuĆŖ cĆ³ tĆŖn trong danh sĆ”ch. Vui lĆ²ng liĆŖn hį»‡ trį»±c tiįŗæp vį»›i chį»§ đįŗ§u tĘ°/ngĘ°į»i quįŗ£n lĆ½ khu nhĆ  nįŗæu cĆ³ bįŗ„t kį»³ cįŗ­p nhįŗ­t nĆ o đį»‘i vį»›i Ä‘Ę”n đăng kĆ½ cį»§a quĆ½ vį»‹.

Nįŗæu chĆŗng tĆ“i khĆ“ng thį»ƒ xĆ”c minh mį»©c Ę°u tiĆŖn bį»‘c thăm nhĆ  į»Ÿ mĆ  quĆ½ vį»‹ Ä‘Ć£ yĆŖu cįŗ§u_ quĆ½ vį»‹ sįŗ½ khĆ“ng nhįŗ­n đʰį»£c Ę°u tiĆŖn Ä‘Ć³ nhĘ°ng sįŗ½ khĆ“ng bį»‹ phįŗ”t.

Viį»‡c hoĆ n thĆ nh Ä‘Ę”n đăng kĆ½ nhĆ  į»Ÿ nĆ y khĆ“ng đį»“ng nghÄ©a vį»›i viį»‡c quĆ½ vį»‹ sįŗ½ cĆ³ đʰį»£c nhĆ  į»Ÿ hoįŗ·c cho thįŗ„y quĆ½ vį»‹ đį»§ điį»u kiį»‡n nhįŗ­n nhĆ  į»Ÿ; tįŗ„t cįŗ£ nhį»Æng ngĘ°į»i nį»™p Ä‘Ę”n sįŗ½ đʰį»£c sĆ ng lį»c nhĘ° Ä‘Ć£ nĆŖu trong TiĆŖu chĆ­ Lį»±a chį»n CĘ° dĆ¢n cį»§a khu nhĆ . ChĆŗng tĆ“i khĆ“ng đįŗ£m bįŗ£o vį» viį»‡c cĆ³ đʰį»£c nhĆ  į»Ÿ.

QuĆ½ vį»‹ khĆ“ng thį»ƒ thay đį»•i Ä‘Ę”n đăng kĆ½ trį»±c tuyįŗæn sau khi gį»­i.

TĆ“i tuyĆŖn bį»‘ rįŗ±ng nhį»Æng điį»u nĆŖu trĆŖn lĆ  đĆŗng vĆ  chĆ­nh xĆ”c_ đį»“ng thį»i thį»«a nhįŗ­n rįŗ±ng bįŗ„t kį»³ sai sĆ³t nĆ o do gian lįŗ­n hoįŗ·c do sĘ” suįŗ„t trong Ä‘Ę”n đăng kĆ½ nĆ y đį»u cĆ³ thį»ƒ dįŗ«n đįŗæn viį»‡c bį»‹ loįŗ”i khį»i bį»‘c thăm.

", + "application.review.terms.text": "NgĘ°į»i đăng kĆ½ sįŗ½ đʰį»£c đįŗ”i lĆ½ cho thuĆŖ liĆŖn hį»‡ theo thį»© tį»± bį»‘c thăm vĆ  thį»© tį»± Ę°u tiĆŖn hoįŗ·c thį»© tį»± danh sĆ”ch chį» cho đįŗæn khi hįŗæt căn hį»™ trį»‘ng. Tįŗ„t cįŗ£ thĆ“ng tin quĆ½ vį»‹ Ä‘Ć£ cung cįŗ„p sįŗ½ đʰį»£c xĆ”c minh vĆ  xĆ”c nhįŗ­n tĆ­nh đį»§ điį»u kiį»‡n. Đʔn đăng kĆ½ cį»§a quĆ½ vį»‹ cĆ³ thį»ƒ bį»‹ loįŗ”i khį»i danh sĆ”ch chį» nįŗæu quĆ½ vį»‹ cĆ³ bįŗ„t kį»³ tuyĆŖn bį»‘ gian dį»‘i nĆ o, cĆ”c Ä‘Ę”n đăng kĆ½ trĆ¹ng lįŗ·p tį»« cĆ¹ng mį»™t hį»™ gia đƬnh cĆ³ thį»ƒ bį»‹ loįŗ”i vƬ mį»—i hį»™ gia đƬnh chį»‰ đʰį»£c phĆ©p đăng kĆ½ mį»™t Ä‘Ę”n. Nįŗæu Ä‘Ę”n đăng kĆ½ cį»§a quĆ½ vį»‹ đʰį»£c chį»n đį»ƒ xem xĆ©t, hĆ£y chuįŗ©n bį»‹ đį»ƒ điį»n vĆ o Ä‘Ę”n đăng kĆ½ chi tiįŗæt hĘ”n vĆ  cung cįŗ„p cĆ”c tĆ i liį»‡u hį»— trį»£ cįŗ§n thiįŗæt. Đį»ƒ biįŗæt thĆŖm thĆ“ng tin, vui lĆ²ng liĆŖn hį»‡ vį»›i chį»§ đįŗ§u tĘ° hoįŗ·c đįŗ”i lĆ½ cho thuĆŖ cĆ³ tĆŖn trong danh sĆ”ch. Vui lĆ²ng liĆŖn hį»‡ trį»±c tiįŗæp vį»›i chį»§ đįŗ§u tĘ°/ngĘ°į»i quįŗ£n lĆ½ khu nhĆ  nįŗæu cĆ³ bįŗ„t kį»³ cįŗ­p nhįŗ­t nĆ o đį»‘i vį»›i Ä‘Ę”n đăng kĆ½ cį»§a quĆ½ vį»‹.

Nįŗæu chĆŗng tĆ“i khĆ“ng thį»ƒ xĆ”c minh mį»©c Ę°u tiĆŖn bį»‘c thăm nhĆ  į»Ÿ mĆ  quĆ½ vį»‹ Ä‘Ć£ yĆŖu cįŗ§u, quĆ½ vį»‹ sįŗ½ khĆ“ng nhįŗ­n đʰį»£c Ę°u tiĆŖn Ä‘Ć³ nhĘ°ng sįŗ½ khĆ“ng bį»‹ phįŗ”t.

Viį»‡c hoĆ n thĆ nh Ä‘Ę”n đăng kĆ½ nhĆ  į»Ÿ nĆ y khĆ“ng đį»“ng nghÄ©a vį»›i viį»‡c quĆ½ vį»‹ sįŗ½ cĆ³ đʰį»£c nhĆ  į»Ÿ hoįŗ·c cho thįŗ„y quĆ½ vį»‹ đį»§ điį»u kiį»‡n nhįŗ­n nhĆ  į»Ÿ; tįŗ„t cįŗ£ nhį»Æng ngĘ°į»i nį»™p Ä‘Ę”n sįŗ½ đʰį»£c sĆ ng lį»c nhĘ° Ä‘Ć£ nĆŖu trong TiĆŖu chĆ­ Lį»±a chį»n CĘ° dĆ¢n cį»§a khu nhĆ . ChĆŗng tĆ“i khĆ“ng đįŗ£m bįŗ£o vį» viį»‡c cĆ³ đʰį»£c nhĆ  į»Ÿ.

QuĆ½ vį»‹ khĆ“ng thį»ƒ thay đį»•i Ä‘Ę”n đăng kĆ½ trį»±c tuyįŗæn sau khi gį»­i.

TĆ“i tuyĆŖn bį»‘ rįŗ±ng nhį»Æng điį»u nĆŖu trĆŖn lĆ  đĆŗng vĆ  chĆ­nh xĆ”c, đį»“ng thį»i thį»«a nhįŗ­n rįŗ±ng bįŗ„t kį»³ sai sĆ³t nĆ o do gian lįŗ­n hoįŗ·c do sĘ” suįŗ„t trong Ä‘Ę”n đăng kĆ½ nĆ y đį»u cĆ³ thį»ƒ dįŗ«n đįŗæn viį»‡c bį»‹ loįŗ”i khį»i bį»‘c thăm.

", "application.review.terms.title": "CĆ”c điį»u khoįŗ£n", "application.review.voucherOrSubsidy": "Phiįŗæu chį»n NhĆ  hoįŗ·c Trį»£ cįŗ„p Tiį»n thuĆŖ nhĆ ", "application.start.whatToExpect.info1": "TrĘ°į»›c tiĆŖn, chĆŗng tĆ“i sįŗ½ hį»i vį» quĆ½ vį»‹ vĆ  nhį»Æng ngĘ°į»i quĆ½ vį»‹ dį»± đį»‹nh sį»‘ng cĆ¹ng. Sau Ä‘Ć³, chĆŗng tĆ“i sįŗ½ hį»i vį» thu nhįŗ­p cį»§a quĆ½ vį»‹. Cuį»‘i cĆ¹ng, chĆŗng tĆ“i sįŗ½ xem liį»‡u quĆ½ vį»‹ cĆ³ hį»™i đį»§ điį»u kiį»‡n cho bįŗ„t kį»³ lį»±a chį»n Ę°u tiĆŖn rĆŗt thăm nhĆ  į»Ÿ giĆ” phįŗ£i chăng nĆ o khĆ“ng.", @@ -381,8 +381,8 @@ "authentication.forgotPassword.errors.tokenExpired": "MĆ£ thĆ“ng bĆ”o đįŗ·t lįŗ”i mįŗ­t khįŗ©u Ä‘Ć£ hįŗæt hįŗ”n. Vui lĆ²ng yĆŖu cįŗ§u mĆ£ mį»›i.", "authentication.forgotPassword.errors.tokenMissing": "KhĆ“ng tƬm thįŗ„y mĆ£ thĆ“ng bĆ”o. Vui lĆ²ng yĆŖu cįŗ§u mĆ£ mį»›i.", "authentication.forgotPassword.sendEmail": "Gį»­i email", - "authentication.signIn.accountHasBeenLocked": "VƬ lĆ½ do bįŗ£o mįŗ­t_ tĆ i khoįŗ£n nĆ y Ä‘Ć£ bį»‹ khĆ³a.", - "authentication.signIn.afterFailedAttempts": "VƬ lĆ½ do bįŗ£o mįŗ­t_ quĆ½ vį»‹ sįŗ½ phįŗ£i chį» 30 phĆŗt trĘ°į»›c khi thį»­ lįŗ”i sau %{count} lįŗ§n thį»­ khĆ“ng thĆ nh cĆ“ng.", + "authentication.signIn.accountHasBeenLocked": "VƬ lĆ½ do bįŗ£o mįŗ­t, tĆ i khoįŗ£n nĆ y Ä‘Ć£ bį»‹ khĆ³a.", + "authentication.signIn.afterFailedAttempts": "VƬ lĆ½ do bįŗ£o mįŗ­t, quĆ½ vį»‹ sįŗ½ phįŗ£i chį» 30 phĆŗt trĘ°į»›c khi thį»­ lįŗ”i sau %{count} lįŗ§n thį»­ khĆ“ng thĆ nh cĆ“ng.", "authentication.signIn.changeYourPassword": "QuĆ½ vį»‹ cĆ³ thį»ƒ đį»•i mįŗ­t khįŗ©u", "authentication.signIn.enterLoginEmail": "Vui lĆ²ng nhįŗ­p email đăng nhįŗ­p cį»§a quĆ½ vį»‹", "authentication.signIn.enterLoginPassword": "Vui lĆ²ng nhįŗ­p mįŗ­t khįŗ©u đăng nhįŗ­p cį»§a quĆ½ vį»‹", @@ -401,7 +401,7 @@ "authentication.terms.acceptToc": "TĆ“i chįŗ„p nhįŗ­n Điį»u khoįŗ£n Dį»‹ch vį»„", "authentication.terms.reviewToc": "Xem lįŗ”i Điį»u khoįŗ£n Dį»‹ch vį»„", "authentication.terms.termsOfService": "Điį»u khoįŗ£n Dį»‹ch vį»„", - "authentication.terms.youMustAcceptToc": "Đį»ƒ tiįŗæp tį»„c_ quĆ½ vį»‹ phįŗ£i chįŗ„p nhįŗ­n Điį»u khoįŗ£n Dį»‹ch vį»„", + "authentication.terms.youMustAcceptToc": "Đį»ƒ tiįŗæp tį»„c, quĆ½ vį»‹ phįŗ£i chįŗ„p nhįŗ­n Điį»u khoįŗ£n Dį»‹ch vį»„", "authentication.timeout.action": "Duy trƬ đăng nhįŗ­p", "authentication.timeout.signOutMessage": "ChĆŗng tĆ“i quan tĆ¢m đįŗæn sį»± bįŗ£o mįŗ­t cį»§a quĆ½ vį»‹. ChĆŗng tĆ“i Ä‘Ć£ đăng xuįŗ„t tĆ i khoįŗ£n cį»§a quĆ½ vį»‹ do khĆ“ng cĆ³ hoįŗ”t đį»™ng nĆ o. Vui lĆ²ng đăng nhįŗ­p đį»ƒ tiįŗæp tį»„c.", "authentication.timeout.text": "Đį»ƒ bįŗ£o vį»‡ danh tĆ­nh cį»§a quĆ½ vį»‹, phiĆŖn truy cįŗ­p cį»§a quĆ½ vį»‹ sįŗ½ hįŗæt hįŗ”n sau mį»™t phĆŗt do khĆ“ng cĆ³ hoįŗ”t đį»™ng. QuĆ½ vį»‹ sįŗ½ mįŗ„t mį»i thĆ“ng tin chĘ°a đʰį»£c lĘ°u vĆ  bį»‹ đăng xuįŗ„t nįŗæu quĆ½ vį»‹ lį»±a chį»n khĆ“ng trįŗ£ lį»i.", @@ -412,7 +412,7 @@ "errors.alert.applicationSubmissionVerificationError": "į»Øng dį»„ng cį»§a bįŗ”n thiįŗæu cĆ”c trĘ°į»ng bįŗÆt buį»™c. Vui lĆ²ng quay lįŗ”i vĆ  sį»­a lį»—i nĆ y trĘ°į»›c khi gį»­i.", "errors.cityError": "Vui lĆ²ng nhįŗ­p thĆ nh phį»‘", "errors.dateError": "Vui lĆ²ng nhįŗ­p ngĆ y hį»£p lį»‡", - "errors.dateOfBirthErrorAge": "Vui lĆ²ng nhįŗ­p NgĆ y sinh hį»£p lį»‡_ phįŗ£i tį»« 18 tuį»•i trį»Ÿ lĆŖn", + "errors.dateOfBirthErrorAge": "Vui lĆ²ng nhįŗ­p NgĆ y sinh hį»£p lį»‡, phįŗ£i tį»« 18 tuį»•i trį»Ÿ lĆŖn", "errors.dateOfBirthError": "Vui lĆ²ng nhįŗ­p NgĆ y sinh hį»£p lį»‡", "errors.emailAddressError": "Vui lĆ²ng nhįŗ­p đį»‹a chį»‰ email", "errors.errorsToResolve": "QuĆ½ vį»‹ cįŗ§n giįŗ£i quyįŗæt nhį»Æng lį»—i nĆ y trĘ°į»›c khi chuyį»ƒn sang bĘ°į»›c tiįŗæp.", @@ -429,7 +429,7 @@ "errors.passwordConfirmationMismatch": "XĆ”c nhįŗ­n mįŗ­t khįŗ©u khĆ“ng khį»›p", "errors.phoneNumberError": "Vui lĆ²ng nhįŗ­p sį»‘ điį»‡n thoįŗ”i", "errors.phoneNumberTypeError": "Vui lĆ²ng nhįŗ­p kiį»ƒu sį»‘ điį»‡n thoįŗ”i", - "errors.rateLimitExceeded": "ÄĆ£ vĘ°į»£t quĆ” giį»›i hįŗ”n tį»· lį»‡_ hĆ£y thį»­ lįŗ”i sau.", + "errors.rateLimitExceeded": "ÄĆ£ vĘ°į»£t quĆ” giį»›i hįŗ”n tį»· lį»‡, hĆ£y thį»­ lįŗ”i sau.", "errors.requiredFieldError": "TrĘ°į»ng nĆ y lĆ  bįŗÆt buį»™c", "errors.requiredFieldsError": "CĆ”c trĘ°į»ng nĆ y lĆ  bįŗÆt buį»™c", "errors.selectAllThatApply": "Vui lĆ²ng chį»n tįŗ„t cįŗ£ cĆ”c cĆ¢u trįŗ£ lį»i phĆ¹ hį»£p", @@ -473,10 +473,10 @@ "listings.apply.pickUpAnApplication": "Nhįŗ­n Ä‘Ę”n ghi danh", "listings.apply.sendByUsMail": "Gį»­i Đʔn ghi danh qua đʰį»ng bĘ°u điį»‡n US Mail", "listings.apply.submitAPaperApplication": "Gį»­i Giįŗ„y ghi danh", - "listings.apply.submitPaperDueDateNoPostMark": "Đʔn đăng kĆ½ phįŗ£i đʰį»£c gį»­i đįŗæn trĘ°į»›c hįŗ”n chĆ³t. Nįŗæu gį»­i bįŗ±ng Dį»‹ch vį»„ ThĘ° tĆ­n Hoa Kį»³_ Ä‘Ę”n đăng kĆ½ phįŗ£i đʰį»£c Ä‘Ć³ng dįŗ„u bĘ°u điį»‡n trĘ°į»›c %{applicationDueDate}. %{developer} khĆ“ng chį»‹u trĆ”ch nhiį»‡m vį» thĘ° bį»‹ thįŗ„t lįŗ”c hoįŗ·c giao chįŗ­m.", + "listings.apply.submitPaperDueDateNoPostMark": "Đʔn đăng kĆ½ phįŗ£i đʰį»£c gį»­i đįŗæn trĘ°į»›c hįŗ”n chĆ³t. Nįŗæu gį»­i bįŗ±ng Dį»‹ch vį»„ ThĘ° tĆ­n Hoa Kį»³, Ä‘Ę”n đăng kĆ½ phįŗ£i đʰį»£c Ä‘Ć³ng dįŗ„u bĘ°u điį»‡n trĘ°į»›c %{applicationDueDate}. %{developer} khĆ“ng chį»‹u trĆ”ch nhiį»‡m vį» thĘ° bį»‹ thįŗ„t lįŗ”c hoįŗ·c giao chįŗ­m.", "listings.apply.submitPaperDueDatePostMark": "Đʔn ghi danh phįŗ£i đʰį»£c nhįŗ­n trĘ°į»›c thį»i hįŗ”n. Nįŗæu gį»­i qua đʰį»ng bĘ°u điį»‡n U.S Mail, Ä‘Ę”n ghi danh phįŗ£i đʰį»£c Ä‘Ć³ng dįŗ„u bĘ°u điį»‡n trĘ°į»›c %{applicationDueDate} vĆ  nhįŗ­n qua thĘ° trĘ°į»›c ngĆ y %{postmarkReceiveByDate}. CĆ”c Ä‘Ę”n ghi danh nhįŗ­n đʰį»£c sau %{postmarkReceiveByDate} qua đʰį»ng bĘ°u điį»‡n sįŗ½ khĆ“ng đʰį»£c chįŗ„p nhįŗ­n ngay cįŗ£ khi chĆŗng đʰį»£c Ä‘Ć³ng dįŗ„u bĘ°u điį»‡n trĘ°į»›c %{applicationDueDate}. %{developer} khĆ“ng chį»‹u trĆ”ch nhiį»‡m vį» thĘ° bį»‹ thįŗ„t lįŗ”c hoįŗ·c bį»‹ trį»….", "listings.apply.submitPaperNoDueDateNoPostMark": "%{developer} khĆ“ng chį»‹u trĆ”ch nhiį»‡m vį» thĘ° bį»‹ thįŗ„t lįŗ”c hoįŗ·c giao chįŗ­m.", - "listings.apply.submitPaperNoDueDatePostMark": "Đʔn đăng kĆ½ phįŗ£i đʰį»£c gį»­i đįŗæn trĘ°į»›c hįŗ”n chĆ³t. Nįŗæu gį»­i bįŗ±ng Dį»‹ch vį»„ ThĘ° tĆ­n Hoa Kį»³_ Ä‘Ę”n đăng kĆ½ phįŗ£i đʰį»£c nhįŗ­n qua đʰį»ng bĘ°u điį»‡n chįŗ­m nhįŗ„t lĆ  %{postmarkReceivedByDate}. Nhį»Æng Ä‘Ę”n đăng kĆ½ đʰį»£c nhįŗ­n sau %{postmarkReceivedByDate} qua đʰį»ng bĘ°u điį»‡n sįŗ½ khĆ“ng đʰį»£c chįŗ„p nhįŗ­n. %{developer} khĆ“ng chį»‹u trĆ”ch nhiį»‡m vį» thĘ° bį»‹ thįŗ„t lįŗ”c hoįŗ·c giao chįŗ­m.", + "listings.apply.submitPaperNoDueDatePostMark": "Đʔn đăng kĆ½ phįŗ£i đʰį»£c gį»­i đįŗæn trĘ°į»›c hįŗ”n chĆ³t. Nįŗæu gį»­i bįŗ±ng Dį»‹ch vį»„ ThĘ° tĆ­n Hoa Kį»³, Ä‘Ę”n đăng kĆ½ phįŗ£i đʰį»£c nhįŗ­n qua đʰį»ng bĘ°u điį»‡n chįŗ­m nhįŗ„t lĆ  %{postmarkReceivedByDate}. Nhį»Æng Ä‘Ę”n đăng kĆ½ đʰį»£c nhįŗ­n sau %{postmarkReceivedByDate} qua đʰį»ng bĘ°u điį»‡n sįŗ½ khĆ“ng đʰį»£c chįŗ„p nhįŗ­n. %{developer} khĆ“ng chį»‹u trĆ”ch nhiį»‡m vį» thĘ° bį»‹ thįŗ„t lįŗ”c hoįŗ·c giao chįŗ­m.", "listings.availableAndWaitlist": "CĆ”c Căn nhĆ  CĆ²n trį»‘ng & Danh sĆ”ch chį» đang Mį»Ÿ", "listings.availableUnitsAndWaitlistDesc": "Sau khi cĆ”c į»©ng viĆŖn lįŗ„y hįŗæt cĆ”c căn nhĆ  cĆ²n trį»‘ng, cĆ”c į»©ng viĆŖn bį»• sung sįŗ½ đʰį»£c đʰa vĆ o danh sĆ”ch chį» cho %{number} căn nhĆ ", "listings.availableUnitsAndWaitlist": "CĆ”c căn nhĆ  cĆ²n trį»‘ng vĆ  danh sĆ”ch chį»", From ef713505f98441e80774aeecae5dc2a62f14c2e1 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Wed, 13 Mar 2024 12:23:21 -0700 Subject: [PATCH 17/35] fix: limit requestedChangesUser in Listings response (#3921) * feat: connect up requsted changes user properly * fix: limit requestedChangesUser in Listings response #3889 * fix: update api service unit tests #3889 * fix: add details view unit test #3889 * fix: send id and name #3889 * fix: correct inport statement * fix: addressing comments #3889 * fix: cleanup swagger changes #3889 * fix: add missing return statement #3889 --------- Co-authored-by: Eric McGarry --- .../06_requested_user_updates/migration.sql | 2 + api/prisma/schema.prisma | 2 + api/src/dtos/listings/listing.dto.ts | 16 +- api/src/services/listing.service.ts | 12 +- api/src/utilities/requested-changes-user.ts | 16 ++ .../unit/services/listing.service.spec.ts | 138 +++++++++++++++++- shared-helpers/src/types/backend-swagger.ts | 3 +- .../listings-approval.spec.ts | 1 + .../sections/DetailNotes.tsx | 4 +- 9 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 api/prisma/migrations/06_requested_user_updates/migration.sql create mode 100644 api/src/utilities/requested-changes-user.ts diff --git a/api/prisma/migrations/06_requested_user_updates/migration.sql b/api/prisma/migrations/06_requested_user_updates/migration.sql new file mode 100644 index 0000000000..e1d4bbebec --- /dev/null +++ b/api/prisma/migrations/06_requested_user_updates/migration.sql @@ -0,0 +1,2 @@ +-- AddForeignKey +ALTER TABLE "listings" ADD CONSTRAINT "listings_requested_changes_user_id_fkey" FOREIGN KEY ("requested_changes_user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 7c59d76202..ba9261fcdc 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -567,6 +567,7 @@ model Listings { requestedChanges String? @map("requested_changes") requestedChangesDate DateTime? @default(dbgenerated("'1970-01-01 00:00:00-07'::timestamp with time zone")) @map("requested_changes_date") @db.Timestamptz(6) requestedChangesUserId String? @map("requested_changes_user_id") @db.Uuid + requestedChangesUser UserAccounts? @relation("requested_changes_user", fields: [requestedChangesUserId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@index([jurisdictionId]) @@map("listings") @@ -797,6 +798,7 @@ model UserAccounts { jurisdictions Jurisdictions[] userPreferences UserPreferences? userRoles UserRoles? + requestedChangesListings Listings[] @relation("requested_changes_user") @@map("user_accounts") } diff --git a/api/src/dtos/listings/listing.dto.ts b/api/src/dtos/listings/listing.dto.ts index 307084d9d1..567e283bdc 100644 --- a/api/src/dtos/listings/listing.dto.ts +++ b/api/src/dtos/listings/listing.dto.ts @@ -34,6 +34,7 @@ import { UnitsSummary } from '../units/units-summary.dto'; import { IdDTO } from '../shared/id.dto'; import { listingUrlSlug } from '../../utilities/listing-url-slug'; import { User } from '../users/user.dto'; +import { requestedChangesUserMapper } from '../../utilities/requested-changes-user'; class Listing extends AbstractDTO { @Expose() @@ -546,9 +547,18 @@ class Listing extends AbstractDTO { @Expose() @ApiPropertyOptional() - @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => User) - requestedChangesUser?: User; + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (obj: any) => { + return obj.obj.requestedChangesUser + ? requestedChangesUserMapper(obj.obj.requestedChangesUser as User) + : undefined; + }, + { + toClassOnly: true, + }, + ) + requestedChangesUser?: IdDTO; } export { Listing as default, Listing }; diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index e3575a744c..d30db6c7d6 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -107,6 +107,7 @@ views.full = { listingsApplicationPickUpAddress: true, listingsApplicationDropOffAddress: true, listingsApplicationMailingAddress: true, + requestedChangesUser: true, units: { include: { unitAmiChartOverrides: true, @@ -891,6 +892,7 @@ export class ListingService implements OnModuleInit { }, } : undefined, + requestedChangesUser: undefined, }, }); @@ -1358,11 +1360,15 @@ export class ListingService implements OnModuleInit { dto.status === ListingsStatusEnum.closed ? new Date() : storedListing.closedAt, - requestedChangesUserId: + requestedChangesUser: dto.status === ListingsStatusEnum.changesRequested && storedListing.status !== ListingsStatusEnum.changesRequested - ? requestingUser.id - : storedListing.requestedChangesUserId, + ? { + connect: { + id: requestingUser.id, + }, + } + : undefined, listingsResult: dto.listingsResult ? { create: { diff --git a/api/src/utilities/requested-changes-user.ts b/api/src/utilities/requested-changes-user.ts new file mode 100644 index 0000000000..4f359d86e3 --- /dev/null +++ b/api/src/utilities/requested-changes-user.ts @@ -0,0 +1,16 @@ +import { IdDTO } from '../dtos/shared/id.dto'; +import { User } from '../dtos/users/user.dto'; + +/* + This maps a user that has requested changes on a listing to a limited IdDTO + This is used by the partner site front end + */ +export function requestedChangesUserMapper(user: User): IdDTO { + return { + id: user?.id, + name: + user?.firstName && user?.lastName + ? user?.firstName + ' ' + user?.lastName + : undefined, + }; +} diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index 3c470cab39..76476db819 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -444,6 +444,7 @@ describe('Testing listing service', () => { include: { jurisdictions: true, listingsBuildingAddress: true, + requestedChangesUser: true, reservedCommunityTypes: true, listingImages: { include: { @@ -828,7 +829,7 @@ describe('Testing listing service', () => { }); }); - it('should handle no records returned when findOne() is called with base view', async () => { + it('should handle no records returned when findOne() is called with details view', async () => { prisma.listings.findUnique = jest.fn().mockResolvedValue(null); await expect( @@ -847,6 +848,7 @@ describe('Testing listing service', () => { include: { jurisdictions: true, listingsBuildingAddress: true, + requestedChangesUser: true, reservedCommunityTypes: true, listingImages: { include: { @@ -1526,6 +1528,136 @@ describe('Testing listing service', () => { }); }); + it('should get records from findOne() with details view found and units', async () => { + const date = new Date(); + + const mockedListing = mockListing(0, { numberToMake: 1, date }); + + prisma.listings.findUnique = jest.fn().mockResolvedValue(mockedListing); + + prisma.amiChart.findMany = jest.fn().mockResolvedValue([ + { + id: 'AMI0', + items: [], + name: '`AMI Name 0`', + }, + { + id: 'AMI1', + items: [], + name: '`AMI Name 1`', + }, + ]); + + const listing: Listing = await service.findOne( + 'listingId', + LanguagesEnum.en, + ListingViews.details, + ); + + expect(listing.id).toEqual('0'); + expect(listing.name).toEqual('listing 1'); + expect(listing.units).toEqual(mockedListing.units); + expect(listing.unitsSummarized.amiPercentages).toEqual(['0']); + expect(listing.unitsSummarized?.byAMI).toEqual([ + { + percent: '0', + byUnitType: [ + { + areaRange: { min: 0, max: 0 }, + minIncomeRange: { min: '$0', max: '$0' }, + occupancyRange: { min: 0, max: 0 }, + rentRange: { min: '$0', max: '$0' }, + rentAsPercentIncomeRange: { min: 0, max: 0 }, + floorRange: { min: 0, max: 0 }, + unitTypes: { + id: 'unitType 0', + createdAt: date, + updatedAt: date, + name: 'SRO', + numBedrooms: 0, + }, + totalAvailable: 1, + }, + ], + }, + ]); + expect(listing.unitsSummarized.unitTypes).toEqual([ + { + createdAt: date, + id: 'unitType 0', + name: 'SRO', + numBedrooms: 0, + updatedAt: date, + }, + ]); + + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + where: { + id: 'listingId', + }, + include: { + jurisdictions: true, + listingsBuildingAddress: true, + requestedChangesUser: true, + reservedCommunityTypes: true, + listingImages: { + include: { + assets: true, + }, + }, + listingMultiselectQuestions: { + include: { + multiselectQuestions: true, + }, + }, + listingFeatures: true, + listingUtilities: true, + applicationMethods: { + include: { + paperApplications: { + include: { + assets: true, + }, + }, + }, + }, + listingsBuildingSelectionCriteriaFile: true, + listingEvents: { + include: { + assets: true, + }, + }, + listingsResult: true, + listingsLeasingAgentAddress: true, + listingsApplicationPickUpAddress: true, + listingsApplicationDropOffAddress: true, + listingsApplicationMailingAddress: true, + units: { + include: { + unitAmiChartOverrides: true, + unitTypes: true, + unitRentTypes: true, + unitAccessibilityPriorityTypes: true, + amiChart: { + include: { + jurisdictions: true, + unitGroupAmiLevels: true, + }, + }, + }, + }, + }, + }); + + expect(prisma.amiChart.findMany).toHaveBeenCalledWith({ + where: { + id: { + in: mockedListing.units.map((unit) => unit.amiChart.id), + }, + }, + }); + }); + it('should return listings from findListingsWithMultiSelectQuestion()', async () => { prisma.listings.findMany = jest.fn().mockResolvedValue([ { @@ -1620,6 +1752,7 @@ describe('Testing listing service', () => { listingsBuildingSelectionCriteriaFile: true, listingsLeasingAgentAddress: true, listingsResult: true, + requestedChangesUser: true, reservedCommunityTypes: true, units: { include: { @@ -1718,6 +1851,7 @@ describe('Testing listing service', () => { listingsBuildingSelectionCriteriaFile: true, listingsLeasingAgentAddress: true, listingsResult: true, + requestedChangesUser: true, reservedCommunityTypes: true, units: { include: { @@ -2153,6 +2287,7 @@ describe('Testing listing service', () => { listingsApplicationMailingAddress: true, listingsLeasingAgentAddress: true, listingsResult: true, + requestedChangesUser: true, reservedCommunityTypes: true, units: { include: { @@ -2275,6 +2410,7 @@ describe('Testing listing service', () => { listingsLeasingAgentAddress: true, listingsResult: true, reservedCommunityTypes: true, + requestedChangesUser: true, units: { include: { amiChart: { diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 47402d859f..a697d72111 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -3060,7 +3060,7 @@ export interface Listing { requestedChangesDate?: Date /** */ - requestedChangesUser?: User + requestedChangesUser?: IdDTO } export interface PaginationMeta { @@ -5223,6 +5223,7 @@ export enum EnumJurisdictionListingApprovalPermissions { "admin" = "admin", "jurisdictionAdmin" = "jurisdictionAdmin", } + export enum AfsView { "pending" = "pending", "pendingNameAndDoB" = "pendingNameAndDoB", diff --git a/sites/partners/cypress/e2e/listings-approval/listings-approval.spec.ts b/sites/partners/cypress/e2e/listings-approval/listings-approval.spec.ts index b45bb21549..7256cf238b 100644 --- a/sites/partners/cypress/e2e/listings-approval/listings-approval.spec.ts +++ b/sites/partners/cypress/e2e/listings-approval/listings-approval.spec.ts @@ -29,6 +29,7 @@ describe("Listings approval feature", () => { searchAndOpenListing(cy, uniqueListingName) cy.getByID("listing-status-changes-requested").should("be.visible") cy.getByID("requestedChanges").contains("Requested changes test summary") + cy.getByID("requestedChangesUser").contains("First Last") cy.getByID("listingEditButton").click() cy.getByTestId("nameField").should("be.visible").click().clear().type(uniqueListingNameEdited) cy.getByID("submitButton").contains("Submit").click() diff --git a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNotes.tsx b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNotes.tsx index dae63a6298..3d9dd0c1c9 100644 --- a/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNotes.tsx +++ b/sites/partners/src/components/listings/PaperListingDetails/sections/DetailNotes.tsx @@ -29,9 +29,9 @@ const DetailListingNotes = () => { {getDetailFieldDate(listing.requestedChangesDate)} - {listing.requestedChangesUser && ( + {listing?.requestedChangesUser?.name && ( - {`${listing.requestedChangesUser?.firstName} ${listing.requestedChangesUser?.lastName}`} + {`${listing.requestedChangesUser.name}`} )} From dfd580c357d25c5b8a57fc5474435d50d8e5e987 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Wed, 13 Mar 2024 15:33:09 -0700 Subject: [PATCH 18/35] feat: security patch (#3946) * feat: security patch * fix: update per eric --- .../seed-helpers/application-factory.ts | 8 ++ api/src/controllers/application.controller.ts | 10 +- .../permission-configs/permission_policy.csv | 15 ++- api/src/services/application.service.ts | 43 +++++-- api/src/services/user.service.ts | 19 +++- api/test/integration/application.e2e-spec.ts | 2 + .../multiselect-question.e2e-spec.ts | 4 + ...n-as-juris-admin-correct-juris.e2e-spec.ts | 17 ++- ...ion-as-juris-admin-wrong-juris.e2e-spec.ts | 21 +++- .../permission-as-no-user.e2e-spec.ts | 14 +-- ...ion-as-partner-correct-listing.e2e-spec.ts | 12 +- ...ssion-as-partner-wrong-listing.e2e-spec.ts | 11 +- .../permission-as-public.e2e-spec.ts | 78 ++++++++----- .../unit/services/application.service.spec.ts | 37 +++++- api/test/unit/services/user.service.spec.ts | 106 +++++++++++++++++- 15 files changed, 323 insertions(+), 74 deletions(-) diff --git a/api/prisma/seed-helpers/application-factory.ts b/api/prisma/seed-helpers/application-factory.ts index 0e1bbe6f29..d3e6f8f622 100644 --- a/api/prisma/seed-helpers/application-factory.ts +++ b/api/prisma/seed-helpers/application-factory.ts @@ -27,6 +27,7 @@ export const applicationFactory = async (optionalParams?: { householdMember?: Prisma.HouseholdMemberCreateWithoutApplicationsInput[]; demographics?: Prisma.DemographicsCreateWithoutApplicationsInput; multiselectQuestions?: Partial[]; + userId?: string; }): Promise => { let preferredUnitTypes: Prisma.UnitTypesCreateNestedManyWithoutApplicationsInput; if (optionalParams?.unitTypeId) { @@ -88,6 +89,13 @@ export const applicationFactory = async (optionalParams?: { demographics: { create: demographics, }, + userAccounts: optionalParams?.userId + ? { + connect: { + id: optionalParams.userId, + }, + } + : undefined, }; }; diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index f50dd0c17c..4f612b49c0 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -88,9 +88,10 @@ export class ApplicationController { }) @ApiOkResponse({ type: Application }) async mostRecentlyCreated( + @Request() req: ExpressRequest, @Query() queryParams: MostRecentApplicationQueryParams, ): Promise { - return await this.applicationService.mostRecentlyCreated(queryParams); + return await this.applicationService.mostRecentlyCreated(queryParams, req); } @Get(`csv`) @@ -119,8 +120,11 @@ export class ApplicationController { operationId: 'retrieve', }) @ApiOkResponse({ type: Application }) - async retrieve(@Param('applicationId') applicationId: string) { - return this.applicationService.findOne(applicationId); + async retrieve( + @Request() req: ExpressRequest, + @Param('applicationId') applicationId: string, + ) { + return this.applicationService.findOne(applicationId, req); } @Post() diff --git a/api/src/permission-configs/permission_policy.csv b/api/src/permission-configs/permission_policy.csv index 7f1bf80ede..d3df6484de 100644 --- a/api/src/permission-configs/permission_policy.csv +++ b/api/src/permission-configs/permission_policy.csv @@ -16,7 +16,6 @@ p, partner, asset, true, .* p, admin, multiselectQuestion, true, .* p, jurisdictionAdmin, multiselectQuestion, true, .* p, partner, multiselectQuestion, true, .* -p, anonymous, multiselectQuestion, true, read p, admin, applicationMethod, true, .* p, jurisdictionAdmin, applicationMethod, true, .* @@ -40,7 +39,7 @@ p, partner, propertyGroup, true, read p, admin, amiChart, true, .* p, jurisdictionAdmin, amiChart, true, .* -p, anonymous, amiChart, true, read +p, partner, amiChart, true, read p, admin, applicationFlaggedSet, true, .* p, jurisdictionAdmin, applicationFlaggedSet, true, .* @@ -57,27 +56,27 @@ p, anonymous, listing, true, read p, admin, reservedCommunityType, true, .* p, jurisdictionAdmin, reservedCommunityType, true, read -p, anonymous, reservedCommunityType, true, read +p, partner, reservedCommunityType, true, read p, admin, unitType, true, .* p, admin, jurisdictionAdmin, true, read -p, anonymous, unitType, true, read +p, partner, unitType, true, read p, admin, unitRentType, true, .* p, jurisdictionAdmin, jurisdictionAdmin, true, read -p, anonymous, unitRentType, true, read +p, partner, unitRentType, true, read p, admin, unitAccessibilityPriorityType, true, .* p, jurisdictionAdmin, jurisdictionAdmin, true, .* -p, anonymous, unitAccessibilityPriorityType, true, read +p, partner, unitAccessibilityPriorityType, true, read p, admin, applicationMethod, true, .* p, jurisdictionAdmin, applicationMethod, true, .* -p, anonymous, applicationMethod, true, read +p, partner, applicationMethod, true, read p, admin, paperApplication, true, .* p, jurisdictionAdmin, paperApplication, true, .* -p, anonymous, paperApplication, true, read +p, partner, paperApplication, true, read p, admin, mapLayers, true, .* p, jurisdictionAdmin, mapLayers, true, .* diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 59342979ff..fc9ea7312b 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -108,6 +108,17 @@ export class ApplicationService { where: whereClause, }); + await Promise.all( + rawApplications.map(async (application) => { + await this.authorizeAction( + user, + application.listings?.id, + permissionActions.read, + application.userId, + ); + }), + ); + const applications = mapTo(Application, rawApplications); const promiseArray = applications.map((application) => @@ -135,6 +146,7 @@ export class ApplicationService { */ async mostRecentlyCreated( params: MostRecentApplicationQueryParams, + req: ExpressRequest, ): Promise { const rawApplication = await this.prisma.applications.findFirst({ select: { @@ -150,7 +162,7 @@ export class ApplicationService { return null; } - return await this.findOne(rawApplication.id); + return await this.findOne(rawApplication.id, req); } /* @@ -262,13 +274,30 @@ export class ApplicationService { /* this will return 1 application or error */ - async findOne(applicationId: string): Promise { + async findOne( + applicationId: string, + req: ExpressRequest, + ): Promise { + const user = mapTo(User, req['user']); + if (!user) { + throw new ForbiddenException(); + } + const rawApplication = await this.findOrThrow( applicationId, ApplicationViews.details, ); - return mapTo(Application, rawApplication); + const application = mapTo(Application, rawApplication); + + await this.authorizeAction( + user, + application.listings?.id, + permissionActions.read, + rawApplication.userId, + ); + + return application; } /* @@ -282,7 +311,6 @@ export class ApplicationService { if (!forPublic) { await this.authorizeAction( requestingUser, - dto as Application, dto.listings.id, permissionActions.create, ); @@ -465,7 +493,6 @@ export class ApplicationService { await this.authorizeAction( requestingUser, - mapTo(Application, rawApplication), rawApplication.listingId, permissionActions.update, ); @@ -616,7 +643,6 @@ export class ApplicationService { await this.authorizeAction( requestingUser, - mapTo(Application, application), application.listingId, permissionActions.delete, ); @@ -674,9 +700,9 @@ export class ApplicationService { async authorizeAction( user: User, - application: Application, listingId: string, action: permissionActions, + applicantUserId?: string, ): Promise { const listingJurisdiction = await this.prisma.jurisdictions.findFirst({ where: { @@ -689,7 +715,8 @@ export class ApplicationService { }); await this.permissionService.canOrThrow(user, 'application', action, { listingId, - jurisdictionId: listingJurisdiction.id, + jurisdictionId: listingJurisdiction?.id, + userId: applicantUserId, }); } diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index 69bcb05bdc..efa6757c9f 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -35,6 +35,7 @@ import { EmailService } from './email.service'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { buildWhereClause } from '../utilities/build-user-where'; +import { UserRole } from '../dtos/users/user-role.dto'; /* this is the service for users @@ -197,7 +198,7 @@ export class UserService { // only update userRoles if something has changed if (dto.userRoles && storedUser.userRoles) { if ( - requestingUser.userRoles.isAdmin && + this.isUserRoleChangeAllowed(requestingUser, dto.userRoles) && !( dto.userRoles.isAdmin === storedUser.userRoles.isAdmin && dto.userRoles.isJurisdictionalAdmin === @@ -858,4 +859,20 @@ export class UserService { containsInvalidCharacters(value: string): boolean { return value.includes('.') || value.includes('http'); } + + isUserRoleChangeAllowed( + requestingUser: User, + userRoleChange: UserRole, + ): boolean { + if (requestingUser?.userRoles?.isAdmin) { + return true; + } else if (requestingUser?.userRoles?.isJurisdictionalAdmin) { + if (userRoleChange?.isAdmin) { + return false; + } + return true; + } + + return false; + } } diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index facec388dd..aaebcfeec3 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -245,6 +245,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications/${applicationA.id}`) + .set('Cookie', cookies) .expect(200); expect(res.body.applicant.firstName).toEqual( @@ -257,6 +258,7 @@ describe('Application Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/applications/${id}`) + .set('Cookie', cookies) .expect(404); expect(res.body.message).toEqual( diff --git a/api/test/integration/multiselect-question.e2e-spec.ts b/api/test/integration/multiselect-question.e2e-spec.ts index ccc0fb8919..6975456a7f 100644 --- a/api/test/integration/multiselect-question.e2e-spec.ts +++ b/api/test/integration/multiselect-question.e2e-spec.ts @@ -74,6 +74,7 @@ describe('MultiselectQuestion Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/multiselectQuestions?`) + .set('Cookie', cookies) .expect(200); expect(res.body.length).toBeGreaterThanOrEqual(2); @@ -102,6 +103,7 @@ describe('MultiselectQuestion Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/multiselectQuestions?${query}`) + .set('Cookie', cookies) .expect(200); expect(res.body.length).toBeGreaterThanOrEqual(2); @@ -114,6 +116,7 @@ describe('MultiselectQuestion Controller Tests', () => { const id = randomUUID(); const res = await request(app.getHttpServer()) .get(`/multiselectQuestions/${id}`) + .set('Cookie', cookies) .expect(404); expect(res.body.message).toEqual( `multiselectQuestionId ${id} was requested but not found`, @@ -127,6 +130,7 @@ describe('MultiselectQuestion Controller Tests', () => { const res = await request(app.getHttpServer()) .get(`/multiselectQuestions/${multiselectQuestionA.id}`) + .set('Cookie', cookies) .expect(200); expect(res.body.text).toEqual(multiselectQuestionA.text); diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts index a66bc80b75..f8ed084916 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts @@ -205,8 +205,13 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for list endpoint', async () => { + const listing1 = await listingFactory(jurisId, prisma); + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + await request(app.getHttpServer()) - .get(`/applications?`) + .get(`/applications?listingId=${listing1Created.id}`) .set('Cookie', cookies) .expect(200); }); @@ -217,8 +222,16 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr UnitTypeEnum.oneBdrm, ); + const listing1 = await listingFactory(jurisId, prisma); + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + const applicationA = await prisma.applications.create({ - data: await applicationFactory({ unitTypeId: unitTypeA.id }), + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: listing1Created.id, + }), include: { applicant: true, }, diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts index cd842535c2..4bf8f2ab71 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts @@ -208,20 +208,33 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for list endpoint', async () => { + const listing1 = await listingFactory(jurisId, prisma); + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + await request(app.getHttpServer()) - .get(`/applications?`) + .get(`/applications?listingId=${listing1Created.id}`) .set('Cookie', cookies) .expect(200); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, ); + const listing1 = await listingFactory(jurisId, prisma); + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + const applicationA = await prisma.applications.create({ - data: await applicationFactory({ unitTypeId: unitTypeA.id }), + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: listing1Created.id, + }), include: { applicant: true, }, @@ -230,7 +243,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron await request(app.getHttpServer()) .get(`/applications/${applicationA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for delete endpoint', async () => { diff --git a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts index 8b0380cd20..e2013f0883 100644 --- a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts @@ -192,7 +192,7 @@ describe('Testing Permissioning of endpoints as logged out user', () => { .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as for retrieve endpoint', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, @@ -208,7 +208,7 @@ describe('Testing Permissioning of endpoints as logged out user', () => { await request(app.getHttpServer()) .get(`/applications/${applicationA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for delete endpoint', async () => { @@ -731,14 +731,14 @@ describe('Testing Permissioning of endpoints as logged out user', () => { ); }); - it('should succeed for list endpoint', async () => { + it('should error as forbidden for list endpoint', async () => { await request(app.getHttpServer()) .get(`/multiselectQuestions?`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ data: multiselectQuestionFactory(jurisdictionId), }); @@ -746,7 +746,7 @@ describe('Testing Permissioning of endpoints as logged out user', () => { await request(app.getHttpServer()) .get(`/multiselectQuestions/${multiselectQuestionA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for create endpoint', async () => { @@ -1052,7 +1052,7 @@ describe('Testing Permissioning of endpoints as logged out user', () => { .expect(403); }); - it('should succeed for process endpoint', async () => { + it('should error as forbidden for process endpoint', async () => { await request(app.getHttpServer()) .put(`/listings/process`) .set('Cookie', cookies) diff --git a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts index cbc719766a..87dbd64656 100644 --- a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts @@ -166,6 +166,8 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( .expect(201); cookies = resLogIn.headers['set-cookie']; + + await unitTypeFactoryAll(prisma); }); afterAll(async () => { @@ -243,7 +245,6 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( describe('Testing application endpoints', () => { beforeAll(async () => { - await unitTypeFactoryAll(prisma); await await prisma.translations.create({ data: translationFactory(), }); @@ -251,7 +252,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for list endpoint', async () => { await request(app.getHttpServer()) - .get(`/applications?`) + .get(`/applications?listingId=${userListingId}`) .set('Cookie', cookies) .expect(200); }); @@ -263,7 +264,10 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( ); const applicationA = await prisma.applications.create({ - data: await applicationFactory({ unitTypeId: unitTypeA.id }), + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: userListingId, + }), include: { applicant: true, }, @@ -1015,7 +1019,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( .expect(403); }); - it('should succeed for process endpoint', async () => { + it('should error as forbidden for process endpoint', async () => { await request(app.getHttpServer()) .put(`/listings/process`) .set('Cookie', cookies) diff --git a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts index ffc2879915..3fd1e914b5 100644 --- a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts @@ -258,19 +258,22 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for list endpoint', async () => { await request(app.getHttpServer()) - .get(`/applications?`) + .get(`/applications?listingId=${listingId}`) .set('Cookie', cookies) .expect(200); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, ); const applicationA = await prisma.applications.create({ - data: await applicationFactory({ unitTypeId: unitTypeA.id }), + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: listingId, + }), include: { applicant: true, }, @@ -279,7 +282,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () await request(app.getHttpServer()) .get(`/applications/${applicationA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for delete endpoint', async () => { diff --git a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts index 1216947fbe..65e3d36152 100644 --- a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts @@ -129,7 +129,7 @@ describe('Testing Permissioning of endpoints as public user', () => { ); }); - it('should succeed for list endpoint', async () => { + it('should error as forbidden for list endpoint', async () => { await prisma.amiChart.create({ data: amiChartFactory(10, jurisdictionAId), }); @@ -141,10 +141,10 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .get(`/amiCharts?${query}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ data: amiChartFactory(10, jurisdictionAId), }); @@ -152,7 +152,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .get(`/amiCharts/${amiChartA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for create endpoint', async () => { @@ -205,8 +205,18 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should succeed for list endpoint', async () => { + const jurisdiction = await generateJurisdiction( + prisma, + 'permission juris public 1', + ); + await reservedCommunityTypeFactoryAll(jurisdiction, prisma); + const listing1 = await listingFactory(jurisdiction, prisma); + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + await request(app.getHttpServer()) - .get(`/applications?`) + .get(`/applications?listingId=${listing1Created.id}`) .set('Cookie', cookies) .expect(200); }); @@ -217,8 +227,22 @@ describe('Testing Permissioning of endpoints as public user', () => { UnitTypeEnum.oneBdrm, ); + const jurisdiction = await generateJurisdiction( + prisma, + 'permission juris public 2', + ); + await reservedCommunityTypeFactoryAll(jurisdiction, prisma); + const listing1 = await listingFactory(jurisdiction, prisma); + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + const applicationA = await prisma.applications.create({ - data: await applicationFactory({ unitTypeId: unitTypeA.id }), + data: await applicationFactory({ + unitTypeId: unitTypeA.id, + listingId: listing1Created.id, + userId: storedUserId, + }), include: { applicant: true, }, @@ -502,14 +526,14 @@ describe('Testing Permissioning of endpoints as public user', () => { await reservedCommunityTypeFactoryAll(jurisdictionAId, prisma); }); - it('should succeed for list endpoint', async () => { + it('should error as forbidden for list endpoint', async () => { await request(app.getHttpServer()) .get(`/reservedCommunityTypes`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, jurisdictionAId, @@ -518,7 +542,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .get(`/reservedCommunityTypes/${reservedCommunityTypeA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for create endpoint', async () => { @@ -559,14 +583,14 @@ describe('Testing Permissioning of endpoints as public user', () => { }); describe('Testing unit rent types endpoints', () => { - it('should succeed for list endpoint', async () => { + it('should error as forbidden for list endpoint', async () => { await request(app.getHttpServer()) .get(`/unitRentTypes?`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const unitRentTypeA = await prisma.unitRentTypes.create({ data: unitRentTypeFactory(), }); @@ -574,7 +598,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .get(`/unitRentTypes/${unitRentTypeA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for create endpoint', async () => { @@ -619,14 +643,14 @@ describe('Testing Permissioning of endpoints as public user', () => { }); describe('Testing unit accessibility priority types endpoints', () => { - it('should succeed for list endpoint', async () => { + it('should error as forbidden for list endpoint', async () => { await request(app.getHttpServer()) .get(`/unitAccessibilityPriorityTypes?`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const unitTypeA = await unitAccessibilityPriorityTypeFactorySingle( prisma, ); @@ -634,7 +658,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .get(`/unitAccessibilityPriorityTypes/${unitTypeA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for create endpoint', async () => { @@ -677,14 +701,14 @@ describe('Testing Permissioning of endpoints as public user', () => { }); describe('Testing unit types endpoints', () => { - it('should succeed for list endpoint', async () => { + it('should error as forbidden for list endpoint', async () => { await request(app.getHttpServer()) .get(`/unitTypes?`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, @@ -693,7 +717,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .get(`/unitTypes/${unitTypeA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for create endpoint', async () => { @@ -747,14 +771,14 @@ describe('Testing Permissioning of endpoints as public user', () => { ); }); - it('should succeed for list endpoint', async () => { + it('should error as forbidden for list endpoint', async () => { await request(app.getHttpServer()) .get(`/multiselectQuestions?`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for retrieve endpoint', async () => { + it('should error as forbidden for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ data: multiselectQuestionFactory(jurisdictionId), }); @@ -762,7 +786,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .get(`/multiselectQuestions/${multiselectQuestionA.id}`) .set('Cookie', cookies) - .expect(200); + .expect(403); }); it('should error as forbidden for create endpoint', async () => { @@ -1079,7 +1103,7 @@ describe('Testing Permissioning of endpoints as public user', () => { .expect(403); }); - it('should succeed for process endpoint', async () => { + it('should error as forbidden for process endpoint', async () => { await request(app.getHttpServer()) .put(`/listings/process`) .set('Cookie', cookies) diff --git a/api/test/unit/services/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index f0b92c2564..e0d066e4da 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -355,11 +355,21 @@ describe('Testing application service', () => { }); it('should get an application when findOne() is called and Id exists', async () => { + const requestingUser = { + firstName: 'requesting fName', + lastName: 'requesting lName', + email: 'requestingUser@email.com', + jurisdictions: [{ id: 'juris id' }], + } as unknown as User; const date = new Date(); const mockedValue = mockApplication(3, date); prisma.applications.findUnique = jest.fn().mockResolvedValue(mockedValue); - expect(await service.findOne('example Id')).toEqual(mockedValue); + expect( + await service.findOne('example Id', { + user: requestingUser, + } as unknown as ExpressRequest), + ).toEqual(mockedValue); expect(prisma.applications.findUnique).toHaveBeenCalledWith({ where: { @@ -395,10 +405,19 @@ describe('Testing application service', () => { }); it("should throw error when findOne() is called and Id doens't exists", async () => { + const requestingUser = { + firstName: 'requesting fName', + lastName: 'requesting lName', + email: 'requestingUser@email.com', + jurisdictions: [{ id: 'juris id' }], + } as unknown as User; prisma.applications.findUnique = jest.fn().mockResolvedValue(null); await expect( - async () => await service.findOne('example Id'), + async () => + await service.findOne('example Id', { + user: requestingUser, + } as unknown as ExpressRequest), ).rejects.toThrowError( 'applicationId example Id was requested but not found', ); @@ -1601,6 +1620,12 @@ describe('Testing application service', () => { }); it('should get most recent application for a user', async () => { + const requestingUser = { + firstName: 'requesting fName', + lastName: 'requesting lName', + email: 'requestingUser@email.com', + jurisdictions: [{ id: 'juris id' }], + } as unknown as User; const date = new Date(); const mockedValue = mockApplication(3, date); prisma.applications.findUnique = jest.fn().mockResolvedValue(mockedValue); @@ -1608,9 +1633,11 @@ describe('Testing application service', () => { .fn() .mockResolvedValue({ id: mockedValue.id }); - expect(await service.mostRecentlyCreated({ userId: 'example Id' })).toEqual( - mockedValue, - ); + expect( + await service.mostRecentlyCreated({ userId: 'example Id' }, { + user: requestingUser, + } as unknown as ExpressRequest), + ).toEqual(mockedValue); expect(prisma.applications.findFirst).toHaveBeenCalledWith({ select: { id: true, diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index 1f09919a40..a46f10715a 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -5,7 +5,6 @@ import { UserService } from '../../../src/services/user.service'; import { randomUUID } from 'crypto'; import { LanguagesEnum } from '@prisma/client'; import { verify } from 'jsonwebtoken'; -import dayjs from 'dayjs'; import { passwordToHash } from '../../../src/utilities/password-helpers'; import { IdDTO } from '../../../src/dtos/shared/id.dto'; import { EmailService } from '../../../src/services/email.service'; @@ -911,6 +910,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, + 'juris name', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -981,6 +981,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, + 'juris name', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1054,6 +1055,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, + 'juris name', ), ).rejects.toThrowError(`userID ${id}: request missing currentPassword`); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ @@ -1110,6 +1112,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, + 'juris name', ), ).rejects.toThrowError( `userID ${id}: incoming password doesn't match stored password`, @@ -1165,6 +1168,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, + 'juris name', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1238,6 +1242,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, + 'juris name', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1329,6 +1334,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, + 'juris name', ), ).rejects.toThrowError(`user id: ${id} was requested but not found`); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ @@ -1660,4 +1666,102 @@ describe('Testing user service', () => { expect(canOrThrowMock).not.toHaveBeenCalled(); }); }); + + describe('isUserRoleChangeAllowed', () => { + it('should allow admin to promote to admin', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isAdmin: true } } as unknown as User, + { isAdmin: true }, + ); + expect(res).toEqual(true); + }); + + it('should allow admin to promote to jurisdictional admin', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isAdmin: true } } as unknown as User, + { isJurisdictionalAdmin: true }, + ); + expect(res).toEqual(true); + }); + + it('should allow admin to promote to partner', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isAdmin: true } } as unknown as User, + { isPartner: true }, + ); + expect(res).toEqual(true); + }); + + it('should allow admin to demote', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isAdmin: true } } as unknown as User, + {}, + ); + expect(res).toEqual(true); + }); + + it('should disallow juris admin to promote to jurisdictional admin', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isJurisdictionalAdmin: true } } as unknown as User, + { isAdmin: true }, + ); + expect(res).toEqual(false); + }); + + it('should allow juris admin to promote to jurisdictional admin', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isJurisdictionalAdmin: true } } as unknown as User, + { isJurisdictionalAdmin: true }, + ); + expect(res).toEqual(true); + }); + + it('should allow juris admin to promote to partner', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isJurisdictionalAdmin: true } } as unknown as User, + { isPartner: true }, + ); + expect(res).toEqual(true); + }); + + it('should allow juris admin to demote', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isJurisdictionalAdmin: true } } as unknown as User, + {}, + ); + expect(res).toEqual(true); + }); + + it('should disallow partner to promote to jurisdictional admin', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isPartner: true } } as unknown as User, + { isAdmin: true }, + ); + expect(res).toEqual(false); + }); + + it('should disallow partner to promote to jurisdictional admin', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isPartner: true } } as unknown as User, + { isJurisdictionalAdmin: true }, + ); + expect(res).toEqual(false); + }); + + it('should disallow partner to promote to partner', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isPartner: true } } as unknown as User, + { isPartner: true }, + ); + expect(res).toEqual(false); + }); + + it('should disallow partner to demote', () => { + const res = service.isUserRoleChangeAllowed( + { userRoles: { isPartner: true } } as unknown as User, + {}, + ); + expect(res).toEqual(false); + }); + }); }); From d54b9a67fb434cc981f41b74bbc13af255a4a57f Mon Sep 17 00:00:00 2001 From: cade-exygy <131277283+cade-exygy@users.noreply.github.com> Date: Thu, 14 Mar 2024 10:05:56 -0500 Subject: [PATCH 19/35] feat: 3909/add redirect url prisma (#3938) * feat: get email url from getPublicEmailURL * fix: handle undefined url case and simplify parsing * fix: use only baseUrl in welcome and password emails * fix: fix test --- api/src/services/email.service.ts | 9 ++-- api/src/services/user.service.ts | 3 +- api/src/utilities/get-public-email-url.ts | 28 ++++++++++++ api/test/unit/services/email.service.spec.ts | 28 ++++++++++++ api/test/unit/services/user.service.spec.ts | 45 ++++++++++++++----- .../src/shared/utils/get-public-email-url.ts | 5 ++- 6 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 api/src/utilities/get-public-email-url.ts diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts index 86e62194cb..040db59a3f 100644 --- a/api/src/services/email.service.ts +++ b/api/src/services/email.service.ts @@ -18,6 +18,7 @@ import { Listing } from '../dtos/listings/listing.dto'; import { SendGridService } from './sendgrid.service'; import { ApplicationCreate } from '../dtos/applications/application-create.dto'; import { User } from '../dtos/users/user.dto'; +import { getPublicEmailURL } from '../utilities/get-public-email-url'; dayjs.extend(utc); dayjs.extend(tz); dayjs.extend(advanced); @@ -194,6 +195,7 @@ export class EmailService { confirmationUrl: string, ) { const jurisdiction = await this.getJurisdiction(null, jurisdictionName); + const baseUrl = appUrl ? new URL(appUrl).origin : undefined; await this.loadTranslations(jurisdiction, user.language); await this.send( user.email, @@ -202,7 +204,7 @@ export class EmailService { this.template('register-email')({ user: user, confirmationUrl: confirmationUrl, - appOptions: { appUrl: appUrl }, + appOptions: { appUrl: baseUrl }, }), ); } @@ -287,7 +289,8 @@ export class EmailService { const jurisdiction = await this.getJurisdiction(jurisdictionIds); void (await this.loadTranslations(jurisdiction, user.language)); const compiledTemplate = this.template('forgot-password'); - const resetUrl = `${appUrl}/reset-password?token=${resetToken}`; + const resetUrl = getPublicEmailURL(appUrl, resetToken, '/reset-password'); + const baseUrl = appUrl ? new URL(appUrl).origin : undefined; const emailFromAddress = await this.getEmailToSendFrom( jurisdictionIds, jurisdiction, @@ -299,7 +302,7 @@ export class EmailService { this.polyglot.t('forgotPassword.subject'), compiledTemplate({ resetUrl: resetUrl, - resetOptions: { appUrl: appUrl }, + resetOptions: { appUrl: baseUrl }, user: user, }), ); diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index efa6757c9f..98ef7e61ca 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -35,6 +35,7 @@ import { EmailService } from './email.service'; import { PermissionService } from './permission.service'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { buildWhereClause } from '../utilities/build-user-where'; +import { getPublicEmailURL } from '../utilities/get-public-email-url'; import { UserRole } from '../dtos/users/user-role.dto'; /* @@ -815,7 +816,7 @@ export class UserService { constructs the url to confirm a public site user */ getPublicConfirmationUrl(appUrl: string, confirmationToken: string) { - return `${appUrl}?token=${confirmationToken}`; + return getPublicEmailURL(appUrl, confirmationToken); } /* diff --git a/api/src/utilities/get-public-email-url.ts b/api/src/utilities/get-public-email-url.ts new file mode 100644 index 0000000000..68fa63f1f0 --- /dev/null +++ b/api/src/utilities/get-public-email-url.ts @@ -0,0 +1,28 @@ +/** + * Creates a email URL object from passed url applies redirectUrl and listingId query params if they exist + * If they do not exist, the return value will be the email url with just the necessary token + */ + +export const getPublicEmailURL = ( + url: string, + token: string, + actionPath?: string, +): string => { + if (!url) { + return; + } + const urlObj = new URL(url); + const redirectUrl = urlObj.searchParams.get('redirectUrl'); + const listingId = urlObj.searchParams.get('listingId'); + + let emailUrl = `${urlObj.origin}${ + actionPath ? actionPath : '' + }?token=${token}`; + + if (!!redirectUrl && !!listingId) { + emailUrl = emailUrl.concat( + `&redirectUrl=${redirectUrl}&listingId=${listingId}`, + ); + } + return emailUrl; +}; diff --git a/api/test/unit/services/email.service.spec.ts b/api/test/unit/services/email.service.spec.ts index a8320e8d49..c264ac874f 100644 --- a/api/test/unit/services/email.service.spec.ts +++ b/api/test/unit/services/email.service.spec.ts @@ -160,6 +160,34 @@ describe('Testing email service', () => { ); }); + it('testing forgot password with query params', async () => { + await service.forgotPassword( + [ + { name: 'test', id: '1234' }, + { name: 'second', id: '1234' }, + { name: 'third', id: '1234' }, + ], + user, + 'http://localhost:3001?redirectUrl=redirect&listingId=123', + 'resetToken', + ); + expect(sendMock).toHaveBeenCalled(); + expect(sendMock.mock.calls[0][0].to).toEqual(user.email); + expect(sendMock.mock.calls[0][0].subject).toEqual('Forgot your password?'); + expect(sendMock.mock.calls[0][0].html).toContain( + 'A request to reset your Bloom Housing Portal website password for http://localhost:3001 has recently been made.', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'If you did make this request, please click on the link below to reset your password:', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'Change my password', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'Your password won't change until you access the link above and create a new one.', + ); + }); + it('should send csv data email', async () => { await service.sendCSV( [{ name: 'test', id: '1234' }], diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index a46f10715a..fb23419a0e 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -311,15 +311,36 @@ describe('Testing user service', () => { describe('getPublicConfirmationUrl', () => { it('should build public confirmation url', () => { - const res = service.getPublicConfirmationUrl('url', 'tokenExample'); - expect(res).toEqual('url?token=tokenExample'); + const res = service.getPublicConfirmationUrl( + 'https://www.example.com', + 'tokenExample', + ); + expect(res).toEqual('https://www.example.com?token=tokenExample'); + }); + it('should build public confirmation url with query params', () => { + const res = service.getPublicConfirmationUrl( + 'https://www.example.com?redirectUrl=redirect&listingId=123', + 'tokenExample', + ); + expect(res).toEqual( + 'https://www.example.com?token=tokenExample&redirectUrl=redirect&listingId=123', + ); + }); + it('should return undefined when url is undefined', () => { + const res = service.getPublicConfirmationUrl(undefined, 'tokenExample'); + expect(res).toEqual(undefined); }); }); describe('getPartnersConfirmationUrl', () => { it('should build partner confirmation url', () => { - const res = service.getPartnersConfirmationUrl('url', 'tokenExample'); - expect(res).toEqual('url/users/confirm?token=tokenExample'); + const res = service.getPartnersConfirmationUrl( + 'https://www.example.com', + 'tokenExample', + ); + expect(res).toEqual( + 'https://www.example.com/users/confirm?token=tokenExample', + ); }); }); @@ -910,7 +931,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -981,7 +1002,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1055,7 +1076,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ), ).rejects.toThrowError(`userID ${id}: request missing currentPassword`); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ @@ -1112,7 +1133,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ), ).rejects.toThrowError( `userID ${id}: incoming password doesn't match stored password`, @@ -1161,14 +1182,14 @@ describe('Testing user service', () => { lastName: 'last name', jurisdictions: [{ id: jurisId }], newEmail: 'new@email.com', - appUrl: 'www.example.com', + appUrl: 'https://www.example.com', agreedToTermsOfService: true, }, { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1242,7 +1263,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1334,7 +1355,7 @@ describe('Testing user service', () => { id: 'requestingUser id', userRoles: { isAdmin: true }, } as unknown as User, - 'juris name', + 'jurisdictionName', ), ).rejects.toThrowError(`user id: ${id} was requested but not found`); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ diff --git a/backend/core/src/shared/utils/get-public-email-url.ts b/backend/core/src/shared/utils/get-public-email-url.ts index 2e6a2912a0..2406fcca08 100644 --- a/backend/core/src/shared/utils/get-public-email-url.ts +++ b/backend/core/src/shared/utils/get-public-email-url.ts @@ -4,12 +4,15 @@ */ export const getPublicEmailURL = (url: string, token: string, actionPath?: string): string => { + if (!url) { + return + } const urlObj = new URL(url) const redirectUrl = urlObj.searchParams.get("redirectUrl") const listingId = urlObj.searchParams.get("listingId") - let emailUrl = `${urlObj.origin}${urlObj.pathname}/${actionPath ? actionPath : ""}?token=${token}` + let emailUrl = `${urlObj.origin}/${actionPath ? actionPath : ""}?token=${token}` if (!!redirectUrl && !!listingId) { emailUrl = emailUrl.concat(`&redirectUrl=${redirectUrl}&listingId=${listingId}`) From 567381f7aebef00188940dddf0556a5e4650be8a Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Thu, 14 Mar 2024 10:04:05 -0700 Subject: [PATCH 20/35] feat: new single use code login endpoint (#3928) * feat: new single use code login endpoint * fix: updates per pr comments --- api/src/controllers/auth.controller.ts | 17 + .../dtos/auth/login-single-use-code.dto.ts | 19 + api/src/guards/single-use-code.guard.ts | 5 + api/src/modules/auth.module.ts | 9 +- api/src/passports/mfa.strategy.ts | 60 +- api/src/passports/single-use-code.strategy.ts | 197 +++++ api/src/services/auth.service.ts | 26 +- .../utilities/passport-validator-utilities.ts | 71 ++ api/test/integration/auth.e2e-spec.ts | 56 +- .../single-use-code.strategy.spec.ts | 683 ++++++++++++++++++ api/test/unit/services/auth.service.spec.ts | 78 +- shared-helpers/src/auth/AuthContext.ts | 19 + shared-helpers/src/types/backend-swagger.ts | 30 + 13 files changed, 1213 insertions(+), 57 deletions(-) create mode 100644 api/src/dtos/auth/login-single-use-code.dto.ts create mode 100644 api/src/guards/single-use-code.guard.ts create mode 100644 api/src/passports/single-use-code.strategy.ts create mode 100644 api/src/utilities/passport-validator-utilities.ts create mode 100644 api/test/unit/passports/single-use-code.strategy.spec.ts diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 59c3fc1fdf..3861e1c807 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -30,6 +30,8 @@ import { Login } from '../dtos/auth/login.dto'; import { mapTo } from '../utilities/mapTo'; import { User } from '../dtos/users/user.dto'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; +import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto'; +import { SingleUseCodeAuthGuard } from '../guards/single-use-code.guard'; @Controller('auth') @ApiTags('auth') @@ -49,6 +51,21 @@ export class AuthController { return await this.authService.setCredentials(res, mapTo(User, req['user'])); } + @Post('loginViaSingleUseCode') + @ApiOperation({ + summary: 'LoginViaSingleUseCode', + operationId: 'login via a single use code', + }) + @ApiOkResponse({ type: SuccessDTO }) + @ApiBody({ type: LoginViaSingleUseCode }) + @UseGuards(SingleUseCodeAuthGuard) + async loginViaSingleUseCode( + @Request() req: ExpressRequest, + @Response({ passthrough: true }) res: ExpressResponse, + ): Promise { + return await this.authService.setCredentials(res, mapTo(User, req['user'])); + } + @Get('logout') @ApiOperation({ summary: 'Logout', operationId: 'logout' }) @ApiOkResponse({ type: SuccessDTO }) diff --git a/api/src/dtos/auth/login-single-use-code.dto.ts b/api/src/dtos/auth/login-single-use-code.dto.ts new file mode 100644 index 0000000000..f39a8ffbf7 --- /dev/null +++ b/api/src/dtos/auth/login-single-use-code.dto.ts @@ -0,0 +1,19 @@ +import { IsEmail, IsString, MaxLength } from 'class-validator'; +import { Expose } from 'class-transformer'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginViaSingleUseCode { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiProperty() + email: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + singleUseCode: string; +} diff --git a/api/src/guards/single-use-code.guard.ts b/api/src/guards/single-use-code.guard.ts new file mode 100644 index 0000000000..109488231c --- /dev/null +++ b/api/src/guards/single-use-code.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class SingleUseCodeAuthGuard extends AuthGuard('single-use-code') {} diff --git a/api/src/modules/auth.module.ts b/api/src/modules/auth.module.ts index 13ff160ec5..9ba4f939e5 100644 --- a/api/src/modules/auth.module.ts +++ b/api/src/modules/auth.module.ts @@ -10,6 +10,7 @@ import { UserModule } from './user.module'; import { MfaStrategy } from '../passports/mfa.strategy'; import { JwtStrategy } from '../passports/jwt.strategy'; import { EmailModule } from './email.module'; +import { SingleUseCodeStrategy } from '../passports/single-use-code.strategy'; @Module({ imports: [ @@ -24,7 +25,13 @@ import { EmailModule } from './email.module'; EmailModule, ], controllers: [AuthController], - providers: [AuthService, PermissionService, MfaStrategy, JwtStrategy], + providers: [ + AuthService, + PermissionService, + MfaStrategy, + JwtStrategy, + SingleUseCodeStrategy, + ], exports: [AuthService, PermissionService], }) export class AuthModule {} diff --git a/api/src/passports/mfa.strategy.ts b/api/src/passports/mfa.strategy.ts index 2101e289bc..5afb8f078b 100644 --- a/api/src/passports/mfa.strategy.ts +++ b/api/src/passports/mfa.strategy.ts @@ -2,8 +2,6 @@ import { Strategy } from 'passport-local'; import { Request } from 'express'; import { PassportStrategy } from '@nestjs/passport'; import { - HttpException, - HttpStatus, Injectable, UnauthorizedException, ValidationPipe, @@ -18,6 +16,11 @@ import { import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; import { Login } from '../dtos/auth/login.dto'; import { MfaType } from '../enums/mfa/mfa-type-enum'; +import { + isUserLockedOut, + singleUseCodePresent, + singleUseCodeValid, +} from '../utilities/passport-validator-utilities'; @Injectable() export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { @@ -53,32 +56,14 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { throw new UnauthorizedException( `user ${dto.email} attempted to log in, but does not exist`, ); - } else if ( - rawUser.lastLoginAt && - rawUser.failedLoginAttemptsCount >= - Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) - ) { - // if a user has logged in, but has since gone over their max failed login attempts - const retryAfter = new Date( - rawUser.lastLoginAt.getTime() + - Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN), - ); - if (retryAfter <= new Date()) { - // if we have passed the login lock TTL, reset login lock countdown - rawUser.failedLoginAttemptsCount = 0; - } else { - // if the login lock is still a valid lock, error - throw new HttpException( - { - statusCode: HttpStatus.TOO_MANY_REQUESTS, - error: 'Too Many Requests', - message: 'Failed login attempts exceeded.', - retryAfter, - }, - 429, - ); - } - } else if (!rawUser.confirmedAt) { + } + isUserLockedOut( + rawUser.lastLoginAt, + rawUser.failedLoginAttemptsCount, + Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS), + Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN), + ); + if (!rawUser.confirmedAt) { // if user is not confirmed already throw new UnauthorizedException( `user ${rawUser.id} attempted to login, but is not confirmed`, @@ -114,9 +99,11 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { let authSuccess = true; if ( - !dto.mfaCode || - !rawUser.singleUseCode || - !rawUser.singleUseCodeUpdatedAt + !singleUseCodePresent( + dto.mfaCode, + rawUser.singleUseCode, + rawUser.singleUseCodeUpdatedAt, + ) ) { // if an mfaCode was not sent, and a singleUseCode wasn't stored in the db for the user // signal to the front end to request an mfa code @@ -125,11 +112,12 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { name: 'mfaCodeIsMissing', }); } else if ( - new Date( - rawUser.singleUseCodeUpdatedAt.getTime() + - Number(process.env.MFA_CODE_VALID), - ) < new Date() || - rawUser.singleUseCode !== dto.mfaCode + singleUseCodeValid( + rawUser.singleUseCodeUpdatedAt, + Number(process.env.MFA_CODE_VALID), + dto.mfaCode, + rawUser.singleUseCode, + ) ) { // if mfaCode TTL has expired, or if the mfa code input was incorrect authSuccess = false; diff --git a/api/src/passports/single-use-code.strategy.ts b/api/src/passports/single-use-code.strategy.ts new file mode 100644 index 0000000000..fdce589101 --- /dev/null +++ b/api/src/passports/single-use-code.strategy.ts @@ -0,0 +1,197 @@ +import { Strategy } from 'passport-local'; +import { Request } from 'express'; +import { PassportStrategy } from '@nestjs/passport'; +import { + BadRequestException, + Injectable, + UnauthorizedException, + ValidationPipe, +} from '@nestjs/common'; +import { User } from '../dtos/users/user.dto'; +import { PrismaService } from '../services/prisma.service'; +import { mapTo } from '../utilities/mapTo'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; +import { + isUserLockedOut, + singleUseCodePresent, + singleUseCodeValid, +} from '../utilities/passport-validator-utilities'; + +@Injectable() +export class SingleUseCodeStrategy extends PassportStrategy( + Strategy, + 'single-use-code', +) { + constructor(private prisma: PrismaService) { + super({ + usernameField: 'email', + passwordField: 'singleUseCode', + passReqToCallback: true, + }); + } + + /* + verifies that the incoming log in information is valid + returns the verified user + */ + async validate(req: Request): Promise { + const validationPipe = new ValidationPipe(defaultValidationPipeOptions); + const dto: LoginViaSingleUseCode = await validationPipe.transform( + req.body, + { + type: 'body', + metatype: LoginViaSingleUseCode, + }, + ); + const jurisName = req?.headers?.jurisdictionname; + if (!jurisName) { + throw new BadRequestException( + 'jurisdictionname is missing from the request headers', + ); + } + + const juris = await this.prisma.jurisdictions.findFirst({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: jurisName as string, + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + if (!juris) { + throw new BadRequestException( + `Jurisidiction ${jurisName} does not exists`, + ); + } + + if (!juris.allowSingleUseCodeLogin) { + throw new BadRequestException( + `Single use code login is not setup for ${jurisName}`, + ); + } + + const rawUser = await this.prisma.userAccounts.findFirst({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: dto.email, + }, + }); + if (!rawUser) { + throw new UnauthorizedException( + `user ${dto.email} attempted to log in, but does not exist`, + ); + } + + isUserLockedOut( + rawUser.lastLoginAt, + rawUser.failedLoginAttemptsCount, + Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS), + Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN), + ); + + let authSuccess = true; + if ( + !singleUseCodePresent( + dto.singleUseCode, + rawUser.singleUseCode, + rawUser.singleUseCodeUpdatedAt, + ) + ) { + // if a singleUseCode was not sent, or a singleUseCode wasn't stored in the db for the user + // signal to the front end to request an single use code + await this.updateFailedLoginCount(0, rawUser.id); + throw new UnauthorizedException({ + name: 'singleUseCodeIsMissing', + }); + } else if ( + singleUseCodeValid( + rawUser.singleUseCodeUpdatedAt, + Number(process.env.MFA_CODE_VALID), + dto.singleUseCode, + rawUser.singleUseCode, + ) + ) { + // if singleUseCode TTL has expired, or if the code input was incorrect + authSuccess = false; + } else { + // if login was a success + rawUser.singleUseCode = null; + rawUser.singleUseCodeUpdatedAt = new Date(); + } + + if (!authSuccess) { + // if we failed login validation + rawUser.failedLoginAttemptsCount += 1; + await this.updateStoredUser( + rawUser.singleUseCode, + rawUser.singleUseCodeUpdatedAt, + rawUser.failedLoginAttemptsCount, + rawUser.id, + ); + throw new UnauthorizedException({ + message: 'singleUseCodeUnauthorized', + failureCountRemaining: + Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) + + 1 - + rawUser.failedLoginAttemptsCount, + }); + } + + // if the password and single use code was valid + rawUser.failedLoginAttemptsCount = 0; + + await this.updateStoredUser( + rawUser.singleUseCode, + rawUser.singleUseCodeUpdatedAt, + rawUser.failedLoginAttemptsCount, + rawUser.id, + ); + return mapTo(User, rawUser); + } + + async updateFailedLoginCount(count: number, userId: string): Promise { + let lastLoginAt = undefined; + if (count === 1) { + // if the count went from 0 -> 1 then we update the lastLoginAt so the count of failed attempts falls off properly + lastLoginAt = new Date(); + } + await this.prisma.userAccounts.update({ + data: { + failedLoginAttemptsCount: count, + lastLoginAt, + }, + where: { + id: userId, + }, + }); + } + + async updateStoredUser( + singleUseCode: string, + singleUseCodeUpdatedAt: Date, + failedLoginAttemptsCount: number, + userId: string, + ): Promise { + await this.prisma.userAccounts.update({ + data: { + singleUseCode, + singleUseCodeUpdatedAt, + failedLoginAttemptsCount, + lastLoginAt: new Date(), + }, + where: { + id: userId, + }, + }); + } +} diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index efce9728eb..2b98b2c342 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -23,6 +23,7 @@ import { Confirm } from '../dtos/auth/confirm.dto'; import { SmsService } from './sms.service'; import { EmailService } from './email.service'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; // since our local env doesn't have an https cert we can't be secure. Hosted envs should be secure const secure = process.env.NODE_ENV !== 'development'; @@ -264,24 +265,35 @@ export class AuthService { return { success: true }; } - if (!req?.headers?.jurisdictionname) { + const jurisName = req?.headers?.jurisdictionname; + if (!jurisName) { throw new BadRequestException( 'jurisdictionname is missing from the request headers', ); } - const jurisName = req.headers['jurisdictionname']; const juris = await this.prisma.jurisdictions.findFirst({ - where: { - name: { - in: Array.isArray(jurisName) ? jurisName : [jurisName], - }, + select: { + id: true, allowSingleUseCodeLogin: true, }, + where: { + name: jurisName as string, + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, }); + if (!juris) { throw new BadRequestException( - 'Single use code login is not setup for this jurisdiction', + `Jurisidiction ${jurisName} does not exists`, + ); + } + + if (!juris.allowSingleUseCodeLogin) { + throw new BadRequestException( + `Single use code login is not setup for ${jurisName}`, ); } diff --git a/api/src/utilities/passport-validator-utilities.ts b/api/src/utilities/passport-validator-utilities.ts new file mode 100644 index 0000000000..cd68af8c9a --- /dev/null +++ b/api/src/utilities/passport-validator-utilities.ts @@ -0,0 +1,71 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +/** + * + * @param lastLoginAt the last time the user logged in (stored in db) + * @param failedLoginAttemptsCount the number of times the user failed to log in (stored in db) + * @param maxAttempts the maximum number of attempts before user is considered locked out (env variable) + * + * @returns throws error if user is already locked out + */ +export function isUserLockedOut( + lastLoginAt: Date, + failedLoginAttemptsCount: number, + maxAttempts: number, + cooldown: number, +): void { + if (lastLoginAt && failedLoginAttemptsCount >= maxAttempts) { + // if a user has logged in, but has since gone over their max failed login attempts + const retryAfter = new Date(lastLoginAt.getTime() + cooldown); + if (retryAfter <= new Date()) { + // if we have passed the login lock TTL, reset login lock countdown + failedLoginAttemptsCount = 0; + } else { + // if the login lock is still a valid lock, error + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + error: 'Too Many Requests', + message: 'Failed login attempts exceeded.', + retryAfter, + }, + 429, + ); + } + } +} + +/** + * + * @param incomingSingleUseCode single use code that was sent as part of the request + * @param storedSingleUseCode single use code that is stored in the db for this user + * @param singleUseCodeUpdatedAt last time a single use code was set for a user (stord in db) + * @returns true if all params are present + */ +export function singleUseCodePresent( + incomingSingleUseCode: string, + storedSingleUseCode: string, + singleUseCodeUpdatedAt: Date, +) { + return incomingSingleUseCode && storedSingleUseCode && singleUseCodeUpdatedAt; +} + +/** + * + * @param singleUseCodeUpdatedAt last time a single use code was set for a user (stored in db) + * @param ttl how long the single use code should stay active (env variable) + * @param incomingSingleUseCode single use code passed in as part of the request + * @param storedSingleUseCode single use code stored on the user + * @returns + */ +export function singleUseCodeValid( + singleUseCodeUpdatedAt: Date, + ttl: number, + incomingSingleUseCode: string, + storedSingleUseCode: string, +): boolean { + return ( + new Date(singleUseCodeUpdatedAt.getTime() + ttl) < new Date() || + storedSingleUseCode !== incomingSingleUseCode + ); +} diff --git a/api/test/integration/auth.e2e-spec.ts b/api/test/integration/auth.e2e-spec.ts index 77021dfffa..5810e2e9ad 100644 --- a/api/test/integration/auth.e2e-spec.ts +++ b/api/test/integration/auth.e2e-spec.ts @@ -18,6 +18,7 @@ import { EmailService } from '../../src/services/email.service'; import { RequestMfaCode } from '../../src/dtos/mfa/request-mfa-code.dto'; import { UpdatePassword } from '../../src/dtos/auth/update-password.dto'; import { Confirm } from '../../src/dtos/auth/confirm.dto'; +import { LoginViaSingleUseCode } from 'src/dtos/auth/login-single-use-code.dto'; describe('Auth Controller Tests', () => { let app: INestApplication; @@ -417,9 +418,9 @@ describe('Auth Controller Tests', () => { } as RequestMfaCode) .set({ jurisdictionname: jurisdiction.name }) .expect(400); - console.log('420:', res.body); + expect(res.body.message).toEqual( - 'Single use code login is not setup for this jurisdiction', + 'Single use code login is not setup for single_use_code_2', ); expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); @@ -454,4 +455,55 @@ describe('Auth Controller Tests', () => { expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); }); + + it('should login successfully through single use code', async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_login_test', + allowSingleUseCodeLogin: true, + rentalAssistanceDefault: 'test', + }, + }); + + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + singleUseCode: 'abcdef', + mfaEnabled: true, + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + }), + }); + const res = await request(app.getHttpServer()) + .post('/auth/loginViaSingleUseCode') + .send({ + email: storedUser.email, + singleUseCode: storedUser.singleUseCode, + } as LoginViaSingleUseCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(201); + + expect(res.body).toEqual({ + success: true, + }); + + const cookies = res.headers['set-cookie'].map( + (cookie) => cookie.split('=')[0], + ); + + expect(cookies).toContain(TOKEN_COOKIE_NAME); + expect(cookies).toContain(REFRESH_COOKIE_NAME); + expect(cookies).toContain(ACCESS_TOKEN_AVAILABLE_NAME); + + const loggedInUser = await prisma.userAccounts.findUnique({ + where: { + id: storedUser.id, + }, + }); + + expect(loggedInUser.lastLoginAt).not.toBeNull(); + expect(loggedInUser.singleUseCode).toBeNull(); + expect(loggedInUser.activeAccessToken).not.toBeNull(); + expect(loggedInUser.activeRefreshToken).not.toBeNull(); + }); }); diff --git a/api/test/unit/passports/single-use-code.strategy.spec.ts b/api/test/unit/passports/single-use-code.strategy.spec.ts new file mode 100644 index 0000000000..f230f3b000 --- /dev/null +++ b/api/test/unit/passports/single-use-code.strategy.spec.ts @@ -0,0 +1,683 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { randomUUID } from 'crypto'; +import { Request } from 'express'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { passwordToHash } from '../../../src/utilities/password-helpers'; +import { SingleUseCodeStrategy } from '../../../src/passports/single-use-code.strategy'; +import { LoginViaSingleUseCode } from '../../../src/dtos/auth/login-single-use-code.dto'; +import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; + +describe('Testing single-use-code strategy', () => { + let strategy: SingleUseCodeStrategy; + let prisma: PrismaService; + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SingleUseCodeStrategy, PrismaService], + }).compile(); + + strategy = module.get(SingleUseCodeStrategy); + prisma = module.get(PrismaService); + }); + + it('should fail because user does not exist', async () => { + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue(null); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError( + `user example@exygy.com attempted to log in, but does not exist`, + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail because user is locked out', async () => { + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + lastLoginAt: new Date(), + failedLoginAttemptsCount: 10, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Failed login attempts exceeded.`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if no singleUseCode is stored', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + mfaCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Unauthorized Exception`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if no singleUseCodeUpdatedAt is stored', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + mfaCode: 'zyxwv', + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Unauthorized Exception`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if no singleUseCode is sent', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + mfaCode: 'zyxwv', + mfaCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Unauthorized Exception`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if singleUseCode is incorrect', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv1', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`singleUseCodeUnauthorized`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: expect.anything(), + lastLoginAt: expect.anything(), + failedLoginAttemptsCount: 1, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if singleUseCode is expired', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`singleUseCodeUnauthorized`); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: expect.anything(), + lastLoginAt: expect.anything(), + failedLoginAttemptsCount: 1, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if jurisdiction does not exist', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Jurisidiction juris 1 does not exists`); + + expect(prisma.userAccounts.findFirst).not.toHaveBeenCalled(); + + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if jurisdiction disallows single use code login', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: false, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError(`Single use code login is not setup for juris 1`); + + expect(prisma.userAccounts.findFirst).not.toHaveBeenCalled(); + + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); + + it('should fail if jurisdiction is missing from header', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(0), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + }; + + await expect( + async () => await strategy.validate(request as unknown as Request), + ).rejects.toThrowError( + `jurisdictionname is missing from the request headers`, + ); + + expect(prisma.userAccounts.findFirst).not.toHaveBeenCalled(); + + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + + expect(prisma.jurisdictions.findFirst).not.toHaveBeenCalled(); + }); + + it('should succeed', async () => { + const id = randomUUID(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id: id, + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + confirmedAt: new Date(), + passwordValidForDays: 100, + passwordUpdatedAt: new Date(), + userRoles: { isAdmin: false }, + passwordHash: await passwordToHash('abcdef'), + mfaEnabled: true, + phoneNumberVerified: false, + singleUseCode: 'zyxwv', + singleUseCodeUpdatedAt: new Date(), + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ id }); + + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: true, + }); + + const request = { + body: { + email: 'example@exygy.com', + singleUseCode: 'zyxwv', + } as LoginViaSingleUseCode, + headers: { jurisdictionname: 'juris 1' }, + }; + + await strategy.validate(request as unknown as Request); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: 'example@exygy.com', + }, + }); + + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: null, + singleUseCodeUpdatedAt: expect.anything(), + lastLoginAt: expect.anything(), + failedLoginAttemptsCount: 0, + }, + where: { + id, + }, + }); + + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + }); +}); diff --git a/api/test/unit/services/auth.service.spec.ts b/api/test/unit/services/auth.service.spec.ts index 198d04a062..ee8a1f702e 100644 --- a/api/test/unit/services/auth.service.spec.ts +++ b/api/test/unit/services/auth.service.spec.ts @@ -29,6 +29,7 @@ import { JurisdictionService } from '../../../src/services/jurisdiction.service' import { GoogleTranslateService } from '../../../src/services/google-translate.service'; import { PermissionService } from '../../../src/services/permission.service'; import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; +import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; describe('Testing auth service', () => { let authService: AuthService; @@ -871,7 +872,7 @@ describe('Testing auth service', () => { }); }); - it('should request single use code but jurisdiction does not allow', async () => { + it('should request single use code but jurisdiction does not exist', async () => { const id = randomUUID(); emailService.sendSingleUseCode = jest.fn(); prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ @@ -890,9 +891,7 @@ describe('Testing auth service', () => { }, { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, ), - ).rejects.toThrowError( - 'Single use code login is not setup for this jurisdiction', - ); + ).rejects.toThrowError('Jurisidiction juris 1 does not exists'); expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ where: { @@ -903,12 +902,64 @@ describe('Testing auth service', () => { }, }); expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, where: { - name: { - in: ['juris 1'], - }, + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should request single use code but jurisdiction disallows single use code login', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: false, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await authService.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ), + ).rejects.toThrowError('Single use code login is not setup for juris 1'); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, allowSingleUseCodeLogin: true, }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, }); expect(prisma.userAccounts.update).not.toHaveBeenCalled(); expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); @@ -960,6 +1011,7 @@ describe('Testing auth service', () => { }); prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ id, + allowSingleUseCodeLogin: true, }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id, @@ -981,12 +1033,16 @@ describe('Testing auth service', () => { }, }); expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - where: { - name: { - in: ['juris 1'], - }, + select: { + id: true, allowSingleUseCodeLogin: true, }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, }); expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index bf340e8fd8..0ef9379809 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -73,6 +73,7 @@ type ContextProps = { mfaType: MfaType, phoneNumber?: string ) => Promise + loginViaSingleUseCode: (email: string, singleUseCode: string) => Promise } // Internal Provider State @@ -239,6 +240,24 @@ export const AuthProvider: FunctionComponent = ({ child dispatch(stopLoading()) } }, + loginViaSingleUseCode: async (email, singleUseCode) => { + dispatch(startLoading()) + try { + const response = await authService?.loginViaASingleUseCode({ + body: { email, singleUseCode }, + }) + if (response) { + const profile = await userService?.profile() + if (profile) { + dispatch(saveProfile(profile)) + return profile + } + } + return undefined + } finally { + dispatch(stopLoading()) + } + }, signOut: async () => { await authService.logout() dispatch(saveProfile(null)) diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index a697d72111..18d0892082 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1814,6 +1814,28 @@ export class AuthService { axios(configs, resolve, reject) }) } + /** + * LoginViaSingleUseCode + */ + loginViaASingleUseCode( + params: { + /** requestBody */ + body?: LoginViaSingleUseCode + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/auth/loginViaSingleUseCode" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Logout */ @@ -5058,6 +5080,14 @@ export interface Login { mfaType?: MfaType } +export interface LoginViaSingleUseCode { + /** */ + email: string + + /** */ + singleUseCode: string +} + export interface RequestMfaCode { /** */ email: string From 18df523fd2197c46fe791bc62451ec9dc38ece6e Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Thu, 14 Mar 2024 15:06:28 -0600 Subject: [PATCH 21/35] fix: update seeds + fix listing delete (#3940) --- .../migration.sql | 110 ++++++++++++++++++ api/prisma/schema.prisma | 20 ++-- api/prisma/seed-dev.ts | 12 +- api/prisma/seed-helpers/listing-factory.ts | 63 +++++++++- api/prisma/seed-staging.ts | 2 +- api/test/integration/listing.e2e-spec.ts | 4 +- .../permission-as-admin.e2e-spec.ts | 4 +- ...n-as-juris-admin-correct-juris.e2e-spec.ts | 4 +- 8 files changed, 197 insertions(+), 22 deletions(-) create mode 100644 api/prisma/migrations/08_updating_on_delete_cascades_listings_and_applications/migration.sql diff --git a/api/prisma/migrations/08_updating_on_delete_cascades_listings_and_applications/migration.sql b/api/prisma/migrations/08_updating_on_delete_cascades_listings_and_applications/migration.sql new file mode 100644 index 0000000000..1ad8aa2a0e --- /dev/null +++ b/api/prisma/migrations/08_updating_on_delete_cascades_listings_and_applications/migration.sql @@ -0,0 +1,110 @@ +-- DropForeignKey + +ALTER TABLE "application_methods" +DROP CONSTRAINT "application_methods_listing_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "application_methods" ADD CONSTRAINT "application_methods_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "applications" +DROP CONSTRAINT "applications_listing_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "applications" ADD CONSTRAINT "applications_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE +SET NULL ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "listing_images" +DROP CONSTRAINT "listing_images_listing_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "listing_images" ADD CONSTRAINT "listing_images_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "listing_events" +DROP CONSTRAINT "listing_events_listing_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "listing_events" ADD CONSTRAINT "listing_events_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "listing_multiselect_questions" +DROP CONSTRAINT "listing_multiselect_questions_listing_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "listing_multiselect_questions" ADD CONSTRAINT "listing_multiselect_questions_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "units_summary" +DROP CONSTRAINT "units_summary_listing_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "units_summary" ADD CONSTRAINT "units_summary_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "application_flagged_set" +DROP CONSTRAINT "application_flagged_set_listing_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "application_flagged_set" ADD CONSTRAINT "application_flagged_set_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "paper_applications" +DROP CONSTRAINT "paper_applications_application_method_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "paper_applications" ADD CONSTRAINT "paper_applications_application_method_id_fkey" +FOREIGN KEY ("application_method_id") REFERENCES "application_methods"("id") ON +DELETE +SET NULL ON +UPDATE NO ACTION; + +-- DropForeignKey + +ALTER TABLE "household_member" +DROP CONSTRAINT "household_member_application_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "household_member" ADD CONSTRAINT "household_member_application_id_fkey" +FOREIGN KEY ("application_id") REFERENCES "applications"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index ba9261fcdc..b0d4442c49 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -29,7 +29,7 @@ model ActivityLog { module String @db.VarChar action String @db.VarChar metadata Json? - recordId String? @map("record_id") @db.Uuid + recordId String? @map("record_id") @db.Uuid userId String? @map("user_id") @db.Uuid userAccounts UserAccounts? @relation(fields: [userId], references: [id], onUpdate: NoAction) @@ -143,7 +143,7 @@ model ApplicationFlaggedSet { status FlaggedSetStatusEnum @default(pending) resolvingUserId String? @map("resolving_user_id") @db.Uuid userAccounts UserAccounts? @relation(fields: [resolvingUserId], references: [id], onDelete: NoAction, onUpdate: NoAction) - listings Listings @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) applications Applications[] @@index([listingId]) @@ -161,7 +161,7 @@ model ApplicationMethods { acceptsPostmarkedApplications Boolean? @map("accepts_postmarked_applications") phoneNumber String? @map("phone_number") listingId String? @map("listing_id") @db.Uuid - listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) paperApplications PaperApplications[] @@map("application_methods") @@ -218,7 +218,7 @@ model Applications { applicationsAlternateAddress Address? @relation("applications_alternate_address", fields: [alternateAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) userAccounts UserAccounts? @relation(fields: [userId], references: [id], onUpdate: NoAction) applicationsMailingAddress Address? @relation("applications_mailing_address", fields: [mailingAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) - listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: SetNull, onUpdate: NoAction) demographics Demographics? @relation(fields: [demographicsId], references: [id], onDelete: NoAction, onUpdate: NoAction) preferredUnitTypes UnitTypes[] householdMember HouseholdMember[] @@ -305,7 +305,7 @@ model HouseholdMember { addressId String? @unique() @map("address_id") @db.Uuid workAddressId String? @unique() @map("work_address_id") @db.Uuid applicationId String? @map("application_id") @db.Uuid - applications Applications? @relation(fields: [applicationId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applications Applications? @relation(fields: [applicationId], references: [id], onDelete: Cascade, onUpdate: NoAction) householdMemberAddress Address? @relation("household_member_address", fields: [addressId], references: [id], onDelete: NoAction, onUpdate: NoAction) householdMemberWorkAddress Address? @relation("household_member_work_address", fields: [workAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@ -365,7 +365,7 @@ model ListingEvents { listingId String? @map("listing_id") @db.Uuid fileId String? @map("file_id") @db.Uuid assets Assets? @relation(fields: [fileId], references: [id], onDelete: NoAction, onUpdate: NoAction) - listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@map("listing_events") } @@ -404,7 +404,7 @@ model ListingImages { listingId String @map("listing_id") @db.Uuid imageId String @map("image_id") @db.Uuid assets Assets @relation(fields: [imageId], references: [id], onDelete: NoAction, onUpdate: NoAction) - listings Listings @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@id([listingId, imageId]) @@index([listingId]) @@ -416,7 +416,7 @@ model ListingMultiselectQuestions { listingId String @map("listing_id") @db.Uuid multiselectQuestionId String @map("multiselect_question_id") @db.Uuid multiselectQuestions MultiselectQuestions @relation(fields: [multiselectQuestionId], references: [id], onDelete: NoAction, onUpdate: NoAction) - listings Listings @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@id([listingId, multiselectQuestionId]) @@map("listing_multiselect_questions") @@ -619,7 +619,7 @@ model PaperApplications { fileId String? @map("file_id") @db.Uuid applicationMethodId String? @map("application_method_id") @db.Uuid assets Assets? @relation(fields: [fileId], references: [id], onDelete: Cascade, onUpdate: NoAction) - applicationMethods ApplicationMethods? @relation(fields: [applicationMethodId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applicationMethods ApplicationMethods? @relation(fields: [applicationMethodId], references: [id], onDelete: SetNull, onUpdate: NoAction) @@map("paper_applications") } @@ -756,7 +756,7 @@ model UnitsSummary { priorityTypeId String? @map("priority_type_id") @db.Uuid unitTypes UnitTypes? @relation(fields: [unitTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) unitAccessibilityPriorityTypes UnitAccessibilityPriorityTypes? @relation(fields: [priorityTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) - listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@map("units_summary") } diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 9ee139fcb6..357118d71a 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -100,13 +100,21 @@ export const devSeeding = async ( const listing = await listingFactory(jurisdiction.id, prismaClient, { amiChart: amiChart, - numberOfUnits: index, + numberOfUnits: index + 1, includeBuildingFeatures: index > 1, includeEligibilityRules: index > 2, - status: listingStatusEnumArray[randomInt(listingStatusEnumArray.length)], + status: + index < 4 + ? ListingsStatusEnum.active + : listingStatusEnumArray[ + index - 3 < listingStatusEnumArray.length + ? index - 3 + : randomInt(listingStatusEnumArray.length - 1) + ], multiselectQuestions: index > 0 ? multiselectQuestions.slice(0, index - 1) : [], applications, + digitalApp: !!(index % 2), }); await prismaClient.listings.create({ data: listing, diff --git a/api/prisma/seed-helpers/listing-factory.ts b/api/prisma/seed-helpers/listing-factory.ts index c5778294f1..232e5e05fc 100644 --- a/api/prisma/seed-helpers/listing-factory.ts +++ b/api/prisma/seed-helpers/listing-factory.ts @@ -4,12 +4,25 @@ import { MultiselectQuestions, PrismaClient, ListingsStatusEnum, + ApplicationMethodsTypeEnum, } from '@prisma/client'; +import { randomInt } from 'crypto'; import { randomName } from './word-generator'; import { addressFactory } from './address-factory'; import { unitFactoryMany } from './unit-factory'; import { reservedCommunityTypeFactoryGet } from './reserved-community-type-factory'; +const cloudinaryIds = [ + 'dev/blake-wheeler-zBHU08hdzhY-unsplash_swqash', + 'dev/krzysztof-hepner-V7Q0Oh3Az-c-unsplash_xoj7sr', + 'dev/dillon-kydd-2keCPb73aQY-unsplash_lm7krp', + 'dev/inside_qo9wre', + 'dev/interior_mc9erd', + 'dev/apartment_ez3yyz', + 'dev/trayan-xIOYJSVEZ8c-unsplash_f1axsg', + 'dev/apartment_building_2_b7ujdd', +]; + export const listingFactory = async ( jurisdictionId: string, prismaClient: PrismaClient, @@ -25,6 +38,8 @@ export const listingFactory = async ( applications?: Prisma.ApplicationsCreateInput[]; applicationDueDate?: Date; afsLastRunSetInPast?: boolean; + digitalApp?: boolean; + noImage?: boolean; }, ): Promise => { const previousListing = optionalParams?.listing || {}; @@ -39,6 +54,11 @@ export const listingFactory = async ( prismaClient, jurisdictionId, ); + + const digitalApp = !!optionalParams?.digitalApp + ? optionalParams.digitalApp + : Math.random() < 0.5; + return { createdAt: new Date(), assets: [], @@ -60,11 +80,14 @@ export const listingFactory = async ( listingsApplicationDropOffAddress: { create: addressFactory(), }, - reservedCommunityTypes: { - connect: { - id: reservedCommunityType.id, - }, - }, + reservedCommunityTypes: + Math.random() < 0.5 + ? { + connect: { + id: reservedCommunityType.id, + }, + } + : {}, // For application flagged set tests the date needs to be before the updated timestamp // All others should be a newer timestamp so that they are not picked up by AFS tests afsLastRunAt: optionalParams?.afsLastRunSetInPast @@ -93,7 +116,6 @@ export const listingFactory = async ( ...featuresAndUtilites(), ...buildingFeatures(optionalParams?.includeBuildingFeatures), ...additionalEligibilityRules(optionalParams?.includeEligibilityRules), - ...previousListing, jurisdictions: { connect: { id: jurisdictionId, @@ -105,6 +127,35 @@ export const listingFactory = async ( } : undefined, applicationDueDate: optionalParams?.applicationDueDate ?? undefined, + developer: randomName(), + leasingAgentName: randomName(), + leasingAgentEmail: 'leasing-agent@example.com', + leasingAgentPhone: '515-604-0183', + digitalApplication: digitalApp, + commonDigitalApplication: digitalApp, + paperApplication: Math.random() < 0.5, + referralOpportunity: Math.random() < 0.5, + applicationMethods: digitalApp + ? { + create: { + type: ApplicationMethodsTypeEnum.Internal, + }, + } + : {}, + listingImages: !optionalParams?.noImage + ? { + create: { + ordinal: 0, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: cloudinaryIds[randomInt(cloudinaryIds.length)], + }, + }, + }, + } + : {}, + ...previousListing, }; }; diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index d9c3247476..2b68d65297 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -855,7 +855,7 @@ export const stagingSeed = async ( assets: { create: { label: 'cloudinaryBuilding', - fileId: 'dev/blake-wheeler-zBHU08hdzhY-unsplash_swqash ', + fileId: 'dev/blake-wheeler-zBHU08hdzhY-unsplash_swqash', }, }, }, diff --git a/api/test/integration/listing.e2e-spec.ts b/api/test/integration/listing.e2e-spec.ts index c7314e3baa..f7631f8132 100644 --- a/api/test/integration/listing.e2e-spec.ts +++ b/api/test/integration/listing.e2e-spec.ts @@ -569,7 +569,9 @@ describe('Listing Controller Tests', () => { data: jurisdictionFactory(), }); await reservedCommunityTypeFactoryAll(jurisdictionA.id, prisma); - const listingData = await listingFactory(jurisdictionA.id, prisma); + const listingData = await listingFactory(jurisdictionA.id, prisma, { + noImage: true, + }); const listing = await prisma.listings.create({ data: listingData, }); diff --git a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts index 0faff9fe95..ab23b7f723 100644 --- a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts @@ -1124,7 +1124,9 @@ describe('Testing Permissioning of endpoints as Admin User', () => { 'permission juris 17', ); await reservedCommunityTypeFactoryAll(jurisdictionA, prisma); - const listingData = await listingFactory(jurisdictionA, prisma); + const listingData = await listingFactory(jurisdictionA, prisma, { + noImage: true, + }); const listing = await prisma.listings.create({ data: listingData, }); diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts index f8ed084916..178f3218e4 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts @@ -1021,7 +1021,9 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for delete endpoint & create an activity log entry', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisId, prisma, { + noImage: true, + }); const listing = await prisma.listings.create({ data: listingData, }); From 5801e314162fefed3cbaf5341340ed791ff4258d Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Thu, 14 Mar 2024 14:13:01 -0700 Subject: [PATCH 22/35] feat: unconfirmed user login error fix (#3949) * feat: unconfirmed user login error fix * fix: unconfirmed user attempting to login, public user logging into partner site, seeding es * updates per cade * fix: undefined check * Merge remote-tracking branch 'origin/main' into security-patch-2 * fix: merge mistakes were made --------- Co-authored-by: Cade Wolcott --- api/Procfile | 2 +- .../migration.sql | 4 +- .../seed-helpers/translation-factory.ts | 348 ++++++++++-------- api/prisma/seed-staging.ts | 4 + api/src/passports/mfa.strategy.ts | 24 +- api/test/unit/passports/mfa.strategy.spec.ts | 2 + shared-helpers/src/auth/AuthContext.ts | 16 +- shared-helpers/src/auth/catchNetworkError.ts | 2 +- sites/partners/src/lib/users/signInHelpers.ts | 4 +- sites/partners/src/pages/sign-in.tsx | 2 +- sites/public/src/pages/sign-in.tsx | 2 +- 11 files changed, 233 insertions(+), 177 deletions(-) diff --git a/api/Procfile b/api/Procfile index 36c6b6bdf1..482d54025a 100644 --- a/api/Procfile +++ b/api/Procfile @@ -1 +1 @@ -web: yarn start:prod +web: yarn db:migration:run && yarn start:prod diff --git a/api/prisma/migrations/05_single_use_code_translation_updates/migration.sql b/api/prisma/migrations/05_single_use_code_translation_updates/migration.sql index 06df833ecb..1fab055382 100644 --- a/api/prisma/migrations/05_single_use_code_translation_updates/migration.sql +++ b/api/prisma/migrations/05_single_use_code_translation_updates/migration.sql @@ -5,6 +5,8 @@ SET translations = jsonb_set(translations, '{singleUseCodeEmail}', '{"greeting": WHERE jurisdiction_id IS NULL and language = 'en'; + UPDATE translations - SET translations = jsonb_set(translations, '{mfaCodeEmail, mfaCode}', '{"mfaCode": "Your access code is: %{singleUseCode}"}') +SET translations = jsonb_set(translations, '{mfaCodeEmail, mfaCode}', '"Your access code is: %{singleUseCode}"') WHERE language = 'en'; + diff --git a/api/prisma/seed-helpers/translation-factory.ts b/api/prisma/seed-helpers/translation-factory.ts index 9158ae499f..3b377d3df8 100644 --- a/api/prisma/seed-helpers/translation-factory.ts +++ b/api/prisma/seed-helpers/translation-factory.ts @@ -1,166 +1,204 @@ import { LanguagesEnum, Prisma } from '@prisma/client'; -const translations = (jurisdictionName?: string) => ({ - t: { - hello: 'Hello', - seeListing: 'See Listing', - partnersPortal: 'Partners Portal', - viewListing: 'View Listing', - editListing: 'Edit Listing', - reviewListing: 'Review Listing', - }, - footer: { - line1: `${jurisdictionName || 'Bloom'}`, - line2: '', - thankYou: 'Thank you', - footer: `${jurisdictionName || 'Bloom Housing'}`, - }, - header: { - logoUrl: - 'https://res.cloudinary.com/exygy/image/upload/w_400,c_limit,q_65/dev/bloom_logo_generic_zgb4sg.jpg', - logoTitle: 'Bloom Housing Portal', - }, - invite: { - hello: 'Welcome to the Partners Portal', - confirmMyAccount: 'Confirm my account', - inviteManageListings: - 'You will now be able to manage listings and applications that you are a part of from one centralized location.', - inviteWelcomeMessage: 'Welcome to the Partners Portal at %{appUrl}.', - toCompleteAccountCreation: - 'To complete your account creation, please click the link below:', - }, - register: { - welcome: 'Welcome', - welcomeMessage: - 'Thank you for setting up your account on %{appUrl}. It will now be easier for you to start, save, and submit online applications for listings that appear on the site.', - confirmMyAccount: 'Confirm my account', - toConfirmAccountMessage: - 'To complete your account creation, please click the link below:', - }, - changeEmail: { - message: 'An email address change has been requested for your account.', - changeMyEmail: 'Confirm email change', - onChangeEmailMessage: - 'To confirm the change to your email address, please click the link below:', - }, - confirmation: { - subject: 'Your Application Confirmation', - eligible: { - fcfs: 'Eligible applicants will be contacted on a first come first serve basis until vacancies are filled.', - lottery: - 'Once the application period closes, eligible applicants will be placed in order based on lottery rank order.', - waitlist: - 'Eligible applicants will be placed on the waitlist on a first come first serve basis until waitlist spots are filled.', - fcfsPreference: - 'Housing preferences, if applicable, will affect first come first serve order.', - waitlistContact: - 'You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.', - lotteryPreference: - 'Housing preferences, if applicable, will affect lottery rank order.', - waitlistPreference: - 'Housing preferences, if applicable, will affect waitlist order.', - }, - interview: - 'If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.', - whatToExpect: { - FCFS: 'Applicants will be contacted by the property agent on a first come first serve basis until vacancies are filled.', - lottery: - 'Applicants will be contacted by the agent in lottery rank order until vacancies are filled.', - noLottery: - 'Applicants will be contacted by the agent in waitlist order until vacancies are filled.', - }, - whileYouWait: - 'While you wait, there are things you can do to prepare for potential next steps and future opportunities.', - shouldBeChosen: - 'Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.', - whatHappensNext: 'What happens next?', - whatToExpectNext: 'What to expect next:', - needToMakeUpdates: 'Need to make updates?', - applicationsClosed: 'Application
closed', - applicationsRanked: 'Application
ranked', - eligibleApplicants: { - FCFS: 'Eligible applicants will be placed in order based on first come first serve basis.', - lottery: - 'Eligible applicants will be placed in order based on preference and lottery rank.', - lotteryDate: 'The lottery will be held on %{lotteryDate}.', - }, - applicationReceived: 'Application
received', - prepareForNextSteps: 'Prepare for next steps', - thankYouForApplying: - 'Thanks for applying. We have received your application for', - readHowYouCanPrepare: 'Read about how you can prepare for next steps', - yourConfirmationNumber: 'Your Confirmation Number', - applicationPeriodCloses: - 'Once the application period closes, the property manager will begin processing applications.', - contactedForAnInterview: - 'If you are contacted for an interview, you will need to fill out a more detailed application and provide supporting documents.', - gotYourConfirmationNumber: 'We got your application for', - }, - leasingAgent: { - officeHours: 'Office Hours:', - propertyManager: 'Property Manager', - contactAgentToUpdateInfo: - 'If you need to update information on your application, do not apply again. Instead, contact the agent for this listing.', - }, - mfaCodeEmail: { - message: 'Access code for your account has been requested.', - mfaCode: 'Your access code is: %{singleUseCode}', - }, - forgotPassword: { - subject: 'Forgot your password?', - callToAction: - 'If you did make this request, please click on the link below to reset your password:', - passwordInfo: - "Your password won't change until you access the link above and create a new one.", - resetRequest: - 'A request to reset your Bloom Housing Portal website password for %{appUrl} has recently been made.', - ignoreRequest: "If you didn't request this, please ignore this email.", - changePassword: 'Change my password', - }, - requestApproval: { - header: 'Listing approval requested', - partnerRequest: - 'A Partner has submitted an approval request to publish the %{listingName} listing.', - logInToReviewStart: 'Please log into the', - logInToReviewEnd: - 'and navigate to the listing detail page to review and publish.', - accessListing: - 'To access the listing after logging in, please click the link below', - }, - changesRequested: { - header: 'Listing changes requested', - adminRequestStart: - 'An administrator is requesting changes to the %{listingName} listing. Please log into the', - adminRequestEnd: - 'and navigate to the listing detail page to view the request and edit the listing.', - }, - listingApproved: { - header: 'New published listing', - adminApproved: - 'The %{listingName} listing has been approved and published by an administrator.', - viewPublished: - 'To view the published listing, please click on the link below', - }, - csvExport: { - body: 'The attached file is %{fileDescription}. If you have any questions, please reach out to your administrator.', - hello: 'Hello,', - title: '%{title}', - }, - singleUseCodeEmail: { - greeting: 'Hi', - message: - 'Use the following code to sign in to your %{jurisdictionName} account. This code will be valid for 5 minutes. Never share this code.', - singleUseCode: '%{singleUseCode}', - }, -}); +const translations = (jurisdictionName?: string, language?: LanguagesEnum) => { + if (!language || language === LanguagesEnum.en) { + return { + t: { + hello: 'Hello', + seeListing: 'See Listing', + partnersPortal: 'Partners Portal', + viewListing: 'View Listing', + editListing: 'Edit Listing', + reviewListing: 'Review Listing', + }, + footer: { + line1: `${jurisdictionName || 'Bloom'}`, + line2: '', + thankYou: 'Thank you', + footer: `${jurisdictionName || 'Bloom Housing'}`, + }, + header: { + logoUrl: + 'https://res.cloudinary.com/exygy/image/upload/w_400,c_limit,q_65/dev/bloom_logo_generic_zgb4sg.jpg', + logoTitle: 'Bloom Housing Portal', + }, + invite: { + hello: 'Welcome to the Partners Portal', + confirmMyAccount: 'Confirm my account', + inviteManageListings: + 'You will now be able to manage listings and applications that you are a part of from one centralized location.', + inviteWelcomeMessage: 'Welcome to the Partners Portal at %{appUrl}.', + toCompleteAccountCreation: + 'To complete your account creation, please click the link below:', + }, + register: { + welcome: 'Welcome', + welcomeMessage: + 'Thank you for setting up your account on %{appUrl}. It will now be easier for you to start, save, and submit online applications for listings that appear on the site.', + confirmMyAccount: 'Confirm my account', + toConfirmAccountMessage: + 'To complete your account creation, please click the link below:', + }, + changeEmail: { + message: 'An email address change has been requested for your account.', + changeMyEmail: 'Confirm email change', + onChangeEmailMessage: + 'To confirm the change to your email address, please click the link below:', + }, + confirmation: { + subject: 'Your Application Confirmation', + eligible: { + fcfs: 'Eligible applicants will be contacted on a first come first serve basis until vacancies are filled.', + lottery: + 'Once the application period closes, eligible applicants will be placed in order based on lottery rank order.', + waitlist: + 'Eligible applicants will be placed on the waitlist on a first come first serve basis until waitlist spots are filled.', + fcfsPreference: + 'Housing preferences, if applicable, will affect first come first serve order.', + waitlistContact: + 'You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.', + lotteryPreference: + 'Housing preferences, if applicable, will affect lottery rank order.', + waitlistPreference: + 'Housing preferences, if applicable, will affect waitlist order.', + }, + interview: + 'If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.', + whatToExpect: { + FCFS: 'Applicants will be contacted by the property agent on a first come first serve basis until vacancies are filled.', + lottery: + 'Applicants will be contacted by the agent in lottery rank order until vacancies are filled.', + noLottery: + 'Applicants will be contacted by the agent in waitlist order until vacancies are filled.', + }, + whileYouWait: + 'While you wait, there are things you can do to prepare for potential next steps and future opportunities.', + shouldBeChosen: + 'Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.', + whatHappensNext: 'What happens next?', + whatToExpectNext: 'What to expect next:', + needToMakeUpdates: 'Need to make updates?', + applicationsClosed: 'Application
closed', + applicationsRanked: 'Application
ranked', + eligibleApplicants: { + FCFS: 'Eligible applicants will be placed in order based on first come first serve basis.', + lottery: + 'Eligible applicants will be placed in order based on preference and lottery rank.', + lotteryDate: 'The lottery will be held on %{lotteryDate}.', + }, + applicationReceived: 'Application
received', + prepareForNextSteps: 'Prepare for next steps', + thankYouForApplying: + 'Thanks for applying. We have received your application for', + readHowYouCanPrepare: 'Read about how you can prepare for next steps', + yourConfirmationNumber: 'Your Confirmation Number', + applicationPeriodCloses: + 'Once the application period closes, the property manager will begin processing applications.', + contactedForAnInterview: + 'If you are contacted for an interview, you will need to fill out a more detailed application and provide supporting documents.', + gotYourConfirmationNumber: 'We got your application for', + }, + leasingAgent: { + officeHours: 'Office Hours:', + propertyManager: 'Property Manager', + contactAgentToUpdateInfo: + 'If you need to update information on your application, do not apply again. Instead, contact the agent for this listing.', + }, + mfaCodeEmail: { + message: 'Access code for your account has been requested.', + mfaCode: 'Your access code is: %{singleUseCode}', + }, + forgotPassword: { + subject: 'Forgot your password?', + callToAction: + 'If you did make this request, please click on the link below to reset your password:', + passwordInfo: + "Your password won't change until you access the link above and create a new one.", + resetRequest: + 'A request to reset your Bloom Housing Portal website password for %{appUrl} has recently been made.', + ignoreRequest: "If you didn't request this, please ignore this email.", + changePassword: 'Change my password', + }, + requestApproval: { + header: 'Listing approval requested', + partnerRequest: + 'A Partner has submitted an approval request to publish the %{listingName} listing.', + logInToReviewStart: 'Please log into the', + logInToReviewEnd: + 'and navigate to the listing detail page to review and publish.', + accessListing: + 'To access the listing after logging in, please click the link below', + }, + changesRequested: { + header: 'Listing changes requested', + adminRequestStart: + 'An administrator is requesting changes to the %{listingName} listing. Please log into the', + adminRequestEnd: + 'and navigate to the listing detail page to view the request and edit the listing.', + }, + listingApproved: { + header: 'New published listing', + adminApproved: + 'The %{listingName} listing has been approved and published by an administrator.', + viewPublished: + 'To view the published listing, please click on the link below', + }, + csvExport: { + body: 'The attached file is %{fileDescription}. If you have any questions, please reach out to your administrator.', + hello: 'Hello,', + title: '%{title}', + }, + singleUseCodeEmail: { + greeting: 'Hi', + message: + 'Use the following code to sign in to your %{jurisdictionName} account. This code will be valid for 5 minutes. Never share this code.', + singleUseCode: '%{singleUseCode}', + }, + }; + } else if (language === LanguagesEnum.es) { + return { + t: { seeListing: 'VER EL LISTADO' }, + footer: { + line1: `${jurisdictionName || 'Bloom'}`, + line2: '', + }, + confirmation: { + eligible: { + waitlist: + 'Los solicitantes que reĆŗnan los requisitos quedarĆ”n en la lista de espera por orden de recepciĆ³n de solicitud hasta que se cubran todos los lugares.', + waitlistContact: + 'Es posible que se comuniquen con usted mientras estĆ© en la lista de espera para confirmar que desea permanecer en la lista.', + waitlistPreference: + 'Las preferencias de vivienda, si corresponde, afectarĆ”n al orden de la lista de espera.', + }, + interview: + 'Si se comunican con usted para una entrevista, se le pedirĆ” que complete una solicitud mĆ”s detallada y presente documentos de respaldo.', + whatHappensNext: 'ĀæQuĆ© sucede luego?', + needToMakeUpdates: 'ĀæNecesita hacer modificaciones?', + applicationsClosed: 'Solicitud
cerrada', + applicationsRanked: 'Solicitud
clasificada', + applicationReceived: 'AplicaciĆ³n
recibida', + yourConfirmationNumber: 'Su nĆŗmero de confirmaciĆ³n', + gotYourConfirmationNumber: 'Recibimos tu solicitud para:', + }, + leasingAgent: { + officeHours: 'Horario de atenciĆ³n', + propertyManager: 'Administrador de propiedades', + contactAgentToUpdateInfo: + 'Si necesita modificar informaciĆ³n en su solicitud, no haga una solicitud nueva. ComunĆ­quese con el agente de este listado.', + }, + }; + } +}; export const translationFactory = ( jurisdictionId?: string, jurisdictionName?: string, + language?: LanguagesEnum, ): Prisma.TranslationsCreateInput => { return { - language: LanguagesEnum.en, - translations: translations(jurisdictionName), + language: language || LanguagesEnum.en, + translations: translations(jurisdictionName, language), jurisdictions: jurisdictionId ? { connect: { diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 2b68d65297..627b7ceea9 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -1,6 +1,7 @@ import { ApplicationAddressTypeEnum, ApplicationMethodsTypeEnum, + LanguagesEnum, ListingsStatusEnum, MultiselectQuestions, MultiselectQuestionsApplicationSectionEnum, @@ -92,6 +93,9 @@ export const stagingSeed = async ( await prismaClient.translations.create({ data: translationFactory(jurisdiction.id, jurisdiction.name), }); + await prismaClient.translations.create({ + data: translationFactory(undefined, undefined, LanguagesEnum.es), + }); await prismaClient.translations.create({ data: translationFactory(), }); diff --git a/api/src/passports/mfa.strategy.ts b/api/src/passports/mfa.strategy.ts index 5afb8f078b..4a510090f2 100644 --- a/api/src/passports/mfa.strategy.ts +++ b/api/src/passports/mfa.strategy.ts @@ -63,7 +63,18 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS), Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN), ); - if (!rawUser.confirmedAt) { + if (!(await isPasswordValid(rawUser.passwordHash, dto.password))) { + // if incoming password does not match + await this.updateFailedLoginCount( + rawUser.failedLoginAttemptsCount + 1, + rawUser.id, + ); + throw new UnauthorizedException({ + failureCountRemaining: + Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) - + rawUser.failedLoginAttemptsCount, + }); + } else if (!rawUser.confirmedAt) { // if user is not confirmed already throw new UnauthorizedException( `user ${rawUser.id} attempted to login, but is not confirmed`, @@ -78,17 +89,6 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { throw new UnauthorizedException( `user ${rawUser.id} attempted to login, but password is no longer valid`, ); - } else if (!(await isPasswordValid(rawUser.passwordHash, dto.password))) { - // if incoming password does not match - await this.updateFailedLoginCount( - rawUser.failedLoginAttemptsCount + 1, - rawUser.id, - ); - throw new UnauthorizedException({ - failureCountRemaining: - Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) - - rawUser.failedLoginAttemptsCount, - }); } if (!rawUser.mfaEnabled) { diff --git a/api/test/unit/passports/mfa.strategy.spec.ts b/api/test/unit/passports/mfa.strategy.spec.ts index 2d5f684623..35bad3d0e5 100644 --- a/api/test/unit/passports/mfa.strategy.spec.ts +++ b/api/test/unit/passports/mfa.strategy.spec.ts @@ -88,6 +88,7 @@ describe('Testing mfa strategy', () => { lastLoginAt: new Date(), failedLoginAttemptsCount: 0, confirmedAt: null, + passwordHash: await passwordToHash('abcdef'), }); const request = { @@ -127,6 +128,7 @@ describe('Testing mfa strategy', () => { passwordValidForDays: 0, passwordUpdatedAt: new Date(0), userRoles: { isAdmin: true }, + passwordHash: await passwordToHash('abcdef'), }); const request = { diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index 0ef9379809..6bf148db37 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -52,7 +52,8 @@ type ContextProps = { email: string, password: string, mfaCode?: string, - mfaType?: MfaType + mfaType?: MfaType, + forPartners?: boolean ) => Promise resetPassword: ( token: string, @@ -223,16 +224,25 @@ export const AuthProvider: FunctionComponent = ({ child email, password, mfaCode: string | undefined = undefined, - mfaType: MfaType | undefined = undefined + mfaType: MfaType | undefined = undefined, + forPartners: boolean | undefined = undefined ) => { dispatch(startLoading()) try { const response = await authService?.login({ body: { email, password, mfaCode, mfaType } }) if (response) { const profile = await userService?.profile() - if (profile) { + if ( + profile && + (!forPartners || + profile.userRoles?.isAdmin || + profile.userRoles?.isJurisdictionalAdmin || + profile.userRoles?.isPartner) + ) { dispatch(saveProfile(profile)) return profile + } else { + throw Error("User cannot log in") } } return undefined diff --git a/shared-helpers/src/auth/catchNetworkError.ts b/shared-helpers/src/auth/catchNetworkError.ts index e4c9cd64d6..e61114a421 100644 --- a/shared-helpers/src/auth/catchNetworkError.ts +++ b/shared-helpers/src/auth/catchNetworkError.ts @@ -38,7 +38,7 @@ export const useCatchNetworkError = () => { const [networkError, setNetworkError] = useState(null) const check401Error = (message: string, error: AxiosError) => { - if (message.includes(NetworkErrorMessage.PasswordOutdated)) { + if (message?.includes(NetworkErrorMessage.PasswordOutdated)) { setNetworkError({ title: t("authentication.signIn.passwordOutdated"), description: `${t( diff --git a/sites/partners/src/lib/users/signInHelpers.ts b/sites/partners/src/lib/users/signInHelpers.ts index 5f46f6a619..e0844969f8 100644 --- a/sites/partners/src/lib/users/signInHelpers.ts +++ b/sites/partners/src/lib/users/signInHelpers.ts @@ -12,7 +12,7 @@ export const onSubmitEmailAndPassword = async (data: { email: string; password: string }) => { const { email, password } = data try { - await login(email, password) + await login(email, password, undefined, undefined, true) await router.push("/") } catch (error) { if (error?.response?.data?.name === "mfaCodeIsMissing") { @@ -86,7 +86,7 @@ export const onSubmitMfaCode = async (data: { mfaCode: string }) => { const { mfaCode } = data try { - await login(email, password, mfaCode, mfaType) + await login(email, password, mfaCode, mfaType, true) resetNetworkError() await router.push("/") } catch (error) { diff --git a/sites/partners/src/pages/sign-in.tsx b/sites/partners/src/pages/sign-in.tsx index aff1ce8b0b..267d88ee64 100644 --- a/sites/partners/src/pages/sign-in.tsx +++ b/sites/partners/src/pages/sign-in.tsx @@ -93,7 +93,7 @@ const SignIn = () => { ) useEffect(() => { - if (networkError?.error.response.data?.message === "accountConfirmed") { + if (networkError?.error.response?.data?.message === "accountConfirmed") { setConfirmationStatusModal(true) } }, [networkError]) diff --git a/sites/public/src/pages/sign-in.tsx b/sites/public/src/pages/sign-in.tsx index ba148406df..55f9676faf 100644 --- a/sites/public/src/pages/sign-in.tsx +++ b/sites/public/src/pages/sign-in.tsx @@ -122,7 +122,7 @@ const SignIn = () => { })() useEffect(() => { - if (networkError?.error?.response?.data?.message === "accountNotConfirmed") { + if (networkError?.error?.response?.data?.message?.includes("but is not confirmed")) { setConfirmationStatusModal(true) } }, [networkError]) From 02a4dbd9438768d590e14f6ef7b57a289a6f1a64 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Thu, 14 Mar 2024 19:32:11 -0700 Subject: [PATCH 23/35] fix: afs needs to be paginating (#3955) --- .../services/application-flagged-set.service.ts | 17 ++++++++++++++++- .../application-flagged-set.service.spec.ts | 1 + 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/api/src/services/application-flagged-set.service.ts b/api/src/services/application-flagged-set.service.ts index 848aa9b917..22bd030d89 100644 --- a/api/src/services/application-flagged-set.service.ts +++ b/api/src/services/application-flagged-set.service.ts @@ -24,7 +24,11 @@ import { AfsQueryParams } from '../dtos/application-flagged-sets/afs-query-param import { AfsMeta } from '../dtos/application-flagged-sets/afs-meta.dto'; import { OrderByEnum } from '../enums/shared/order-by-enum'; import { View } from '../enums/application-flagged-sets/view'; -import { buildPaginationMetaInfo } from '../utilities/pagination-helpers'; +import { + buildPaginationMetaInfo, + calculateSkip, + calculateTake, +} from '../utilities/pagination-helpers'; import { AfsResolve } from '../dtos/application-flagged-sets/afs-resolve.dto'; import { User } from '../dtos/users/user.dto'; import { Application } from '../dtos/applications/application.dto'; @@ -67,6 +71,15 @@ export class ApplicationFlaggedSetService implements OnModuleInit { where: whereClause, }); + // if passed in page and limit would result in no results because there aren't that many listings + // revert back to the first page + let page = params.page; + if (count && params.limit && params.limit !== 'all' && params.page > 1) { + if (Math.ceil(count / params.limit) < params.page) { + page = 1; + } + } + const rawAfs = await this.prisma.applicationFlaggedSet.findMany({ include: { listings: true, @@ -80,6 +93,8 @@ export class ApplicationFlaggedSetService implements OnModuleInit { orderBy: { id: OrderByEnum.DESC, }, + skip: calculateSkip(params.limit, page), + take: calculateTake(params.limit), }); const totalFlagged = await this.prisma.applicationFlaggedSet.count({ diff --git a/api/test/unit/services/application-flagged-set.service.spec.ts b/api/test/unit/services/application-flagged-set.service.spec.ts index dfa0cd8f24..8e1e34f8b6 100644 --- a/api/test/unit/services/application-flagged-set.service.spec.ts +++ b/api/test/unit/services/application-flagged-set.service.spec.ts @@ -409,6 +409,7 @@ describe('Testing application flagged set service', () => { orderBy: { id: OrderByEnum.DESC, }, + skip: 0, }); expect(prisma.applicationFlaggedSet.count).toHaveBeenNthCalledWith(1, { From fc912c886f648ee34adafc6c2b759e22e5e2169d Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:45:16 -0600 Subject: [PATCH 24/35] feat: passwordless frontend (#3941) --- shared-helpers/index.ts | 2 + shared-helpers/src/locales/es.json | 4 +- shared-helpers/src/locales/general.json | 22 +++- shared-helpers/src/locales/vi.json | 5 +- shared-helpers/src/locales/zh.json | 4 +- .../src/views/sign-in/FormSignIn.module.scss | 26 ++++ .../src/views/sign-in/FormSignIn.tsx | 72 +++-------- .../src/views/sign-in/FormSignInDefault.tsx | 76 +++++++++++ .../src/views/sign-in/FormSignInPwdless.tsx | 91 ++++++++++++++ sites/partners/src/pages/sign-in.tsx | 27 ++-- sites/public/.env.template | 3 +- sites/public/next.config.js | 1 + sites/public/src/pages/sign-in.tsx | 19 ++- sites/public/src/pages/verify.tsx | 119 ++++++++++++++++++ sites/public/styles/verify.module.scss | 33 +++++ 15 files changed, 428 insertions(+), 76 deletions(-) create mode 100644 shared-helpers/src/views/sign-in/FormSignInDefault.tsx create mode 100644 shared-helpers/src/views/sign-in/FormSignInPwdless.tsx create mode 100644 sites/public/src/pages/verify.tsx create mode 100644 sites/public/styles/verify.module.scss diff --git a/shared-helpers/index.ts b/shared-helpers/index.ts index 50f4aa02d6..d111acb81d 100644 --- a/shared-helpers/index.ts +++ b/shared-helpers/index.ts @@ -26,5 +26,7 @@ export * from "./src/views/summaryTables" export * from "./src/views/forgot-password/FormForgotPassword" export * from "./src/views/layout/ExygyFooter" export * from "./src/views/sign-in/FormSignIn" +export * from "./src/views/sign-in/FormSignInDefault" export * from "./src/views/sign-in/FormSignInErrorBox" +export * from "./src/views/sign-in/FormSignInPwdless" export * from "./src/views/sign-in/ResendConfirmationModal" diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 18ba7829c8..e8eb483555 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -386,8 +386,8 @@ "authentication.signIn.changeYourPassword": "Puede cambiar su contraseƱa", "authentication.signIn.enterLoginEmail": "Por favor, escriba su correo electrĆ³nico de inicio de sesiĆ³n", "authentication.signIn.enterLoginPassword": "Por favor, escriba su contraseƱa de inicio de sesiĆ³n", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Por favor, escriba un cĆ³digo vĆ”lido.", - "authentication.signIn.enterValidEmailAndPassword": "Por favor, escriba un correo electrĆ³nico y una contraseƱa vĆ”lidos.", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Por favor, escriba un cĆ³digo vĆ”lido", + "authentication.signIn.enterValidEmailAndPassword": "Por favor, escriba un correo electrĆ³nico y una contraseƱa vĆ”lidos", "authentication.signIn.errorGenericMessage": "Por favor intĆ©ntelo de nuevo, o comunĆ­quese con servicio al cliente para recibir asistencia.", "authentication.signIn.error": "Hubo un error cuando usted iniciĆ³ sesiĆ³n", "authentication.signIn.forgotPassword": "OlvidĆ© la contraseƱa", diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 10ba804f5c..50872db5f6 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -15,6 +15,8 @@ "account.viewApplications": "View applications", "account.myApplicationsSubtitle": "See lottery dates and listings for properties for which you've applied", "account.noApplications": "It looks like you haven't applied to any listings yet.", + "account.reviewTerms": "Review Terms of Use", + "account.reviewTermsHelper": "You must accept the terms of use before creating an account.", "account.signUpSaveTime.applyFaster": "Apply faster with saved application details", "account.signUpSaveTime.checkStatus": "Check on the status of an application at any time", "account.signUpSaveTime.resetPassword": "Simply reset your password if you forget it", @@ -37,6 +39,16 @@ "account.settings.placeholders.year": "YYYY", "account.settings.update": "Update", "account.settings.iconTitle": "generic user", + "account.pwdless.code": "Your code", + "account.pwdless.codeAlert": "We sent a code to %{email} to finish signing up. Be aware, the code will expire in 5 minutes.", + "account.pwdless.codeNewAlert": "A new code has been sent to %{email}. Be aware, the code will expire in 5 minutes.", + "account.pwdless.continue": "Continue", + "account.pwdless.notReceived": "Didn't receive your code?", + "account.pwdless.resend": "Resend", + "account.pwdless.resendCode": "Resend Code", + "account.pwdless.resendCodeButton": "Resend the code", + "account.pwdless.resendCodeHelper": "If there is an account made with that email, weā€™ll send a new code. Be aware, the code will expire in 5 minutes.", + "account.pwdless.verifyTitle": "Verify that it's you", "alert.maintenance": "This site is undergoing scheduled maintenance. We apologize for any inconvenience.", "application.ada.hearing": "For Hearing Impairments", "application.ada.label": "ADA Accessible Units", @@ -530,14 +542,19 @@ "authentication.signIn.changeYourPassword": "You can change your password", "authentication.signIn.enterLoginEmail": "Please enter your login email", "authentication.signIn.enterLoginPassword": "Please enter your login password", - "authentication.signIn.enterValidEmailAndPassword": "Please enter a valid email and password.", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Please enter a valid code.", + "authentication.signIn.enterValidEmailAndPassword": "Please enter a valid email and password", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Please enter a valid code", "authentication.signIn.error": "There was an error signing you in", "authentication.signIn.errorGenericMessage": "Please try again, or contact support for help.", "authentication.signIn.forgotPassword": "Forgot password?", "authentication.signIn.loginError": "Please enter a valid email address", "authentication.signIn.passwordError": "Please enter a valid password", "authentication.signIn.passwordOutdated": "Your password has expired. Please reset your password.", + "authentication.signIn.pwdless.createAccountCopy": "Sign up quicky with no need to remember any passwords.", + "authentication.signIn.pwdless.emailHelperText": "Enter your email and we'll send you a code to sign in.", + "authentication.signIn.pwdless.getCode": "Get code to sign in", + "authentication.signIn.pwdless.useCode": "Get a code instead", + "authentication.signIn.pwdless.usePassword": "Use your password instead", "authentication.signIn.success": "Welcome back, %{name}!", "authentication.signIn.youHaveToWait": "Youā€™ll have to wait 30 minutes since the last failed attempt before trying again.", "authentication.signIn.yourAccountIsNotConfirmed": "Your account is not confirmed", @@ -917,6 +934,7 @@ "t.email": "Email", "t.emailAddressPlaceholder": "you@myemail.com", "t.filter": "Filter", + "t.finish": "Finish", "t.floor": "floor", "t.floors": "floors", "t.getDirections": "Get Directions", diff --git a/shared-helpers/src/locales/vi.json b/shared-helpers/src/locales/vi.json index 5fc73c518a..bef713010c 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -372,6 +372,7 @@ "authentication.createAccount.password": "Mįŗ­t khįŗ©u", "authentication.createAccount.reEnterEmail": "NhĆ¢Ģ£p laĢ£i điĢ£a chiĢ‰ Email", "authentication.createAccount.reEnterPassword": "Nhįŗ­p lįŗ”i mįŗ­t khįŗ©u cį»§a bįŗ”n", + "authentication.createAccount.resendAnEmailTo": "Gį»­i lįŗ”i email đįŗæn", "authentication.createAccount.resendEmailInfo": "Vui lĆ²ng nhįŗ„p vĆ o liĆŖn kįŗæt trong email mĆ  chĆŗng tĆ“i gį»­i cho quĆ½ vį»‹ trong vĆ²ng 24 giį» đį»ƒ hoĆ n tįŗ„t viį»‡c tįŗ”o tĆ i khoįŗ£n.", "authentication.createAccount.resendTheEmail": "Gį»­i lįŗ”i Email", @@ -386,8 +387,8 @@ "authentication.signIn.changeYourPassword": "QuĆ½ vį»‹ cĆ³ thį»ƒ đį»•i mįŗ­t khįŗ©u", "authentication.signIn.enterLoginEmail": "Vui lĆ²ng nhįŗ­p email đăng nhįŗ­p cį»§a quĆ½ vį»‹", "authentication.signIn.enterLoginPassword": "Vui lĆ²ng nhįŗ­p mįŗ­t khįŗ©u đăng nhįŗ­p cį»§a quĆ½ vį»‹", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Vui lĆ²ng nhįŗ­p mĆ£ hį»£p lį»‡.", - "authentication.signIn.enterValidEmailAndPassword": "Vui lĆ²ng nhįŗ­p email vĆ  mįŗ­t khįŗ©u hį»£p lį»‡.", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Vui lĆ²ng nhįŗ­p mĆ£ hį»£p lį»‡", + "authentication.signIn.enterValidEmailAndPassword": "Vui lĆ²ng nhįŗ­p email vĆ  mįŗ­t khįŗ©u hį»£p lį»‡", "authentication.signIn.errorGenericMessage": "Vui lĆ²ng thį»­ lįŗ”i hoįŗ·c liĆŖn lįŗ”c vį»›i bį»™ phįŗ­n hį»— trį»£ đį»ƒ đʰį»£c trį»£ giĆŗp.", "authentication.signIn.error": "ÄĆ£ xįŗ£y ra lį»—i khi quĆ½ vį»‹ đăng nhįŗ­p", "authentication.signIn.forgotPassword": "QuĆŖn mįŗ­t khįŗ©u", diff --git a/shared-helpers/src/locales/zh.json b/shared-helpers/src/locales/zh.json index 1f0bed2be7..2bcfc03a44 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -386,8 +386,8 @@ "authentication.signIn.changeYourPassword": "ę‚ØåÆ仄變ꛓåƆē¢¼", "authentication.signIn.enterLoginEmail": "č«‹č¼øå…„ę‚Øēš„ē™»å…„電子郵件", "authentication.signIn.enterLoginPassword": "č«‹č¼øå…„ę‚Øēš„ē™»å…„åƆē¢¼", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "č«‹č¼øå…„ęœ‰ę•ˆēš„代ē¢¼ć€‚", - "authentication.signIn.enterValidEmailAndPassword": "č«‹č¼øå…„ęœ‰ę•ˆēš„電子郵件和åƆē¢¼ć€‚", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "č«‹č¼øå…„ęœ‰ę•ˆēš„代ē¢¼", + "authentication.signIn.enterValidEmailAndPassword": "č«‹č¼øå…„ęœ‰ę•ˆēš„電子郵件和åƆē¢¼", "authentication.signIn.errorGenericMessage": "č«‹å†č©¦äø€ę¬”ļ¼Œęˆ–čÆēµ”ę”Æę“äŗŗå“”å°‹ę±‚å”åŠ©ć€‚", "authentication.signIn.error": "ę‚ØåœØē™»å…„Ꙃå‡ŗē¾éŒÆčŖ¤", "authentication.signIn.forgotPassword": "åæ˜čؘåƆē¢¼", diff --git a/shared-helpers/src/views/sign-in/FormSignIn.module.scss b/shared-helpers/src/views/sign-in/FormSignIn.module.scss index 3bf53b4c23..b8bad21b91 100644 --- a/shared-helpers/src/views/sign-in/FormSignIn.module.scss +++ b/shared-helpers/src/views/sign-in/FormSignIn.module.scss @@ -17,3 +17,29 @@ margin-top: var(--seeds-s6); width: 100%; } + +.sign-in-email-input { + margin-bottom: var(--seeds-s6); +} + +.sign-in-password-input { + margin-bottom: var(--seeds-s3); +} + +.sign-in-action { + margin-top: var(--seeds-s6); +} + +.create-account-copy { + padding-bottom: var(--seeds-s6); + color: var(--seeds-text-color-light); + font-size: var(--seeds-type-label-size); +} + +.pwdless-header { + margin-bottom: var(--seeds-s3); +} + +.default-header { + margin-bottom: var(--seeds-s6); +} diff --git a/shared-helpers/src/views/sign-in/FormSignIn.tsx b/shared-helpers/src/views/sign-in/FormSignIn.tsx index b46b05434a..6204feb755 100644 --- a/shared-helpers/src/views/sign-in/FormSignIn.tsx +++ b/shared-helpers/src/views/sign-in/FormSignIn.tsx @@ -1,27 +1,24 @@ -import React, { useContext } from "react" +import React from "react" import type { UseFormMethods } from "react-hook-form" -import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" +import { useRouter } from "next/router" +import { t } from "@bloom-housing/ui-components" import { Button, Heading } from "@bloom-housing/ui-seeds" import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" import { FormSignInErrorBox } from "./FormSignInErrorBox" import { NetworkStatus } from "../../auth/catchNetworkError" import { BloomCard } from "../components/BloomCard" -import { useRouter } from "next/router" import { getListingRedirectUrl } from "../../utilities/getListingRedirectUrl" import styles from "./FormSignIn.module.scss" export type FormSignInProps = { control: FormSignInControl - onSubmit: (data: FormSignInValues) => void networkStatus: NetworkStatus showRegisterBtn?: boolean + children: React.ReactNode } export type FormSignInControl = { errors: UseFormMethods["errors"] - handleSubmit: UseFormMethods["handleSubmit"] - register: UseFormMethods["register"] - watch: UseFormMethods["watch"] } export type FormSignInValues = { @@ -30,18 +27,13 @@ export type FormSignInValues = { } const FormSignIn = ({ - onSubmit, + children, networkStatus, showRegisterBtn, - control: { errors, register, handleSubmit }, + control: { errors }, }: FormSignInProps) => { - const onError = () => { - window.scrollTo(0, 0) - } - const { LinkComponent } = useContext(NavigationContext) const router = useRouter() const listingIdRedirect = router.query?.listingId as string - const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") const createAccountUrl = getListingRedirectUrl(listingIdRedirect, "/create-account") return ( @@ -53,49 +45,23 @@ const FormSignIn = ({ errorMessageId={"main-sign-in"} className={styles["sign-in-error-container"]} /> - -
- - - -
- -
- -
+ {children} {showRegisterBtn && ( - + {t("authentication.createAccount.noAccount")} - + {process.env.showPwdless && ( +
+ {t("authentication.signIn.pwdless.createAccountCopy")} +
+ )} diff --git a/shared-helpers/src/views/sign-in/FormSignInDefault.tsx b/shared-helpers/src/views/sign-in/FormSignInDefault.tsx new file mode 100644 index 0000000000..783d942c49 --- /dev/null +++ b/shared-helpers/src/views/sign-in/FormSignInDefault.tsx @@ -0,0 +1,76 @@ +import React, { useContext } from "react" +import { useRouter } from "next/router" +import type { UseFormMethods } from "react-hook-form" +import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" +import { Button } from "@bloom-housing/ui-seeds" +import { getListingRedirectUrl } from "../../utilities/getListingRedirectUrl" +import styles from "./FormSignIn.module.scss" + +export type FormSignInDefaultProps = { + control: FormSignInDefaultControl + onSubmit: (data: FormSignInDefaultValues) => void +} + +export type FormSignInDefaultValues = { + email: string + password: string +} + +export type FormSignInDefaultControl = { + errors: UseFormMethods["errors"] + handleSubmit: UseFormMethods["handleSubmit"] + register: UseFormMethods["register"] +} + +const FormSignInDefault = ({ + onSubmit, + control: { errors, register, handleSubmit }, +}: FormSignInDefaultProps) => { + const onError = () => { + window.scrollTo(0, 0) + } + const { LinkComponent } = useContext(NavigationContext) + const router = useRouter() + const listingIdRedirect = router.query?.listingId as string + const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") + + return ( +
+ + + +
+ +
+ + ) +} + +export { FormSignInDefault as default, FormSignInDefault } diff --git a/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx new file mode 100644 index 0000000000..cae31bf2c8 --- /dev/null +++ b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx @@ -0,0 +1,91 @@ +import React, { useContext, useState } from "react" +import { useRouter } from "next/router" +import type { UseFormMethods } from "react-hook-form" +import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" +import { Button } from "@bloom-housing/ui-seeds" +import { getListingRedirectUrl } from "../../utilities/getListingRedirectUrl" +import styles from "./FormSignIn.module.scss" + +export type FormSignInPwdlessProps = { + control: FormSignInPwdlessControl + onSubmit: (data: FormSignInPwdlessValues) => void +} + +export type FormSignInPwdlessValues = { + email: string + password: string +} + +export type FormSignInPwdlessControl = { + errors: UseFormMethods["errors"] + handleSubmit: UseFormMethods["handleSubmit"] + register: UseFormMethods["register"] +} + +const FormSignInPwdless = ({ + onSubmit, + control: { errors, register, handleSubmit }, +}: FormSignInPwdlessProps) => { + const onError = () => { + window.scrollTo(0, 0) + } + const { LinkComponent } = useContext(NavigationContext) + const router = useRouter() + const listingIdRedirect = router.query?.listingId as string + const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") + + const [useCode, setUseCode] = useState(true) + + return ( +
+ + + {!useCode && ( + <> + + + + )} +
+ +
+
+ +
+ + ) +} + +export { FormSignInPwdless as default, FormSignInPwdless } diff --git a/sites/partners/src/pages/sign-in.tsx b/sites/partners/src/pages/sign-in.tsx index 267d88ee64..16d4793f04 100644 --- a/sites/partners/src/pages/sign-in.tsx +++ b/sites/partners/src/pages/sign-in.tsx @@ -8,6 +8,7 @@ import { AuthContext, FormSignIn, ResendConfirmationModal, + FormSignInDefault, } from "@bloom-housing/shared-helpers" import { useMutate, t } from "@bloom-housing/ui-components" import FormsLayout from "../layouts/forms" @@ -124,16 +125,6 @@ const SignIn = () => { formToRender = ( { setConfirmationStatusMessage(undefined) }, }} - /> + control={{ errors }} + > + + ) } else if (renderStep === EnumRenderStep.mfaType) { formToRender = ( diff --git a/sites/public/.env.template b/sites/public/.env.template index e91dda99a3..fc7defa67c 100644 --- a/sites/public/.env.template +++ b/sites/public/.env.template @@ -24,4 +24,5 @@ MAINTENANCE_WINDOW= GTM_KEY=GTM-KF22FJP # feature toggles -SHOW_MANDATED_ACCOUNTS=FALSE \ No newline at end of file +SHOW_MANDATED_ACCOUNTS=FALSE +SHOW_PWDLESS=FALSE diff --git a/sites/public/next.config.js b/sites/public/next.config.js index a81686633e..e96bf244dd 100644 --- a/sites/public/next.config.js +++ b/sites/public/next.config.js @@ -41,6 +41,7 @@ module.exports = withBundleAnalyzer({ cacheRevalidate: process.env.CACHE_REVALIDATE ? Number(process.env.CACHE_REVALIDATE) : 60, cloudinaryCloudName: process.env.CLOUDINARY_CLOUD_NAME, showMandatedAccounts: process.env.SHOW_MANDATED_ACCOUNTS === "TRUE", + showPwdless: process.env.SHOW_PWDLESS === "TRUE", maintenanceWindow: process.env.MAINTENANCE_WINDOW, }, i18n: { diff --git a/sites/public/src/pages/sign-in.tsx b/sites/public/src/pages/sign-in.tsx index 55f9676faf..d895d14c60 100644 --- a/sites/public/src/pages/sign-in.tsx +++ b/sites/public/src/pages/sign-in.tsx @@ -12,6 +12,8 @@ import { AuthContext, FormSignIn, ResendConfirmationModal, + FormSignInDefault, + FormSignInPwdless, } from "@bloom-housing/shared-helpers" import { UserStatus } from "../lib/constants" import { SuccessDTO } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -138,8 +140,6 @@ const SignIn = () => { )}
void onSubmit(data)} - control={{ register, errors, handleSubmit, watch }} networkStatus={{ content: networkStatusContent, type: networkStatusType, @@ -150,7 +150,20 @@ const SignIn = () => { }, }} showRegisterBtn={true} - /> + control={{ errors }} + > + {process.env.showPwdless ? ( + void onSubmit(data)} + control={{ register, errors, handleSubmit }} + /> + ) : ( + void onSubmit(data)} + control={{ register, errors, handleSubmit }} + /> + )} +
{signUpCopy && (
diff --git a/sites/public/src/pages/verify.tsx b/sites/public/src/pages/verify.tsx new file mode 100644 index 0000000000..3a5d8e023d --- /dev/null +++ b/sites/public/src/pages/verify.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from "react" +import { useForm } from "react-hook-form" +import { Button, Alert, Message, Dialog } from "@bloom-housing/ui-seeds" +import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" +import { + DialogContent, + DialogFooter, + DialogHeader, +} from "@bloom-housing/ui-seeds/src/overlays/Dialog" +import { Field, Form, t, SiteAlert } from "@bloom-housing/ui-components" +import { + PageView, + pushGtmEvent, + useCatchNetworkError, + BloomCard, +} from "@bloom-housing/shared-helpers" +import { UserStatus } from "../lib/constants" +import FormsLayout from "../layouts/forms" +import styles from "../../styles/verify.module.scss" + +const Verify = () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, handleSubmit, errors } = useForm() + const { determineNetworkError } = useCatchNetworkError() + + const [isModalOpen, setIsModalOpen] = useState(false) + const [alertMessage, setAlertMessage] = useState( + t("account.pwdless.codeAlert", { email: "example@email.com" }) + ) // This copy will change based on coming from the sign in flow or create account flow + + useEffect(() => { + pushGtmEvent({ + event: "pageView", + pageTitle: "Verify", + status: UserStatus.NotLoggedIn, + }) + }, []) + + const onSubmit = (data: { code: string }) => { + // const { code } = data + + try { + // Attempt to either create an account or sign in + } catch (error) { + const { status } = error.response || {} + determineNetworkError(status, error) + // "The code you've used is invalid or expired" + } + } + + return ( + + + <> + + + + {!!Object.keys(errors).length && ( + + {t("errors.errorsToResolve")} + + )} + + {alertMessage} + +
+ +
+ +
+ + + +
+ +
+ setIsModalOpen(false)}> + {t("account.pwdless.resendCode")} + {t("account.pwdless.resendCodeHelper")} + + + + + +
+ ) +} + +export { Verify as default, Verify } diff --git a/sites/public/styles/verify.module.scss b/sites/public/styles/verify.module.scss new file mode 100644 index 0000000000..791692ac81 --- /dev/null +++ b/sites/public/styles/verify.module.scss @@ -0,0 +1,33 @@ +.verify-resend-link { + margin-bottom: var(--seeds-s6); +} + +.verify-message { + max-width: 100%; + margin-bottom: var(--seeds-s4); +} + +.verify-error { + margin-bottom: var(--seeds-s4); +} + +.verify-code { + margin-bottom: 0; +} + +.terms-card { + overflow-y: auto; + max-height: calc(100vh - 24rem); + min-height: 30rem; + position: relative; + margin-bottom: var(--seeds-s12); + @media (max-width: theme("screens.sm")) { + height: 100%; + max-height: 100%; + margin-bottom: 0; + } +} + +.terms-form { + margin-bottom: var(--seeds-12); +} From 5c96b0128d38068572adf001644e0375a9447802 Mon Sep 17 00:00:00 2001 From: Morgan Ludtke <42942267+ludtkemorgan@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:40:58 -0500 Subject: [PATCH 25/35] fix: geocoding layer fix (#3951) --- api/prisma/seed-helpers/map-layer-factory.ts | 26 +++++++++---------- api/src/services/geocoding.service.ts | 21 ++++----------- .../unit/services/geocoding.service.spec.ts | 19 ++++++++++++-- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/api/prisma/seed-helpers/map-layer-factory.ts b/api/prisma/seed-helpers/map-layer-factory.ts index e571c4625a..afa4ba20db 100644 --- a/api/prisma/seed-helpers/map-layer-factory.ts +++ b/api/prisma/seed-helpers/map-layer-factory.ts @@ -22,20 +22,18 @@ export const simplifiedDCMap = { geometry: { coordinates: [ [ - [ - [-77.0392589333301, 38.79186072967565], - [-76.90981025809415, 38.89293952026222], - [-77.04122027689426, 38.996161202682146], - [-77.12000091005532, 38.93465307055658], - [-77.10561772391833, 38.91990351952725], - [-77.09123453778136, 38.90565966392609], - [-77.06802530560486, 38.9015894658674], - [-77.06181438431805, 38.889377471720564], - [-77.03697069917165, 38.870801038935525], - [-77.03043288729134, 38.850437727576235], - [-77.03435557441966, 38.80816525459605], - [-77.0392589333301, 38.79186072967565], - ], + [-77.0392589333301, 38.79186072967565], + [-76.90981025809415, 38.89293952026222], + [-77.04122027689426, 38.996161202682146], + [-77.12000091005532, 38.93465307055658], + [-77.10561772391833, 38.91990351952725], + [-77.09123453778136, 38.90565966392609], + [-77.06802530560486, 38.9015894658674], + [-77.06181438431805, 38.889377471720564], + [-77.03697069917165, 38.870801038935525], + [-77.03043288729134, 38.850437727576235], + [-77.03435557441966, 38.80816525459605], + [-77.0392589333301, 38.79186072967565], ], ], type: 'Polygon', diff --git a/api/src/services/geocoding.service.ts b/api/src/services/geocoding.service.ts index 540f363349..1e285b8a6e 100644 --- a/api/src/services/geocoding.service.ts +++ b/api/src/services/geocoding.service.ts @@ -1,4 +1,4 @@ -import { FeatureCollection, point, polygons } from '@turf/helpers'; +import { FeatureCollection, Polygon, point } from '@turf/helpers'; import buffer from '@turf/buffer'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import { MapLayers, Prisma } from '@prisma/client'; @@ -72,21 +72,10 @@ export class GeocodingService { Number.parseFloat(preferenceAddress.latitude.toString()), ]); - // Convert the features to the format that turfjs wants - const polygonsFromFeature = []; - featureCollectionLayers.features.forEach((feature) => { - if ( - feature.geometry.type === 'MultiPolygon' || - feature.geometry.type === 'Polygon' - ) { - feature.geometry.coordinates.forEach((coordinate) => { - polygonsFromFeature.push(coordinate); - }); - } - }); - const layer = polygons(polygonsFromFeature); - - const points = pointsWithinPolygon(preferencePoint, layer); + const points = pointsWithinPolygon( + preferencePoint, + featureCollectionLayers as FeatureCollection, + ); if (points && points.features?.length) { return true; } diff --git a/api/test/unit/services/geocoding.service.spec.ts b/api/test/unit/services/geocoding.service.spec.ts index 1fb1c59924..7c0506547d 100644 --- a/api/test/unit/services/geocoding.service.spec.ts +++ b/api/test/unit/services/geocoding.service.spec.ts @@ -6,7 +6,10 @@ import { Address } from '../../../src/dtos/addresses/address.dto'; import { ValidationMethod } from '../../../src/enums/multiselect-questions/validation-method-enum'; import { InputType } from '../../../src/enums/shared/input-type-enum'; import Listing from '../../../src/dtos/listings/listing.dto'; -import { simplifiedDCMap } from '../../../prisma/seed-helpers/map-layer-factory'; +import { + redlinedMap, + simplifiedDCMap, +} from '../../../prisma/seed-helpers/map-layer-factory'; import { FeatureCollection } from '@turf/helpers'; import { ApplicationMultiselectQuestion } from '../../../src/dtos/applications/application-multiselect-question.dto'; import { Application } from '../../../src/dtos/applications/application.dto'; @@ -29,6 +32,7 @@ describe('GeocodingService', () => { longitude: -77.0365, }; const featureCollection = simplifiedDCMap as unknown as FeatureCollection; + const featureCollection2 = redlinedMap as unknown as FeatureCollection; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -107,14 +111,25 @@ describe('GeocodingService', () => { }); it("should return 'true' if address is within layer", () => { expect(service.verifyLayers(address, featureCollection)).toBe(true); + expect( + service.verifyLayers( + { + ...address, + latitude: 37.870318963458324, + longitude: -122.30141799736678, + }, + featureCollection2, + ), + ).toBe(true); }); - it("should return 'false' if address is within layer", () => { + it("should return 'false' if address is not within layer", () => { expect( service.verifyLayers( { ...address, latitude: 39.284205, longitude: -76.621698 }, featureCollection, ), ).toBe(false); + expect(service.verifyLayers(address, featureCollection2)).toBe(false); }); }); From ca4c8434ea9b9ee01627897d0ef193af1a037e2f Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Tue, 19 Mar 2024 12:40:42 -0600 Subject: [PATCH 26/35] feat: pwdless sign in flow (#3960) --- api/prisma/seed-dev.ts | 5 +- api/src/views/partials/user-name.hbs | 2 +- api/src/views/single-use-code.hbs | 1 - shared-helpers/src/auth/AuthContext.ts | 12 ++ shared-helpers/src/auth/catchNetworkError.ts | 9 ++ shared-helpers/src/locales/general.json | 8 +- .../src/views/sign-in/FormSignInPwdless.tsx | 12 +- sites/public/src/pages/sign-in.tsx | 44 ++++++- sites/public/src/pages/verify.tsx | 114 +++++++++++++----- sites/public/styles/overrides.scss | 3 + sites/public/styles/verify.module.scss | 23 ++-- 11 files changed, 178 insertions(+), 55 deletions(-) diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 357118d71a..73139b5bb9 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -45,7 +45,10 @@ export const devSeeding = async ( jurisdictionName?: string, ) => { const jurisdiction = await prismaClient.jurisdictions.create({ - data: jurisdictionFactory(jurisdictionName), + data: { + ...jurisdictionFactory(jurisdictionName), + allowSingleUseCodeLogin: true, + }, }); await prismaClient.userAccounts.create({ data: await userFactory({ diff --git a/api/src/views/partials/user-name.hbs b/api/src/views/partials/user-name.hbs index 5f8a7f3e24..52e44c0cf3 100644 --- a/api/src/views/partials/user-name.hbs +++ b/api/src/views/partials/user-name.hbs @@ -1,4 +1,4 @@ {{user.firstName}} {{#if user.middleName}} {{user.middleName}} -{{/if}} {{user.lastName}} +{{/if}} {{user.lastName}} \ No newline at end of file diff --git a/api/src/views/single-use-code.hbs b/api/src/views/single-use-code.hbs index 857f09b468..026666eb37 100644 --- a/api/src/views/single-use-code.hbs +++ b/api/src/views/single-use-code.hbs @@ -2,7 +2,6 @@

{{t "singleUseCodeEmail.message" singleUseCodeOptions }}

-

{{t "singleUseCodeEmail.singleUseCode" singleUseCodeOptions}}

diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index 6bf148db37..d6e141d290 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -31,6 +31,7 @@ import { UserCreate, UserService, serviceOptions, + SuccessDTO, } from "../types/backend-swagger" import { getListingRedirectUrl } from "../utilities/getListingRedirectUrl" @@ -74,6 +75,7 @@ type ContextProps = { mfaType: MfaType, phoneNumber?: string ) => Promise + requestSingleUseCode: (email: string) => Promise loginViaSingleUseCode: (email: string, singleUseCode: string) => Promise } @@ -360,6 +362,16 @@ export const AuthProvider: FunctionComponent = ({ child dispatch(stopLoading()) } }, + requestSingleUseCode: async (email) => { + dispatch(startLoading()) + try { + return await authService?.requestSingleUseCode({ + body: { email }, + }) + } finally { + dispatch(stopLoading()) + } + }, } return createElement(AuthContext.Provider, { value: contextValues }, children) } diff --git a/shared-helpers/src/auth/catchNetworkError.ts b/shared-helpers/src/auth/catchNetworkError.ts index e61114a421..a3ee02e457 100644 --- a/shared-helpers/src/auth/catchNetworkError.ts +++ b/shared-helpers/src/auth/catchNetworkError.ts @@ -29,6 +29,7 @@ export type NetworkErrorReset = () => void export enum NetworkErrorMessage { PasswordOutdated = "but password is no longer valid", MfaUnauthorized = "mfaUnauthorized", + SingleUseCodeUnauthorized = "singleUseCodeUnauthorized", } /** @@ -54,6 +55,14 @@ export const useCatchNetworkError = () => { }), error, }) + } else if (message === NetworkErrorMessage.SingleUseCodeUnauthorized) { + setNetworkError({ + title: t("authentication.signIn.pwdless.error"), + description: t("authentication.signIn.afterFailedAttempts", { + count: error?.response?.data?.failureCountRemaining || 5, + }), + error, + }) } else { setNetworkError({ title: t("authentication.signIn.enterValidEmailAndPassword"), diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 50872db5f6..c4b57ec052 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -40,14 +40,16 @@ "account.settings.update": "Update", "account.settings.iconTitle": "generic user", "account.pwdless.code": "Your code", - "account.pwdless.codeAlert": "We sent a code to %{email} to finish signing up. Be aware, the code will expire in 5 minutes.", + "account.pwdless.createMessage": "We sent a code to %{email} to finish signing up. Be aware, the code will expire in 5 minutes.", + "account.pwdless.loginMessage": "If there is an account made with %{email}, weā€™ll send a code within 5 minutes. If you donā€™t receive a code, sign in with your password and confirm your email address under account settings.", "account.pwdless.codeNewAlert": "A new code has been sent to %{email}. Be aware, the code will expire in 5 minutes.", "account.pwdless.continue": "Continue", "account.pwdless.notReceived": "Didn't receive your code?", "account.pwdless.resend": "Resend", "account.pwdless.resendCode": "Resend Code", "account.pwdless.resendCodeButton": "Resend the code", - "account.pwdless.resendCodeHelper": "If there is an account made with that email, weā€™ll send a new code. Be aware, the code will expire in 5 minutes.", + "account.pwdless.resendCodeHelper": "If there is an account made with %{email}, weā€™ll send a new code. Be aware, the code will expire in 5 minutes.", + "account.pwdless.signInWithYourPassword": "Sign in with your password", "account.pwdless.verifyTitle": "Verify that it's you", "alert.maintenance": "This site is undergoing scheduled maintenance. We apologize for any inconvenience.", "application.ada.hearing": "For Hearing Impairments", @@ -552,6 +554,7 @@ "authentication.signIn.passwordOutdated": "Your password has expired. Please reset your password.", "authentication.signIn.pwdless.createAccountCopy": "Sign up quicky with no need to remember any passwords.", "authentication.signIn.pwdless.emailHelperText": "Enter your email and we'll send you a code to sign in.", + "authentication.signIn.pwdless.error": "The code you've used is invalid or expired.", "authentication.signIn.pwdless.getCode": "Get code to sign in", "authentication.signIn.pwdless.useCode": "Get a code instead", "authentication.signIn.pwdless.usePassword": "Use your password instead", @@ -948,6 +951,7 @@ "t.lastUpdated": "Last Updated", "t.less": "Less", "t.letter": "Letter", + "t.loading": "Loading", "t.loginIsRequired": "Login is required to view this page.", "t.menu": "Menu", "t.minimumIncome": "Minimum Income", diff --git a/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx index cae31bf2c8..03acb314ae 100644 --- a/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx +++ b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from "react" +import React, { useContext } from "react" import { useRouter } from "next/router" import type { UseFormMethods } from "react-hook-form" import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" @@ -9,6 +9,8 @@ import styles from "./FormSignIn.module.scss" export type FormSignInPwdlessProps = { control: FormSignInPwdlessControl onSubmit: (data: FormSignInPwdlessValues) => void + useCode: boolean + setUseCode: React.Dispatch> } export type FormSignInPwdlessValues = { @@ -25,6 +27,8 @@ export type FormSignInPwdlessControl = { const FormSignInPwdless = ({ onSubmit, control: { errors, register, handleSubmit }, + useCode, + setUseCode, }: FormSignInPwdlessProps) => { const onError = () => { window.scrollTo(0, 0) @@ -34,15 +38,13 @@ const FormSignInPwdless = ({ const listingIdRedirect = router.query?.listingId as string const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") - const [useCode, setUseCode] = useState(true) - return (
{ - const { login, userService } = useContext(AuthContext) + const router = useRouter() + + const { login, requestSingleUseCode, userService } = useContext(AuthContext) const signUpCopy = process.env.showMandatedAccounts /* Form Handler */ // This is causing a linting issue with unbound-method, see open issue as of 10/21/2020: // https://github.com/react-hook-form/react-hook-form/issues/2887 // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, errors, watch, reset } = useForm() + const { register, handleSubmit, errors, watch, reset, clearErrors } = useForm() const redirectToPage = useRedirectToPrevPage("/account/dashboard") const { networkError, determineNetworkError, resetNetworkError } = useCatchNetworkError() @@ -41,6 +44,11 @@ const SignIn = () => { type: NetworkStatusType }>() + type LoginType = "pwd" | "code" + const loginType = router.query?.loginType as LoginType + + const [useCode, setUseCode] = useState(loginType !== "pwd") + const { mutate: mutateResendConfirmation, reset: resetResendConfirmation, @@ -68,6 +76,34 @@ const SignIn = () => { } } + const onSubmitPwdless = async (data: { email: string; password: string }) => { + const { email, password } = data + + try { + if (useCode) { + clearErrors() + await requestSingleUseCode(email) + const redirectUrl = router.query?.redirectUrl as string + const listingId = router.query?.listingId as string + let queryParams: { [key: string]: string } = { email, flowType: "login" } + if (redirectUrl) queryParams = { ...queryParams, redirectUrl } + if (listingId) queryParams = { ...queryParams, listingId } + + await router.push({ + pathname: "/verify", + query: queryParams, + }) + } else { + const user = await login(email, password) + setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + await redirectToPage() + } + } catch (error) { + const { status } = error.response || {} + determineNetworkError(status, error) + } + } + const onResendConfirmationSubmit = useCallback( (email: string) => { void mutateResendConfirmation( @@ -154,8 +190,10 @@ const SignIn = () => { > {process.env.showPwdless ? ( void onSubmit(data)} + onSubmit={(data) => void onSubmitPwdless(data)} control={{ register, errors, handleSubmit }} + useCode={useCode} + setUseCode={setUseCode} /> ) : ( { + const router = useRouter() + // eslint-disable-next-line @typescript-eslint/unbound-method - const { register, handleSubmit, errors } = useForm() - const { determineNetworkError } = useCatchNetworkError() + const { register, handleSubmit, errors, reset } = useForm() + const { networkError, determineNetworkError, resetNetworkError } = useCatchNetworkError() + const { requestSingleUseCode, loginViaSingleUseCode } = useContext(AuthContext) + const redirectToPage = useRedirectToPrevPage("/account/dashboard") + + type FlowType = "create" | "login" + const email = router.query?.email as string + const flowType = router.query?.flowType as FlowType const [isModalOpen, setIsModalOpen] = useState(false) - const [alertMessage, setAlertMessage] = useState( - t("account.pwdless.codeAlert", { email: "example@email.com" }) - ) // This copy will change based on coming from the sign in flow or create account flow + const [isResendLoading, setIsResendLoading] = useState(false) + const [isLoginLoading, setIsLoginLoading] = useState(false) + const alertMessage = + flowType === "create" + ? t("account.pwdless.createMessage", { email }) + : t("account.pwdless.loginMessage", { email }) useEffect(() => { pushGtmEvent({ @@ -36,15 +51,19 @@ const Verify = () => { }) }, []) - const onSubmit = (data: { code: string }) => { - // const { code } = data + const onSubmit = async (data: { code: string }) => { + const { code } = data try { - // Attempt to either create an account or sign in + setIsLoginLoading(true) + const user = await loginViaSingleUseCode(email, code) + setIsLoginLoading(false) + setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + await redirectToPage() } catch (error) { + setIsLoginLoading(false) const { status } = error.response || {} determineNetworkError(status, error) - // "The code you've used is invalid or expired" } } @@ -53,20 +72,27 @@ const Verify = () => { <> - + { + reset() + resetNetworkError() + }, + }} + errorMessageId={"verify-sign-in"} + className={styles["verify-error-container"]} + /> {!!Object.keys(errors).length && ( - + {t("errors.errorsToResolve")} )} - {alertMessage} + {alertMessage && {alertMessage}} { + {flowType === "login" && ( + + {" "} + {t("t.or")}{" "} + + + )}
- @@ -95,19 +140,34 @@ const Verify = () => { setIsModalOpen(false)}> {t("account.pwdless.resendCode")} - {t("account.pwdless.resendCodeHelper")} + {t("account.pwdless.resendCodeHelper", { email })} - diff --git a/sites/public/styles/overrides.scss b/sites/public/styles/overrides.scss index d4664d0f81..ab11e79338 100644 --- a/sites/public/styles/overrides.scss +++ b/sites/public/styles/overrides.scss @@ -9,4 +9,7 @@ text-transform: none; color: var(--seeds-input-text-label-color); } + .label.text__caps-spaced { + margin-bottom: var(--seeds-s1); + } } diff --git a/sites/public/styles/verify.module.scss b/sites/public/styles/verify.module.scss index 791692ac81..ccadaeb89b 100644 --- a/sites/public/styles/verify.module.scss +++ b/sites/public/styles/verify.module.scss @@ -1,10 +1,12 @@ .verify-resend-link { margin-bottom: var(--seeds-s6); + font-size: var(--seeds-type-caption-size); + color: var(--seeds-text-color); } .verify-message { max-width: 100%; - margin-bottom: var(--seeds-s4); + margin-bottom: var(--seeds-s8); } .verify-error { @@ -15,19 +17,10 @@ margin-bottom: 0; } -.terms-card { - overflow-y: auto; - max-height: calc(100vh - 24rem); - min-height: 30rem; - position: relative; - margin-bottom: var(--seeds-s12); - @media (max-width: theme("screens.sm")) { - height: 100%; - max-height: 100%; - margin-bottom: 0; - } -} +.verify-error-container { + margin-inline: var(--seeds-s4); -.terms-form { - margin-bottom: var(--seeds-12); + @media (min-width: theme("screens.sm")) { + margin-inline: var(--seeds-s12); + } } From 7bfd41bc26aea1fbd6ff91d5ce4757e3912fb14a Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Tue, 19 Mar 2024 13:01:47 -0700 Subject: [PATCH 27/35] fix: Application perm hotfix (#680) (#3963) * fix: afs needs to be paginating (#3955) * fix: application permissioning fix --- api/src/services/application.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index fc9ea7312b..489160d929 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -47,6 +47,11 @@ export const view: Partial< address: true, }, }, + listings: { + select: { + id: true, + }, + }, }, }; From fe803b5e3bfa8c472901fbe3b4e212a8f52694a0 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Tue, 19 Mar 2024 13:53:50 -0700 Subject: [PATCH 28/35] feat: creating rate limiting (#3961) --- api/.env.template | 4 ++++ api/package.json | 3 ++- api/src/controllers/ami-chart.controller.ts | 3 ++- api/src/controllers/app.controller.ts | 2 ++ .../application-flagged-set.controller.ts | 3 ++- api/src/controllers/application.controller.ts | 3 ++- api/src/controllers/asset.controller.ts | 3 ++- api/src/controllers/auth.controller.ts | 3 ++- .../controllers/jurisdiction.controller.ts | 3 ++- api/src/controllers/listing.controller.ts | 3 ++- api/src/controllers/map-layer.controller.ts | 3 ++- .../multiselect-question.controller.ts | 3 ++- .../reserved-community-type.controller.ts | 3 ++- ...-accessibility-priority-type.controller.ts | 3 ++- .../controllers/unit-rent-type.controller.ts | 3 ++- api/src/controllers/unit-type.controller.ts | 3 ++- api/src/controllers/user.controller.ts | 2 ++ api/src/guards/throttler.guard.ts | 19 +++++++++++++++++++ api/src/modules/app.module.ts | 19 ++++++++++++++++++- api/yarn.lock | 5 +++++ 20 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 api/src/guards/throttler.guard.ts diff --git a/api/.env.template b/api/.env.template index 637b94ffb5..537c1333fb 100644 --- a/api/.env.template +++ b/api/.env.template @@ -44,3 +44,7 @@ CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"] CORS_REGEX=["test1", "test2"] # controls the repetition of the temp file clearing cron job TEMP_FILE_CLEAR_CRON_STRING=0 * * * +# how long we maintain our request time outs (60 * 60 * 1000 ms) +THROTTLE_TTL=3600000 +# how many requests before we throttle +THROTTLE_LIMIT=100 diff --git a/api/package.json b/api/package.json index 612f6b3b23..3833cae39c 100644 --- a/api/package.json +++ b/api/package.json @@ -43,11 +43,12 @@ "@nestjs/platform-express": "^10.3.2", "@nestjs/schedule": "^4.0.1", "@nestjs/swagger": "~7.1.12", + "@nestjs/throttler": "^5.1.2", "@prisma/client": "^5.0.0", "@sendgrid/mail": "7.7.0", + "@turf/boolean-point-in-polygon": "6.5.0", "@turf/buffer": "6.5.0", "@turf/helpers": "6.5.0", - "@turf/boolean-point-in-polygon": "6.5.0", "@turf/points-within-polygon": "6.5.0", "@types/archiver": "^6.0.2", "archiver": "^6.0.1", diff --git a/api/src/controllers/ami-chart.controller.ts b/api/src/controllers/ami-chart.controller.ts index e5047d4565..199bd370aa 100644 --- a/api/src/controllers/ami-chart.controller.ts +++ b/api/src/controllers/ami-chart.controller.ts @@ -28,12 +28,13 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/amiCharts') @ApiTags('amiCharts') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @PermissionTypeDecorator('amiChart') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) @ApiExtraModels(AmiChartQueryParams) export class AmiChartController { constructor(private readonly AmiChartService: AmiChartService) {} diff --git a/api/src/controllers/app.controller.ts b/api/src/controllers/app.controller.ts index 7f5c557e80..03f831174f 100644 --- a/api/src/controllers/app.controller.ts +++ b/api/src/controllers/app.controller.ts @@ -18,8 +18,10 @@ import { PermissionAction } from '../decorators/permission-action.decorator'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; import { AppService } from '../services/app.service'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller() +@UseGuards(ThrottleGuard) @ApiExtraModels(SuccessDTO) @ApiTags('root') export class AppController { diff --git a/api/src/controllers/application-flagged-set.controller.ts b/api/src/controllers/application-flagged-set.controller.ts index 761a0e98a6..b6b4703e82 100644 --- a/api/src/controllers/application-flagged-set.controller.ts +++ b/api/src/controllers/application-flagged-set.controller.ts @@ -32,11 +32,12 @@ import { mapTo } from '../utilities/mapTo'; import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/applicationFlaggedSets') @ApiExtraModels(SuccessDTO) @ApiTags('applicationFlaggedSets') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) @PermissionTypeDecorator('applicationFlaggedSet') @UsePipes( new ValidationPipe({ diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 4f612b49c0..2e277d45d9 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -49,6 +49,7 @@ import { ApplicationCsvExporterService } from '../services/application-csv-expor import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; import { MostRecentApplicationQueryParams } from '../dtos/applications/most-recent-application-query-params.dto'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('applications') @ApiTags('applications') @@ -59,7 +60,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; }), ) @ApiExtraModels(IdDTO, AddressInput, BooleanInput, TextInput) -@UseGuards(OptionalAuthGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard) @PermissionTypeDecorator('application') @UseInterceptors(ActivityLogInterceptor) export class ApplicationController { diff --git a/api/src/controllers/asset.controller.ts b/api/src/controllers/asset.controller.ts index dbb66c04f7..ae21f50683 100644 --- a/api/src/controllers/asset.controller.ts +++ b/api/src/controllers/asset.controller.ts @@ -19,6 +19,7 @@ import { CreatePresignedUploadMetadataResponse } from '../dtos/assets/create-pre import { CreatePresignedUploadMetadata } from '../dtos/assets/create-presigned-upload-meta.dto'; import { AssetService } from '../services/asset.service'; import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('assets') @ApiTags('assets') @@ -28,7 +29,7 @@ import { defaultValidationPipeOptions } from '../utilities/default-validation-pi CreatePresignedUploadMetadataResponse, ) @PermissionTypeDecorator('asset') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class AssetController { constructor(private readonly assetService: AssetService) {} diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 3861e1c807..966e8b4848 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -32,6 +32,7 @@ import { User } from '../dtos/users/user.dto'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto'; import { SingleUseCodeAuthGuard } from '../guards/single-use-code.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('auth') @ApiTags('auth') @@ -43,7 +44,7 @@ export class AuthController { @ApiOperation({ summary: 'Login', operationId: 'login' }) @ApiOkResponse({ type: SuccessDTO }) @ApiBody({ type: Login }) - @UseGuards(MfaAuthGuard) + @UseGuards(ThrottleGuard, MfaAuthGuard) async login( @Request() req: ExpressRequest, @Response({ passthrough: true }) res: ExpressResponse, diff --git a/api/src/controllers/jurisdiction.controller.ts b/api/src/controllers/jurisdiction.controller.ts index e7263240c3..2e0433cb18 100644 --- a/api/src/controllers/jurisdiction.controller.ts +++ b/api/src/controllers/jurisdiction.controller.ts @@ -27,13 +27,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('jurisdictions') @ApiTags('jurisdictions') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(JurisdictionCreate, JurisdictionUpdate, IdDTO) @PermissionTypeDecorator('jurisdiction') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) export class JurisdictionController { constructor(private readonly jurisdictionService: JurisdictionService) {} diff --git a/api/src/controllers/listing.controller.ts b/api/src/controllers/listing.controller.ts index 8fae0b99fc..d661a434a5 100644 --- a/api/src/controllers/listing.controller.ts +++ b/api/src/controllers/listing.controller.ts @@ -53,6 +53,7 @@ import { ListingCsvExporterService } from '../services/listing-csv-export.servic import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto'; import { PermissionGuard } from '../guards/permission.guard'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('listings') @ApiTags('listings') @@ -63,7 +64,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; PaginationAllowsAllQueryParams, IdDTO, ) -@UseGuards(OptionalAuthGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard) @PermissionTypeDecorator('listing') @ActivityLogMetadata([{ targetPropertyName: 'status', propertyPath: 'status' }]) @UseInterceptors(ActivityLogInterceptor) diff --git a/api/src/controllers/map-layer.controller.ts b/api/src/controllers/map-layer.controller.ts index 205e53dcb9..36c498debf 100644 --- a/api/src/controllers/map-layer.controller.ts +++ b/api/src/controllers/map-layer.controller.ts @@ -14,10 +14,11 @@ import { defaultValidationPipeOptions } from '../utilities/default-validation-pi import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/mapLayers') @ApiTags('mapLayers') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) @PermissionTypeDecorator('mapLayers') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) export class MapLayersController { diff --git a/api/src/controllers/multiselect-question.controller.ts b/api/src/controllers/multiselect-question.controller.ts index 4a578f30bf..0b642e612b 100644 --- a/api/src/controllers/multiselect-question.controller.ts +++ b/api/src/controllers/multiselect-question.controller.ts @@ -33,6 +33,7 @@ import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('multiselectQuestions') @ApiTags('multiselectQuestions') @@ -46,7 +47,7 @@ import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor IdDTO, ) @PermissionTypeDecorator('multiselectQuestion') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) export class MultiselectQuestionController { constructor( private readonly multiselectQuestionService: MultiselectQuestionService, diff --git a/api/src/controllers/reserved-community-type.controller.ts b/api/src/controllers/reserved-community-type.controller.ts index a8d936e2e4..1925b3bff1 100644 --- a/api/src/controllers/reserved-community-type.controller.ts +++ b/api/src/controllers/reserved-community-type.controller.ts @@ -28,13 +28,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('reservedCommunityTypes') @ApiTags('reservedCommunityTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(ReservedCommunityTypeQueryParams) @PermissionTypeDecorator('reservedCommunityType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class ReservedCommunityTypeController { constructor( private readonly ReservedCommunityTypeService: ReservedCommunityTypeService, diff --git a/api/src/controllers/unit-accessibility-priority-type.controller.ts b/api/src/controllers/unit-accessibility-priority-type.controller.ts index a185a240ab..14d82739b8 100644 --- a/api/src/controllers/unit-accessibility-priority-type.controller.ts +++ b/api/src/controllers/unit-accessibility-priority-type.controller.ts @@ -26,13 +26,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitAccessibilityPriorityTypes') @ApiTags('unitAccessibilityPriorityTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(IdDTO) @PermissionTypeDecorator('unitAccessibilityPriorityType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitAccessibilityPriorityTypeController { constructor( private readonly unitAccessibilityPriorityTypeService: UnitAccessibilityPriorityTypeService, diff --git a/api/src/controllers/unit-rent-type.controller.ts b/api/src/controllers/unit-rent-type.controller.ts index b7035ec142..ee8901afa1 100644 --- a/api/src/controllers/unit-rent-type.controller.ts +++ b/api/src/controllers/unit-rent-type.controller.ts @@ -26,13 +26,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitRentTypes') @ApiTags('unitRentTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(UnitRentTypeCreate, UnitRentTypeUpdate, IdDTO) @PermissionTypeDecorator('unitRentType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitRentTypeController { constructor(private readonly unitRentTypeService: UnitRentTypeService) {} diff --git a/api/src/controllers/unit-type.controller.ts b/api/src/controllers/unit-type.controller.ts index ac23f9c3fa..04e9cdcc72 100644 --- a/api/src/controllers/unit-type.controller.ts +++ b/api/src/controllers/unit-type.controller.ts @@ -21,12 +21,13 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitTypes') @ApiTags('unitTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @PermissionTypeDecorator('unitType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitTypeController { constructor(private readonly unitTypeService: UnitTypeService) {} diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index eb0de49ef4..3afa5259ec 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -49,8 +49,10 @@ import { PermissionTypeDecorator } from '../decorators/permission-type.decorator import { UserFilterParams } from '../dtos/users/user-filter-params.dto'; import { UserCsvExporterService } from '../services/user-csv-export.service'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('user') +@UseGuards(ThrottleGuard) @ApiTags('user') @PermissionTypeDecorator('user') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) diff --git a/api/src/guards/throttler.guard.ts b/api/src/guards/throttler.guard.ts new file mode 100644 index 0000000000..e5530cdc4d --- /dev/null +++ b/api/src/guards/throttler.guard.ts @@ -0,0 +1,19 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { ThrottlerLimitDetail } from '@nestjs/throttler/dist/throttler.guard.interface'; + +@Injectable() +export class ThrottleGuard extends ThrottlerGuard { + protected async getTracker(req: Record): Promise { + console.log('7:', req.ips.length ? req.ips : req.ip); + return req.ips.length ? req.ips[0] : req.ip; + } + + protected async throwThrottlingException( + context: ExecutionContext, + throttlerLimitDetail: ThrottlerLimitDetail, + ): Promise { + console.error(`IP Address: ${throttlerLimitDetail.tracker} was throttled`); + await super.throwThrottlingException(context, throttlerLimitDetail); + } +} diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index d8c57952e3..787f233909 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -17,6 +17,9 @@ import { UserModule } from './user.module'; import { AuthModule } from './auth.module'; import { ApplicationFlaggedSetModule } from './application-flagged-set.module'; import { MapLayerModule } from './map-layer.module'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Module({ imports: [ @@ -35,9 +38,23 @@ import { MapLayerModule } from './map-layer.module'; AuthModule, ApplicationFlaggedSetModule, MapLayerModule, + ThrottlerModule.forRoot([ + { + ttl: Number(process.env.THROTTLE_TTL), + limit: Number(process.env.THROTTLE_LIMIT), + }, + ]), ], controllers: [AppController], - providers: [AppService, Logger, SchedulerRegistry], + providers: [ + AppService, + Logger, + SchedulerRegistry, + { + provide: APP_GUARD, + useClass: ThrottleGuard, + }, + ], exports: [ ListingModule, AmiChartModule, diff --git a/api/yarn.lock b/api/yarn.lock index c7fd783cd7..47c2c1a949 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -1102,6 +1102,11 @@ dependencies: tslib "2.6.2" +"@nestjs/throttler@^5.1.2": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@nestjs/throttler/-/throttler-5.1.2.tgz#dc65634153c8b887329b1cc6061db2e556517dcb" + integrity sha512-60MqhSLYUqWOgc38P6C6f76JIpf6mVjly7gpuPBCKtVd0p5e8Fq855j7bJuO4/v25vgaOo1OdVs0U1qtgYioGw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" From 89478cc3375cfa126444f442fbf462103f8917e9 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Wed, 20 Mar 2024 08:36:54 -0700 Subject: [PATCH 29/35] fix: possibly throttling through the proxy stuff (#3966) --- api/src/guards/throttler.guard.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/api/src/guards/throttler.guard.ts b/api/src/guards/throttler.guard.ts index e5530cdc4d..cc6976f791 100644 --- a/api/src/guards/throttler.guard.ts +++ b/api/src/guards/throttler.guard.ts @@ -5,7 +5,16 @@ import { ThrottlerLimitDetail } from '@nestjs/throttler/dist/throttler.guard.int @Injectable() export class ThrottleGuard extends ThrottlerGuard { protected async getTracker(req: Record): Promise { - console.log('7:', req.ips.length ? req.ips : req.ip); + console.log( + 'forwarded for:', + req?.headers && req.headers['X-Forwarded-For'], + ); + console.log('ip:', req.ips.length ? req.ips : req.ip); + + if (req?.headers && req.headers['X-Forwarded-For']) { + // if we are passing through the proxy use forwarded for + return req.headers['X-Forwarded-For']; + } return req.ips.length ? req.ips[0] : req.ip; } From 4517900bd0cd62f9e232bde53a4a800e0cba6e7d Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:29:28 -0600 Subject: [PATCH 30/35] feat: pwdless create account flow (#3964) --- api/prisma/seed-dev.ts | 2 +- api/src/controllers/auth.controller.ts | 18 +- api/src/controllers/user.controller.ts | 25 +- api/src/services/auth.service.ts | 104 ++---- api/src/services/user.service.ts | 108 +++++- api/src/utilities/generate-single-use-code.ts | 10 + api/test/integration/auth.e2e-spec.ts | 109 +----- api/test/integration/user.e2e-spec.ts | 111 ++++++ api/test/unit/services/auth.service.spec.ts | 225 +---------- api/test/unit/services/user.service.spec.ts | 262 ++++++++++++- .../generate-single-use-code.spec.ts | 22 ++ shared-helpers/src/auth/AuthContext.ts | 2 +- shared-helpers/src/locales/general.json | 5 + shared-helpers/src/types/backend-swagger.ts | 348 +++++++++--------- .../src/components/account/SignUpBenefits.tsx | 8 +- .../src/pages/applications/contact/name.tsx | 4 +- sites/public/src/pages/create-account.tsx | 35 +- sites/public/src/pages/verify.tsx | 11 +- .../public/styles/create-account.module.scss | 8 + 19 files changed, 767 insertions(+), 650 deletions(-) create mode 100644 api/src/utilities/generate-single-use-code.ts create mode 100644 api/test/unit/utilities/generate-single-use-code.spec.ts diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 73139b5bb9..1ef6491b50 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -47,7 +47,7 @@ export const devSeeding = async ( const jurisdiction = await prismaClient.jurisdictions.create({ data: { ...jurisdictionFactory(jurisdictionName), - allowSingleUseCodeLogin: true, + allowSingleUseCodeLogin: false, }, }); await prismaClient.userAccounts.create({ diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 966e8b4848..497d01e4c8 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -64,7 +64,10 @@ export class AuthController { @Request() req: ExpressRequest, @Response({ passthrough: true }) res: ExpressResponse, ): Promise { - return await this.authService.setCredentials(res, mapTo(User, req['user'])); + return await this.authService.confirmAndSetCredentials( + mapTo(User, req['user']), + res, + ); } @Get('logout') @@ -90,19 +93,6 @@ export class AuthController { return await this.authService.requestMfaCode(dto); } - @Post('request-single-use-code') - @ApiOperation({ - summary: 'Request single use code', - operationId: 'requestSingleUseCode', - }) - @ApiOkResponse({ type: SuccessDTO }) - async requestSingleUseCode( - @Request() req: ExpressRequest, - @Body() dto: RequestSingleUseCode, - ): Promise { - return await this.authService.requestSingleUseCode(dto, req); - } - @Get('requestNewToken') @ApiOperation({ summary: 'Requests a new token given a refresh token', diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index 3afa5259ec..4b371086da 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -49,6 +49,7 @@ import { PermissionTypeDecorator } from '../decorators/permission-type.decorator import { UserFilterParams } from '../dtos/users/user-filter-params.dto'; import { UserCsvExporterService } from '../services/user-csv-export.service'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('user') @@ -170,13 +171,11 @@ export class UserController { @Body() dto: UserCreate, @Query() queryParams: UserCreateParams, ): Promise { - const jurisdictionName = req.headers['jurisdictionname'] || ''; return await this.userService.create( dto, false, queryParams.noWelcomeEmail !== true, - mapTo(User, req['user']), - jurisdictionName as string, + req, ); } @@ -189,12 +188,20 @@ export class UserController { @Body() dto: UserInvite, @Request() req: ExpressRequest, ): Promise { - return await this.userService.create( - dto, - true, - undefined, - mapTo(User, req['user']), - ); + return await this.userService.create(dto, true, undefined, req); + } + + @Post('request-single-use-code') + @ApiOperation({ + summary: 'Request single use code', + operationId: 'requestSingleUseCode', + }) + @ApiOkResponse({ type: SuccessDTO }) + async requestSingleUseCode( + @Request() req: ExpressRequest, + @Body() dto: RequestSingleUseCode, + ): Promise { + return await this.userService.requestSingleUseCode(dto, req); } @Post('resend-confirmation') diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index 2b98b2c342..4e5a995ad7 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -4,9 +4,8 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; -import { CookieOptions, Request, Response } from 'express'; +import { CookieOptions, Response } from 'express'; import { sign, verify } from 'jsonwebtoken'; -import { randomInt } from 'crypto'; import { Prisma } from '@prisma/client'; import { UpdatePassword } from '../dtos/auth/update-password.dto'; import { MfaType } from '../enums/mfa/mfa-type-enum'; @@ -19,11 +18,10 @@ import { PrismaService } from './prisma.service'; import { UserService } from './user.service'; import { IdDTO } from '../dtos/shared/id.dto'; import { mapTo } from '../utilities/mapTo'; +import { generateSingleUseCode } from '../utilities/generate-single-use-code'; import { Confirm } from '../dtos/auth/confirm.dto'; import { SmsService } from './sms.service'; import { EmailService } from './email.service'; -import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; -import { OrderByEnum } from '../enums/shared/order-by-enum'; // since our local env doesn't have an https cert we can't be secure. Hosted envs should be secure const secure = process.env.NODE_ENV !== 'development'; @@ -220,7 +218,7 @@ export class AuthService { } } - const singleUseCode = this.generateSingleUseCode(); + const singleUseCode = generateSingleUseCode(); await this.prisma.userAccounts.update({ data: { singleUseCode, @@ -246,76 +244,6 @@ export class AuthService { }; } - /** - * - * @param dto the incoming request with the email - * @returns a SuccessDTO always, and if the user exists it will send a code to the requester - */ - async requestSingleUseCode( - dto: RequestSingleUseCode, - req: Request, - ): Promise { - const user = await this.prisma.userAccounts.findFirst({ - where: { email: dto.email }, - include: { - jurisdictions: true, - }, - }); - if (!user) { - return { success: true }; - } - - const jurisName = req?.headers?.jurisdictionname; - if (!jurisName) { - throw new BadRequestException( - 'jurisdictionname is missing from the request headers', - ); - } - - const juris = await this.prisma.jurisdictions.findFirst({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: jurisName as string, - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - - if (!juris) { - throw new BadRequestException( - `Jurisidiction ${jurisName} does not exists`, - ); - } - - if (!juris.allowSingleUseCodeLogin) { - throw new BadRequestException( - `Single use code login is not setup for ${jurisName}`, - ); - } - - const singleUseCode = this.generateSingleUseCode(); - await this.prisma.userAccounts.update({ - data: { - singleUseCode, - singleUseCodeUpdatedAt: new Date(), - }, - where: { - id: user.id, - }, - }); - - await this.emailsService.sendSingleUseCode( - mapTo(User, user), - singleUseCode, - ); - - return { success: true }; - } - /* updates a user's password and logs them in */ @@ -399,14 +327,26 @@ export class AuthService { } /* - generates a numeric mfa code + confirms a user if using pwdless */ - generateSingleUseCode() { - let out = ''; - const characters = '0123456789'; - for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) { - out += characters.charAt(randomInt(characters.length)); + async confirmAndSetCredentials( + user: User, + res: Response, + ): Promise { + if (!user.confirmedAt) { + const data: Prisma.UserAccountsUpdateInput = { + confirmedAt: new Date(), + confirmationToken: null, + }; + + await this.prisma.userAccounts.update({ + data, + where: { + id: user.id, + }, + }); } - return out; + + return await this.setCredentials(res, user); } } diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index 98ef7e61ca..734fe809c4 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -11,6 +11,8 @@ import dayjs from 'dayjs'; import advancedFormat from 'dayjs/plugin/advancedFormat'; import crypto from 'crypto'; import { verify, sign } from 'jsonwebtoken'; +import { Request } from 'express'; + import { PrismaService } from './prisma.service'; import { User } from '../dtos/users/user.dto'; import { mapTo } from '../utilities/mapTo'; @@ -37,6 +39,8 @@ import { permissionActions } from '../enums/permissions/permission-actions-enum' import { buildWhereClause } from '../utilities/build-user-where'; import { getPublicEmailURL } from '../utilities/get-public-email-url'; import { UserRole } from '../dtos/users/user-role.dto'; +import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; +import { generateSingleUseCode } from '../utilities/generate-single-use-code'; /* this is the service for users @@ -479,9 +483,11 @@ export class UserService { dto: UserCreate | UserInvite, forPartners: boolean, sendWelcomeEmail = false, - requestingUser: User, - jurisdictionName?: string, + req: Request, ): Promise { + const requestingUser = mapTo(User, req['user']); + const jurisdictionName = (req.headers['jurisdictionname'] as string) || ''; + if ( this.containsInvalidCharacters(dto.firstName) || this.containsInvalidCharacters(dto.lastName) @@ -646,16 +652,27 @@ export class UserService { // Public user that needs email if (!forPartners && sendWelcomeEmail) { - const confirmationUrl = this.getPublicConfirmationUrl( - dto.appUrl, - confirmationToken, - ); - this.emailService.welcome( - jurisdictionName, - mapTo(User, newUser), - dto.appUrl, - confirmationUrl, - ); + const fullJurisdiction = await this.prisma.jurisdictions.findFirst({ + where: { + name: jurisdictionName as string, + }, + }); + + if (fullJurisdiction?.allowSingleUseCodeLogin) { + this.requestSingleUseCode(dto, req); + } else { + const confirmationUrl = this.getPublicConfirmationUrl( + dto.appUrl, + confirmationToken, + ); + this.emailService.welcome( + jurisdictionName, + mapTo(User, newUser), + dto.appUrl, + confirmationUrl, + ); + } + // Partner user that is given access to an additional jurisdiction } else if ( forPartners && @@ -876,4 +893,71 @@ export class UserService { return false; } + + /** + * + * @param dto the incoming request with the email + * @returns a SuccessDTO always, and if the user exists it will send a code to the requester + */ + async requestSingleUseCode( + dto: RequestSingleUseCode, + req: Request, + ): Promise { + const user = await this.prisma.userAccounts.findFirst({ + where: { email: dto.email }, + include: { + jurisdictions: true, + }, + }); + if (!user) { + return { success: true }; + } + + const jurisdictionName = req?.headers?.jurisdictionname; + if (!jurisdictionName) { + throw new BadRequestException( + 'jurisdictionname is missing from the request headers', + ); + } + + const juris = await this.prisma.jurisdictions.findFirst({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: jurisdictionName as string, + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + + if (!juris) { + throw new BadRequestException( + `Jurisidiction ${jurisdictionName} does not exists`, + ); + } + + if (!juris.allowSingleUseCodeLogin) { + throw new BadRequestException( + `Single use code login is not setup for ${jurisdictionName}`, + ); + } + + const singleUseCode = generateSingleUseCode(); + await this.prisma.userAccounts.update({ + data: { + singleUseCode, + singleUseCodeUpdatedAt: new Date(), + }, + where: { + id: user.id, + }, + }); + + await this.emailService.sendSingleUseCode(mapTo(User, user), singleUseCode); + + return { success: true }; + } } diff --git a/api/src/utilities/generate-single-use-code.ts b/api/src/utilities/generate-single-use-code.ts new file mode 100644 index 0000000000..0619d0a76a --- /dev/null +++ b/api/src/utilities/generate-single-use-code.ts @@ -0,0 +1,10 @@ +import { randomInt } from 'crypto'; + +export const generateSingleUseCode = () => { + let out = ''; + const characters = '0123456789'; + for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) { + out += characters.charAt(randomInt(characters.length)); + } + return out; +}; diff --git a/api/test/integration/auth.e2e-spec.ts b/api/test/integration/auth.e2e-spec.ts index 5810e2e9ad..fe04098905 100644 --- a/api/test/integration/auth.e2e-spec.ts +++ b/api/test/integration/auth.e2e-spec.ts @@ -18,7 +18,7 @@ import { EmailService } from '../../src/services/email.service'; import { RequestMfaCode } from '../../src/dtos/mfa/request-mfa-code.dto'; import { UpdatePassword } from '../../src/dtos/auth/update-password.dto'; import { Confirm } from '../../src/dtos/auth/confirm.dto'; -import { LoginViaSingleUseCode } from 'src/dtos/auth/login-single-use-code.dto'; +import { LoginViaSingleUseCode } from '../../src/dtos/auth/login-single-use-code.dto'; describe('Auth Controller Tests', () => { let app: INestApplication; @@ -349,113 +349,6 @@ describe('Auth Controller Tests', () => { .expect(200); }); - it('should request single use code successfully', async () => { - const storedUser = await prisma.userAccounts.create({ - data: await userFactory({ - roles: { isAdmin: true }, - mfaEnabled: true, - confirmedAt: new Date(), - phoneNumber: '111-111-1111', - phoneNumberVerified: true, - }), - }); - - const jurisdiction = await prisma.jurisdictions.create({ - data: { - name: 'single_use_code_1', - allowSingleUseCodeLogin: true, - rentalAssistanceDefault: 'test', - }, - }); - emailService.sendSingleUseCode = jest.fn(); - - const res = await request(app.getHttpServer()) - .post('/auth/request-single-use-code') - .send({ - email: storedUser.email, - } as RequestMfaCode) - .set({ jurisdictionname: jurisdiction.name }) - .expect(201); - - expect(res.body).toEqual({ success: true }); - - expect(emailService.sendSingleUseCode).toHaveBeenCalled(); - - const user = await prisma.userAccounts.findUnique({ - where: { - id: storedUser.id, - }, - }); - - expect(user.singleUseCode).not.toBeNull(); - expect(user.singleUseCodeUpdatedAt).not.toBeNull(); - }); - - it('should request single use code, but jurisdiction does not allow', async () => { - const storedUser = await prisma.userAccounts.create({ - data: await userFactory({ - roles: { isAdmin: true }, - mfaEnabled: true, - confirmedAt: new Date(), - phoneNumber: '111-111-1111', - phoneNumberVerified: true, - }), - }); - - const jurisdiction = await prisma.jurisdictions.create({ - data: { - name: 'single_use_code_2', - allowSingleUseCodeLogin: false, - rentalAssistanceDefault: 'test', - }, - }); - emailService.sendSingleUseCode = jest.fn(); - - const res = await request(app.getHttpServer()) - .post('/auth/request-single-use-code') - .send({ - email: storedUser.email, - } as RequestMfaCode) - .set({ jurisdictionname: jurisdiction.name }) - .expect(400); - - expect(res.body.message).toEqual( - 'Single use code login is not setup for single_use_code_2', - ); - - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - - const user = await prisma.userAccounts.findUnique({ - where: { - id: storedUser.id, - }, - }); - - expect(user.singleUseCode).toBeNull(); - }); - - it('should request single use code, but user does not exist', async () => { - const jurisdiction = await prisma.jurisdictions.create({ - data: { - name: 'single_use_code_3', - allowSingleUseCodeLogin: true, - rentalAssistanceDefault: 'test', - }, - }); - emailService.sendSingleUseCode = jest.fn(); - - const res = await request(app.getHttpServer()) - .post('/auth/request-single-use-code') - .send({ - email: 'thisEmailDoesNotExist@exygy.com', - } as RequestMfaCode) - .set({ jurisdictionname: jurisdiction.name }) - .expect(201); - expect(res.body.success).toEqual(true); - - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - it('should login successfully through single use code', async () => { const jurisdiction = await prisma.jurisdictions.create({ data: { diff --git a/api/test/integration/user.e2e-spec.ts b/api/test/integration/user.e2e-spec.ts index 874e024c28..096d2c30be 100644 --- a/api/test/integration/user.e2e-spec.ts +++ b/api/test/integration/user.e2e-spec.ts @@ -19,11 +19,13 @@ import { applicationFactory } from '../../prisma/seed-helpers/application-factor import { UserInvite } from '../../src/dtos/users/user-invite.dto'; import { EmailService } from '../../src/services/email.service'; import { Login } from '../../src/dtos/auth/login.dto'; +import { RequestMfaCode } from '../../src/dtos/mfa/request-mfa-code.dto'; describe('User Controller Tests', () => { let app: INestApplication; let prisma: PrismaService; let userService: UserService; + let emailService: EmailService; let cookies = ''; const invitePartnerUserMock = jest.fn(); @@ -53,6 +55,8 @@ describe('User Controller Tests', () => { app.use(cookieParser()); prisma = moduleFixture.get(PrismaService); userService = moduleFixture.get(UserService); + emailService = moduleFixture.get(EmailService); + await app.init(); const storedUser = await prisma.userAccounts.create({ @@ -604,4 +608,111 @@ describe('User Controller Tests', () => { ]); expect(res.body.email).toEqual('partneruser@email.com'); }); + + it('should request single use code successfully', async () => { + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: true, + confirmedAt: new Date(), + phoneNumber: '111-111-1111', + phoneNumberVerified: true, + }), + }); + + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_1', + allowSingleUseCodeLogin: true, + rentalAssistanceDefault: 'test', + }, + }); + emailService.sendSingleUseCode = jest.fn(); + + const res = await request(app.getHttpServer()) + .post('/user/request-single-use-code') + .send({ + email: storedUser.email, + } as RequestMfaCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(201); + + expect(res.body).toEqual({ success: true }); + + expect(emailService.sendSingleUseCode).toHaveBeenCalled(); + + const user = await prisma.userAccounts.findUnique({ + where: { + id: storedUser.id, + }, + }); + + expect(user.singleUseCode).not.toBeNull(); + expect(user.singleUseCodeUpdatedAt).not.toBeNull(); + }); + + it('should request single use code, but jurisdiction does not allow', async () => { + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: true, + confirmedAt: new Date(), + phoneNumber: '111-111-1111', + phoneNumberVerified: true, + }), + }); + + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_2', + allowSingleUseCodeLogin: false, + rentalAssistanceDefault: 'test', + }, + }); + emailService.sendSingleUseCode = jest.fn(); + + const res = await request(app.getHttpServer()) + .post('/user/request-single-use-code') + .send({ + email: storedUser.email, + } as RequestMfaCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(400); + + expect(res.body.message).toEqual( + 'Single use code login is not setup for single_use_code_2', + ); + + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + + const user = await prisma.userAccounts.findUnique({ + where: { + id: storedUser.id, + }, + }); + + expect(user.singleUseCode).toBeNull(); + }); + + it('should request single use code, but user does not exist', async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_3', + allowSingleUseCodeLogin: true, + rentalAssistanceDefault: 'test', + }, + }); + emailService.sendSingleUseCode = jest.fn(); + + const res = await request(app.getHttpServer()) + .post('/user/request-single-use-code') + .send({ + email: 'thisEmailDoesNotExist@exygy.com', + } as RequestMfaCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(201); + expect(res.body.success).toEqual(true); + + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); }); diff --git a/api/test/unit/services/auth.service.spec.ts b/api/test/unit/services/auth.service.spec.ts index ee8a1f702e..8e920525e9 100644 --- a/api/test/unit/services/auth.service.spec.ts +++ b/api/test/unit/services/auth.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { randomUUID } from 'crypto'; import { sign } from 'jsonwebtoken'; -import { Response, Request } from 'express'; +import { Response } from 'express'; import { ConfigService } from '@nestjs/config'; import { MailService } from '@sendgrid/mail'; import { @@ -29,7 +29,7 @@ import { JurisdictionService } from '../../../src/services/jurisdiction.service' import { GoogleTranslateService } from '../../../src/services/google-translate.service'; import { PermissionService } from '../../../src/services/permission.service'; import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; -import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; +import { generateSingleUseCode } from '../../../src/utilities/generate-single-use-code'; describe('Testing auth service', () => { let authService: AuthService; @@ -609,12 +609,6 @@ describe('Testing auth service', () => { expect(prisma.userAccounts.update).not.toHaveBeenCalled(); }); - it('should generate mfa code', () => { - expect(authService.generateSingleUseCode().length).toEqual( - Number(process.env.MFA_CODE_LENGTH), - ); - }); - it('should update password when correct token passed in', async () => { const id = randomUUID(); const token = sign( @@ -841,219 +835,4 @@ describe('Testing auth service', () => { ACCESS_TOKEN_AVAILABLE_OPTIONS, ); }); - - it('should request single use code but user does not exist', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue(null); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - const res = await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - expect(res).toEqual({ - success: true, - }); - }); - - it('should request single use code but jurisdiction does not exist', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - await expect( - async () => - await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ), - ).rejects.toThrowError('Jurisidiction juris 1 does not exists'); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: 'juris 1', - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - - it('should request single use code but jurisdiction disallows single use code login', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ - id: randomUUID(), - allowSingleUseCodeLogin: false, - }); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - await expect( - async () => - await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ), - ).rejects.toThrowError('Single use code login is not setup for juris 1'); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: 'juris 1', - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - - it('should request single use code but jurisdictionname was not sent', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - await expect( - async () => - await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - {} as unknown as Request, - ), - ).rejects.toThrowError( - 'jurisdictionname is missing from the request headers', - ); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).not.toHaveBeenCalled(); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - - it('should successfully request single use code', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ - id, - allowSingleUseCodeLogin: true, - }); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - const res = await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: 'juris 1', - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - expect(prisma.userAccounts.update).toHaveBeenCalledWith({ - data: { - singleUseCode: expect.anything(), - singleUseCodeUpdatedAt: expect.anything(), - }, - where: { - id, - }, - }); - expect(emailService.sendSingleUseCode).toHaveBeenCalled(); - expect(res.success).toEqual(true); - }); }); diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index fb23419a0e..6cfd6a259e 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; import { PrismaService } from '../../../src/services/prisma.service'; import { UserService } from '../../../src/services/user.service'; import { randomUUID } from 'crypto'; @@ -15,6 +16,7 @@ import { SendGridService } from '../../../src/services/sendgrid.service'; import { User } from '../../../src/dtos/users/user.dto'; import { PermissionService } from '../../../src/services/permission.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; +import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; describe('Testing user service', () => { let service: UserService; @@ -1400,10 +1402,14 @@ describe('Testing user service', () => { }, true, undefined, + { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1472,9 +1478,12 @@ describe('Testing user service', () => { true, undefined, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1552,9 +1561,12 @@ describe('Testing user service', () => { true, undefined, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ), ).rejects.toThrowError('emailInUse'); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ @@ -1613,9 +1625,12 @@ describe('Testing user service', () => { false, undefined, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1629,13 +1644,19 @@ describe('Testing user service', () => { }); expect(prisma.userAccounts.create).toHaveBeenCalledWith({ data: { + dob: undefined, passwordHash: expect.anything(), + phoneNumber: undefined, + userRoles: undefined, email: 'publicUser@email.com', firstName: 'public User firstName', lastName: 'public User lastName', + language: undefined, + listings: undefined, + middleName: undefined, mfaEnabled: false, jurisdictions: { - connect: [{ id: jurisId }], + connect: { name: 'juris 1' }, }, }, }); @@ -1785,4 +1806,219 @@ describe('Testing user service', () => { expect(res).toEqual(false); }); }); + + it('should request single use code but user does not exist', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue(null); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + const res = await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + expect(res).toEqual({ + success: true, + }); + }); + + it('should request single use code but jurisdiction does not exist', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ), + ).rejects.toThrowError('Jurisidiction juris 1 does not exists'); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should request single use code but jurisdiction disallows single use code login', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: false, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ), + ).rejects.toThrowError('Single use code login is not setup for juris 1'); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should request single use code but jurisdictionname was not sent', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + {} as unknown as Request, + ), + ).rejects.toThrowError( + 'jurisdictionname is missing from the request headers', + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).not.toHaveBeenCalled(); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should successfully request single use code', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id, + allowSingleUseCodeLogin: true, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + const res = await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: expect.anything(), + singleUseCodeUpdatedAt: expect.anything(), + }, + where: { + id, + }, + }); + expect(emailService.sendSingleUseCode).toHaveBeenCalled(); + expect(res.success).toEqual(true); + }); }); diff --git a/api/test/unit/utilities/generate-single-use-code.spec.ts b/api/test/unit/utilities/generate-single-use-code.spec.ts new file mode 100644 index 0000000000..a2cc479526 --- /dev/null +++ b/api/test/unit/utilities/generate-single-use-code.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { generateSingleUseCode } from '../../../src/utilities/generate-single-use-code'; +import { AppModule } from '../../../src/modules/app.module'; + +describe('generateSingleUseCode', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + it('should generate mfa code of the specified length', () => { + expect(generateSingleUseCode().length).toEqual( + Number(process.env.MFA_CODE_LENGTH), + ); + }); +}); diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index d6e141d290..f3399c1078 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -365,7 +365,7 @@ export const AuthProvider: FunctionComponent = ({ child requestSingleUseCode: async (email) => { dispatch(startLoading()) try { - return await authService?.requestSingleUseCode({ + return await userService?.requestSingleUseCode({ body: { email }, }) } finally { diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index c4b57ec052..fbe99470ad 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -20,6 +20,7 @@ "account.signUpSaveTime.applyFaster": "Apply faster with saved application details", "account.signUpSaveTime.checkStatus": "Check on the status of an application at any time", "account.signUpSaveTime.resetPassword": "Simply reset your password if you forget it", + "account.signUpSaveTime.useACode": "Use a code to sign in without a password", "account.signUpSaveTime.subTitle": "Having an account will save you time by using saved application details, and allow you to check the status of an application at anytime.", "account.signUpSaveTime.title": "Sign up quickly and check application status at anytime", "account.settings.alerts.currentPassword": "Invalid current password. Please try again.", @@ -211,15 +212,19 @@ "application.household.preferredUnit.title": "What unit sizes are you interested in?", "application.household.primaryApplicant": "Primary Applicant", "application.name.dobHelper": "For example: 01 19 2000", + "application.name.dobHelper2": "This is collected to verify that you are at least 18 years old.", "application.name.emailPrivacy": "We will only use your email address to contact you about your application.", "application.name.firstName": "First Name", + "application.name.firstOrGivenName": "First or Given Name", "application.name.lastName": "Last Name", + "application.name.lastOrFamilyName": "Last or Family Name", "application.name.middleName": "Middle Name", "application.name.middleNameOptional": "Middle Name (optional)", "application.name.noEmailAddress": "I don't have an email address", "application.name.title": "What's your name?", "application.name.yourDateOfBirth": "Your Date of Birth", "application.name.yourEmailAddress": "Your Email Address", + "application.name.yourEmailAddressPwdlessHelper": "Enter your email address and we'll send you a code for a password-free sign in.", "application.name.yourName": "Your Name", "application.preferences.HOPWA.doNotConsider.label": "I don't want to be considered", "application.preferences.HOPWA.hopwa.description": "%{county} copy goes hereā€¦", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 18d0892082..35479ac75f 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1723,6 +1723,28 @@ export class UserService { axios(configs, resolve, reject) }) } + /** + * Request single use code + */ + requestSingleUseCode( + params: { + /** requestBody */ + body?: RequestSingleUseCode + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/request-single-use-code" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Resend public confirmation */ @@ -1872,28 +1894,6 @@ export class AuthService { axios(configs, resolve, reject) }) } - /** - * Request single use code - */ - requestSingleUseCode( - params: { - /** requestBody */ - body?: RequestSingleUseCode - } = {} as any, - options: IRequestOptions = {} - ): Promise { - return new Promise((resolve, reject) => { - let url = basePath + "/auth/request-single-use-code" - - const configs: IRequestConfig = getConfigs("post", "application/json", url, options) - - let data = params.body - - configs.data = data - - axios(configs, resolve, reject) - }) - } /** * Requests a new token given a refresh token */ @@ -2666,147 +2666,6 @@ export interface UnitsSummary { totalAvailable?: number } -export interface UserRole { - /** */ - isAdmin?: boolean - - /** */ - isJurisdictionalAdmin?: boolean - - /** */ - isPartner?: boolean -} - -export interface Jurisdiction { - /** */ - id: string - - /** */ - createdAt: Date - - /** */ - updatedAt: Date - - /** */ - name: string - - /** */ - notificationsSignUpUrl?: string - - /** */ - languages: LanguagesEnum[] - - /** */ - multiselectQuestions: IdDTO[] - - /** */ - partnerTerms?: string - - /** */ - publicUrl: string - - /** */ - emailFromAddress: string - - /** */ - rentalAssistanceDefault: string - - /** */ - enablePartnerSettings?: boolean - - /** */ - enablePartnerDemographics?: boolean - - /** */ - enableGeocodingPreferences?: boolean - - /** */ - enableAccessibilityFeatures: boolean - - /** */ - enableUtilitiesIncluded: boolean - - /** */ - allowSingleUseCodeLogin: boolean - - /** */ - listingApprovalPermissions: EnumJurisdictionListingApprovalPermissions[] -} - -export interface User { - /** */ - id: string - - /** */ - createdAt: Date - - /** */ - updatedAt: Date - - /** */ - passwordUpdatedAt: Date - - /** */ - passwordValidForDays: number - - /** */ - confirmedAt?: Date - - /** */ - email: string - - /** */ - firstName: string - - /** */ - middleName?: string - - /** */ - lastName: string - - /** */ - dob?: Date - - /** */ - phoneNumber?: string - - /** */ - listings: IdDTO[] - - /** */ - userRoles?: UserRole - - /** */ - language?: LanguagesEnum - - /** */ - jurisdictions: Jurisdiction[] - - /** */ - mfaEnabled?: boolean - - /** */ - lastLoginAt?: Date - - /** */ - failedLoginAttemptsCount?: number - - /** */ - phoneNumberVerified?: boolean - - /** */ - agreedToTermsOfService: boolean - - /** */ - hitConfirmationURL?: Date - - /** */ - activeAccessToken?: string - - /** */ - activeRefreshToken?: string -} - export interface Listing { /** */ id: string @@ -4445,6 +4304,62 @@ export interface JurisdictionUpdate { listingApprovalPermissions: EnumJurisdictionUpdateListingApprovalPermissions[] } +export interface Jurisdiction { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string + + /** */ + notificationsSignUpUrl?: string + + /** */ + languages: LanguagesEnum[] + + /** */ + multiselectQuestions: IdDTO[] + + /** */ + partnerTerms?: string + + /** */ + publicUrl: string + + /** */ + emailFromAddress: string + + /** */ + rentalAssistanceDefault: string + + /** */ + enablePartnerSettings?: boolean + + /** */ + enablePartnerDemographics?: boolean + + /** */ + enableGeocodingPreferences?: boolean + + /** */ + enableAccessibilityFeatures: boolean + + /** */ + enableUtilitiesIncluded: boolean + + /** */ + allowSingleUseCodeLogin: boolean + + /** */ + listingApprovalPermissions: EnumJurisdictionListingApprovalPermissions[] +} + export interface MultiselectQuestionCreate { /** */ text: string @@ -4913,6 +4828,91 @@ export interface EmailAndAppUrl { appUrl?: string } +export interface UserRole { + /** */ + isAdmin?: boolean + + /** */ + isJurisdictionalAdmin?: boolean + + /** */ + isPartner?: boolean +} + +export interface User { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + passwordUpdatedAt: Date + + /** */ + passwordValidForDays: number + + /** */ + confirmedAt?: Date + + /** */ + email: string + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + phoneNumber?: string + + /** */ + listings: IdDTO[] + + /** */ + userRoles?: UserRole + + /** */ + language?: LanguagesEnum + + /** */ + jurisdictions: Jurisdiction[] + + /** */ + mfaEnabled?: boolean + + /** */ + lastLoginAt?: Date + + /** */ + failedLoginAttemptsCount?: number + + /** */ + phoneNumberVerified?: boolean + + /** */ + agreedToTermsOfService: boolean + + /** */ + hitConfirmationURL?: Date + + /** */ + activeAccessToken?: string + + /** */ + activeRefreshToken?: string +} + export interface UserFilterParams { /** */ isPortalUser?: boolean @@ -5061,6 +5061,11 @@ export interface UserInvite { jurisdictions: IdDTO[] } +export interface RequestSingleUseCode { + /** */ + email: string +} + export interface ConfirmationRequest { /** */ token: string @@ -5113,11 +5118,6 @@ export interface RequestMfaCodeResponse { phoneNumberVerified?: boolean } -export interface RequestSingleUseCode { - /** */ - email: string -} - export interface UpdatePassword { /** */ password: string @@ -5247,12 +5247,6 @@ export enum UnitRentTypeEnum { "fixed" = "fixed", "percentageOfIncome" = "percentageOfIncome", } -export enum EnumJurisdictionListingApprovalPermissions { - "user" = "user", - "partner" = "partner", - "admin" = "admin", - "jurisdictionAdmin" = "jurisdictionAdmin", -} export enum AfsView { "pending" = "pending", @@ -5312,6 +5306,12 @@ export enum EnumJurisdictionUpdateListingApprovalPermissions { "admin" = "admin", "jurisdictionAdmin" = "jurisdictionAdmin", } +export enum EnumJurisdictionListingApprovalPermissions { + "user" = "user", + "partner" = "partner", + "admin" = "admin", + "jurisdictionAdmin" = "jurisdictionAdmin", +} export enum EnumMultiselectQuestionFilterParamsComparison { "=" = "=", "<>" = "<>", diff --git a/sites/public/src/components/account/SignUpBenefits.tsx b/sites/public/src/components/account/SignUpBenefits.tsx index 2a4b6c8864..d706621328 100644 --- a/sites/public/src/components/account/SignUpBenefits.tsx +++ b/sites/public/src/components/account/SignUpBenefits.tsx @@ -11,8 +11,14 @@ const SignUpBenefits = (props: SignUpBenefitsProps) => { const iconListItems = [ { icon: faStopwatch, text: t("account.signUpSaveTime.applyFaster") }, { icon: faEye, text: t("account.signUpSaveTime.checkStatus") }, - { icon: faLock, text: t("account.signUpSaveTime.resetPassword") }, + { + icon: faLock, + text: process.env.showPwdless + ? t("account.signUpSaveTime.useACode") + : t("account.signUpSaveTime.resetPassword"), + }, ] + const classNames = [styles["sign-up-benefits-container"]] if (props.className) classNames.push(props.className) return ( diff --git a/sites/public/src/pages/applications/contact/name.tsx b/sites/public/src/pages/applications/contact/name.tsx index f05c3b181a..6cfe6e3cc7 100644 --- a/sites/public/src/pages/applications/contact/name.tsx +++ b/sites/public/src/pages/applications/contact/name.tsx @@ -106,7 +106,7 @@ const ApplicationName = () => { { { listingIdRedirect ) - setOpenModal(true) + if (process.env.showPwdless) { + const redirectUrl = router.query?.redirectUrl as string + const listingId = router.query?.listingId as string + let queryParams: { [key: string]: string } = { email: data.email, flowType: "create" } + if (redirectUrl) queryParams = { ...queryParams, redirectUrl } + if (listingId) queryParams = { ...queryParams, listingId } + + await router.push({ + pathname: "/verify", + query: queryParams, + }) + } else { + setOpenModal(true) + } } catch (err) { const { status, data } = err.response || {} if (status === 400) { @@ -108,7 +121,7 @@ export default () => { { { />
@@ -170,7 +179,12 @@ export default () => { errorMessage={t("errors.dateOfBirthErrorAge")} label={t("application.name.yourDateOfBirth")} /> -

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

+

+ {t("application.name.dobHelper2")} +

+

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

{ register={register} controlClassName={styles["create-account-input"]} labelClassName={"text__caps-spaced"} + note={ + process.env.showPwdless + ? t("application.name.yourEmailAddressPwdlessHelper") + : null + } /> { const [isModalOpen, setIsModalOpen] = useState(false) const [isResendLoading, setIsResendLoading] = useState(false) const [isLoginLoading, setIsLoginLoading] = useState(false) - const alertMessage = + const [alertMessage, setAlertMessage] = useState( flowType === "create" ? t("account.pwdless.createMessage", { email }) : t("account.pwdless.loginMessage", { email }) + ) useEffect(() => { pushGtmEvent({ @@ -58,7 +59,11 @@ const Verify = () => { setIsLoginLoading(true) const user = await loginViaSingleUseCode(email, code) setIsLoginLoading(false) - setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + if (flowType === "login") { + setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + } else { + setSiteAlertMessage(t("authentication.createAccount.accountConfirmed"), "success") + } await redirectToPage() } catch (error) { setIsLoginLoading(false) @@ -149,9 +154,11 @@ const Verify = () => { setIsResendLoading(true) await requestSingleUseCode(email) setIsResendLoading(false) + setAlertMessage(t("account.pwdless.codeNewAlert", { email })) setIsModalOpen(false) } catch (error) { setIsResendLoading(false) + setIsModalOpen(false) const { status } = error.response || {} determineNetworkError(status, error) } diff --git a/sites/public/styles/create-account.module.scss b/sites/public/styles/create-account.module.scss index ebfa1a3103..549b85a6a2 100644 --- a/sites/public/styles/create-account.module.scss +++ b/sites/public/styles/create-account.module.scss @@ -24,3 +24,11 @@ .create-account-label { margin-bottom: var(--bloom-s1); } + +.create-account-dob-age-helper { + margin-top: var(--seeds-s4); +} + +.create-account-dob-example { + margin-top: var(--seeds-s2); +} From b5f2c8df4345cf87dfdf8a2ffe0325d8a507efaf Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Wed, 20 Mar 2024 12:12:59 -0700 Subject: [PATCH 31/35] fix: Proxy attempt3 (#3967) * fix: possibly throttling through the proxy stuff * fix: changing logging to get some more info hopefully * fix: small logging tweak * fix: forwarding fix * fix: removings logs --- api/src/guards/throttler.guard.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/api/src/guards/throttler.guard.ts b/api/src/guards/throttler.guard.ts index cc6976f791..6d140e0d16 100644 --- a/api/src/guards/throttler.guard.ts +++ b/api/src/guards/throttler.guard.ts @@ -5,15 +5,9 @@ import { ThrottlerLimitDetail } from '@nestjs/throttler/dist/throttler.guard.int @Injectable() export class ThrottleGuard extends ThrottlerGuard { protected async getTracker(req: Record): Promise { - console.log( - 'forwarded for:', - req?.headers && req.headers['X-Forwarded-For'], - ); - console.log('ip:', req.ips.length ? req.ips : req.ip); - - if (req?.headers && req.headers['X-Forwarded-For']) { + if (req?.headers && req.headers['x-forwarded-for']) { // if we are passing through the proxy use forwarded for - return req.headers['X-Forwarded-For']; + return req.headers['x-forwarded-for']; } return req.ips.length ? req.ips[0] : req.ip; } From bc759c2a03e6867823e4a96f5ffd050f9fd5410b Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Wed, 20 Mar 2024 16:45:45 -0700 Subject: [PATCH 32/35] feat: Throttle update for proxy (#3968) * fix: proxy updates * feat: modifying adapters to forward ip properly * fix: adjusting for hosted env * fix: updates after testing with next proxy * fix: clean up --- api/src/guards/throttler.guard.ts | 2 +- sites/partners/src/pages/api/adapter/[...backendUrl].ts | 1 + sites/public/src/pages/api/adapter/[...backendUrl].ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/guards/throttler.guard.ts b/api/src/guards/throttler.guard.ts index 6d140e0d16..d31dee1631 100644 --- a/api/src/guards/throttler.guard.ts +++ b/api/src/guards/throttler.guard.ts @@ -7,7 +7,7 @@ export class ThrottleGuard extends ThrottlerGuard { protected async getTracker(req: Record): Promise { if (req?.headers && req.headers['x-forwarded-for']) { // if we are passing through the proxy use forwarded for - return req.headers['x-forwarded-for']; + return req.headers['x-forwarded-for'].split(',')[0]; } return req.ips.length ? req.ips[0] : req.ip; } diff --git a/sites/partners/src/pages/api/adapter/[...backendUrl].ts b/sites/partners/src/pages/api/adapter/[...backendUrl].ts index 93fe79cf2f..b50e2505dc 100644 --- a/sites/partners/src/pages/api/adapter/[...backendUrl].ts +++ b/sites/partners/src/pages/api/adapter/[...backendUrl].ts @@ -24,6 +24,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { jurisdictionName: req.headers.jurisdictionname, language: req.headers.language, appUrl: req.headers.appurl, + "x-forwarded-for": req.headers["x-forwarded-for"] || "", }, paramsSerializer: (params) => { return qs.stringify(params) diff --git a/sites/public/src/pages/api/adapter/[...backendUrl].ts b/sites/public/src/pages/api/adapter/[...backendUrl].ts index 0e32a30d3b..818d9d49ec 100644 --- a/sites/public/src/pages/api/adapter/[...backendUrl].ts +++ b/sites/public/src/pages/api/adapter/[...backendUrl].ts @@ -21,6 +21,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { jurisdictionName: req.headers.jurisdictionname, language: req.headers.language, appUrl: req.headers.appurl, + "x-forwarded-for": req.headers["x-forwarded-for"] || "", }, paramsSerializer: (params) => { return qs.stringify(params) From 7c361084c9b2a5c9c80f2743903f755056bf21e3 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Fri, 22 Mar 2024 14:18:41 -0700 Subject: [PATCH 33/35] feat: better memory management on app export (#3965) --- .../application-csv-export.service.ts | 161 ++-- api/src/services/application.service.ts | 211 ++++- .../unit/services/application.service.spec.ts | 722 ++++++++++++++---- 3 files changed, 853 insertions(+), 241 deletions(-) diff --git a/api/src/services/application-csv-export.service.ts b/api/src/services/application-csv-export.service.ts index 5ae32d2d4d..495dfaad0d 100644 --- a/api/src/services/application-csv-export.service.ts +++ b/api/src/services/application-csv-export.service.ts @@ -23,7 +23,11 @@ import { mapTo } from '../utilities/mapTo'; view.csv = { ...view.details, - applicationFlaggedSet: true, + applicationFlaggedSet: { + select: { + id: true, + }, + }, listings: false, }; @@ -81,12 +85,10 @@ export class ApplicationCsvExporterService filename: string, queryParams: QueryParams, ): Promise { - if (queryParams.includeDemographics) { - view.csv.demographics = true; - } - const applications = await this.prisma.applications.findMany({ - include: view.csv, + select: { + id: true, + }, where: { listingId: queryParams.listingId, deletedAt: null, @@ -110,7 +112,7 @@ export class ApplicationCsvExporterService queryParams.includeDemographics, ); - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { // create stream const writableStream = fs.createWriteStream(`${filename}`); writableStream @@ -122,80 +124,109 @@ export class ApplicationCsvExporterService .on('close', () => { resolve(); }) - .on('open', () => { + .on('open', async () => { writableStream.write( csvHeaders .map((header) => `"${header.label.replace(/"/g, `""`)}"`) .join(',') + '\n', ); - // now loop over applications and write them to file - applications.forEach((app) => { - let row = ''; - let preferences: ApplicationMultiselectQuestion[]; - csvHeaders.forEach((header, index) => { - let multiselectQuestionValue = false; - let parsePreference = false; - let value = header.path.split('.').reduce((acc, curr) => { - // return preference/program as value for the format function to accept - if (multiselectQuestionValue) { - return acc; - } + for (let i = 0; i < applications.length / 1000 + 1; i++) { + // grab applications 1k at a time + const paginatedApplications = + await this.prisma.applications.findMany({ + include: { + ...view.csv, + demographics: queryParams.includeDemographics + ? { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + } + : false, + }, + where: { + listingId: queryParams.listingId, + deletedAt: null, + }, + skip: i * 1000, + take: 1000, + }); - if (parsePreference) { - // curr should equal the preference id we're pulling from - if (!preferences) { - preferences = - app.preferences as unknown as ApplicationMultiselectQuestion[]; + // now loop over applications and write them to file + paginatedApplications.forEach((app) => { + let row = ''; + let preferences: ApplicationMultiselectQuestion[]; + csvHeaders.forEach((header, index) => { + let multiselectQuestionValue = false; + let parsePreference = false; + let value = header.path.split('.').reduce((acc, curr) => { + // return preference/program as value for the format function to accept + if (multiselectQuestionValue) { + return acc; } - parsePreference = false; - // there aren't typically many preferences, but if there, then a object map should be created and used - const preference = preferences.find( - (preference) => preference.multiselectQuestionId === curr, - ); - multiselectQuestionValue = true; - return preference; - } - // sets parsePreference to true, for the next iteration - if (curr === 'preferences') { - parsePreference = true; - } + if (parsePreference) { + // curr should equal the preference id we're pulling from + if (!preferences) { + preferences = + app.preferences as unknown as ApplicationMultiselectQuestion[]; + } + parsePreference = false; + // there aren't typically many preferences, but if there, then a object map should be created and used + const preference = preferences.find( + (preference) => preference.multiselectQuestionId === curr, + ); + multiselectQuestionValue = true; + return preference; + } - if (acc === null || acc === undefined) { - return ''; - } + // sets parsePreference to true, for the next iteration + if (curr === 'preferences') { + parsePreference = true; + } - // handles working with arrays, e.g. householdMember.0.firstName - if (!isNaN(Number(curr))) { - const index = Number(curr); - return acc[index]; - } + if (acc === null || acc === undefined) { + return ''; + } - return acc[curr]; - }, app); - value = value === undefined ? '' : value === null ? '' : value; - if (header.format) { - value = header.format(value); - } + // handles working with arrays, e.g. householdMember.0.firstName + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } - row += value ? `"${value.toString().replace(/"/g, `""`)}"` : ''; - if (index < csvHeaders.length - 1) { - row += ','; - } - }); + return acc[curr]; + }, app); + value = value === undefined ? '' : value === null ? '' : value; + if (header.format) { + value = header.format(value); + } - try { - writableStream.write(row + '\n'); - } catch (e) { - console.log('writeStream write error = ', e); - writableStream.once('drain', () => { - console.log('drain buffer'); - writableStream.write(row + '\n'); + row += value ? `"${value.toString().replace(/"/g, `""`)}"` : ''; + if (index < csvHeaders.length - 1) { + row += ','; + } }); - } - }); + try { + writableStream.write(row + '\n'); + } catch (e) { + console.log('writeStream write error = ', e); + writableStream.once('drain', () => { + console.log('drain buffer'); + writableStream.write(row + '\n'); + }); + } + }); + } writableStream.end(); }); }); diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 489160d929..952c10c485 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -33,18 +33,125 @@ export const view: Partial< > = { partnerList: { applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + householdMember: { + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, }, }, - householdMember: true, - accessibility: true, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, alternateContact: { - include: { - address: true, + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, }, }, listings: { @@ -57,20 +164,92 @@ export const view: Partial< view.base = { ...view.partnerList, - demographics: true, - preferredUnitTypes: true, - listings: true, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }, householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, }, }, }; view.details = { ...view.base, - userAccounts: true, + userAccounts: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, }; /* diff --git a/api/test/unit/services/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index e0d066e4da..5a0c78d1d1 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -233,6 +233,382 @@ export const mockCreateApplicationData = ( } as ApplicationCreate; }; +const detailView = { + applicant: { + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + alternateContact: { + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }, + householdMember: { + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + userAccounts: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, +}; + +const baseView = { + applicant: { + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + alternateContact: { + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }, + householdMember: { + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, +}; + describe('Testing application service', () => { let service: ApplicationService; let prisma: PrismaService; @@ -376,30 +752,7 @@ describe('Testing application service', () => { id: 'example Id', }, include: { - userAccounts: true, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, - alternateContact: { - include: { - address: true, - }, - }, - accessibility: true, - demographics: true, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - listings: true, - preferredUnitTypes: true, + ...detailView, }, }); }); @@ -427,30 +780,7 @@ describe('Testing application service', () => { id: 'example Id', }, include: { - userAccounts: true, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, - alternateContact: { - include: { - address: true, - }, - }, - accessibility: true, - demographics: true, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - listings: true, - preferredUnitTypes: true, + ...detailView, }, }); }); @@ -649,29 +979,7 @@ describe('Testing application service', () => { id: 'example Id', }, include: { - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, - alternateContact: { - include: { - address: true, - }, - }, - accessibility: true, - demographics: true, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - listings: true, - preferredUnitTypes: true, + ...baseView, }, }); }); @@ -822,32 +1130,7 @@ describe('Testing application service', () => { }); expect(prisma.applications.create).toHaveBeenCalledWith({ - include: { - accessibility: true, - applicationsAlternateAddress: true, - applicationsMailingAddress: true, - demographics: true, - listings: true, - preferredUnitTypes: true, - userAccounts: true, - alternateContact: { - include: { - address: true, - }, - }, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - }, + include: { ...detailView }, data: { contactPreferences: ['example contact preference'], status: ApplicationStatusEnum.submitted, @@ -1108,30 +1391,7 @@ describe('Testing application service', () => { expect(prisma.applications.create).toHaveBeenCalledWith({ include: { - accessibility: true, - applicationsAlternateAddress: true, - applicationsMailingAddress: true, - demographics: true, - listings: true, - preferredUnitTypes: true, - userAccounts: true, - alternateContact: { - include: { - address: true, - }, - }, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, + ...detailView, }, data: { contactPreferences: ['example contact preference'], @@ -1359,30 +1619,7 @@ describe('Testing application service', () => { expect(prisma.applications.update).toHaveBeenCalledWith({ include: { - accessibility: true, - applicationsAlternateAddress: true, - applicationsMailingAddress: true, - demographics: true, - listings: true, - preferredUnitTypes: true, - userAccounts: true, - alternateContact: { - include: { - address: true, - }, - }, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, + ...detailView, }, data: { contactPreferences: ['example contact preference'], @@ -1652,30 +1889,195 @@ describe('Testing application service', () => { id: mockedValue.id, }, include: { - userAccounts: true, applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, }, }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, alternateContact: { - include: { - address: true, + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, }, }, - accessibility: true, - demographics: true, householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + userAccounts: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, }, }, - listings: true, - preferredUnitTypes: true, }, }); }); From e73aa2c2282b0b6f197b9fae16cb3bddc6830d40 Mon Sep 17 00:00:00 2001 From: Yazeed Loonat Date: Mon, 25 Mar 2024 14:14:28 -0700 Subject: [PATCH 34/35] fix: removing dupes --- api/src/services/application.service.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 4e1c9e92ac..952c10c485 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -159,11 +159,6 @@ export const view: Partial< id: true, }, }, - listings: { - select: { - id: true, - }, - }, }, }; From 9dd9a87a481c6c3777faa5f4e7fa45cc4d9d2ef2 Mon Sep 17 00:00:00 2001 From: ColinBuyck <53269332+ColinBuyck@users.noreply.github.com> Date: Mon, 25 Mar 2024 14:37:56 -0700 Subject: [PATCH 35/35] fix: update naming to applicantWorkAddress (#3975) * fix: update naming to applicantWorkAddress * fix: clean up erroring --- .../pages/applications/contact/address.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/sites/public/src/pages/applications/contact/address.tsx b/sites/public/src/pages/applications/contact/address.tsx index c735d73960..f405a85243 100644 --- a/sites/public/src/pages/applications/contact/address.tsx +++ b/sites/public/src/pages/applications/contact/address.tsx @@ -563,13 +563,13 @@ const ApplicationAddress = () => { { /> {
{ />