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])