From 23cb4c9ea5b0aa5a8ee05d35b8eb2bb06c3fa37f Mon Sep 17 00:00:00 2001 From: Lin Huiqing <37061143+LinHuiqing@users.noreply.github.com> Date: Wed, 19 Jul 2023 16:41:53 +0800 Subject: [PATCH 1/5] ci: adjust timeouts for playwright pipe (#6551) --- .github/workflows/playwright.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 328223cbae..30bf68e7c7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -6,7 +6,7 @@ on: branches: [develop, release-al2, master] jobs: test: - timeout-minutes: 60 + timeout-minutes: 40 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -24,10 +24,13 @@ jobs: run: npm run build - name: Run Playwright tests (login) run: npx playwright test __tests__/e2e/login.spec.ts + timeout-minutes: 5 - name: Run Playwright tests (email-submission) run: npx playwright test __tests__/e2e/email-submission.spec.ts + timeout-minutes: 15 - name: Run Playwright tests (encrypt-submission) run: npx playwright test __tests__/e2e/encrypt-submission.spec.ts + timeout-minutes: 10 - uses: actions/upload-artifact@v3 if: always() with: From 6e3f690fc01980271fec112df8a1931d74952d2c Mon Sep 17 00:00:00 2001 From: Ken Jin <119096102+kenjin-work@users.noreply.github.com> Date: Wed, 19 Jul 2023 18:41:21 +0800 Subject: [PATCH 2/5] feat: MyInfo Children compound fields beta (#6523) * feat: MyInfo Children compound fields beta * chore: bump myinfo-gov-client to 4.1.1 --- .../FieldRow/FieldRowContainer.tsx | 3 + .../EditFieldDrawer/EditFieldDrawer.tsx | 23 +- .../EditMyInfoChildren/EditMyInfoChildren.tsx | 148 +++++++ .../EditMyInfoChildren/index.ts | 10 + .../field-panels/MyInfoPanel.tsx | 25 ++ .../BuilderAndDesignTab.tsx | 11 + .../create/builder-and-design/constants.ts | 30 ++ .../builder-and-design/utils/fieldCreation.ts | 21 +- .../features/admin-form/create/constants.ts | 53 +++ .../EditCondition/EditConditionBlock.tsx | 1 + .../features/logic/utils/getIfLogicType.ts | 1 + frontend/src/features/myinfo/types.ts | 1 + .../myinfo/utils/augmentWithMyInfo.ts | 6 + .../src/features/myinfo/utils/isMyInfo.ts | 1 + .../components/FormFields/FieldFactory.tsx | 11 + .../public-form/utils/inputTransformation.ts | 18 + .../public-form/utils/validateResponses.ts | 3 + .../ChildrenCompoundField.stories.tsx | 92 ++++ .../ChildrenCompoundField.tsx | 402 ++++++++++++++++++ .../templates/Field/ChildrenCompound/index.ts | 1 + frontend/src/templates/Field/index.ts | 2 + frontend/src/templates/Field/types.ts | 18 + frontend/src/utils/fieldValidation.ts | 15 + package-lock.json | 14 +- package.json | 2 +- shared/constants/field/myinfo/index.ts | 82 +++- shared/types/field/base.ts | 25 ++ shared/types/field/childrenCompoundField.ts | 20 + shared/types/field/index.ts | 9 +- shared/types/form/form.ts | 4 +- shared/types/response.ts | 15 +- shared/types/user.ts | 1 + src/app/models/field/childrenCompoundField.ts | 14 + src/app/models/field/index.ts | 2 + src/app/models/form.server.model.ts | 15 +- src/app/models/user.server.model.ts | 1 + .../public-form/public-form.controller.ts | 46 +- src/app/modules/myinfo/myinfo.adapter.ts | 138 +++++- src/app/modules/myinfo/myinfo.service.ts | 42 +- src/app/modules/myinfo/myinfo.types.ts | 20 +- src/app/modules/myinfo/myinfo.util.ts | 159 ++++++- .../ParsedResponsesObject.class.ts | 20 +- .../email-submission.constants.ts | 1 + .../email-submission.types.ts | 3 +- .../email-submission/email-submission.util.ts | 60 ++- .../modules/submission/submission.types.ts | 5 + .../answerValidator.factory.ts | 11 + .../field-validation.guards.ts | 13 + src/app/utils/field-validation/index.ts | 21 + .../validators/childrenValidator.ts | 142 +++++++ src/types/field/childrenCompoundField.ts | 15 + src/types/field/index.ts | 4 + src/types/myinfo_hash.ts | 5 +- src/types/response/index.ts | 16 +- 54 files changed, 1759 insertions(+), 62 deletions(-) create mode 100644 frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx create mode 100644 frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/index.ts create mode 100644 frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx create mode 100644 frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx create mode 100644 frontend/src/templates/Field/ChildrenCompound/index.ts create mode 100644 shared/types/field/childrenCompoundField.ts create mode 100644 src/app/models/field/childrenCompoundField.ts create mode 100644 src/app/utils/field-validation/validators/childrenValidator.ts create mode 100644 src/types/field/childrenCompoundField.ts diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/FieldRow/FieldRowContainer.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/FieldRow/FieldRowContainer.tsx index c8c99ca8ed..3759fc554c 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/FieldRow/FieldRowContainer.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/FieldRow/FieldRowContainer.tsx @@ -24,6 +24,7 @@ import Tooltip from '~components/Tooltip' import { AttachmentField, CheckboxField, + ChildrenCompoundField, DateField, DecimalField, DropdownField, @@ -495,5 +496,7 @@ const FieldRow = ({ field, ...rest }: FieldRowProps) => { return case BasicField.Table: return + case BasicField.Children: + return } } diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/EditFieldDrawer.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/EditFieldDrawer.tsx index 8236895503..5dbd3889c2 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/EditFieldDrawer.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/EditFieldDrawer.tsx @@ -1,12 +1,17 @@ import { memo, useMemo } from 'react' -import { BasicField, FieldCreateDto } from '~shared/types/field' +import { + BasicField, + FieldCreateDto, + MyInfoAttribute, +} from '~shared/types/field' import { BASICFIELD_TO_DRAWER_META, MYINFO_FIELD_TO_DRAWER_META, } from '~features/admin-form/create/constants' import { isMyInfo } from '~features/myinfo/utils' +import { useUser } from '~features/user/queries' import { useBuilderFields } from '../../BuilderAndDesignContent/useBuilderFields' import { @@ -16,6 +21,10 @@ import { } from '../../useFieldBuilderStore' import { BuilderDrawerContainer } from '../common/BuilderDrawerContainer' +import { + ChildrenCompoundFieldMyInfo, + EditMyInfoChildren, +} from './edit-fieldtype/EditMyInfoChildren' import { EditAttachment, EditCheckbox, @@ -98,7 +107,19 @@ interface MemoFieldDrawerContentProps { export const MemoFieldDrawerContent = memo( ({ field, ...props }) => { + const { user } = useUser() if (isMyInfo(field)) { + if ( + field?.myInfo?.attr === MyInfoAttribute.ChildrenBirthRecords && + user?.betaFlags?.children + ) { + return ( + + ) + } return } diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx new file mode 100644 index 0000000000..22e674c320 --- /dev/null +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/EditMyInfoChildren.tsx @@ -0,0 +1,148 @@ +import { Controller } from 'react-hook-form' +import { BiCheck, BiData, BiX } from 'react-icons/bi' +import { Box, FormControl, HStack, Icon, Text, VStack } from '@chakra-ui/react' +import { extend } from 'lodash' + +import { MyInfoChildAttributes } from '~shared/types' + +import { SINGPASS_FAQ } from '~constants/links' +import { MultiSelect } from '~components/Dropdown' +import Link from '~components/Link' +import { Toggle } from '~components/Toggle/Toggle' + +import { CREATE_MYINFO_CHILDREN_SUBFIELDS_OPTIONS } from '~features/admin-form/create/builder-and-design/constants' + +import { CreatePageDrawerContentContainer } from '../../../../../common' +import { FormFieldDrawerActions } from '../common/FormFieldDrawerActions' +import { EditFieldProps } from '../common/types' +import { useEditFieldForm } from '../common/useEditFieldForm' +import { extendWithMyInfo } from '../EditMyInfo/utils' + +import { ChildrenCompoundFieldMyInfo } from '.' + +const VerifiedIcon = ({ isVerified }: { isVerified: boolean }): JSX.Element => { + return ( + + ) +} + +const EDIT_MYINFO_CHILDREN = ['allowMultiple', 'childrenSubFields'] as const + +type EditMyInfoChildrenProps = EditFieldProps +type EditMyInfoChildrenInputs = Pick< + ChildrenCompoundFieldMyInfo, + typeof EDIT_MYINFO_CHILDREN[number] +> + +export const EditMyInfoChildren = ({ + field, +}: EditMyInfoChildrenProps): JSX.Element => { + const extendedField = extendWithMyInfo(field) + const { + control, + register, + buttonText, + handleUpdateField, + isLoading, + handleCancel, + } = useEditFieldForm({ + field, + transform: { + // MyInfo fields are not editable (except for Child compound field), + // so omit any transformation and output the original field + input: (inputField) => inputField, + output: (formOutput, originalField) => + extend({}, originalField, formOutput), + }, + }) + + return ( + + + Data source + {extendedField.dataSource.map((dataSource, idx) => ( + + + {dataSource} + + ))} + + + Verified for + {/* NOTE: Not creating an array from the keys then enumerating because order has to be enforced in UI. + * This allows the object to be created with arbitrary ordered keys. + */} + + + Singaporeans + + + + Permanent Residents + + + + + Foreigners with{' '} + + Singpass + + + + + + + Collect the following child information + + + ( + + onChange([ + MyInfoChildAttributes.ChildName, + ...val.filter( + (val) => val !== MyInfoChildAttributes.ChildName, + ), + ]) + } + {...rest} + /> + )} + /> + + + + + + + + + Field details + {extendedField.details} + + + + ) +} diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/index.ts b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/index.ts new file mode 100644 index 0000000000..13e47f5faf --- /dev/null +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMyInfoChildren/index.ts @@ -0,0 +1,10 @@ +import { SetRequired } from 'type-fest' + +import { ChildrenCompoundFieldBase } from '~shared/types' + +export * from './EditMyInfoChildren' + +export type ChildrenCompoundFieldMyInfo = SetRequired< + ChildrenCompoundFieldBase, + 'myInfo' +> diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx index c28120f0e9..341abababf 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx @@ -11,6 +11,8 @@ import InlineMessage from '~components/InlineMessage' import Link from '~components/Link' import { + CREATE_MYINFO_CHILDREN_DROP_ID, + CREATE_MYINFO_CHILDREN_FIELDS_ORDERED, CREATE_MYINFO_CONTACT_DROP_ID, CREATE_MYINFO_CONTACT_FIELDS_ORDERED, CREATE_MYINFO_MARRIAGE_DROP_ID, @@ -21,6 +23,7 @@ import { CREATE_MYINFO_PERSONAL_FIELDS_ORDERED, } from '~features/admin-form/create/builder-and-design/constants' import { isMyInfo } from '~features/myinfo/utils' +import { useUser } from '~features/user/queries' import { useCreateTabForm } from '../../../../builder-and-design/useCreateTabForm' import { DraggableMyInfoFieldListOption } from '../FieldListOption' @@ -41,6 +44,7 @@ export const MyInfoFieldPanel = () => { [form], ) const isDisabled = isMyInfoDisabled || isLoading + const { user } = useUser() return ( <> @@ -115,6 +119,27 @@ export const MyInfoFieldPanel = () => { )} + {user?.betaFlags?.children ? ( + + {(provided) => ( + + + {CREATE_MYINFO_CHILDREN_FIELDS_ORDERED.map( + (fieldType, index) => ( + + ), + )} + + {provided.placeholder} + + )} + + ) : null} ) } diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignTab.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignTab.tsx index 4872093dd4..d918db477a 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignTab.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignTab.tsx @@ -23,6 +23,8 @@ import { BuilderAndDesignDrawer } from './BuilderAndDesignDrawer' import { BASIC_FIELDS_ORDERED, CREATE_FIELD_DROP_ID, + CREATE_MYINFO_CHILDREN_DROP_ID, + CREATE_MYINFO_CHILDREN_FIELDS_ORDERED, CREATE_MYINFO_CONTACT_DROP_ID, CREATE_MYINFO_CONTACT_FIELDS_ORDERED, CREATE_MYINFO_MARRIAGE_DROP_ID, @@ -122,6 +124,15 @@ export const BuilderAndDesignTab = (): JSX.Element => { ) } + case CREATE_MYINFO_CHILDREN_DROP_ID: { + return setToCreating( + getMyInfoFieldCreationMeta( + CREATE_MYINFO_CHILDREN_FIELDS_ORDERED[source.index], + ), + destination.index, + ) + } + case FIELD_LIST_DROP_ID: { if (destination.index === source.index) { return diff --git a/frontend/src/features/admin-form/create/builder-and-design/constants.ts b/frontend/src/features/admin-form/create/builder-and-design/constants.ts index 77e62c6d09..8d72f19b21 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/constants.ts +++ b/frontend/src/features/admin-form/create/builder-and-design/constants.ts @@ -1,14 +1,18 @@ import { BasicField, + ChildrenCompoundFieldBase, DateFieldBase, DropdownFieldBase, MobileFieldBase, MyInfoAttribute, + MyInfoChildAttributes, ShortTextFieldBase, } from '~shared/types/field' import { MyInfoFieldMeta } from '~features/myinfo/types' +import { MYINFO_FIELD_TO_DRAWER_META } from '../constants' + export const BASIC_FIELDS_ORDERED = [ BasicField.ShortText, BasicField.LongText, @@ -61,6 +65,8 @@ export const MYINFO_FIELDS_ORDERED: MyInfoAttribute[] = [ MyInfoAttribute.MarriageCertNo, MyInfoAttribute.MarriageDate, MyInfoAttribute.DivorceDate, + // Children section + MyInfoAttribute.ChildrenBirthRecords, ] export const MYINFO_TEXTFIELD_META: MyInfoFieldMeta = { @@ -86,6 +92,12 @@ export const MYINFO_DATEFIELD_META: MyInfoFieldMeta = { }, } +export const MYINFO_CHILDRENFIELD_META: MyInfoFieldMeta = + { + childrenSubFields: [MyInfoChildAttributes.ChildName], + allowMultiple: false, + } + export const CREATE_MYINFO_PERSONAL_FIELDS_ORDERED = MYINFO_FIELDS_ORDERED.slice(0, 13) @@ -100,6 +112,9 @@ export const CREATE_MYINFO_PARTICULARS_FIELDS_ORDERED = export const CREATE_MYINFO_MARRIAGE_FIELDS_ORDERED = MYINFO_FIELDS_ORDERED.slice(19, 24) +export const CREATE_MYINFO_CHILDREN_FIELDS_ORDERED = + MYINFO_FIELDS_ORDERED.slice(24, 25) + export const CREATE_FIELD_DROP_ID = 'create-fields-field' export const CREATE_MYINFO_PERSONAL_DROP_ID = 'create-myinfo-personal' @@ -109,6 +124,9 @@ export const CREATE_MYINFO_CONTACT_DROP_ID = 'create-myinfo-drop' export const CREATE_MYINFO_PARTICULARS_DROP_ID = 'create-myinfo-particulars' export const CREATE_MYINFO_MARRIAGE_DROP_ID = 'create-myinfo-marriage' + +export const CREATE_MYINFO_CHILDREN_DROP_ID = 'create-myinfo-children' + export const FIELD_LIST_DROP_ID = 'formFieldList' export const PENDING_CREATE_FIELD_ID = 'FIELD-PENDING-CREATION' @@ -117,3 +135,15 @@ export enum FieldListTabIndex { MyInfo, Payments, } + +export const CREATE_MYINFO_CHILDREN_SUBFIELDS_OPTIONS: { + value: MyInfoChildAttributes + label: string +}[] = Object.values(MyInfoChildAttributes) + .filter((e) => e !== MyInfoChildAttributes.ChildName) + .map((value) => { + return { + value, + label: MYINFO_FIELD_TO_DRAWER_META[value].label, + } + }) diff --git a/frontend/src/features/admin-form/create/builder-and-design/utils/fieldCreation.ts b/frontend/src/features/admin-form/create/builder-and-design/utils/fieldCreation.ts index 87f249dbe5..fb40dfc8b1 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/utils/fieldCreation.ts +++ b/frontend/src/features/admin-form/create/builder-and-design/utils/fieldCreation.ts @@ -4,12 +4,14 @@ import { BasicField, FieldCreateDto, MyInfoAttribute, + MyInfoChildAttributes, MyInfoField, RatingShape, } from '~shared/types/field' import { BASICFIELD_TO_DRAWER_META } from '../../constants' import { + MYINFO_CHILDRENFIELD_META, MYINFO_DATEFIELD_META, MYINFO_DROPDOWNFIELD_META, MYINFO_MOBILEFIELD_META, @@ -182,11 +184,20 @@ export const getFieldCreationMeta = (fieldType: BasicField): FieldCreateDto => { minimumRows: 2, } } + case BasicField.Children: { + return { + fieldType, + ...baseMeta, + } + } } } export const getMyInfoFieldCreationMeta = ( - myInfoAttribute: MyInfoAttribute, + myInfoAttribute: Exclude< + MyInfoAttribute, + MyInfoChildAttributes[keyof MyInfoChildAttributes] + >, ): MyInfoField => { const baseMeta: Pick< MyInfoField, @@ -255,6 +266,14 @@ export const getMyInfoFieldCreationMeta = ( } } + case MyInfoAttribute.ChildrenBirthRecords: { + return { + ...baseMeta, + fieldType: BasicField.Children, + ...MYINFO_CHILDRENFIELD_META, + } + } + default: { const exception: never = myInfoAttribute throw new Error(`MyInfo type is not implemented: ${exception}`) diff --git a/frontend/src/features/admin-form/create/constants.ts b/frontend/src/features/admin-form/create/constants.ts index e09cd1d01d..93e8864852 100644 --- a/frontend/src/features/admin-form/create/constants.ts +++ b/frontend/src/features/admin-form/create/constants.ts @@ -7,6 +7,7 @@ import { BiBriefcase, BiBuilding, BiCalculator, + BiCalendar, BiCalendarAlt, BiCalendarEvent, BiCalendarHeart, @@ -17,6 +18,7 @@ import { BiCloudUpload, BiFlag, BiGlobe, + BiGroup, BiHash, BiHeading, BiHeartCircle, @@ -176,8 +178,16 @@ export const BASICFIELD_TO_DRAWER_META: { icon: BiToggleLeft, isSubmitted: true, }, + + [BasicField.Children]: { + label: 'Children', + icon: BiGroup, + isSubmitted: true, + }, } +const BiDummyIcon = BiCalendar // random icon that is not actually shown in app + export const MYINFO_FIELD_TO_DRAWER_META: { [key in MyInfoAttribute]: BuilderSidebarFieldMeta } = { @@ -301,4 +311,47 @@ export const MYINFO_FIELD_TO_DRAWER_META: { icon: BiCalendarX, isSubmitted: true, }, + [MyInfoAttribute.ChildrenBirthRecords]: { + label: 'Child Records', + icon: BiGroup, + isSubmitted: true, + }, + + // The following child field's icons will never be used. + // So they can be any placeholder. + [MyInfoAttribute.ChildBirthCertNo]: { + label: 'Birth Certificate Number', + icon: BiDummyIcon, + isSubmitted: true, + }, + [MyInfoAttribute.ChildDateOfBirth]: { + label: 'Date of Birth', + icon: BiDummyIcon, + isSubmitted: true, + }, + [MyInfoAttribute.ChildName]: { + label: 'Name', + icon: BiDummyIcon, + isSubmitted: true, + }, + [MyInfoAttribute.ChildVaxxStatus]: { + label: 'Vaccination Requirements', + icon: BiDummyIcon, + isSubmitted: true, + }, + [MyInfoAttribute.ChildGender]: { + label: 'Gender', + icon: BiDummyIcon, + isSubmitted: true, + }, + [MyInfoAttribute.ChildRace]: { + label: 'Race', + icon: BiDummyIcon, + isSubmitted: true, + }, + [MyInfoAttribute.ChildSecondaryRace]: { + label: 'Secondary Race', + icon: BiDummyIcon, + isSubmitted: true, + }, } diff --git a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx index 6909e25f01..57dc561539 100644 --- a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx +++ b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/EditConditionBlock.tsx @@ -172,6 +172,7 @@ export const EditConditionBlock = ({ if (!currentSelectedField) return '9rem' switch (currentSelectedField.fieldType) { case BasicField.Dropdown: + case BasicField.Children: case BasicField.Radio: case BasicField.YesNo: return '9rem' diff --git a/frontend/src/features/logic/utils/getIfLogicType.ts b/frontend/src/features/logic/utils/getIfLogicType.ts index 039cffb728..446f610ab0 100644 --- a/frontend/src/features/logic/utils/getIfLogicType.ts +++ b/frontend/src/features/logic/utils/getIfLogicType.ts @@ -20,6 +20,7 @@ export const getIfLogicType = ({ ? LogicIfValue.SingleSelect : LogicIfValue.MultiSelect } + case BasicField.Children: case BasicField.Rating: case BasicField.YesNo: return LogicIfValue.SingleSelect diff --git a/frontend/src/features/myinfo/types.ts b/frontend/src/features/myinfo/types.ts index 4bf2457855..bd2d723fdd 100644 --- a/frontend/src/features/myinfo/types.ts +++ b/frontend/src/features/myinfo/types.ts @@ -13,6 +13,7 @@ export enum MyInfoDataSource { URA = 'Urban Redevelopment Authority', User = 'User-provided', MSFD = 'Ministry of Social and Family Development', + HPB = 'Health Promotion Board', } // Type for MyInfo field within the editor view diff --git a/frontend/src/features/myinfo/utils/augmentWithMyInfo.ts b/frontend/src/features/myinfo/utils/augmentWithMyInfo.ts index 037378adaa..5f2d36ecd1 100644 --- a/frontend/src/features/myinfo/utils/augmentWithMyInfo.ts +++ b/frontend/src/features/myinfo/utils/augmentWithMyInfo.ts @@ -18,6 +18,12 @@ export const augmentWithMyInfo = ({ field.fieldOptions = myInfoBlock.fieldOptions ?? [] return field } + case BasicField.Children: { + if (!field.myInfo?.attr) return field + const myInfoBlock = MAP_ATTR_TO_NAME[field.myInfo.attr] + field.title = myInfoBlock.value + return field + } default: return field } diff --git a/frontend/src/features/myinfo/utils/isMyInfo.ts b/frontend/src/features/myinfo/utils/isMyInfo.ts index 599c7e8de2..fe67177ea5 100644 --- a/frontend/src/features/myinfo/utils/isMyInfo.ts +++ b/frontend/src/features/myinfo/utils/isMyInfo.ts @@ -6,6 +6,7 @@ export const isMyInfo = (field: FormField): field is MyInfoField => { case BasicField.Dropdown: case BasicField.Mobile: case BasicField.ShortText: + case BasicField.Children: return !!field.myInfo default: return false diff --git a/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx b/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx index db747f77dd..d8de3e8e76 100644 --- a/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx +++ b/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx @@ -6,6 +6,7 @@ import { FormColorTheme } from '~shared/types/form' import { AttachmentField, CheckboxField, + ChildrenCompoundField, DateField, DecimalField, DropdownField, @@ -27,6 +28,7 @@ import { } from '~templates/Field' import { FormFieldWithQuestionNo } from '~features/form/types' +import { usePublicFormContext } from '~features/public-form/PublicFormContext' import { VerifiableEmailField, VerifiableEmailFieldSchema, @@ -46,6 +48,7 @@ interface FieldFactoryProps { export const FieldFactory = memo( ({ field, ...rest }: FieldFactoryProps) => { + const { myInfoChildrenBirthRecords } = usePublicFormContext() switch (field.fieldType) { case BasicField.Section: return @@ -103,6 +106,14 @@ export const FieldFactory = memo( return case BasicField.Table: return + case BasicField.Children: + return ( + + ) } }, (prevProps, nextProps) => diff --git a/frontend/src/features/public-form/utils/inputTransformation.ts b/frontend/src/features/public-form/utils/inputTransformation.ts index caaffbb868..ffc92b3685 100644 --- a/frontend/src/features/public-form/utils/inputTransformation.ts +++ b/frontend/src/features/public-form/utils/inputTransformation.ts @@ -5,6 +5,7 @@ import { BasicField, FormFieldDto } from '~shared/types/field' import { AttachmentResponse, CheckboxResponse, + ChildBirthRecordsResponse, FieldResponse, HeaderResponse, RadioResponse, @@ -22,6 +23,8 @@ import { BaseFieldOutput, CheckboxFieldSchema, CheckboxFieldValues, + ChildrenCompoundFieldSchema, + ChildrenCompoundFieldValues, DateFieldSchema, EmailFieldSchema, FormFieldValue, @@ -182,6 +185,16 @@ const transformToSectionOutput = ( } } +const transformToChildOutput = ( + schema: ChildrenCompoundFieldSchema, + input: ChildrenCompoundFieldValues, +): ChildBirthRecordsResponse => { + return { + ...pickBaseOutputFromSchema(schema), + answerArray: input.child, + } +} + /** * Transforms form inputs to their desire output shapes for sending to the server * @param field schema to retrieve base field info @@ -248,5 +261,10 @@ export const transformInputsToOutputs = ( case BasicField.Image: // No output needed. return undefined + case BasicField.Children: + return transformToChildOutput( + field, + input as FormFieldValue, + ) } } diff --git a/frontend/src/features/public-form/utils/validateResponses.ts b/frontend/src/features/public-form/utils/validateResponses.ts index 62ec81f715..0be87d96a1 100644 --- a/frontend/src/features/public-form/utils/validateResponses.ts +++ b/frontend/src/features/public-form/utils/validateResponses.ts @@ -2,6 +2,7 @@ import { AttachmentResponse, BasicField, CheckboxResponse, + ChildBirthRecordsResponse, DateResponse, DecimalResponse, DropdownResponse, @@ -83,6 +84,8 @@ export const validateResponses = (responses: unknown): FieldResponse[] => { return NumberResponse.parse(response) case BasicField.YesNo: return YesNoResponse.parse(response) + case BasicField.Children: + return ChildBirthRecordsResponse.parse(response) default: throw new Error( `Invalid fieldType provided for response validation: ${response.fieldType}`, diff --git a/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx b/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx new file mode 100644 index 0000000000..da237030e1 --- /dev/null +++ b/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.stories.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { Text } from '@chakra-ui/react' +import { Meta, Story } from '@storybook/react' +import { merge } from 'lodash' + +import { + BasicField, + MyInfoChildAttributes, + MyInfoChildData, +} from '~shared/types/field' + +import Button from '~components/Button' + +import { ChildrenCompoundFieldSchema } from '../types' + +import { + ChildrenCompoundField as ChildrenCompoundComponent, + ChildrenCompoundFieldProps, +} from './ChildrenCompoundField' + +export default { + title: 'Templates/Field/ChildrenCompoundField', + component: ChildrenCompoundComponent, + decorators: [], + parameters: { + docs: { + // Required in this story due to react-hook-form conflicting with + // Storybook somehow. + // See https://github.com/storybookjs/storybook/issues/12747. + source: { + type: 'code', + }, + }, + }, +} as Meta + +const baseSchema: ChildrenCompoundFieldSchema = { + childrenSubFields: [MyInfoChildAttributes.ChildName], + allowMultiple: false, + title: '', + description: '', + required: true, + disabled: false, + fieldType: BasicField.Children, + _id: '611b94dfbb9e300012f702a7', +} + +interface StoryChildrenCompoundFieldProps extends ChildrenCompoundFieldProps { + childrenBirthRecords: MyInfoChildData +} + +const Template: Story = ({ + childrenBirthRecords, + ...args +}) => { + const formMethods = useForm() + + const [submitValues, setSubmitValues] = useState() + + const onSubmit = (values: Record) => { + setSubmitValues( + JSON.stringify(values[`${args.schema._id}.child`]) || + 'Nothing was selected', + ) + } + + return ( + +
+ + + {submitValues && You have submitted: {submitValues}} + +
+ ) +} + +export const AllowMultipleChildren = Template.bind({}) +AllowMultipleChildren.args = { + schema: merge({}, baseSchema, { allowMultiple: true }), +} diff --git a/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx b/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx new file mode 100644 index 0000000000..e331d9cd8e --- /dev/null +++ b/frontend/src/templates/Field/ChildrenCompound/ChildrenCompoundField.tsx @@ -0,0 +1,402 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { + FieldArrayWithId, + useFieldArray, + UseFieldArrayRemove, + useFormContext, + UseFormReturn, + useFormState, +} from 'react-hook-form' +import { BiPlus, BiTrash } from 'react-icons/bi' +import { + Box, + Divider, + Flex, + FormControl, + HStack, + Input as ChakraInput, + Spacer, + Text, + VisuallyHidden, + VStack, +} from '@chakra-ui/react' +import simplur from 'simplur' + +import { MYINFO_ATTRIBUTE_MAP } from '~shared/constants/field/myinfo' +import { + FormColorTheme, + MyInfoAttribute, + MyInfoChildAttributes, + MyInfoChildData, + MyInfoChildVaxxStatus, +} from '~shared/types' + +import { createChildrenValidationRules } from '~utils/fieldValidation' +import { Button } from '~components/Button/Button' +import { DatePicker } from '~components/DatePicker' +import { SingleSelect } from '~components/Dropdown/SingleSelect' +import { FormLabel } from '~components/FormControl/FormLabel/FormLabel' +import { IconButton } from '~components/IconButton/IconButton' + +import { BaseFieldProps, FieldContainer } from '../FieldContainer' +import { + ChildrenCompoundFieldInputs, + ChildrenCompoundFieldSchema, +} from '../types' + +export interface ChildrenCompoundFieldProps extends BaseFieldProps { + schema: ChildrenCompoundFieldSchema + myInfoChildrenBirthRecords?: MyInfoChildData +} + +/** + * Compound field for child information. + * This is "compound" because it can contain multiple subfields. + * The internal data representation is an array of arrays, where each + * subarray contains strings that represent the subfield array inputs. + * + * @precondition Must have a parent `react-hook-form#FormProvider` component. + */ +export const ChildrenCompoundField = ({ + schema, + colorTheme = FormColorTheme.Blue, + myInfoChildrenBirthRecords, + ...fieldContainerProps +}: ChildrenCompoundFieldProps): JSX.Element => { + const childrenInputName = useMemo( + () => `${schema._id}` as const, + [schema._id], + ) + + const formContext = useFormContext() + const { isSubmitting } = useFormState({ + name: schema._id, + }) + + const { fields, append, remove } = useFieldArray( + { + control: formContext.control, + name: `${schema._id}.child`, + }, + ) + + useEffect(() => { + if (schema.childrenSubFields) { + formContext.setValue( + `${schema._id}.childFields`, + schema.childrenSubFields, + ) + } + }, [schema.childrenSubFields, formContext, schema._id]) + + // Initialize with a single child section + useEffect(() => { + if (!fields || !fields.length) { + append(['']) + } + }, [fields, append]) + + const ariaChildrenDescription = useMemo(() => { + let description = simplur`This is a children field. There [is|are] ${fields.length} child[|ren].` + description += `Each child has multiple fields to fill.` + description += `You can fill the child by selecting the child's name from the child name dropdown.` + if (schema.allowMultiple) { + description += ` You can add another child if you'd like by clicking the "Add another child" button below` + } + + return description + }, [fields.length, schema.allowMultiple]) + + const numChild = fields.length ?? 0 + + return ( + + + {ariaChildrenDescription} + + + + {fields.map((field, currChildBodyIdx) => ( + + ))} + + {schema.allowMultiple ? ( + + + + {simplur`${numChild} child[|ren] added`} + + ) : null} + + + ) +} + +interface ChildrenBodyProps { + currChildBodyIdx: number + schema: ChildrenCompoundFieldSchema + fields: FieldArrayWithId< + ChildrenCompoundFieldInputs, + `${string}.child`, + 'id' + >[] + field: FieldArrayWithId + colorTheme: FormColorTheme + remove: UseFieldArrayRemove + myInfoChildrenBirthRecords?: MyInfoChildData + isSubmitting: boolean + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formContext: UseFormReturn +} + +const ChildrenBody = ({ + currChildBodyIdx, + schema, + fields, + field, + colorTheme, + remove, + myInfoChildrenBirthRecords, + isSubmitting, + formContext, +}: ChildrenBodyProps): JSX.Element => { + const { register, getValues, setValue, watch } = formContext + + const childNamePath = useMemo( + () => `${schema._id}.child.${currChildBodyIdx}.0`, + [schema._id, currChildBodyIdx], + ) + + const validationRules = useMemo( + () => createChildrenValidationRules(schema), + [schema], + ) + + const { onChange: selectOnChange, ...selectRest } = register( + childNamePath, + validationRules, + ) + + const childName = watch(childNamePath) as unknown as string + + const allChildren = useMemo(() => { + if (myInfoChildrenBirthRecords === undefined) { + return [] + } + return myInfoChildrenBirthRecords[MyInfoAttribute.ChildName] ?? [] + }, [myInfoChildrenBirthRecords]) + + // useCallback to re-compute names because for some reason watch doesn't + // work on this nested field in react-hook-form. + const allSelectedNames = useCallback((): string[] => { + // Really important to note that sometimes react-hook-form stores our "array" + // as a object with key=index and values=array entry. + return Object.values(getValues(`${schema._id}.child`)).map((arr) => arr[0]) + }, [getValues, schema._id]) + + // useCallback to re-compute names, again because of buggy allSelectedNames + const namesNotSelected = useCallback((): string[] => { + if (myInfoChildrenBirthRecords === undefined) { + return [] + } + const temp = new Set(allSelectedNames()) + // We want all child names that haven't already been selected. + // O(n^2) but n is small so it should be okay. + return allChildren.filter((name) => !temp.has(name)) + }, [myInfoChildrenBirthRecords, allChildren, allSelectedNames]) + + const indexOfChild: number = useMemo(() => { + return ( + myInfoChildrenBirthRecords?.[MyInfoChildAttributes.ChildName]?.indexOf( + childName, + ) ?? -1 + ) + }, [myInfoChildrenBirthRecords, childName]) + + const getChildAttr = useCallback( + (attr: MyInfoChildAttributes): string => { + if (myInfoChildrenBirthRecords === undefined) { + return '' + } + + if (indexOfChild === undefined || indexOfChild < 0) { + return '' + } + const result = myInfoChildrenBirthRecords?.[attr]?.[indexOfChild] + // Unknown basically means no result + if ( + attr === MyInfoChildAttributes.ChildVaxxStatus && + result === MyInfoChildVaxxStatus.Unknown + ) { + return '' + } + return result ?? '' + }, + [indexOfChild, myInfoChildrenBirthRecords], + ) + return ( + + + Child + + + e !== '')} + value={childName} + isDisabled={isSubmitting} + initialIsOpen={!!myInfoChildrenBirthRecords?.childname} + onChange={(name) => { + // This is bad practice but we have no choice because our + // custom Select doesn't forward the event. + setValue(childNamePath, name) + }} + /> + + } + aria-label="Remove child" + alignSelf="end" + disabled={fields.length <= 1} + onClick={() => { + if (fields.length > 1) { + remove(fields.length - 1) + } + }} + /> + + + {schema.childrenSubFields + ?.filter((subField) => subField !== MyInfoChildAttributes.ChildName) + .map((subField, index) => { + // First index taken by name. + index += 1 + const key = `${field.id}+${index}` + const fieldPath = `${schema._id}.child.${currChildBodyIdx}.${index}` + const myInfoValue = getChildAttr(subField) + const value = watch(fieldPath) as unknown as string + if (myInfoValue && value !== myInfoValue) { + // We need to do this as the underlying data is not updated + // by the field's value, but rather by onChange, which we did + // not trigger via prefill. + setValue(fieldPath, myInfoValue) + } + const isDisabled = isSubmitting || !!myInfoValue + switch (subField) { + case MyInfoChildAttributes.ChildBirthCertNo: { + return ( + + + {MYINFO_ATTRIBUTE_MAP[subField].description} + + + + ) + } + case MyInfoChildAttributes.ChildVaxxStatus: + case MyInfoChildAttributes.ChildGender: + case MyInfoChildAttributes.ChildRace: + case MyInfoChildAttributes.ChildSecondaryRace: { + return ( + + + {MYINFO_ATTRIBUTE_MAP[subField].description} + + + // This is bad practice but we have no choice because our + // custom Select doesn't forward the event. + setValue(fieldPath, option) + } + /> + + ) + } + case MyInfoChildAttributes.ChildDateOfBirth: { + const { onChange, ...rest } = register(fieldPath, validationRules) + return ( + + + {MYINFO_ATTRIBUTE_MAP[subField].description} + + { + setValue(fieldPath, date) + }} + colorScheme={`theme-${colorTheme}`} + /> + + ) + } + default: + return
Unsupported child subfield
+ } + })} + {/* No divider on last child */} + {currChildBodyIdx < fields.length - 1 ? ( + <> + + + + + ) : null} +
+ ) +} diff --git a/frontend/src/templates/Field/ChildrenCompound/index.ts b/frontend/src/templates/Field/ChildrenCompound/index.ts new file mode 100644 index 0000000000..9f6c8b7c7a --- /dev/null +++ b/frontend/src/templates/Field/ChildrenCompound/index.ts @@ -0,0 +1 @@ +export { ChildrenCompoundField as default } from './ChildrenCompoundField' diff --git a/frontend/src/templates/Field/index.ts b/frontend/src/templates/Field/index.ts index 189a611c39..154c16276a 100644 --- a/frontend/src/templates/Field/index.ts +++ b/frontend/src/templates/Field/index.ts @@ -1,5 +1,6 @@ import AttachmentField from './Attachment' import CheckboxField from './Checkbox' +import ChildrenCompoundField from './ChildrenCompound' import DateField from './Date' import DecimalField from './Decimal' import DropdownField from './Dropdown' @@ -24,6 +25,7 @@ export * from './types' export { AttachmentField, CheckboxField, + ChildrenCompoundField, DateField, DecimalField, DropdownField, diff --git a/frontend/src/templates/Field/types.ts b/frontend/src/templates/Field/types.ts index cf1ffeccf5..c686f7e789 100644 --- a/frontend/src/templates/Field/types.ts +++ b/frontend/src/templates/Field/types.ts @@ -14,6 +14,7 @@ import { ImageFieldBase, LongTextFieldBase, MobileFieldBase, + MyInfoChildAttributes, NricFieldBase, NumberFieldBase, RadioFieldBase, @@ -25,6 +26,7 @@ import { UenFieldBase, YesNoFieldBase, } from '~shared/types/field' +import { ChildrenCompoundFieldBase } from '~shared/types/field/childrenCompoundField' import { SingleAnswerResponse, VerifiableResponseBase, @@ -56,6 +58,8 @@ export type TableFieldInputs = FieldInput export type YesNoFieldInput = FieldInput export type SingleAnswerFieldInput = FieldInput export type VerifiableFieldInput = FieldInput +export type ChildrenCompoundFieldInputs = + FieldInput export type FormFieldValue = F extends | BasicField.Number @@ -81,6 +85,8 @@ export type FormFieldValue = F extends ? RadioFieldValues : F extends BasicField.Checkbox ? CheckboxFieldValues + : F extends BasicField.Children + ? ChildrenCompoundFieldValues : never // Input values, what each field contains @@ -116,6 +122,16 @@ export type VerifiableAnswerOutput = Merge< VerifiableResponseBase > +export type ChildrenCompoundFieldValues = { + // Each subarray represents one child value + // e.g. + // child : [['NAME', 'T12345678Z', 'VACCINATED'], ... ] + // cildFields: [name, bc number, vaxx status] + child: string[][] + // Array of attribute names + childFields: MyInfoChildAttributes[] +} + // Various schemas used by different fields export type SectionFieldSchema = FormFieldWithId export type ParagraphFieldSchema = FormFieldWithId @@ -139,3 +155,5 @@ export type ShortTextFieldSchema = FormFieldWithQuestionNo export type TableFieldSchema = FormFieldWithQuestionNo export type UenFieldSchema = FormFieldWithQuestionNo export type YesNoFieldSchema = FormFieldWithQuestionNo +export type ChildrenCompoundFieldSchema = + FormFieldWithQuestionNo diff --git a/frontend/src/utils/fieldValidation.ts b/frontend/src/utils/fieldValidation.ts index 92acc06c2d..14b2bb6b76 100644 --- a/frontend/src/utils/fieldValidation.ts +++ b/frontend/src/utils/fieldValidation.ts @@ -12,6 +12,7 @@ import { AttachmentFieldBase, BasicField, CheckboxFieldBase, + ChildrenCompoundFieldBase, DateFieldBase, DateSelectedValidation, DecimalFieldBase, @@ -47,6 +48,7 @@ import { import { DATE_PARSE_FORMAT } from '~templates/Field/Date/DateField' import { CheckboxFieldValues, + ChildrenCompoundFieldValues, SingleAnswerValue, VerifiableFieldValues, } from '~templates/Field/types' @@ -506,3 +508,16 @@ export const baseMobileValidationFn = isMobilePhoneNumber(inputValue) || 'Please enter a valid mobile number' ) } + +export const createChildrenValidationRules: ValidationRuleFn< + ChildrenCompoundFieldBase +> = (schema): RegisterOptions => { + return { + validate: { + required: (value: string) => { + if (!schema.required) return true + if (!value || !value.trim()) return REQUIRED_ERROR + }, + }, + } +} diff --git a/package-lock.json b/package-lock.json index 145b1ab8e9..23f253bc3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@opengovsg/angular-legacy-sortablejs-maintained": "^1.0.0", "@opengovsg/angular-recaptcha-fallback": "^5.0.0", "@opengovsg/formsg-sdk": "^0.10.0", - "@opengovsg/myinfo-gov-client": "^4.0.2", + "@opengovsg/myinfo-gov-client": "^4.1.1", "@opengovsg/ng-file-upload": "^12.2.15", "@opengovsg/sgid-client": "1.0.4", "@sentry/browser": "^7.51.2", @@ -5057,9 +5057,9 @@ } }, "node_modules/@opengovsg/myinfo-gov-client": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@opengovsg/myinfo-gov-client/-/myinfo-gov-client-4.0.2.tgz", - "integrity": "sha512-x6EVG++Xv/+NId2wD64f7m3TSp+O6Jf/tmCXLdN5Q/mxW501fFEwUvtzp5a5DTX6RBSodXLP772rM6SCLkgEgQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@opengovsg/myinfo-gov-client/-/myinfo-gov-client-4.1.1.tgz", + "integrity": "sha512-s/YklXL9TTHjvtpVXQM8dSss1GWkIZtKXQ2k9n/6ESEFC90GX658OzmckPy4PBHtqxVUha2Pec+IIoX1338jHg==", "dependencies": { "axios": "^0.27.2", "jsonwebtoken": "^8.5.1", @@ -32432,9 +32432,9 @@ } }, "@opengovsg/myinfo-gov-client": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@opengovsg/myinfo-gov-client/-/myinfo-gov-client-4.0.2.tgz", - "integrity": "sha512-x6EVG++Xv/+NId2wD64f7m3TSp+O6Jf/tmCXLdN5Q/mxW501fFEwUvtzp5a5DTX6RBSodXLP772rM6SCLkgEgQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@opengovsg/myinfo-gov-client/-/myinfo-gov-client-4.1.1.tgz", + "integrity": "sha512-s/YklXL9TTHjvtpVXQM8dSss1GWkIZtKXQ2k9n/6ESEFC90GX658OzmckPy4PBHtqxVUha2Pec+IIoX1338jHg==", "requires": { "axios": "^0.27.2", "jsonwebtoken": "^8.5.1", diff --git a/package.json b/package.json index 018ee77af1..98cf65cf0a 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@opengovsg/angular-legacy-sortablejs-maintained": "^1.0.0", "@opengovsg/angular-recaptcha-fallback": "^5.0.0", "@opengovsg/formsg-sdk": "^0.10.0", - "@opengovsg/myinfo-gov-client": "^4.0.2", + "@opengovsg/myinfo-gov-client": "^4.1.1", "@opengovsg/ng-file-upload": "^12.2.15", "@opengovsg/sgid-client": "1.0.4", "@sentry/browser": "^7.51.2", diff --git a/shared/constants/field/myinfo/index.ts b/shared/constants/field/myinfo/index.ts index d6c046abaf..777695d51b 100644 --- a/shared/constants/field/myinfo/index.ts +++ b/shared/constants/field/myinfo/index.ts @@ -1,5 +1,10 @@ import keyBy from 'lodash/keyBy' -import { BasicField, MyInfoAttribute, MyInfoField } from '../../../types/field' +import { + BasicField, + MyInfoAttribute, + MyInfoChildVaxxStatus, + MyInfoField, +} from '../../../types/field' import COUNTRIES from './myinfo-countries' import DIALECTS from './myinfo-dialects' import NATIONALITIES from './myinfo-nationalities' @@ -306,6 +311,81 @@ export const types: MyInfoFieldBlock[] = [ fieldType: BasicField.Mobile, previewValue: '98765432', }, + { + name: MyInfoAttribute.ChildrenBirthRecords, + value: 'Child records', + category: 'children', + verified: ['SG', 'PR', 'F'], + source: 'Immigration & Checkpoints Authority / Health Promotion Board', + description: + "The data of the form-filler's children. This field is verified by ICA and HPB for vaccination status.", + fieldType: BasicField.Children, + previewValue: 'Child 1', + }, + { + name: MyInfoAttribute.ChildBirthCertNo, + value: "Child's birth certificte number", + category: 'children', + verified: [], + source: 'Immigration & Checkpoints Authority', + description: 'Birth certificate number', + fieldType: BasicField.ShortText, + previewValue: 'T1234567X', + }, + { + name: MyInfoAttribute.ChildDateOfBirth, + value: "Child's date of birth", + category: 'children', + verified: [], + source: 'Immigration & Checkpoints Authority', + description: 'Date of birth', + fieldType: BasicField.ShortText, + previewValue: '2010-01-01', + }, + { + name: MyInfoAttribute.ChildGender, + value: "Child's gender", + category: 'children', + verified: [], + source: 'Immigration & Checkpoints Authority', + description: 'Gender', + fieldType: BasicField.ShortText, + fieldOptions: ['FEMALE', 'MALE', 'UNKNOWN'], + previewValue: 'MALE', + }, + { + name: MyInfoAttribute.ChildVaxxStatus, + value: "Child's vaccination status", + category: 'children', + verified: [], + source: 'Heath Promotion Board', + description: 'Vaccination status', + fieldType: BasicField.Dropdown, + fieldOptions: Object.values(MyInfoChildVaxxStatus), + previewValue: MyInfoChildVaxxStatus.ONEM3D, + }, + { + name: MyInfoAttribute.ChildRace, + value: "Child's race", + category: 'children', + verified: [], + source: 'Immigration & Checkpoints Authority', + description: 'Race', + fieldType: BasicField.Dropdown, + fieldOptions: RACES, + previewValue: 'CHINESE', + }, + { + name: MyInfoAttribute.ChildSecondaryRace, + value: "Child's secondary race", + category: 'children', + verified: [], + source: 'Immigration & Checkpoints Authority', + description: 'Secondary race', + fieldType: BasicField.Dropdown, + fieldOptions: RACES, + previewValue: 'CHINESE', + }, ] export const MYINFO_ATTRIBUTE_MAP = keyBy(types, 'name') diff --git a/shared/types/field/base.ts b/shared/types/field/base.ts index 407044741c..66b5c88457 100644 --- a/shared/types/field/base.ts +++ b/shared/types/field/base.ts @@ -19,6 +19,7 @@ export enum BasicField { Nric = 'nric', Table = 'table', Uen = 'uen', + Children = 'children', } export enum MyInfoAttribute { @@ -47,8 +48,32 @@ export enum MyInfoAttribute { DivorceDate = 'divorcedate', WorkpassStatus = 'workpassstatus', WorkpassExpiryDate = 'workpassexpirydate', + ChildrenBirthRecords = 'childrenbirthrecords', + // Children fields. MAKE SURE TO KEEP IN SYNC WITH MyInfoChildAttributes BELOW. + ChildName = 'childname', + ChildBirthCertNo = 'childbirthcertno', + ChildDateOfBirth = 'childdateofbirth', + ChildVaxxStatus = 'childvaxxstatus', + ChildGender = 'childgender', + ChildRace = 'childrace', + ChildSecondaryRace = 'childsecondaryrace', } +// We need to write this manually otherwise TS merges the names and keys +export enum MyInfoChildAttributes { + ChildName = 'childname', + ChildBirthCertNo = 'childbirthcertno', + ChildDateOfBirth = 'childdateofbirth', + ChildVaxxStatus = 'childvaxxstatus', + ChildGender = 'childgender', + ChildRace = 'childrace', + ChildSecondaryRace = 'childsecondaryrace', +} + +export type MyInfoChildData = Partial<{ + [key in MyInfoChildAttributes]: string[] +}> + export type AllowMyInfoBase = { myInfo?: { attr: MyInfoAttribute diff --git a/shared/types/field/childrenCompoundField.ts b/shared/types/field/childrenCompoundField.ts new file mode 100644 index 0000000000..44097c5edb --- /dev/null +++ b/shared/types/field/childrenCompoundField.ts @@ -0,0 +1,20 @@ +import { + BasicField, + MyInfoChildAttributes, + MyInfoableFieldBase, +} from './base' + +export interface ChildrenCompoundFieldBase extends MyInfoableFieldBase { + fieldType: BasicField.Children + // Stores the sub-field data. + childrenSubFields?: MyInfoChildAttributes[] + // Whether the response should accept more than one children. + // Default (undefined) is no. + allowMultiple?: boolean +} + +export enum MyInfoChildVaxxStatus { + // Vaccinatinon requirement code 1M3D in the MyInfo API code tables + ONEM3D = 'MINIMUM VACCINATION REQUIREMENT FOR PRESCHOOL ADMISSION FULFILLED', + Unknown = 'UNKNOWN', +} diff --git a/shared/types/field/index.ts b/shared/types/field/index.ts index 7b1fd85690..d20ebdbaf0 100644 --- a/shared/types/field/index.ts +++ b/shared/types/field/index.ts @@ -1,5 +1,6 @@ import type { AttachmentFieldBase } from './attachmentField' import type { CheckboxFieldBase } from './checkboxField' +import type { ChildrenCompoundFieldBase } from './childrenCompoundField' import type { DateFieldBase } from './dateField' import type { DecimalFieldBase } from './decimalField' import type { DropdownFieldBase } from './dropdownField' @@ -42,6 +43,7 @@ export * from './tableField' export * from './uenField' export * from './utils' export * from './yesNoField' +export * from './childrenCompoundField' export type FormField = | AttachmentFieldBase @@ -64,11 +66,16 @@ export type FormField = | TableFieldBase | UenFieldBase | YesNoFieldBase + | ChildrenCompoundFieldBase // These types are extended through using MyInfoableFieldBase // And might possibly be myInfo fields if the attribute is set export type MyInfoField = SetRequired< - DateFieldBase | DropdownFieldBase | MobileFieldBase | ShortTextFieldBase, + | DateFieldBase + | DropdownFieldBase + | MobileFieldBase + | ShortTextFieldBase + | ChildrenCompoundFieldBase, 'myInfo' > diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index 1888056b17..d35c50ee18 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -1,5 +1,5 @@ import { PublicUserDto, UserDto } from '../user' -import { FormField, FormFieldDto } from '../field' +import { FormField, FormFieldDto, MyInfoChildData } from '../field' import { FormLogo } from './form_logo' import type { Merge, Opaque, PartialDeep } from 'type-fest' @@ -13,6 +13,7 @@ import { import { DateString } from '../generic' import { FormLogic, LogicDto } from './form_logic' import { PaymentChannel, PaymentType } from '../payment' +import { MyInfoChildrenBirthRecords } from '@opengovsg/myinfo-gov-client' export type FormId = Opaque @@ -224,6 +225,7 @@ export type PublicFormViewDto = { spcpSession?: SpcpSession isIntranetUser?: boolean myInfoError?: true + myInfoChildrenBirthRecords?: MyInfoChildData } export type PreviewFormViewDto = Pick diff --git a/shared/types/response.ts b/shared/types/response.ts index 31d05c79a4..25f36fadca 100644 --- a/shared/types/response.ts +++ b/shared/types/response.ts @@ -1,6 +1,6 @@ import type { Opaque } from 'type-fest' import { z } from 'zod' -import { BasicField, MyInfoAttribute } from './field' +import { BasicField, MyInfoAttribute, MyInfoChildAttributes } from './field' const ResponseBase = z.object({ myInfo: z.never().optional(), @@ -127,6 +127,18 @@ export const UenResponse = SingleAnswerResponse.extend({ }) export type UenResponse = z.infer +export const ChildBirthRecordsResponse = ResponseBase.merge( + MyInfoResponseBase, +).extend({ + fieldType: z.literal(BasicField.Children), + // Basically same as Table + answerArray: z.array(z.array(z.string())) as unknown as z.Schema, +}) + +export type ChildBirthRecordsResponse = z.infer< + typeof ChildBirthRecordsResponse +> + export type FieldResponse = | HeaderResponse | EmailResponse @@ -146,3 +158,4 @@ export type FieldResponse = | NricResponse | TableResponse | UenResponse + | ChildBirthRecordsResponse diff --git a/shared/types/user.ts b/shared/types/user.ts index 8c08066e11..fb7b9a5f78 100644 --- a/shared/types/user.ts +++ b/shared/types/user.ts @@ -14,6 +14,7 @@ export const UserBase = z.object({ betaFlags: z .object({ payment: z.boolean().optional(), + children: z.boolean().optional(), }) .optional(), flags: z diff --git a/src/app/models/field/childrenCompoundField.ts b/src/app/models/field/childrenCompoundField.ts new file mode 100644 index 0000000000..d59cf5ecaa --- /dev/null +++ b/src/app/models/field/childrenCompoundField.ts @@ -0,0 +1,14 @@ +import { Schema } from 'mongoose' + +import { IChildrenCompoundFieldSchema } from '../../../types' + +import { MyInfoSchema } from './baseField' + +const createchildrenCompoundFieldSchema = () => { + return new Schema({ + childrenSubFields: [String], + allowMultiple: Boolean, + myInfo: MyInfoSchema, + }) +} +export default createchildrenCompoundFieldSchema diff --git a/src/app/models/field/index.ts b/src/app/models/field/index.ts index ae852535c5..e48d56da68 100644 --- a/src/app/models/field/index.ts +++ b/src/app/models/field/index.ts @@ -1,6 +1,7 @@ import createAttachmentFieldSchema from './attachmentField' import { BaseFieldSchema } from './baseField' import createCheckboxFieldSchema from './checkboxField' +import createchildrenCompoundFieldSchema from './childrenCompoundField' import createDateFieldSchema from './dateField' import createDecimalFieldSchema from './decimalField' import createDropdownFieldSchema from './dropdownField' @@ -41,5 +42,6 @@ export { createTableFieldSchema, createUenFieldSchema, createYesNoFieldSchema, + createchildrenCompoundFieldSchema, BaseFieldSchema, } diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index d55394ac29..7da691c30f 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -65,12 +65,14 @@ import { import { IPopulatedUser, IUserSchema } from '../../types/user' import { OverrideProps } from '../modules/form/admin-form/admin-form.types' import { getFormFieldById, transformEmails } from '../modules/form/form.utils' +import { getMyInfoAttr } from '../modules/myinfo/myinfo.util' import { validateWebhookUrl } from '../modules/webhook/webhook.validation' import { BaseFieldSchema, createAttachmentFieldSchema, createCheckboxFieldSchema, + createchildrenCompoundFieldSchema, createDateFieldSchema, createDecimalFieldSchema, createDropdownFieldSchema, @@ -547,6 +549,10 @@ const compileFormModel = (db: Mongoose): IFormModel => { createAttachmentFieldSchema(), ) FormFieldPath.discriminator(BasicField.Dropdown, createDropdownFieldSchema()) + FormFieldPath.discriminator( + BasicField.Children, + createchildrenCompoundFieldSchema(), + ) FormFieldPath.discriminator(BasicField.Radio, createRadioFieldSchema()) FormFieldPath.discriminator(BasicField.Checkbox, createCheckboxFieldSchema()) FormFieldPath.discriminator( @@ -607,7 +613,13 @@ const compileFormModel = (db: Mongoose): IFormModel => { } // Compact is used to remove undefined from array - return compact(uniq(this.form_fields?.map((field) => field.myInfo?.attr))) + return compact( + uniq( + this.form_fields?.flatMap((field) => { + return getMyInfoAttr(field) + }), + ), + ) } // Return essential form creation parameters with the given properties @@ -743,6 +755,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { const formFields = this.form_fields as Types.DocumentArray // Must use undefined check since number can be 0; i.e. falsey. if (to !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any formFields.splice(to, 0, newField as any) // Typings are not complete for splice. } else { formFields.push(newField) diff --git a/src/app/models/user.server.model.ts b/src/app/models/user.server.model.ts index 2a1c1b86e4..6d95058f83 100644 --- a/src/app/models/user.server.model.ts +++ b/src/app/models/user.server.model.ts @@ -72,6 +72,7 @@ const compileUserModel = (db: Mongoose) => { }, betaFlags: { payment: Boolean, + children: Boolean, }, flags: { lastSeenFeatureUpdateVersion: Number, diff --git a/src/app/modules/form/public-form/public-form.controller.ts b/src/app/modules/form/public-form/public-form.controller.ts index 8bed2ead40..2f79ce0e6a 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -203,25 +203,36 @@ export const handleGetPublicForm: ControllerHandler< prefilledFields, spcpSession: { userName: myInfoData.getUinFin() }, myInfoLoginCookie: createMyInfoLoginCookie(myInfoData.getUinFin()), + myInfoChildrenBirthRecords: myInfoData.getChildrenBirthRecords( + form.getUniqueMyInfoAttrs(), + ), })) }) - .map(({ myInfoLoginCookie, prefilledFields, spcpSession }) => { - // Set the updated cookie accordingly and return the form back to the user - return res - .cookie( - MYINFO_LOGIN_COOKIE_NAME, - myInfoLoginCookie, - MYINFO_LOGIN_COOKIE_OPTIONS, - ) - .json({ - spcpSession, - form: { - ...publicForm, - form_fields: prefilledFields as FormFieldDto[], - }, - isIntranetUser, - }) - }) + .map( + ({ + myInfoLoginCookie, + prefilledFields, + spcpSession, + myInfoChildrenBirthRecords, + }) => { + // Set the updated cookie accordingly and return the form back to the user + return res + .cookie( + MYINFO_LOGIN_COOKIE_NAME, + myInfoLoginCookie, + MYINFO_LOGIN_COOKIE_OPTIONS, + ) + .json({ + spcpSession, + form: { + ...publicForm, + form_fields: prefilledFields as FormFieldDto[], + }, + isIntranetUser, + myInfoChildrenBirthRecords, + }) + }, + ) .mapErr((error) => { logger.error({ message: 'MyInfo login error', @@ -328,6 +339,7 @@ export const handleGetPublicFormSampleSubmission: ControllerHandler< formId, } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const formResult = await getFormIfPublic(formId) // Early return if form is not public or any error occurred. if (formResult.isErr()) { diff --git a/src/app/modules/myinfo/myinfo.adapter.ts b/src/app/modules/myinfo/myinfo.adapter.ts index 81493a8170..18e569f303 100644 --- a/src/app/modules/myinfo/myinfo.adapter.ts +++ b/src/app/modules/myinfo/myinfo.adapter.ts @@ -2,12 +2,16 @@ import { IPerson, IPersonResponse, MyInfoAttribute as ExternalAttr, + MyInfoChildBirthRecordBelow21, MyInfoScope, MyInfoSource, } from '@opengovsg/myinfo-gov-client' import { MyInfoAttribute as InternalAttr, + MyInfoChildAttributes, + MyInfoChildData, + MyInfoChildVaxxStatus, MyInfoDataTransformer, } from '../../../../shared/types' @@ -20,6 +24,7 @@ import { formatVehicleNumbers, formatWorkpassStatus, } from './myinfo.format' +import { isMyInfoChildrenBirthRecords } from './myinfo.util' /** * Converts an internal MyInfo attribute used in FormSG to a scope @@ -78,6 +83,22 @@ export const internalAttrToScope = (attr: InternalAttr): MyInfoScope => { return ExternalAttr.MarriageDate case InternalAttr.DivorceDate: return ExternalAttr.DivorceDate + case InternalAttr.ChildrenBirthRecords: + return ExternalAttr.ChildrenBirthRecords + case InternalAttr.ChildName: + return `${ExternalAttr.ChildrenBirthRecords}.name` + case InternalAttr.ChildBirthCertNo: + return `${ExternalAttr.ChildrenBirthRecords}.birthcertno` + case InternalAttr.ChildDateOfBirth: + return `${ExternalAttr.ChildrenBirthRecords}.dob` + case InternalAttr.ChildVaxxStatus: + return `${ExternalAttr.ChildrenBirthRecords}.vaccinationrequirements` + case InternalAttr.ChildGender: + return `${ExternalAttr.ChildrenBirthRecords}.sex` + case InternalAttr.ChildRace: + return `${ExternalAttr.ChildrenBirthRecords}.race` + case InternalAttr.ChildSecondaryRace: + return `${ExternalAttr.ChildrenBirthRecords}.secondaryrace` } } @@ -138,6 +159,15 @@ export const internalAttrToExternal = (attr: InternalAttr): ExternalAttr => { return ExternalAttr.MarriageDate case InternalAttr.DivorceDate: return ExternalAttr.DivorceDate + case InternalAttr.ChildName: + case InternalAttr.ChildBirthCertNo: + case InternalAttr.ChildDateOfBirth: + case InternalAttr.ChildrenBirthRecords: + case InternalAttr.ChildVaxxStatus: + case InternalAttr.ChildGender: + case InternalAttr.ChildRace: + case InternalAttr.ChildSecondaryRace: + return ExternalAttr.ChildrenBirthRecords } } @@ -149,9 +179,55 @@ export const internalAttrToExternal = (attr: InternalAttr): ExternalAttr => { */ export const internalAttrListToScopes = ( attrs: InternalAttr[], -): MyInfoScope[] => +): MyInfoScope[] => { // Always ask for consent for UinFin, even though it is not a form field - attrs.map(internalAttrToScope).concat(ExternalAttr.UinFin) + const scopes = attrs.map(internalAttrToScope).concat(ExternalAttr.UinFin) + // Only for MockPass compatbility. For production we don't want to + // ask for the most general Children scope. + if ( + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'test' + ) { + for (const attr of attrs) { + if (isMyInfoChildrenBirthRecords(attr)) { + scopes.push(ExternalAttr.ChildrenBirthRecords) + break + } + } + } + + return scopes +} + +/** + * Converts whatever preschool vaccination data + * we get directly from MyInfo to out internal representation. + * + * NOTE: As of the time of writing this, there is only one possible + * vaccination status code. So the array input doesn't matter + * and we can just output a single enum. However, if this changes + * in the future, we need to support multiple vaccination statuses. + * + * @param vaccinationRequirement The preschool child records vaccination requirements. + * @returns Vaccination status of the child. Unknown status should be treated as missing data. + */ +const requirementToVaccinationEnum = ( + vaccinationRequirement: + | undefined + | { + requirement: { code: string; desc: string } + fulfilled: { value: boolean } + }[], +): MyInfoChildVaxxStatus => { + if (vaccinationRequirement === undefined || !vaccinationRequirement.length) { + return MyInfoChildVaxxStatus.Unknown + } + return vaccinationRequirement.some((req) => req?.requirement?.code === '1M3D') + ? MyInfoChildVaxxStatus.ONEM3D + : MyInfoChildVaxxStatus.Unknown +} + +const MyInfoChildAttributesSorted = Object.values(MyInfoChildAttributes).sort() /** * Wrapper class for MyInfo data. Provides public methods to safely @@ -173,6 +249,64 @@ export class MyInfoData return this.#uinFin } + /** + * Accesses children birth records fields from MyInfo. + * These are special as they return an array of children for each + * scope type. + * + * @param childAttr The child attribute you're requesting. + * @returns Array of children's values. + */ + #accessChildrenAttrFromMyInfo(childAttr: MyInfoChildAttributes): string[] { + const records = this.#personData + .childrenbirthrecords as Array + if (records === undefined) { + return [] + } + // Note: need ?. operator because above 21 children may + // not have these fields. + switch (childAttr) { + case MyInfoChildAttributes.ChildName: + return records.map((c) => c?.name?.value ?? '') + case MyInfoChildAttributes.ChildDateOfBirth: + return records.map((c) => c?.dob?.value ?? '') + case MyInfoChildAttributes.ChildBirthCertNo: + return records.map((c) => c?.birthcertno?.value ?? '') + case MyInfoChildAttributes.ChildVaxxStatus: + return records.map( + (c) => + requirementToVaccinationEnum(c?.vaccinationrequirements) as string, + ) + case MyInfoChildAttributes.ChildGender: + return records.map((c) => c?.sex?.desc ?? '') + case MyInfoChildAttributes.ChildRace: + return records.map((c) => c?.race?.desc ?? '') + case MyInfoChildAttributes.ChildSecondaryRace: + return records.map((c) => c?.secondaryrace?.desc ?? '') + default: { + const never: never = childAttr + return never + } + } + } + + getChildrenBirthRecords( + allMyInfoAttrs: InternalAttr[], + ): MyInfoChildData | undefined { + if (this.#personData?.childrenbirthrecords === undefined) { + return + } + const myInfoAttrsSet = new Set(allMyInfoAttrs) + + const result = Object.fromEntries( + MyInfoChildAttributesSorted + // Filter out records that aren't requested by our scope. + .filter((attr) => myInfoAttrsSet.has(attr as unknown as InternalAttr)) + .map((attr) => [attr, this.#accessChildrenAttrFromMyInfo(attr)]), + ) + return result + } + _formatFieldValue(attr: ExternalAttr): string | undefined { switch (attr) { case ExternalAttr.MobileNo: diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 2bd343b000..3b0aefd028 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -11,6 +11,10 @@ import mongoose, { LeanDocument } from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import CircuitBreaker from 'opossum' +import { + MyInfoAttribute as InternalAttr, + MyInfoChildData, +} from '../../../../shared/types' import { Environment, IFieldSchema, @@ -48,13 +52,16 @@ import { import { IMyInfoRedirectURLArgs, IMyInfoServiceConfig, + MyInfoKey, MyInfoLoginCookiePayload, MyInfoRelayState, } from './myinfo.types' import { compareHashedValues, createRelayState, + getMyInfoAttr, hashFieldValues, + isMyInfoChildrenBirthRecords, isMyInfoLoginCookie, isMyInfoRelayState, validateMyInfoForm, @@ -245,15 +252,26 @@ export class MyInfoServiceClass { myInfoData: MyInfoData | SGIDMyInfoData, currFormFields: LeanDocument, ): ResultAsync { + const allChildAttrs: InternalAttr[] = [] const prefilledFields = currFormFields.map((field) => { - if (!field.myInfo?.attr) return field + const myInfoAttr = getMyInfoAttr(field) + // Children field prefilling is handled by the frontend. + if (isMyInfoChildrenBirthRecords(field.myInfo?.attr)) { + // Compound field, explode subfields. + allChildAttrs.push(...(myInfoAttr as InternalAttr[])) + // This compound field is responsible for its own filling. + return field + } - const myInfoAttr = field.myInfo.attr - const { fieldValue, isReadOnly } = - myInfoData.getFieldValueForAttr(myInfoAttr) + if (myInfoAttr === undefined) { + return field + } + + const { fieldValue, isReadOnly } = myInfoData.getFieldValueForAttr( + myInfoAttr as InternalAttr, + ) const prefilledField = cloneDeep(field) as PossiblyPrefilledField prefilledField.fieldValue = fieldValue - // Disable field prefilledField.disabled = isReadOnly return prefilledField @@ -262,6 +280,9 @@ export class MyInfoServiceClass { myInfoData.getUinFin(), formId, prefilledFields, + myInfoData instanceof MyInfoData + ? myInfoData.getChildrenBirthRecords(allChildAttrs) + : undefined, ).map(() => prefilledFields) } @@ -277,8 +298,12 @@ export class MyInfoServiceClass { uinFin: string, formId: string, prefilledFormFields: PossiblyPrefilledField[], + childrenBirthRecords?: MyInfoChildData, ): ResultAsync { - const readOnlyHashPromises = hashFieldValues(prefilledFormFields) + const readOnlyHashPromises = hashFieldValues( + prefilledFormFields, + childrenBirthRecords, + ) return ResultAsync.fromPromise( Bluebird.props(readOnlyHashPromises), (error) => { @@ -367,7 +392,10 @@ export class MyInfoServiceClass { checkMyInfoHashes( responses: ProcessedFieldResponse[], hashes: IHashes, - ): ResultAsync, MyInfoHashingError | MyInfoHashDidNotMatchError> { + ): ResultAsync< + Set, + MyInfoHashingError | MyInfoHashDidNotMatchError + > { const comparisonPromises = compareHashedValues(responses, hashes) return ResultAsync.fromPromise( Bluebird.props(comparisonPromises), diff --git a/src/app/modules/myinfo/myinfo.types.ts b/src/app/modules/myinfo/myinfo.types.ts index fc4490538f..17938972dc 100644 --- a/src/app/modules/myinfo/myinfo.types.ts +++ b/src/app/modules/myinfo/myinfo.types.ts @@ -1,4 +1,8 @@ -import { FormAuthType, MyInfoAttribute } from '../../../../shared/types' +import { + FormAuthType, + MyInfoAttribute, + MyInfoChildAttributes, +} from '../../../../shared/types' import { Environment, IFormSchema, IMyInfo } from '../../../types' import { ISpcpMyInfo } from '../../config/features/spcp-myinfo.config' import { ProcessedFieldResponse } from '../submission/submission.types' @@ -16,8 +20,15 @@ export interface IMyInfoRedirectURLArgs { encodedQuery?: string } +// Field ID or a special key for a Child +export type MyInfoKey = string | MyInfoChildKey + +// Field type, field ID, child attribute type, child index, child name +export type MyInfoChildKey = + `${MyInfoAttribute.ChildrenBirthRecords}.${string}.${MyInfoChildAttributes}.${number}.${string}` + export type MyInfoHashPromises = Partial< - Record> + Record> > export type VisibleMyInfoResponse = ProcessedFieldResponse & { @@ -26,7 +37,10 @@ export type VisibleMyInfoResponse = ProcessedFieldResponse & { answer: string } -export type MyInfoComparePromises = Map> +export type MyInfoComparePromises = Map< + string | MyInfoChildKey, + Promise +> export type MyInfoLoginCookiePayload = { uinFin: string diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 6d43c31a23..a37b2e8d2a 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -2,14 +2,24 @@ import bcrypt from 'bcrypt' import { StatusCodes } from 'http-status-codes' import jwt from 'jsonwebtoken' import moment from 'moment' -import mongoose from 'mongoose' +import mongoose, { LeanDocument } from 'mongoose' import { err, ok, Result } from 'neverthrow' import { v4 as uuidv4, validate as validateUUID } from 'uuid' import { types as myInfoTypes } from '../../../../shared/constants/field/myinfo' -import { BasicField, FormAuthType } from '../../../../shared/types' +import { + BasicField, + ChildrenCompoundFieldBase, + FormAuthType, + MyInfoAttribute as InternalAttr, + MyInfoAttribute, + MyInfoChildAttributes, + MyInfoChildData, + MyInfoChildVaxxStatus, +} from '../../../../shared/types' import { hasProp } from '../../../../shared/utils/has-prop' import { + IFieldSchema, IFormSchema, IHashes, IMyInfo, @@ -25,7 +35,10 @@ import { FormNotFoundError, } from '../form/form.errors' import { SGID_MYINFO_LOGIN_COOKIE_NAME } from '../sgid/sgid.constants' -import { ProcessedFieldResponse } from '../submission/submission.types' +import { + ProcessedChildrenResponse, + ProcessedFieldResponse, +} from '../submission/submission.types' import { MYINFO_LOGIN_COOKIE_NAME } from './myinfo.constants' import { @@ -39,6 +52,7 @@ import { import { MyInfoAuthCodeCookiePayload, MyInfoAuthCodeCookieState, + MyInfoChildKey, MyInfoComparePromises, MyInfoForm, MyInfoHashPromises, @@ -50,6 +64,41 @@ import { const logger = createLoggerWithLabel(module) const HASH_SALT_ROUNDS = 1 +/** + * See hashFieldValues for usage. + * + * @param field + * @param childrenBirthRecords + * @param readOnlyHashPromises + */ +function hashChildrenFieldValues( + field: PossiblyPrefilledField, + childrenBirthRecords: MyInfoChildData, + readOnlyHashPromises: MyInfoHashPromises, +) { + const subFields = getMyInfoAttr(field) as MyInfoChildAttributes[] + subFields.forEach((subField) => { + const fieldArr = childrenBirthRecords[subField] + fieldArr?.forEach((value, childIdx) => { + const childName = + childrenBirthRecords?.[MyInfoChildAttributes.ChildName]?.[childIdx] + if (childName === undefined) { + return + } + // Skip all unknown vaccination statuses, let the user fill it in themselves. + if ( + subField === MyInfoChildAttributes.ChildVaxxStatus && + value === MyInfoChildVaxxStatus.Unknown + ) { + return + } + readOnlyHashPromises[ + getMyInfoChildHashKey(field._id, subField, childIdx, childName) + ] = bcrypt.hash(value, HASH_SALT_ROUNDS) + }) + }) +} + /** * Hashes field values which are prefilled and MyInfo-verified. * @param prefilledFormFields Fields with fieldValue prefilled using MyInfo and disabled @@ -58,16 +107,26 @@ const HASH_SALT_ROUNDS = 1 */ export const hashFieldValues = ( prefilledFormFields: PossiblyPrefilledField[], + childrenBirthRecords?: MyInfoChildData, ): MyInfoHashPromises => { const readOnlyHashPromises: MyInfoHashPromises = {} prefilledFormFields.forEach((field) => { + // For children fields, we need to explode their subfields. + if ( + isMyInfoChildrenBirthRecords(field.myInfo?.attr) && + childrenBirthRecords !== undefined + ) { + hashChildrenFieldValues(field, childrenBirthRecords, readOnlyHashPromises) + return + } if (!field.myInfo?.attr || !field.fieldValue || !field.disabled) return readOnlyHashPromises[field.myInfo.attr] = bcrypt.hash( field.fieldValue.toString(), HASH_SALT_ROUNDS, ) }) + return readOnlyHashPromises } @@ -109,6 +168,12 @@ export const compareHashedValues = ( const myInfoResponsesMap: MyInfoComparePromises = new Map() responses.forEach((field) => { if (hasMyInfoAnswer(field)) { + // Children birth records have multiple possible hash values so they + // need to be checked one by one. + if (field.myInfo.attr === MyInfoAttribute.ChildrenBirthRecords) { + handleMyInfoChildHashResponse(field, hashes, myInfoResponsesMap) + return + } const hash = hashes[field.myInfo.attr] if (hash) { myInfoResponsesMap.set(field._id, compareSingleHash(hash, field)) @@ -360,3 +425,91 @@ export const isMyInfoRelayState = (obj: unknown): obj is MyInfoRelayState => validateUUID(obj.uuid) && ((hasProp(obj, 'encodedQuery') && typeof obj.encodedQuery === 'string') || !hasProp(obj, 'encodedQuery')) + +const MyInfoChildAttributeSet = new Set(Object.values(MyInfoChildAttributes)) + +export const isMyInfoChildrenBirthRecords = ( + attr: InternalAttr | undefined, +): boolean => { + return ( + attr === InternalAttr.ChildrenBirthRecords || + MyInfoChildAttributeSet.has(attr as unknown as MyInfoChildAttributes) + ) +} + +/** + * Helper to access a MyInfo attribute from a field. + * + * This helps to explode compound fields as well into its constituent subfields. + * @param field The field we want to access. + * @returns Either the MyInfoAttribute, or an array of MyInfoAttribute, or not found. + */ +export const getMyInfoAttr = ( + field: IFieldSchema | LeanDocument, +): string | string[] | undefined => { + // Need to explode compound field. + if (field.myInfo?.attr === MyInfoAttribute.ChildrenBirthRecords) { + return ( + (field as ChildrenCompoundFieldBase).childrenSubFields ?? ([] as string[]) + ) + } + return field.myInfo?.attr +} + +/** + * Helper function to get a MyInfo child's hash key inside an IHashes. + * + * @param fieldId The ID of the field the Child response belongs to. + * @param childIdx The nth child to look for. + * @returns An IHashes-compatible key. + */ +export const getMyInfoChildHashKey = ( + fieldId: string, + childAttr: MyInfoChildAttributes, + childIdx: number, + childName: string, +): MyInfoChildKey => { + return `${MyInfoAttribute.ChildrenBirthRecords}.${fieldId}.${childAttr}.${childIdx}.${childName}` +} + +/** + * This function is responsible for checking the validity of hashes of + * MyInfo Child fields. + * + * NOTE: if the hashes comparison fail, it assumes that it's a manually user + * inputted child. As such, it will just not indicate in the response + * that it is MyInfo verified. + * @param field the processed response + * @param hashes a map containing all the attributes mapped to hashes + * @param myInfoResponsesMap the response to give to the user + */ +export const handleMyInfoChildHashResponse = ( + field: ProcessedFieldResponse, + hashes: IHashes, + myInfoResponsesMap: MyInfoComparePromises, +) => { + const childField = field as ProcessedChildrenResponse + const subFields = childField.childSubFieldsArray + if (!subFields) { + return + } + childField.answerArray.forEach((childAnswer, childIndex) => { + // Name should be first field for child answers + const childName = childAnswer[0] + // Validate each answer (child) + childAnswer.forEach((attrAnswer, subFieldIndex) => { + const key = getMyInfoChildHashKey( + field._id as string, + subFields[subFieldIndex], + childIndex, + childName, + ) + const hash = hashes[key] + // Intentional, to allow user-filled fields to pass through. + if (hash) { + myInfoResponsesMap.set(key, bcrypt.compare(attrAnswer, hash)) + } + }) + }) + return +} diff --git a/src/app/modules/submission/email-submission/ParsedResponsesObject.class.ts b/src/app/modules/submission/email-submission/ParsedResponsesObject.class.ts index 8df76207be..9a12f8a8f9 100644 --- a/src/app/modules/submission/email-submission/ParsedResponsesObject.class.ts +++ b/src/app/modules/submission/email-submission/ParsedResponsesObject.class.ts @@ -1,6 +1,11 @@ import { err, ok, Result } from 'neverthrow' -import { FormAuthType, FormResponseMode } from '../../../../../shared/types' +import { + ChildrenCompoundFieldBase, + FormAuthType, + FormResponseMode, + MyInfoAttribute, +} from '../../../../../shared/types' import { FieldResponse, FormFieldSchema, @@ -21,7 +26,10 @@ import { ProcessingError, ValidateFieldError, } from '../submission.errors' -import { ProcessedFieldResponse } from '../submission.types' +import { + ProcessedChildrenResponse, + ProcessedFieldResponse, +} from '../submission.types' import { getFilteredResponses } from '../submission.utils' type NdiUserInfo = @@ -159,6 +167,14 @@ export default class ParsedResponsesObject { // Inject myinfo to response if field is a myinfo field for downstream processing. if (formField.myInfo?.attr) { processingResponse.myInfo = formField.myInfo + if (formField.myInfo.attr === MyInfoAttribute.ChildrenBirthRecords) { + // eslint and prettier fight over the next line. + // eslint-disable-next-line @typescript-eslint/no-extra-semi + ;( + processingResponse as ProcessedChildrenResponse + ).childSubFieldsArray = + (formField as ChildrenCompoundFieldBase).childrenSubFields ?? [] + } } // Error will be returned if the processed response is not valid. diff --git a/src/app/modules/submission/email-submission/email-submission.constants.ts b/src/app/modules/submission/email-submission/email-submission.constants.ts index 560b21ea17..470ee8513a 100644 --- a/src/app/modules/submission/email-submission/email-submission.constants.ts +++ b/src/app/modules/submission/email-submission/email-submission.constants.ts @@ -3,6 +3,7 @@ export const MYINFO_PREFIX = '[MyInfo] ' export const VERIFIED_PREFIX = '[verified] ' export const TABLE_PREFIX = '[table] ' export const ATTACHMENT_PREFIX = '[attachment] ' +export const CHILD_PREFIX = '[child]' // Parameters for hashing submissions export const SALT_LENGTH = 32 diff --git a/src/app/modules/submission/email-submission/email-submission.types.ts b/src/app/modules/submission/email-submission/email-submission.types.ts index b3fcfcf724..289cd7716e 100644 --- a/src/app/modules/submission/email-submission/email-submission.types.ts +++ b/src/app/modules/submission/email-submission/email-submission.types.ts @@ -1,6 +1,7 @@ import { ResponseMetadata } from 'shared/types' import { FieldResponse, IPopulatedEmailForm } from '../../../../types' +import { MyInfoKey } from '../../myinfo/myinfo.types' import { ProcessedResponse } from '../submission.types' import ParsedResponsesObject from './ParsedResponsesObject.class' @@ -24,5 +25,5 @@ export interface SubmissionHash { export interface IPopulatedEmailFormWithResponsesAndHash { form: IPopulatedEmailForm parsedResponses: ParsedResponsesObject - hashedFields?: Set + hashedFields?: Set } diff --git a/src/app/modules/submission/email-submission/email-submission.util.ts b/src/app/modules/submission/email-submission/email-submission.util.ts index 80f8bee957..b03bd409e8 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -1,7 +1,11 @@ import { StatusCodes } from 'http-status-codes' import { compact, flattenDeep, sumBy } from 'lodash' -import { BasicField, FormAuthType } from '../../../../../shared/types' +import { + BasicField, + FormAuthType, + MyInfoAttribute, +} from '../../../../../shared/types' import * as FileValidation from '../../../../../shared/utils/file-validation' import { EmailAdminDataField, @@ -34,6 +38,7 @@ import { } from '../../../services/turnstile/turnstile.errors' import { isProcessedCheckboxResponse, + isProcessedChildResponse, isProcessedTableResponse, } from '../../../utils/field-validation/field-validation.guards' import { @@ -56,6 +61,8 @@ import { MyInfoMissingHashError, MyInfoMissingLoginCookieError, } from '../../myinfo/myinfo.errors' +import { MyInfoKey } from '../../myinfo/myinfo.types' +import { getMyInfoChildHashKey } from '../../myinfo/myinfo.util' import { SgidInvalidJwtError, SgidMissingJwtError, @@ -75,12 +82,14 @@ import { } from '../submission.errors' import { ProcessedCheckboxResponse, + ProcessedChildrenResponse, ProcessedFieldResponse, ProcessedTableResponse, } from '../submission.types' import { ATTACHMENT_PREFIX, + CHILD_PREFIX, MYINFO_PREFIX, TABLE_PREFIX, VERIFIED_PREFIX, @@ -100,12 +109,12 @@ const logger = createLoggerWithLabel(module) * Determines the prefix for a question based on whether it is verified * by MyInfo. * @param response - * @param hashedFields Hash for verifying MyInfo fields + * @param hashedFields Field ids of hashed fields. * @returns the prefix */ const getMyInfoPrefix = ( response: ResponseFormattedForEmail, - hashedFields: Set, + hashedFields: Set, ): string => { return !!response.myInfo?.attr && hashedFields.has(response._id) ? MYINFO_PREFIX @@ -137,6 +146,8 @@ const getFieldTypePrefix = (response: ResponseFormattedForEmail): string => { return TABLE_PREFIX case BasicField.Attachment: return ATTACHMENT_PREFIX + case BasicField.Children: + return CHILD_PREFIX default: return '' } @@ -214,6 +225,35 @@ export const getAnswerForCheckbox = ( } } +export const getAnswersForChild = ( + response: ProcessedChildrenResponse, +): ResponseFormattedForEmail[] => { + const subFields = response.childSubFieldsArray + if (!subFields) { + return [] + } + return response.answerArray.flatMap((arr, childIdx) => { + // First array element is always child name + const childName = arr[0] + return arr.map((answer, idx) => ({ + _id: getMyInfoChildHashKey( + response._id, + subFields[idx], + childIdx, + childName, + ), + fieldType: response.fieldType, + question: `Child-${childIdx + 1}.${subFields[idx]}`, + myInfo: { + attr: subFields[idx] as unknown as MyInfoAttribute, + }, + isVisible: response.isVisible, + isUserVerified: response.isUserVerified, + answer, + })) + }) +} + /** * Formats the response for sending to the submitter (autoReplyData), * the table that is sent to the admin (formData), @@ -603,10 +643,10 @@ export const concatAttachmentsAndResponses = ( */ const createFormattedDataForOneField = ( response: ProcessedFieldResponse, - hashedFields: Set, + hashedFields: Set, getFormattedFunction: ( response: ResponseFormattedForEmail, - hashedFields: Set, + hashedFields: Set, ) => T, ): T[] => { if (isProcessedTableResponse(response)) { @@ -616,6 +656,10 @@ const createFormattedDataForOneField = ( } else if (isProcessedCheckboxResponse(response)) { const checkbox = getAnswerForCheckbox(response) return [getFormattedFunction(checkbox, hashedFields)] + } else if (isProcessedChildResponse(response)) { + return getAnswersForChild(response).map((childField) => + getFormattedFunction(childField, hashedFields), + ) } else { return [getFormattedFunction(response, hashedFields)] } @@ -694,7 +738,7 @@ const getDataCollationFormattedResponse = ( */ const getFormFormattedResponse = ( response: ResponseFormattedForEmail, - hashedFields: Set, + hashedFields: Set, ): EmailAdminDataField => { const { answer, fieldType } = response const answerSplitByNewLine = answer.split('\n') @@ -727,12 +771,12 @@ const getAutoReplyFormattedResponse = ( export class SubmissionEmailObj { parsedResponses: ProcessedFieldResponse[] - hashedFields: Set + hashedFields: Set authType: FormAuthType constructor( parsedResponses: ProcessedFieldResponse[], - hashedFields: Set = new Set(), + hashedFields: Set = new Set(), authType: FormAuthType, ) { this.parsedResponses = parsedResponses diff --git a/src/app/modules/submission/submission.types.ts b/src/app/modules/submission/submission.types.ts index f274c7e9d6..6fd5e44050 100644 --- a/src/app/modules/submission/submission.types.ts +++ b/src/app/modules/submission/submission.types.ts @@ -3,6 +3,8 @@ import type { Opaque } from 'type-fest' import { BasicField, CheckboxResponse, + ChildBirthRecordsResponse, + MyInfoChildAttributes, TableResponse, } from '../../../../shared/types' import { @@ -56,6 +58,8 @@ export type ProcessedSingleAnswerResponse< export type ProcessedCheckboxResponse = CheckboxResponse & ProcessedResponse export type ProcessedTableResponse = TableResponse & ProcessedResponse +export type ProcessedChildrenResponse = ChildBirthRecordsResponse & + ProcessedResponse & { childSubFieldsArray?: MyInfoChildAttributes[] } /** * Can be either email or storage mode attachment response. * Email mode attachment response in the server will have extra metadata injected @@ -73,3 +77,4 @@ export type ProcessedFieldResponse = | ProcessedCheckboxResponse | ProcessedTableResponse | ProcessedAttachmentResponse + | ProcessedChildrenResponse diff --git a/src/app/utils/field-validation/answerValidator.factory.ts b/src/app/utils/field-validation/answerValidator.factory.ts index 827594c1c4..ff8497722b 100644 --- a/src/app/utils/field-validation/answerValidator.factory.ts +++ b/src/app/utils/field-validation/answerValidator.factory.ts @@ -6,12 +6,14 @@ import { ResponseValidator } from '../../../types/field/utils/validation' import { ProcessedAttachmentResponse, ProcessedCheckboxResponse, + ProcessedChildrenResponse, ProcessedSingleAnswerResponse, ProcessedTableResponse, } from '../../modules/submission/submission.types' import { constructAttachmentValidator } from './validators/attachmentValidator' import { constructCheckboxValidator } from './validators/checkboxValidator' +import { constructChildrenValidator } from './validators/childrenValidator' import { constructDateValidator } from './validators/dateValidator' import { constructDecimalValidator } from './validators/decimalValidator' import { constructDropdownValidator } from './validators/dropdownValidator' @@ -90,6 +92,15 @@ export const constructCheckboxFieldValidator = ( return () => left('Unsupported field type') } +export const constructChildFieldValidator = ( + formField: FieldValidationSchema, +): ResponseValidator => { + if (formField.fieldType === BasicField.Children) { + return constructChildrenValidator(formField) + } + return () => left('Unsupported field type') +} + export const constructTableFieldValidator = ( formField: FieldValidationSchema, ): ResponseValidator => { diff --git a/src/app/utils/field-validation/field-validation.guards.ts b/src/app/utils/field-validation/field-validation.guards.ts index 380e0b2afe..376189c935 100644 --- a/src/app/utils/field-validation/field-validation.guards.ts +++ b/src/app/utils/field-validation/field-validation.guards.ts @@ -7,6 +7,7 @@ import { ColumnResponse, ProcessedAttachmentResponse, ProcessedCheckboxResponse, + ProcessedChildrenResponse, ProcessedFieldResponse, ProcessedSingleAnswerResponse, ProcessedTableResponse, @@ -36,6 +37,18 @@ export const isProcessedCheckboxResponse = ( ) } +export const isProcessedChildResponse = ( + response: ProcessedFieldResponse, +): response is ProcessedChildrenResponse => { + return ( + response.fieldType === BasicField.Children && + 'answerArray' in response && + response.answerArray.every((subArr) => isStringArray(subArr)) && + 'childSubFieldsArray' in response && + isStringArray(response.childSubFieldsArray) + ) +} + const isStringArray = (arr: unknown): arr is string[] => Array.isArray(arr) && arr.every((item) => typeof item === 'string') diff --git a/src/app/utils/field-validation/index.ts b/src/app/utils/field-validation/index.ts index 391f3722d0..56262edb88 100644 --- a/src/app/utils/field-validation/index.ts +++ b/src/app/utils/field-validation/index.ts @@ -6,6 +6,7 @@ import { BasicField } from '../../../../shared/types' import { ProcessedAttachmentResponse, ProcessedCheckboxResponse, + ProcessedChildrenResponse, ProcessedFieldResponse, ProcessedSingleAnswerResponse, ProcessedTableResponse, @@ -22,12 +23,14 @@ import { ValidateFieldError } from '../../modules/submission/submission.errors' import { constructAttachmentFieldValidator, constructCheckboxFieldValidator, + constructChildFieldValidator, constructSingleAnswerValidator, constructTableFieldValidator, } from './answerValidator.factory' import { isProcessedAttachmentResponse, isProcessedCheckboxResponse, + isProcessedChildResponse, isProcessedSingleAnswerResponse, isProcessedTableResponse, } from './field-validation.guards' @@ -115,6 +118,14 @@ const checkboxRequiresValidation = ( ) => (formField.required && response.isVisible) || response.answerArray.length > 0 +const childrenRequiresValidation = ( + formField: FieldValidationSchema, + response: ProcessedChildrenResponse, +) => + (formField.required && response.isVisible) || + response.answerArray.length > 0 || + (response?.childSubFieldsArray?.length ?? -1) > 0 + const tableRequiresValidation = ( formField: OmitUnusedValidatorProps, response: ProcessedTableResponse, @@ -231,6 +242,16 @@ export const validateField = ( response, ) } + } else if (isProcessedChildResponse(response)) { + if (childrenRequiresValidation(formField, response)) { + const validator = constructChildFieldValidator(formField) + return validateResponseWithValidator( + validator, + formId, + formField, + response, + ) + } } else if ( isProcessedTableResponse(response) && formField.fieldType === BasicField.Table diff --git a/src/app/utils/field-validation/validators/childrenValidator.ts b/src/app/utils/field-validation/validators/childrenValidator.ts new file mode 100644 index 0000000000..c78ef6f49f --- /dev/null +++ b/src/app/utils/field-validation/validators/childrenValidator.ts @@ -0,0 +1,142 @@ +import { chain, left, right } from 'fp-ts/lib/Either' +import { flow } from 'fp-ts/lib/function' + +import { + ChildrenCompoundFieldBase, + MyInfoChildAttributes, +} from '../../../../../shared/types' +import { + IChildrenCompoundFieldSchema, + OmitUnusedValidatorProps, +} from '../../../../types/field' +import { ResponseValidator } from '../../../../types/field/utils/validation' +import { ProcessedChildrenResponse } from '../../../modules/submission/submission.types' + +type ChildrenValidator = ResponseValidator +type ChildrenValidatorConstructor = ( + checkboxField: OmitUnusedValidatorProps, +) => ChildrenValidator + +/** + * Returns a validator to check if answerArray is empty + */ +const childrenAnswerValidator: ChildrenValidator = (response) => { + const { answerArray } = response + + return answerArray.length === 0 + ? left(`ChildrenValidator:\t Answer is empty array`) + : right(response) +} + +/** + * Returns a validation function to check if the + * that the first answer subarray has length > 0. + */ +const validChildAnswerFirstArray: ChildrenValidator = (response) => { + const { answerArray } = response + const first = answerArray[0] + return Array.isArray(first) && first.length > 0 + ? right(response) + : left(`ChildrenValidator:\t first subarray length is invalid`) +} + +/** + * Returns a validation function to check if the + * answerArray subarrays are all equal length. + */ +const validChildAnswerConsistency: ChildrenValidator = (response) => { + const { answerArray } = response + const len = answerArray[0].length + return answerArray.every((subArr) => subArr.length === len) + ? right(response) + : left(`ChildrenValidator:\t inconsistent answer array subarrays`) +} + +/** + * Returns a validation function to check if + * all the answers are non-empty. + */ +const validChildAnswersNonEmpty: ChildrenValidator = (response) => { + const { answerArray } = response + return answerArray.every((subArr) => + subArr.every((val) => typeof val === 'string' && !!val.trim()), + ) + ? right(response) + : left(`ChildrenValidator:\t inconsistent answer array subarrays`) +} + +/** + * Returns a validation function to check if the + * answerArray and subFields array are equal length. + */ +const validChildAnswerAndSubFields: ChildrenValidator = (response) => { + const { childSubFieldsArray, answerArray } = response + + return childSubFieldsArray?.length === answerArray[0].length + ? right(response) + : left( + `ChildrenValidator:\t inconsistent child subfield and answer array length`, + ) +} + +/** + * Returns a validation function to check if there are + * invalid subfields given for children. + */ +const validChildSubFieldsValidator: ChildrenValidatorConstructor = + (childrenField) => (response) => { + const { childrenSubFields } = childrenField + + const attrs = new Set(Object.values(MyInfoChildAttributes)) + return childrenSubFields.every((subfield) => attrs.has(subfield)) + ? right(response) + : left(`ChildrenValidator:\t one or more subfields are invalid`) + } + +/** + * Returns a validation function to check if there are + * invalid subfields given for children. + */ +const validChildSubFieldsResponseValidator: ChildrenValidator = (response) => { + const { childSubFieldsArray } = response + + const attrs = new Set(Object.values(MyInfoChildAttributes)) + return childSubFieldsArray?.every((subfield) => attrs.has(subfield)) + ? right(response) + : left(`ChildrenValidator:\t one or more subfields responses are invalid`) +} + +/** + * Returns a validation function to check if the subfields + * for both the response and the field itself match. + */ +const validChildSubFieldsAndResponseSubFieldsMatch: ChildrenValidatorConstructor = + (childrenField) => (response) => { + const { childrenSubFields } = childrenField as ChildrenCompoundFieldBase + const { childSubFieldsArray } = response + + return childSubFieldsArray?.every( + (subfield, i) => (childrenSubFields ?? [])[i] === subfield, + ) + ? right(response) + : left( + `ChildrenValidator:\t one or more subfields responses do not match the field's`, + ) + } + +/** + * Returns a validation function for a children field when called. + */ +export const constructChildrenValidator: ChildrenValidatorConstructor = ( + childrenField, +) => + flow( + childrenAnswerValidator, + chain(validChildAnswerFirstArray), + chain(validChildAnswerConsistency), + chain(validChildAnswersNonEmpty), + chain(validChildAnswerAndSubFields), + chain(validChildSubFieldsValidator(childrenField)), + chain(validChildSubFieldsAndResponseSubFieldsMatch(childrenField)), + chain(validChildSubFieldsResponseValidator), + ) diff --git a/src/types/field/childrenCompoundField.ts b/src/types/field/childrenCompoundField.ts new file mode 100644 index 0000000000..53a380b12e --- /dev/null +++ b/src/types/field/childrenCompoundField.ts @@ -0,0 +1,15 @@ +import { + BasicField, + ChildrenCompoundFieldBase, + MyInfoChildAttributes, +} from '../../../shared/types' + +import { IFieldSchema } from './baseField' + +export interface IChildrenCompoundFieldSchema + extends ChildrenCompoundFieldBase, + IFieldSchema { + fieldType: BasicField.Children + childrenSubFields: MyInfoChildAttributes[] + allowMultiple: boolean +} diff --git a/src/types/field/index.ts b/src/types/field/index.ts index 4058fcef5b..b68bb2c1b1 100644 --- a/src/types/field/index.ts +++ b/src/types/field/index.ts @@ -3,6 +3,7 @@ import type { ConditionalExcept, Merge } from 'type-fest' import type { IAttachmentFieldSchema } from './attachmentField' import type { ICheckboxFieldSchema } from './checkboxField' +import type { IChildrenCompoundFieldSchema } from './childrenCompoundField' import type { IDateFieldSchema } from './dateField' import type { IDecimalFieldSchema } from './decimalField' import type { IDropdownFieldSchema } from './dropdownField' @@ -44,6 +45,7 @@ export * from './statementField' export * from './tableField' export * from './uenField' export * from './yesNoField' +export * from './childrenCompoundField' export enum SPCPFieldTitle { SpNric = 'SingPass Validated NRIC', @@ -76,6 +78,7 @@ export type FormFieldSchema = | ITableFieldSchema | IUenFieldSchema | IYesNoFieldSchema + | IChildrenCompoundFieldSchema /** * Helper type to only retain from FormFieldSchema the props required to create @@ -116,3 +119,4 @@ export type FieldValidationSchema = | OmitUnusedValidatorProps | OmitUnusedValidatorProps | OmitUnusedValidatorProps + | OmitUnusedValidatorProps diff --git a/src/types/myinfo_hash.ts b/src/types/myinfo_hash.ts index d4cdc18880..4d095e00be 100644 --- a/src/types/myinfo_hash.ts +++ b/src/types/myinfo_hash.ts @@ -1,10 +1,13 @@ import { Document, Model } from 'mongoose' import { MyInfoAttribute } from '../../shared/types' +import { MyInfoChildKey } from '../app/modules/myinfo/myinfo.types' import { IFormSchema } from './form' -export type IHashes = Partial<{ [key in MyInfoAttribute]: string }> +export type IHashes = Partial<{ + [key in MyInfoAttribute | MyInfoChildKey]: string +}> interface IMyInfoHash { uinFin: string diff --git a/src/types/response/index.ts b/src/types/response/index.ts index a1c9cab79d..dd6607b200 100644 --- a/src/types/response/index.ts +++ b/src/types/response/index.ts @@ -1,4 +1,8 @@ -import { CheckboxResponse, TableResponse } from '../../../shared/types' +import { + CheckboxResponse, + ChildBirthRecordsResponse, + TableResponse, +} from '../../../shared/types' import { EncryptAttachmentResponse, EncryptFormFieldResponse, @@ -13,11 +17,17 @@ export type IAttachmentResponse = export type SingleAnswerFieldResponse = | Exclude< EncryptFormFieldResponse, - TableResponse | CheckboxResponse | IAttachmentResponse + | TableResponse + | CheckboxResponse + | IAttachmentResponse + | ChildBirthRecordsResponse > | Exclude< ParsedEmailFormFieldResponse, - TableResponse | CheckboxResponse | IAttachmentResponse + | TableResponse + | CheckboxResponse + | IAttachmentResponse + | ChildBirthRecordsResponse > export type FieldResponse = From f748008dde2b82e20e3c3a7fa0d8109adfedea72 Mon Sep 17 00:00:00 2001 From: wanlingt <56983748+wanlingt@users.noreply.github.com> Date: Thu, 20 Jul 2023 08:02:37 +0800 Subject: [PATCH 3/5] fix: change instances of invoice to proof of payment (#6553) fix: change instances of invoice to proof of payment" --- .../src/features/admin-form/create/end-page/EndPageDrawer.tsx | 2 +- .../FormPaymentPage/stripe/StripeReceiptContainer.tsx | 2 +- .../FormPaymentPage/stripe/components/DownloadReceiptBlock.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/admin-form/create/end-page/EndPageDrawer.tsx b/frontend/src/features/admin-form/create/end-page/EndPageDrawer.tsx index da3159b37d..d1e330398f 100644 --- a/frontend/src/features/admin-form/create/end-page/EndPageDrawer.tsx +++ b/frontend/src/features/admin-form/create/end-page/EndPageDrawer.tsx @@ -80,7 +80,7 @@ export const EndPageInput = ({ const paymentDefaults = { title: 'Your payment has been made successfully.', paragraph: 'Your form has been submitted and payment has been made.', - buttonLink: 'Default invoice link', + buttonLink: 'Default proof of payment link', buttonText: 'Save proof of payment', } diff --git a/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripeReceiptContainer.tsx b/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripeReceiptContainer.tsx index b0f6341101..aec2b5a7fa 100644 --- a/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripeReceiptContainer.tsx +++ b/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripeReceiptContainer.tsx @@ -55,7 +55,7 @@ export const StripeReceiptContainer = ({ diff --git a/frontend/src/features/public-form/components/FormPaymentPage/stripe/components/DownloadReceiptBlock.tsx b/frontend/src/features/public-form/components/FormPaymentPage/stripe/components/DownloadReceiptBlock.tsx index 8e7cd0cd01..0304ac64f8 100644 --- a/frontend/src/features/public-form/components/FormPaymentPage/stripe/components/DownloadReceiptBlock.tsx +++ b/frontend/src/features/public-form/components/FormPaymentPage/stripe/components/DownloadReceiptBlock.tsx @@ -32,7 +32,7 @@ export const DownloadReceiptBlock = ({ const handleInvoiceClick = () => { toast({ - description: 'Invoice download started', + description: 'Proof of payment download started', }) window.location.href = getPaymentInvoiceDownloadUrl(formId, paymentId) } From b762985c839f165c6e6da2893ba66e4db37e73f4 Mon Sep 17 00:00:00 2001 From: Justyn Oh Date: Thu, 20 Jul 2023 13:43:42 +0800 Subject: [PATCH 4/5] feat: experimental login integration with SGID (#6552) * feat: initial integration * feat: use actual user work email scope * chore: update scope name * fix: use frontend redirect for login flow * fix: prevent useEffect re-renders * fix: add back data argument * fix: redirect to backend route flow, UI and code cleanup * test: update test env * fix: add null nonce explicitly * test: update sgid service tests * fix: handle error cases by passing status via params * test: update sgid tests * fix: reduce query params sent to redirected frontend * chore: fix nits * chore: revert packages * test: fix sgid tests * chore: remove toast message for 200s * fix: change frontend route to /ogp-login * chore: fix nits --- __tests__/setup/.test-env | 10 +- docker-compose.yml | 3 +- frontend/src/app/AppRouter.tsx | 6 + frontend/src/constants/routes.ts | 1 + frontend/src/features/login/LoginPage.tsx | 180 ++---------------- .../src/features/login/LoginPageTemplate.tsx | 170 +++++++++++++++++ frontend/src/features/login/SgidLoginPage.tsx | 63 ++++++ frontend/src/features/login/index.ts | 1 + frontend/src/services/AuthService.ts | 17 +- shared/types/user.ts | 16 +- src/app/config/features/sgid.config.ts | 12 +- .../modules/auth/sgid/auth-sgid.controller.ts | 114 +++++++++++ .../modules/auth/sgid/auth-sgid.service.ts | 122 ++++++++++++ .../sgid/__tests__/sgid.service.spec.ts | 13 +- .../sgid/__tests__/sgid.test.constants.ts | 6 +- src/app/modules/sgid/sgid.service.ts | 9 +- .../routes/api/v3/auth/auth-sgid.routes.ts | 9 + src/app/routes/api/v3/auth/auth.routes.ts | 4 + src/types/config.ts | 3 +- 19 files changed, 564 insertions(+), 195 deletions(-) create mode 100644 frontend/src/features/login/LoginPageTemplate.tsx create mode 100644 frontend/src/features/login/SgidLoginPage.tsx create mode 100644 src/app/modules/auth/sgid/auth-sgid.controller.ts create mode 100644 src/app/modules/auth/sgid/auth-sgid.service.ts create mode 100644 src/app/routes/api/v3/auth/auth-sgid.routes.ts diff --git a/__tests__/setup/.test-env b/__tests__/setup/.test-env index 3a7207e70b..9c0e6ebdbd 100644 --- a/__tests__/setup/.test-env +++ b/__tests__/setup/.test-env @@ -24,7 +24,8 @@ SINGPASS_ESRVC_ID=Test-eServiceId-Sp # Needed for MyInfo SGID_HOSTNAME=http://localhost:5156/sgid SGID_CLIENT_ID=sgidclientid SGID_CLIENT_SECRET=sgidclientsecret -SGID_REDIRECT_URI=http://localhost:5000/sgid/login +SGID_FORM_LOGIN_REDIRECT_URI=http://localhost:5000/sgid/login +SGID_ADMIN_LOGIN_REDIRECT_URI=http://localhost:5000/api/v3/auth/sgid/login SGID_PRIVATE_KEY=./node_modules/@opengovsg/mockpass/static/certs/key.pem SGID_PUBLIC_KEY=./node_modules/@opengovsg/mockpass/static/certs/server.crt @@ -74,13 +75,6 @@ APP_URL=http://localhost:5000 SECRET_ENV=development INTRANET_IP_LIST_PATH=__tests__/setup/mock-intranet-ips.txt -REACT_SWITCH_ENV_FEEDBACK_FORM_ID_ADMIN=62da6a569ee8e90143b5da26 -REACT_SWITCH_ENV_FEEDBACK_FORM_ID_RESPONDENT=62da6a569ee8e90143b5da26 -REACT_MIGRATION_ANGULAR_END_DATE=15 September 2022 -REACT_MIGRATION_RESP_ROLLOUT_EMAIL=100 -REACT_MIGRATION_RESP_ROLLOUT_STORAGE=100 -REACT_MIGRATION_ADMIN_ROLLOUT=100 - # Mockpass env vars MOCKPASS_NRIC=S9812379B MOCKPASS_UID=S8979373D # Not used by mockpass but keep in sync with MOCKPASS_UEN for Corppass tests diff --git a/docker-compose.yml b/docker-compose.yml index c2c0a8ae99..a4c068e345 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,8 @@ services: - SGID_HOSTNAME=http://localhost:5156/sgid - SGID_CLIENT_ID=sgidclientid - SGID_CLIENT_SECRET=sgidclientsecret - - SGID_REDIRECT_URI=http://localhost:5001/sgid/login + - SGID_ADMIN_LOGIN_REDIRECT_URI=http://localhost:5001/api/v3/auth/sgid/login + - SGID_FORM_LOGIN_REDIRECT_URI=http://localhost:5001/sgid/login - SGID_PRIVATE_KEY=./node_modules/@opengovsg/mockpass/static/certs/key.pem - SGID_PUBLIC_KEY=./node_modules/@opengovsg/mockpass/static/certs/server.crt - SES_PASS diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 4a14d53f0a..b34717e646 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -13,6 +13,7 @@ import { LANDING_PAYMENTS_ROUTE, LANDING_ROUTE, LOGIN_ROUTE, + OGP_LOGIN_ROUTE, PAYMENT_PAGE_SUBROUTE, PRIVACY_POLICY_ROUTE, PUBLICFORM_ROUTE, @@ -34,6 +35,7 @@ import { ResponsesPage, } from '~features/admin-form/responses' import { SettingsPage } from '~features/admin-form/settings/SettingsPage' +import { SgidLoginPage } from '~features/login' import { FormPaymentPage } from '~features/public-form/components/FormPaymentPage/FormPaymentPage' import { BillingPage } from '~features/user/billing' @@ -88,6 +90,10 @@ export const AppRouter = (): JSX.Element => { path={LOGIN_ROUTE} element={} />} /> + } />} + /> } />} diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index c50f167292..7c56f70e51 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -2,6 +2,7 @@ export const LANDING_ROUTE = '/' export const LANDING_PAYMENTS_ROUTE = '/payments' export const DASHBOARD_ROUTE = '/dashboard' export const LOGIN_ROUTE = '/login' +export const OGP_LOGIN_ROUTE = '/ogp-login' export const TOU_ROUTE = '/terms' export const PRIVACY_POLICY_ROUTE = '/privacy' diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index 70f6752e56..30f7a1c0e8 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -1,118 +1,26 @@ -import { FC, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Link as ReactLink } from 'react-router-dom' -import { Box, chakra, Flex, GridItem, GridProps, Text } from '@chakra-ui/react' +import { useState } from 'react' -import { AppFooter } from '~/app/AppFooter' - -import { ReactComponent as BrandLogoSvg } from '~assets/svgs/brand/brand-hort-colour.svg' import { LOGGED_IN_KEY } from '~constants/localStorage' -import { LANDING_PAYMENTS_ROUTE, LANDING_ROUTE } from '~constants/routes' import { useLocalStorage } from '~hooks/useLocalStorage' -import { getBannerProps } from '~utils/getBannerProps' import { sendLoginOtp, verifyLoginOtp } from '~services/AuthService' -import { Banner } from '~components/Banner' -import { FeatureBanner } from '~components/FeatureBanner/FeatureBanner' -import Link from '~components/Link' -import { AppGrid } from '~templates/AppGrid' import { trackAdminLogin, trackAdminLoginFailure, } from '~features/analytics/AnalyticsService' -import { useEnv } from '~features/env/queries' import { LoginForm, LoginFormInputs } from './components/LoginForm' -import { LoginImageSvgr } from './components/LoginImageSvgr' import { OtpForm, OtpFormInputs } from './components/OtpForm' +import { LoginPageTemplate } from './LoginPageTemplate' export type LoginOtpData = { email: string } -const BrandLogo = chakra(BrandLogoSvg, { - baseStyle: { - h: { base: '1.5rem', lg: '2rem' }, - }, -}) - -// Component for the split blue/white background. -const BackgroundBox: FC = ({ children }) => ( - -) - -// Component that controls the various grid areas according to responsive breakpoints. -const BaseGridLayout = (props: GridProps) => ( - -) - -// Grid area styling for the login form. -const LoginGridArea: FC = ({ children }) => ( - -) - -// Grid area styling for the footer. -const FooterGridArea: FC = ({ children }) => ( - -) - -// Grid area styling for the left sidebar that only displays on tablet and desktop breakpoints. -const NonMobileSidebarGridArea: FC = ({ children }) => ( - -) - export const LoginPage = (): JSX.Element => { - const { data: { siteBannerContent, isLoginBanner } = {} } = useEnv() const [, setIsAuthenticated] = useLocalStorage(LOGGED_IN_KEY) const [email, setEmail] = useState() const [otpPrefix, setOtpPrefix] = useState('') - const { t } = useTranslation() - - const bannerContent = useMemo( - // Use || instead of ?? so that we fall through even if previous banners are empty string. - () => siteBannerContent || isLoginBanner, - [siteBannerContent, isLoginBanner], - ) - - const bannerProps = useMemo( - () => getBannerProps(bannerContent), - [bannerContent], - ) const handleSendOtp = async ({ email }: LoginFormInputs) => { const trimmedEmail = email.trim() @@ -149,78 +57,18 @@ export const LoginPage = (): JSX.Element => { await sendLoginOtp(email).then(({ otpPrefix }) => setOtpPrefix(otpPrefix)) } - const bannerColorIntensity = 600 - const bannerColor = `primary.${bannerColorIntensity}` // So banner colors are different from the blue background (left of login screen). - return ( - - {bannerProps ? ( - - {bannerProps.msg} - - ) : null} - - - - - - - - - - {t('features.login.LoginPage.slogan')} - - - - - - - {t('features.login.LoginPage.slogan')} - - - - {!email ? ( - - ) : ( - - )} - - - - - - - - - + + {!email ? ( + + ) : ( + + )} + ) } diff --git a/frontend/src/features/login/LoginPageTemplate.tsx b/frontend/src/features/login/LoginPageTemplate.tsx new file mode 100644 index 0000000000..641e6a4eaa --- /dev/null +++ b/frontend/src/features/login/LoginPageTemplate.tsx @@ -0,0 +1,170 @@ +import { FC, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { Link as ReactLink } from 'react-router-dom' +import { Box, chakra, Flex, GridItem, GridProps, Text } from '@chakra-ui/react' + +import { AppFooter } from '~/app/AppFooter' + +import { ReactComponent as BrandLogoSvg } from '~assets/svgs/brand/brand-hort-colour.svg' +import { LANDING_PAYMENTS_ROUTE, LANDING_ROUTE } from '~constants/routes' +import { getBannerProps } from '~utils/getBannerProps' +import { Banner } from '~components/Banner' +import { FeatureBanner } from '~components/FeatureBanner/FeatureBanner' +import Link from '~components/Link' +import { AppGrid } from '~templates/AppGrid' + +import { useEnv } from '~features/env/queries' + +import { LoginImageSvgr } from './components/LoginImageSvgr' + +export type LoginOtpData = { + email: string +} + +export const BrandLogo = chakra(BrandLogoSvg, { + baseStyle: { + h: { base: '1.5rem', lg: '2rem' }, + }, +}) + +// Component for the split blue/white background. +export const BackgroundBox: FC = ({ children }) => ( + +) + +// Component that controls the various grid areas according to responsive breakpoints. +export const BaseGridLayout = (props: GridProps) => ( + +) + +// Grid area styling for the login form. +export const LoginGridArea: FC = ({ children }) => ( + +) + +// Grid area styling for the footer. +export const FooterGridArea: FC = ({ children }) => ( + +) + +// Grid area styling for the left sidebar that only displays on tablet and desktop breakpoints. +export const NonMobileSidebarGridArea: FC = ({ children }) => ( + +) + +export const LoginPageTemplate: FC = ({ children }) => { + const { data: { siteBannerContent, isLoginBanner } = {} } = useEnv() + const { t } = useTranslation() + + const bannerContent = useMemo( + // Use || instead of ?? so that we fall through even if previous banners are empty string. + () => siteBannerContent || isLoginBanner, + [siteBannerContent, isLoginBanner], + ) + + const bannerProps = useMemo( + () => getBannerProps(bannerContent), + [bannerContent], + ) + + const bannerColorIntensity = 600 + const bannerColor = `primary.${bannerColorIntensity}` // So banner colors are different from the blue background (left of login screen). + + return ( + + {bannerProps ? ( + + {bannerProps.msg} + + ) : null} + + + + + + + + + + {t('features.login.LoginPage.slogan')} + + + + + + + {t('features.login.LoginPage.slogan')} + + + + {children} + + + + + + + + + + ) +} diff --git a/frontend/src/features/login/SgidLoginPage.tsx b/frontend/src/features/login/SgidLoginPage.tsx new file mode 100644 index 0000000000..d1b4a97f37 --- /dev/null +++ b/frontend/src/features/login/SgidLoginPage.tsx @@ -0,0 +1,63 @@ +import { useEffect, useMemo } from 'react' +import { BiLogInCircle } from 'react-icons/bi' +import { useMutation } from 'react-query' +import { useSearchParams } from 'react-router-dom' + +import { DASHBOARD_ROUTE } from '~constants/routes' +import { useToast } from '~hooks/useToast' +import { getSgidAuthUrl } from '~services/AuthService' +import Button from '~components/Button' +import { InlineMessage } from '~components/InlineMessage/InlineMessage' + +import { useUser } from '~features/user/queries' + +import { LoginPageTemplate } from './LoginPageTemplate' + +export const SgidLoginPage = (): JSX.Element => { + const [params] = useSearchParams() + const toast = useToast({ isClosable: true, status: 'danger' }) + + const statusCode = params.get('status') + const toastMessage = useMemo(() => { + switch (statusCode) { + case null: + case '200': + return + case '401': + return 'Your SGID-linked work email does not belong to a whitelisted public service email domain. Please use OTP login instead.' + default: + return 'Something went wrong. Please try again later.' + } + }, [statusCode]) + + useEffect(() => { + if (!toastMessage) return + toast({ description: toastMessage }) + }, [toast, toastMessage]) + + const { user } = useUser() + + // If redirected back here but already authed, redirect to dashboard. + if (user) window.location.replace(DASHBOARD_ROUTE) + + const handleLoginMutation = useMutation(getSgidAuthUrl, { + onSuccess: (data) => { + window.location.assign(data.redirectUrl) + }, + }) + + return ( + + + This is an experimental service currently offered to OGP officers only. + + + + ) +} diff --git a/frontend/src/features/login/index.ts b/frontend/src/features/login/index.ts index 5d7df0526e..f22bc829d7 100644 --- a/frontend/src/features/login/index.ts +++ b/frontend/src/features/login/index.ts @@ -1 +1,2 @@ export { LoginPage as default } from './LoginPage' +export { SgidLoginPage } from './SgidLoginPage' diff --git a/frontend/src/services/AuthService.ts b/frontend/src/services/AuthService.ts index e2ba148c19..94a929ab46 100644 --- a/frontend/src/services/AuthService.ts +++ b/frontend/src/services/AuthService.ts @@ -1,4 +1,8 @@ -import { SendOtpResponseDto, UserDto } from '~shared/types/user' +import { + GetSgidAuthUrlResponseDto, + SendOtpResponseDto, + UserDto, +} from '~shared/types/user' import { LOCAL_STORAGE_EVENT, LOGGED_IN_KEY } from '~constants/localStorage' @@ -35,6 +39,17 @@ export const verifyLoginOtp = async (params: { ) } +/** + * Gets the SGID authentication endpoint URL + * @returns SGID login redirect url + * @throws Error on non 2xx response + */ +export const getSgidAuthUrl = async (): Promise => { + return ApiService.get( + `${AUTH_ENDPOINT}/sgid/authurl`, + ).then(({ data }) => data) +} + export const logout = async (): Promise => { // Remove logged in state from localStorage localStorage.removeItem(LOGGED_IN_KEY) diff --git a/shared/types/user.ts b/shared/types/user.ts index fb7b9a5f78..896cb4def3 100644 --- a/shared/types/user.ts +++ b/shared/types/user.ts @@ -25,12 +25,12 @@ export const UserBase = z.object({ updatedAt: z.date(), contact: z.string().optional(), apiToken: z - .object({ - keyHash: z.string(), - createdAt: z.date(), - lastUsedAt: z.date().optional(), - }) - .optional(), + .object({ + keyHash: z.string(), + createdAt: z.date(), + lastUsedAt: z.date().optional(), + }) + .optional(), }) export type UserBase = z.infer @@ -66,3 +66,7 @@ export type SendOtpResponseDto = { message: string otpPrefix: string } + +export type GetSgidAuthUrlResponseDto = { + redirectUrl: string +} diff --git a/src/app/config/features/sgid.config.ts b/src/app/config/features/sgid.config.ts index bbb03c67ed..2d77600790 100644 --- a/src/app/config/features/sgid.config.ts +++ b/src/app/config/features/sgid.config.ts @@ -33,11 +33,17 @@ export const sgidVarsSchema: Schema = { default: null, env: 'SGID_PUBLIC_KEY', }, - redirectUri: { - doc: 'The callback uri that sgID will pass the authorization code and state to', + formLoginRedirectUri: { + doc: 'The callback uri that sgID will pass the authorization code and state to for form logins', format: 'url', default: null, - env: 'SGID_REDIRECT_URI', + env: 'SGID_FORM_LOGIN_REDIRECT_URI', + }, + adminLoginRedirectUri: { + doc: 'The callback uri that sgID will pass the authorization code and state to for admin application logins', + format: 'url', + default: null, + env: 'SGID_ADMIN_LOGIN_REDIRECT_URI', }, cookieMaxAge: { doc: 'Max sgID cookie age with remember me unchecked', diff --git a/src/app/modules/auth/sgid/auth-sgid.controller.ts b/src/app/modules/auth/sgid/auth-sgid.controller.ts new file mode 100644 index 0000000000..238b2bbdf4 --- /dev/null +++ b/src/app/modules/auth/sgid/auth-sgid.controller.ts @@ -0,0 +1,114 @@ +import { StatusCodes } from 'http-status-codes' +import { ErrorDto, GetSgidAuthUrlResponseDto } from 'shared/types' + +import { createLoggerWithLabel } from '../../../config/logger' +import { createReqMeta } from '../../../utils/request' +import { ControllerHandler } from '../../core/core.types' +import * as UserService from '../../user/user.service' +import * as AuthService from '../auth.service' +import { SessionUser } from '../auth.types' +import { mapRouteError } from '../auth.utils' + +import { AuthSgidService, SGID_LOGIN_OAUTH_STATE } from './auth-sgid.service' + +const logger = createLoggerWithLabel(module) + +export const generateAuthUrl: ControllerHandler< + unknown, + ErrorDto | GetSgidAuthUrlResponseDto +> = async (req, res) => { + const logMeta = { + action: 'generateAuthUrl', + ...createReqMeta(req), + } + + return AuthSgidService.createRedirectUrl() + .map((redirectUrl) => res.status(StatusCodes.OK).send({ redirectUrl })) + .mapErr((error) => { + logger.error({ + message: 'Failed to generate SGID auth url', + meta: logMeta, + error, + }) + return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR).json({ + message: + 'Generating SGID authentication url failed. Please try again later.', + }) + }) +} + +export const handleLogin: ControllerHandler< + unknown, + ErrorDto | undefined, + unknown, + { code: string; state: string } +> = async (req, res) => { + const { code, state } = req.query + + const logMeta = { + action: 'handleLogin', + code, + state, + ...createReqMeta(req), + } + + const coreErrorMessage = 'Failed to log in via SGID. Please try again later.' + + let status + + if (!code || state !== SGID_LOGIN_OAUTH_STATE) { + logger.error({ + message: + 'Error logging in with SGID: code not provided or state is incorrect.', + meta: logMeta, + }) + + status = StatusCodes.BAD_REQUEST + } else { + await AuthSgidService.retrieveAccessToken(code) + .andThen(({ accessToken }) => + AuthSgidService.retrieveUserInfo(accessToken), + ) + .andThen((email) => + AuthService.validateEmailDomain(email).andThen((agency) => + UserService.retrieveUser(email, agency._id), + ), + ) + .map((user) => { + if (!req.session) { + logger.error({ + message: 'Error logging in user; req.session is undefined', + meta: logMeta, + }) + + status = StatusCodes.INTERNAL_SERVER_ERROR + return + } + + // Add user info to session. + const { _id } = user.toObject() as SessionUser + req.session.user = { _id } + logger.info({ + message: `Successfully logged in user ${user._id}`, + meta: logMeta, + }) + + // Redirect user to the SGID login page + status = StatusCodes.OK + }) + // Step 3b: Error occured in one of the steps. + .mapErr((error) => { + logger.warn({ + message: 'Error occurred when trying to log in via SGID', + meta: logMeta, + error, + }) + + const { statusCode } = mapRouteError(error, coreErrorMessage) + + status = statusCode + }) + } + + return res.redirect(`/ogp-login?status=${status}`) +} diff --git a/src/app/modules/auth/sgid/auth-sgid.service.ts b/src/app/modules/auth/sgid/auth-sgid.service.ts new file mode 100644 index 0000000000..fb753b7875 --- /dev/null +++ b/src/app/modules/auth/sgid/auth-sgid.service.ts @@ -0,0 +1,122 @@ +import { SgidClient } from '@opengovsg/sgid-client' +import fs from 'fs' +import { err, ok, Result, ResultAsync } from 'neverthrow' + +import { ISgidVarsSchema } from 'src/types' + +import { sgid } from '../../../config/features/sgid.config' +import { createLoggerWithLabel } from '../../../config/logger' +import { + SgidCreateRedirectUrlError, + SgidFetchAccessTokenError, + SgidFetchUserInfoError, +} from '../../sgid/sgid.errors' + +const logger = createLoggerWithLabel(module) + +export const SGID_LOGIN_OAUTH_STATE = 'login' +const SGID_OGP_WORK_EMAIL_SCOPE = 'ogpofficerinfo.work_email' + +export class AuthSgidServiceClass { + private client: SgidClient + private privateKey: string + + constructor({ + privateKeyPath, + hostname, + adminLoginRedirectUri: redirectUri, + clientId, + clientSecret, + }: ISgidVarsSchema) { + this.privateKey = fs.readFileSync(privateKeyPath, { encoding: 'utf8' }) + this.client = new SgidClient({ + // If hostname is empty, use the default provided by sgid-client. + hostname: hostname || undefined, + clientId, + clientSecret, + redirectUri, + privateKey: this.privateKey, + }) + } + + /** + * Create a URL to sgID which is used to redirect the user for authentication + */ + createRedirectUrl(): Result { + const logMeta = { + action: 'createRedirectUrl', + } + + try { + const result = this.client.authorizationUrl( + SGID_LOGIN_OAUTH_STATE, + ['openid', SGID_OGP_WORK_EMAIL_SCOPE].join(' '), + null, + ) + return ok(result.url) + } catch (error) { + logger.error({ + message: 'Error while creating redirect URL', + meta: logMeta, + error, + }) + return err(new SgidCreateRedirectUrlError()) + } + } + + /** + * Given the OIDC authorization code from sgID, obtain the corresponding + * access token, which will be used later to retrieve user information + * @param code - the authorization code + */ + retrieveAccessToken( + code: string, + ): ResultAsync< + { sub: string; accessToken: string }, + SgidFetchAccessTokenError + > { + return ResultAsync.fromPromise( + this.client.callback(code, null), + (error) => { + logger.error({ + message: 'Failed to retrieve access token from sgID', + meta: { + action: 'retrieveAccessToken', + code, + }, + error, + }) + return new SgidFetchAccessTokenError() + }, + ) + } + + /** + * Given the OIDC access token from sgID, obtain the user's information + * (depending on OAuth scopes associated with the accessToken) + * @param accessToken - the access token + * @returns the authenticated OGP user's email + */ + retrieveUserInfo( + accessToken: string, + ): ResultAsync { + return ResultAsync.fromPromise( + this.client + .userinfo(accessToken) + .then(({ data }) => data[SGID_OGP_WORK_EMAIL_SCOPE]), + (error) => { + logger.error({ + message: 'Failed to retrieve user info from sgID', + meta: { + action: 'retrieveUserInfo', + accessToken, + }, + error, + }) + return new SgidFetchUserInfoError() + }, + ) + } +} + +export const AuthSgidService = new AuthSgidServiceClass(sgid) diff --git a/src/app/modules/sgid/__tests__/sgid.service.spec.ts b/src/app/modules/sgid/__tests__/sgid.service.spec.ts index a7aa4a5efc..529acd7164 100644 --- a/src/app/modules/sgid/__tests__/sgid.service.spec.ts +++ b/src/app/modules/sgid/__tests__/sgid.service.spec.ts @@ -49,16 +49,21 @@ describe('sgid.service', () => { }) describe('constructor', () => { it('should create an SgidClient correctly', () => { - const { endpoint, clientId, clientSecret, privateKeyPath, redirectUri } = - MOCK_OPTIONS + const { + hostname, + clientId, + clientSecret, + privateKeyPath, + formLoginRedirectUri, + } = MOCK_OPTIONS const SgidService = new SgidServiceClass(MOCK_OPTIONS) expect(SgidService).toBeInstanceOf(SgidServiceClass) expect(MockSgidClient).toHaveBeenCalledWith({ - endpoint, + hostname, clientId, clientSecret, privateKey: privateKeyPath, - redirectUri, + redirectUri: formLoginRedirectUri, }) }) }) diff --git a/src/app/modules/sgid/__tests__/sgid.test.constants.ts b/src/app/modules/sgid/__tests__/sgid.test.constants.ts index 69254c7f38..1fc5d8799b 100644 --- a/src/app/modules/sgid/__tests__/sgid.test.constants.ts +++ b/src/app/modules/sgid/__tests__/sgid.test.constants.ts @@ -66,13 +66,13 @@ export const MOCK_COOKIE_SETTINGS = { export const MOCK_NONCE = 'nonce' export const MOCK_OPTIONS = { - endpoint: 'http://localhost/', clientId: 'client-id', clientSecret: 'client-secret', privateKeyPath: 'private-key', publicKeyPath: 'public-key', - hostname: undefined, - redirectUri: MOCK_REDIRECT_URL, + hostname: 'http://localhost:5156/', + formLoginRedirectUri: MOCK_REDIRECT_URL, + adminLoginRedirectUri: MOCK_REDIRECT_URL, cookieDomain: MOCK_COOKIE_SETTINGS.domain, cookieMaxAge: MOCK_COOKIE_AGE, cookieMaxAgePreserved: MOCK_COOKIE_AGE * 2, diff --git a/src/app/modules/sgid/sgid.service.ts b/src/app/modules/sgid/sgid.service.ts index 5727bd4ae4..cbf52b38d0 100644 --- a/src/app/modules/sgid/sgid.service.ts +++ b/src/app/modules/sgid/sgid.service.ts @@ -48,13 +48,17 @@ export class SgidServiceClass { privateKeyPath, publicKeyPath, hostname, - ...sgidOptions + formLoginRedirectUri: redirectUri, + clientId, + clientSecret, }: ISgidVarsSchema) { this.privateKey = fs.readFileSync(privateKeyPath, { encoding: 'utf8' }) this.client = new SgidClient({ // If hostname is empty, use the default provided by sgid-client. hostname: hostname || undefined, - ...sgidOptions, + clientId, + clientSecret, + redirectUri, privateKey: this.privateKey, }) this.publicKey = fs.readFileSync(publicKeyPath) @@ -80,6 +84,7 @@ export class SgidServiceClass { const state = encodedQuery ? `${formId},${rememberMe},${encodedQuery}` : `${formId},${rememberMe}` + const logMeta = { action: 'createRedirectUrl', state, diff --git a/src/app/routes/api/v3/auth/auth-sgid.routes.ts b/src/app/routes/api/v3/auth/auth-sgid.routes.ts new file mode 100644 index 0000000000..bbc1cf0a06 --- /dev/null +++ b/src/app/routes/api/v3/auth/auth-sgid.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express' + +import * as AuthSgidController from '../../../../modules/auth/sgid/auth-sgid.controller' + +export const AuthSGIDRouter = Router() + +AuthSGIDRouter.get('/authurl', AuthSgidController.generateAuthUrl) + +AuthSGIDRouter.get('/login', AuthSgidController.handleLogin) diff --git a/src/app/routes/api/v3/auth/auth.routes.ts b/src/app/routes/api/v3/auth/auth.routes.ts index d97d8e24b2..d82a442fc8 100644 --- a/src/app/routes/api/v3/auth/auth.routes.ts +++ b/src/app/routes/api/v3/auth/auth.routes.ts @@ -4,6 +4,8 @@ import { rateLimitConfig } from '../../../../config/config' import * as AuthController from '../../../../modules/auth/auth.controller' import { limitRate } from '../../../../utils/limit-rate' +import { AuthSGIDRouter } from './auth-sgid.routes' + export const AuthRouter = Router() /** * Check if email domain is a valid agency @@ -62,3 +64,5 @@ AuthRouter.post( * @returns 500 when the session fails to be destroyed */ AuthRouter.get('/logout', AuthController.handleSignout) + +AuthRouter.use('/sgid', AuthSGIDRouter) diff --git a/src/types/config.ts b/src/types/config.ts index 0a2649cf7f..95218244cf 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -127,7 +127,8 @@ export interface ISgidVarsSchema { clientSecret: string privateKeyPath: string publicKeyPath: string - redirectUri: string + formLoginRedirectUri: string + adminLoginRedirectUri: string cookieMaxAge: number cookieMaxAgePreserved: number cookieDomain: string From 6ca03c68cb162a4ae3af01f172425b3305742ae0 Mon Sep 17 00:00:00 2001 From: Justyn Oh Date: Thu, 20 Jul 2023 13:46:03 +0800 Subject: [PATCH 5/5] chore: bump version to v6.67.0 --- CHANGELOG.md | 23 +++++++++++++++-------- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3551cc214d..072ea106f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,24 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -#### [v6.66.0](https://github.com/opengovsg/FormSG/compare/v6.66.0...v6.66.0) - +#### [v6.67.0](https://github.com/opengovsg/FormSG/compare/v6.66.0...v6.67.0) + +- feat: experimental login integration with SGID [`#6552`](https://github.com/opengovsg/FormSG/pull/6552) +- build: merge v6.66.0 into develop [`#6554`](https://github.com/opengovsg/FormSG/pull/6554) +- fix: change instances of invoice to proof of payment [`#6553`](https://github.com/opengovsg/FormSG/pull/6553) +- build: release v6.66.0 [`#6550`](https://github.com/opengovsg/FormSG/pull/6550) +- feat: MyInfo Children compound fields beta [`#6523`](https://github.com/opengovsg/FormSG/pull/6523) +- ci: adjust timeouts for playwright pipe [`#6551`](https://github.com/opengovsg/FormSG/pull/6551) - fix: use proof-of-payment for payment receipt/invoice [`#6549`](https://github.com/opengovsg/FormSG/pull/6549) +- build(deps-dev): bump word-wrap from 1.2.3 to 1.2.4 in /frontend [`#6548`](https://github.com/opengovsg/FormSG/pull/6548) +- fix(deps): bump word-wrap from 1.2.3 to 1.2.4 [`#6547`](https://github.com/opengovsg/FormSG/pull/6547) +- build(deps): bump winston-cloudwatch to v6.2.0 [`#6545`](https://github.com/opengovsg/FormSG/pull/6545) +- chore: bump version to v6.66.0 [`0233ba8`](https://github.com/opengovsg/FormSG/commit/0233ba8018532d4978d13717fac065f81e2eb515) #### [v6.66.0](https://github.com/opengovsg/FormSG/compare/v6.65.0...v6.66.0) -> 19 July 2023 +> 18 July 2023 -- build(deps-dev): bump word-wrap from 1.2.3 to 1.2.4 in /frontend [`#6548`](https://github.com/opengovsg/FormSG/pull/6548) -- fix(deps): bump word-wrap from 1.2.3 to 1.2.4 [`#6547`](https://github.com/opengovsg/FormSG/pull/6547) -- build(deps): bump winston-cloudwatch to v6.2.0 [`#6545`](https://github.com/opengovsg/FormSG/pull/6545) - fix(deps): [Snyk] Security upgrade mongoose from 5.13.15 to 5.13.20 [`#6541`](https://github.com/opengovsg/FormSG/pull/6541) - feat: indicate if GST has been applied to a payment transaction [`#6538`](https://github.com/opengovsg/FormSG/pull/6538) - chore: reduce max payment limit [`#6543`](https://github.com/opengovsg/FormSG/pull/6543) @@ -26,7 +33,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge v6.65.0 into develop [`#6531`](https://github.com/opengovsg/FormSG/pull/6531) - build: release v6.65.0 [`#6528`](https://github.com/opengovsg/FormSG/pull/6528) - fix: add FE validation rule that email domains must be non empty [`#6529`](https://github.com/opengovsg/FormSG/pull/6529) -- chore: bump version to v6.66.0 [`ab91cd7`](https://github.com/opengovsg/FormSG/commit/ab91cd7dea0940d8cc05cd9d7ef9c642f8e37571) +- chore: bump version to v6.66.0 [`b3a9818`](https://github.com/opengovsg/FormSG/commit/b3a9818762e334bc7126c029cdba63156bac85ed) #### [v6.65.0](https://github.com/opengovsg/FormSG/compare/v6.64.0...v6.65.0) @@ -379,7 +386,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: only return previous payment id [`#6210`](https://github.com/opengovsg/FormSG/pull/6210) - build: merge release v6.45.0 to develop [`#6205`](https://github.com/opengovsg/FormSG/pull/6205) - build: release v6.45.0 [`#6200`](https://github.com/opengovsg/FormSG/pull/6200) -- chore: bump version to v6.46.0 [`87fda0e`](https://github.com/opengovsg/FormSG/commit/87fda0e0ec4c937350ebd013a4a9762f4acee802) +- chore: bump version to v6.46.0 [`767e7e5`](https://github.com/opengovsg/FormSG/commit/767e7e55c3e8f058963e890b1c249704d6db1459) #### [v6.45.0](https://github.com/opengovsg/FormSG/compare/v6.44.1...v6.45.0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4759180f06..8bc1a55fd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.66.0", + "version": "6.67.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.66.0", + "version": "6.67.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index 14a19eb5e7..20b768ace6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.66.0", + "version": "6.67.0", "homepage": ".", "private": true, "dependencies": { diff --git a/package-lock.json b/package-lock.json index c0978a7eee..f4086cb529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.66.0", + "version": "6.67.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.66.0", + "version": "6.67.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index 5dc63e7eb8..7e9c1c4c29 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.66.0", + "version": "6.67.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG "