}
variant={"text"}
@@ -45,67 +45,72 @@ const ApplicationFormLayout = (props: ApplicationFormLayoutProps) => {
return (
<>
-
-
-
- {props.listingName}
-
-
-
-
-
-
-
-
-
-
-
- {props.backLink && getBackLink(props.backLink.url, props.backLink.onClickFxn)}
-
- {props.heading}
-
- {props.subheading && {props.subheading}
}
-
- {props.children}
- {props.conductor && (
-
-
-
- {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 (
<>
-
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}
+
+ )}
>
-
+
)
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 () => {
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}
-
- )}
-
-
-
+
+
+ >
+
)
}
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 (
+
+ )
+}
+
+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}
+
+
+
+ >
+
+
+
+ )
+}
+
+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 (
-
@@ -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 = () => {
{
/>
{
{
/>