diff --git a/CHANGELOG.md b/CHANGELOG.md index 0572f78013..059196805b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,27 @@ 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.89.0](https://github.com/opengovsg/FormSG/compare/v6.88.0...v6.89.0) + +- feat: myinfo for storage-mode [`#6870`](https://github.com/opengovsg/FormSG/pull/6870) +- feat: announcement modal and what's new for myinfo storage-mode [`#6892`](https://github.com/opengovsg/FormSG/pull/6892) +- chore: remove eb shift frontend feature flags [`#6869`](https://github.com/opengovsg/FormSG/pull/6869) +- chore(deps-dev): bump @types/lodash from 4.14.200 to 4.14.201 in /shared [`#6888`](https://github.com/opengovsg/FormSG/pull/6888) +- chore(deps): bump axios from 1.2.1 to 1.6.0 in /frontend [`#6887`](https://github.com/opengovsg/FormSG/pull/6887) +- fix(deps): bump axios from 1.2.1 to 1.6.0 [`#6886`](https://github.com/opengovsg/FormSG/pull/6886) +- fix(deps): bump type-fest from 4.5.0 to 4.7.1 in /shared [`#6883`](https://github.com/opengovsg/FormSG/pull/6883) +- build: merge release 6.88.0 into develop [`#6882`](https://github.com/opengovsg/FormSG/pull/6882) +- build: release v6.88.0 [`#6881`](https://github.com/opengovsg/FormSG/pull/6881) + #### [v6.88.0](https://github.com/opengovsg/FormSG/compare/v6.87.0...v6.88.0) +> 9 November 2023 + - chore: add note to update guide to sync with file exts [`#6876`](https://github.com/opengovsg/FormSG/pull/6876) - fix(datepicker): webkit-related stylings [`#6875`](https://github.com/opengovsg/FormSG/pull/6875) - build: merge release 6.87.0 into develop [`#6879`](https://github.com/opengovsg/FormSG/pull/6879) - build: release v6.87.0 [`#6878`](https://github.com/opengovsg/FormSG/pull/6878) +- chore: bump version to v6.88.0 [`352ae5c`](https://github.com/opengovsg/FormSG/commit/352ae5c49ed30d73450364f8b4d6048e21f72ee2) #### [v6.87.0](https://github.com/opengovsg/FormSG/compare/v6.86.0...v6.87.0) diff --git a/__tests__/e2e/encrypt-submission.spec.ts b/__tests__/e2e/encrypt-submission.spec.ts index 4d42ce557d..8b6a65fadc 100644 --- a/__tests__/e2e/encrypt-submission.spec.ts +++ b/__tests__/e2e/encrypt-submission.spec.ts @@ -1,6 +1,11 @@ import mongoose from 'mongoose' import { featureFlags } from 'shared/constants/feature-flags' -import { BasicField, FormResponseMode } from 'shared/types' +import { + BasicField, + FormAuthType, + FormResponseMode, + MyInfoAttribute, +} from 'shared/types' import { IFeatureFlagModel, IFormModel } from 'src/types' @@ -19,6 +24,7 @@ import { } from './helpers' import { createBlankVersion, + createMyInfoField, createOptionalVersion, deleteDocById, getSettings, @@ -143,4 +149,33 @@ test.describe('Storage form submission', () => { ) await deleteDocById(Form, form._id) }) + + test('Create and submit storage mode form with MyInfo fields', async ({ + page, + }) => { + // Define + const formFields = [ + // Short answer + createMyInfoField(MyInfoAttribute.Name, 'LIM YONG XIANG', true), + // Dropdown + createMyInfoField(MyInfoAttribute.Sex, 'MALE', true), + // Date + createMyInfoField(MyInfoAttribute.DateOfBirth, '06/10/1980', true), + // Mobile + createMyInfoField(MyInfoAttribute.MobileNo, '97399245', false), + // Unverified + createMyInfoField(MyInfoAttribute.WorkpassStatus, 'Live', false), + ] + const formLogics = NO_LOGIC + const formSettings = getSettings({ + authType: FormAuthType.MyInfo, + }) + + // Test + await runEncryptSubmissionTest(page, Form, { + formFields, + formLogics, + formSettings, + }) + }) }) diff --git a/__tests__/integration/helpers/express-auth.ts b/__tests__/integration/helpers/express-auth.ts index d8545efa48..14b4fb27ee 100644 --- a/__tests__/integration/helpers/express-auth.ts +++ b/__tests__/integration/helpers/express-auth.ts @@ -6,6 +6,7 @@ import * as OtpUtils from 'src/app/utils/otp' const MOCK_VALID_OTP = '123456' const MOCK_OTP_PREFIX = 'ABC' +const ADMIN_LOGIN_SESSION_COOKIE_NAME = 'formsg.connect.sid' /** * Integration test helper to create an authenticated session where the user @@ -50,7 +51,7 @@ export const createAuthedSession = async ( // Assert // Should have session cookie returned. const sessionCookie = request.cookies.find( - (cookie) => cookie.name === 'connect.sid', + (cookie) => cookie.name === ADMIN_LOGIN_SESSION_COOKIE_NAME, ) expect(sessionCookie).toBeDefined() @@ -68,7 +69,7 @@ export const logoutSession = async (request: Session): Promise => { expect(response.status).toEqual(200) const sessionCookie = request.cookies.find( - (cookie) => cookie.name === 'connect.sid', + (cookie) => cookie.name === ADMIN_LOGIN_SESSION_COOKIE_NAME, ) expect(sessionCookie).not.toBeDefined() diff --git a/__tests__/integration/helpers/express-setup.ts b/__tests__/integration/helpers/express-setup.ts index 106b393a2c..6b2afebe97 100644 --- a/__tests__/integration/helpers/express-setup.ts +++ b/__tests__/integration/helpers/express-setup.ts @@ -17,7 +17,7 @@ const testSessionMiddlewares = () => { saveUninitialized: false, resave: false, secret: 'test-session-secret', - name: 'connect.sid', + name: 'formsg.connect.sid', store: new session.MemoryStore(), }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2e12da404..6a053ceef8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.88.0", + "version": "6.89.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.88.0", + "version": "6.89.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", @@ -20,7 +20,7 @@ "@stablelib/base64": "^1.0.1", "@stripe/react-stripe-js": "^1.15.0", "@stripe/stripe-js": "^1.44.1", - "axios": "^1.2.1", + "axios": "^1.6.0", "broadcast-channel": "^4.13.0", "browser-image-compression": "^2.0.2", "comlink": "^4.3.1", @@ -17324,9 +17324,9 @@ } }, "node_modules/axios": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", - "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -61200,9 +61200,9 @@ "dev": true }, "axios": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", - "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", "requires": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", diff --git a/frontend/package.json b/frontend/package.json index 74bd289e2b..bbd7fa0645 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.88.0", + "version": "6.89.0", "homepage": ".", "private": true, "dependencies": { @@ -15,7 +15,7 @@ "@stablelib/base64": "^1.0.1", "@stripe/react-stripe-js": "^1.15.0", "@stripe/stripe-js": "^1.44.1", - "axios": "^1.2.1", + "axios": "^1.6.0", "broadcast-channel": "^4.13.0", "browser-image-compression": "^2.0.2", "comlink": "^4.3.1", diff --git a/frontend/src/constants/localStorage.ts b/frontend/src/constants/localStorage.ts index 3af13425f8..126ff0ecb5 100644 --- a/frontend/src/constants/localStorage.ts +++ b/frontend/src/constants/localStorage.ts @@ -17,7 +17,7 @@ export const LOCAL_STORAGE_EVENT = 'local-storage' * Key to store whether a user has seen the rollout announcements before. */ export const ROLLOUT_ANNOUNCEMENT_KEY_PREFIX = - 'has-seen-rollout-announcement-20231026-' + 'has-seen-rollout-announcement-20231116-' /** * Key to store whether the admin has seen the feature tour in localStorage. 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 60d36bde4c..5d728ddc30 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 @@ -5,12 +5,7 @@ import { Box, Text } from '@chakra-ui/react' import { useFeatureIsOn, useGrowthBook } from '@growthbook/growthbook-react' import { featureFlags } from '~shared/constants' -import { - AdminFormDto, - FormAuthType, - FormResponseMode, - MyInfoAttribute, -} from '~shared/types' +import { AdminFormDto, FormAuthType, MyInfoAttribute } from '~shared/types' import { GUIDE_EMAIL_MODE } from '~constants/links' import { ADMINFORM_SETTINGS_SINGPASS_SUBROUTE } from '~constants/routes' @@ -113,14 +108,12 @@ export const MyInfoFieldPanel = () => { ) // myInfo should be disabled if - // 1. form response mode is not email mode - // 2. form auth type is not myInfo - // 3. # of myInfo fields >= 30 + // 1. form auth type is not myInfo + // 2. # of myInfo fields >= 30 const isMyInfoDisabled = useMemo( () => form ? form.form_fields.filter(isMyInfo).length >= 30 || - form.responseMode !== FormResponseMode.Email || (form.authType !== FormAuthType.MyInfo && form.authType !== FormAuthType.SGID_MyInfo) : true, @@ -232,14 +225,10 @@ export const MyInfoFieldPanel = () => { ) } -type MyInfoTextProps = Pick< - AdminFormDto, - 'authType' | 'responseMode' | 'form_fields' -> +type MyInfoTextProps = Pick const MyInfoText = ({ authType, - responseMode, form_fields, }: MyInfoTextProps): JSX.Element => { const isMyInfoDisabled = @@ -249,10 +238,6 @@ const MyInfoText = ({ [form_fields], ) - if (responseMode !== FormResponseMode.Email) { - return MyInfo fields are not available in Storage mode forms. - } - if (isMyInfoDisabled) { return ( diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx index f824086b98..6cb0e880d3 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx @@ -21,7 +21,7 @@ import { isMyInfo } from '~features/myinfo/utils' import { useMutateFormSettings } from '../../mutations' -import { AUTHTYPE_TO_TEXT, STORAGE_MODE_AUTHTYPES } from './constants' +import { FORM_AUTHTYPES } from './constants' import { EsrvcIdBox } from './EsrvcIdBox' const esrvcidRequired = (authType: FormAuthType) => { @@ -42,13 +42,11 @@ interface AuthSettingsSectionProps { export const AuthSettingsSectionSkeleton = (): JSX.Element => { return ( - {Object.entries(STORAGE_MODE_AUTHTYPES).map( - ([authType, textToRender]) => ( - - {textToRender} - - ), - )} + {Object.entries(FORM_AUTHTYPES).map(([authType, textToRender]) => ( + + {textToRender} + + ))} ) } @@ -116,12 +114,9 @@ export const AuthSettingsSection = ({ [isDisabled, mutateFormAuthType, settings.authType], ) - const radioOptions: [FormAuthType, string][] = useMemo(() => { - return Object.entries(AUTHTYPE_TO_TEXT[settings.responseMode]) as [ - FormAuthType, - string, - ][] - }, [settings.responseMode]) + const radioOptions: [FormAuthType, string][] = Object.entries( + FORM_AUTHTYPES, + ) as [FormAuthType, string][] return ( diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts index 6a4357d815..01a6e5837e 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts @@ -1,21 +1,12 @@ -import { FormAuthType, FormResponseMode } from '~shared/types/form' +import { FormAuthType } from '~shared/types/form' export type EmailFormAuthType = FormAuthType -export type StorageFormAuthType = - | FormAuthType.NIL - | FormAuthType.SP - | FormAuthType.CP - | FormAuthType.SGID +export type StorageFormAuthType = FormAuthType -export const STORAGE_MODE_AUTHTYPES: Record = { - [FormAuthType.NIL]: 'None', - [FormAuthType.SGID]: 'Singpass App-only Login', - [FormAuthType.SP]: 'Singpass', - [FormAuthType.CP]: 'Singpass (Corporate)', -} - -// Not using STORAGE_MODE_AUTHTYPES due to wanting a different order. -export const EMAIL_MODE_AUTHTYPES: Record = { +export const FORM_AUTHTYPES: Record< + StorageFormAuthType | EmailFormAuthType, + string +> = { [FormAuthType.NIL]: 'None', [FormAuthType.SGID]: 'Singpass App-only Login', [FormAuthType.SGID_MyInfo]: 'Singpass App-only with Myinfo', @@ -23,7 +14,3 @@ export const EMAIL_MODE_AUTHTYPES: Record = { [FormAuthType.MyInfo]: 'Singpass with Myinfo', [FormAuthType.CP]: 'Singpass (Corporate)', } -export const AUTHTYPE_TO_TEXT = { - [FormResponseMode.Email]: EMAIL_MODE_AUTHTYPES, - [FormResponseMode.Encrypt]: STORAGE_MODE_AUTHTYPES, -} diff --git a/frontend/src/features/admin-form/template/UseTemplateModal/UseTemplateWizardProvider.tsx b/frontend/src/features/admin-form/template/UseTemplateModal/UseTemplateWizardProvider.tsx index a4f3b1f0ea..b39b8fc4d6 100644 --- a/frontend/src/features/admin-form/template/UseTemplateModal/UseTemplateWizardProvider.tsx +++ b/frontend/src/features/admin-form/template/UseTemplateModal/UseTemplateWizardProvider.tsx @@ -1,9 +1,8 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { FormResponseMode } from '~shared/types' import { useFormTemplate } from '~features/admin-form/common/queries' -import { isMyInfo } from '~features/myinfo/utils' import { CreateFormFlowStates, CreateFormWizardContext, @@ -23,11 +22,6 @@ export const useUseTemplateWizardContext = ( /* enabled= */ !!formId, ) - const containsMyInfoFields = useMemo( - () => !!templateFormData?.form.form_fields.find((ff) => isMyInfo(ff)), - [templateFormData?.form.form_fields], - ) - const { formMethods, currentStep, direction, keypair, setCurrentStep } = useCommonFormWizardProvider() @@ -41,18 +35,9 @@ export const useUseTemplateWizardContext = ( reset({ ...getValues(), - responseMode: containsMyInfoFields - ? FormResponseMode.Email - : FormResponseMode.Encrypt, title: `[Template] ${templateFormData?.form.title}`, }) - }, [ - reset, - getValues, - containsMyInfoFields, - isTemplateFormLoading, - templateFormData?.form.title, - ]) + }, [reset, getValues, isTemplateFormLoading, templateFormData?.form.title]) const { handleSubmit } = formMethods @@ -98,7 +83,6 @@ export const useUseTemplateWizardContext = ( formMethods, handleDetailsSubmit, handleCreateStorageModeForm, - containsMyInfoFields, modalHeader: 'Duplicate form', } } diff --git a/frontend/src/features/public-form/PublicFormProvider.tsx b/frontend/src/features/public-form/PublicFormProvider.tsx index fc3feeaffb..ecc8b777ff 100644 --- a/frontend/src/features/public-form/PublicFormProvider.tsx +++ b/frontend/src/features/public-form/PublicFormProvider.tsx @@ -11,11 +11,6 @@ import { SubmitHandler } from 'react-hook-form' import { useNavigate } from 'react-router-dom' import { useDisclosure } from '@chakra-ui/react' import { datadogLogs } from '@datadog/browser-logs' -import { - useFeatureIsOn, - useFeatureValue, - useGrowthBook, -} from '@growthbook/growthbook-react' import { differenceInMilliseconds, isPast } from 'date-fns' import get from 'lodash/get' import simplur from 'simplur' @@ -135,23 +130,6 @@ export const PublicFormProvider = ({ /* enabled= */ !submissionData, ) - const growthbook = useGrowthBook() - - useEffect(() => { - if (growthbook) { - growthbook.setAttributes({ - // Only update the `formId` attribute, keep the rest the same - ...growthbook.getAttributes(), - formId, - }) - } - }, [growthbook, formId]) - - const enableEncryptionBoundaryShift = useFeatureValue( - featureFlags.encryptionBoundaryShift, - true, - ) - // Scroll to top of page when user has finished their submission. useLayoutEffect(() => { if (submissionData) { @@ -269,18 +247,11 @@ export const PublicFormProvider = ({ } }, [data?.form.form_fields, toast, vfnToastIdRef]) - const enableVirusScanner = useFeatureIsOn( - featureFlags.encryptionBoundaryShiftVirusScanner, - ) - const { submitEmailModeFormMutation, - submitStorageModeFormMutation, submitEmailModeFormFetchMutation, - submitStorageModeFormFetchMutation, - submitStorageModeClearFormMutation, - submitStorageModeClearFormFetchMutation, - submitStorageModeClearFormWithVirusScanningMutation, + submitStorageModeFormWithVirusScanningFetchMutation, + submitStorageModeFormWithVirusScanningMutation, } = usePublicFormMutations(formId, submissionData?.id ?? '') const { handleLogoutMutation } = usePublicAuthMutations(formId) @@ -484,9 +455,7 @@ export const PublicFormProvider = ({ : {}), } - const submitStorageFormWithFetch = function ( - routeToNewStorageModeSubmission: boolean, - ) { + const submitStorageFormWithFetch = function () { datadogLogs.logger.info(`handleSubmitForm: submitting via fetch`, { meta: { ...logMeta, @@ -495,16 +464,11 @@ export const PublicFormProvider = ({ }, }) - return ( - routeToNewStorageModeSubmission - ? submitStorageModeClearFormFetchMutation - : submitStorageModeFormFetchMutation - ) + return submitStorageModeFormWithVirusScanningFetchMutation .mutateAsync( { ...formData, ...formPaymentData, - publicKey: form.publicKey, }, { onSuccess: ({ @@ -546,7 +510,7 @@ export const PublicFormProvider = ({ // TODO (#5826): Toggle to use fetch for submissions instead of axios. If enabled, this is used for testing and to use fetch instead of axios by default if testing shows fetch is more stable. Remove once network error is resolved if (useFetchForSubmissions) { - return submitStorageFormWithFetch(enableEncryptionBoundaryShift) + return submitStorageFormWithFetch() } datadogLogs.logger.info(`handleSubmitForm: submitting via axios`, { meta: { @@ -556,9 +520,8 @@ export const PublicFormProvider = ({ }, }) - // TODO (FRM-1413): Move to main return statement once virus scanner has been fully rolled out - if (enableEncryptionBoundaryShift && enableVirusScanner) { - return submitStorageModeClearFormWithVirusScanningMutation.mutateAsync( + return submitStorageModeFormWithVirusScanningMutation + .mutateAsync( { ...formData, ...formPaymentData, @@ -582,86 +545,27 @@ export const PublicFormProvider = ({ timestamp, }) }, - onError: (error) => { - // TODO(#5826): Remove when we have resolved the Network Error - datadogLogs.logger.warn( - `handleSubmitForm: submit with virus scan`, - { - meta: { - ...logMeta, - responseMode: 'storage', - method: 'axios', - error, - }, - }, - ) - - // defaults to the safest option of storage submission without virus scanning - return submitStorageFormWithFetch( - enableEncryptionBoundaryShift, - ) - }, }, ) - } - - return ( - ( - enableEncryptionBoundaryShift - ? submitStorageModeClearFormMutation - : submitStorageModeFormMutation - ) - .mutateAsync( - { - ...formData, - ...formPaymentData, - publicKey: form.publicKey, - }, + .catch(async (error) => { + datadogLogs.logger.warn( + `handleSubmitForm: submit with virus scan`, { - onSuccess: ({ - submissionId, - timestamp, - // payment forms will have non-empty paymentData field - paymentData, - }) => { - trackSubmitForm(form) - - if (paymentData) { - navigate(getPaymentPageUrl(formId, paymentData.paymentId)) - storePaymentMemory(paymentData.paymentId) - return - } - setSubmissionData({ - id: submissionId, - timestamp, - }) - }, - }, - ) - // Using catch since we are using mutateAsync and react-hook-form will continue bubbling this up. - .catch(async (error) => { - // TODO(#5826): Remove when we have resolved the Network Error - datadogLogs.logger.warn(`handleSubmitForm: ${error.message}`, { meta: { ...logMeta, responseMode: 'storage', method: 'axios', - error: { - message: error.message, - stack: error.stack, - }, + error, }, - }) - - if (/Network Error/i.test(error.message)) { - axiosDebugFlow() - return submitStorageFormWithFetch( - enableEncryptionBoundaryShift, - ) - } - showErrorToast(error, form) - }) - ) + }, + ) + if (/Network Error/i.test(error.message)) { + axiosDebugFlow() + // fallback to fetch + return submitStorageFormWithFetch() + } + showErrorToast(error, form) + }) } } }, @@ -678,16 +582,11 @@ export const PublicFormProvider = ({ getCaptchaResponse, submitEmailModeFormFetchMutation, submitEmailModeFormMutation, - enableEncryptionBoundaryShift, - enableVirusScanner, - submitStorageModeClearFormMutation, - submitStorageModeFormMutation, - submitStorageModeClearFormFetchMutation, - submitStorageModeFormFetchMutation, + submitStorageModeFormWithVirusScanningMutation, + submitStorageModeFormWithVirusScanningFetchMutation, navigate, formId, storePaymentMemory, - submitStorageModeClearFormWithVirusScanningMutation, ], ) diff --git a/frontend/src/features/public-form/PublicFormService.ts b/frontend/src/features/public-form/PublicFormService.ts index 1ae78819d0..9cab540bee 100644 --- a/frontend/src/features/public-form/PublicFormService.ts +++ b/frontend/src/features/public-form/PublicFormService.ts @@ -1,10 +1,7 @@ import { PresignedPost } from 'aws-sdk/clients/s3' import axios from 'axios' -import { - ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, - VIRUS_SCANNER_SUBMISSION_VERSION, -} from '~shared/constants' +import { VIRUS_SCANNER_SUBMISSION_VERSION } from '~shared/constants' import { SubmitFormIssueBodyDto, SuccessMessageDto } from '~shared/types' import { AttachmentPresignedPostDataMapType, @@ -39,7 +36,6 @@ import { FormFieldValues } from '~templates/Field' import { createClearSubmissionFormData, createClearSubmissionWithVirusScanningFormData, - createEncryptedSubmissionData, getAttachmentsMap, } from './utils/createSubmission' import { filterHiddenInputs } from './utils/filterHiddenInputs' @@ -155,46 +151,9 @@ export const submitEmailModeForm = async ({ ).then(({ data }) => data) } -export const submitStorageModeForm = async ({ - formFields, - formLogics, - formInputs, - formId, - publicKey, - captchaResponse = null, - captchaType = '', - paymentReceiptEmail, - responseMetadata, - paymentProducts, - payments, -}: SubmitStorageFormArgs) => { - const filteredInputs = filterHiddenInputs({ - formFields, - formInputs, - formLogics, - }) - const submissionContent = await createEncryptedSubmissionData({ - formFields, - formInputs: filteredInputs, - publicKey, - responseMetadata, - paymentReceiptEmail, - payments, - paymentProducts, - }) - return ApiService.post( - `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/encrypt`, - submissionContent, - { - params: { - captchaResponse: String(captchaResponse), - captchaType, - }, - }, - ).then(({ data }) => data) -} - -export const submitStorageModeClearForm = async ({ +// TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved +// Submit storage mode form with virus scanning (storage v2.1+) +export const submitStorageModeFormWithVirusScanningWithFetch = async ({ formFields, formLogics, formInputs, @@ -205,63 +164,26 @@ export const submitStorageModeClearForm = async ({ responseMetadata, paymentProducts, payments, -}: SubmitStorageFormClearArgs) => { + fieldIdToQuarantineKeyMap, +}: SubmitStorageFormWithVirusScanningArgs) => { const filteredInputs = filterHiddenInputs({ formFields, formInputs, formLogics, }) - const formData = createClearSubmissionFormData({ - formFields, - formInputs: filteredInputs, - responseMetadata, - paymentReceiptEmail, - paymentProducts, - payments, - version: ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, - }) - - return ApiService.post( - `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/storage`, - formData, + const formData = createClearSubmissionWithVirusScanningFormData( { - params: { - captchaResponse: String(captchaResponse), - captchaType: captchaType, - }, + formFields, + formInputs: filteredInputs, + responseMetadata, + paymentReceiptEmail, + paymentProducts, + payments, + version: VIRUS_SCANNER_SUBMISSION_VERSION, }, - ).then(({ data }) => data) -} - -// TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved -export const submitStorageModeClearFormWithFetch = async ({ - formFields, - formLogics, - formInputs, - formId, - captchaResponse = null, - captchaType = '', - paymentReceiptEmail, - responseMetadata, - paymentProducts, - payments, -}: SubmitStorageFormClearArgs) => { - const filteredInputs = filterHiddenInputs({ - formFields, - formInputs, - formLogics, - }) - - const formData = createClearSubmissionFormData({ - formFields, - formInputs: filteredInputs, - responseMetadata, - paymentReceiptEmail, - paymentProducts, - payments, - version: ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION, - }) + fieldIdToQuarantineKeyMap, + ) // Add captcha response to query string const queryString = new URLSearchParams({ @@ -284,7 +206,7 @@ export const submitStorageModeClearFormWithFetch = async ({ } // Submit storage mode form with virus scanning (storage v2.1+) -export const submitStorageModeClearFormWithVirusScanning = async ({ +export const submitStorageModeFormWithVirusScanning = async ({ formFields, formLogics, formInputs, @@ -369,56 +291,6 @@ export const submitEmailModeFormWithFetch = async ({ return processFetchResponse(response) } -// TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved -export const submitStorageModeFormWithFetch = async ({ - formFields, - formLogics, - formInputs, - formId, - publicKey, - captchaResponse = null, - captchaType = '', - paymentReceiptEmail, - responseMetadata, - paymentProducts, - payments, -}: SubmitStorageFormArgs) => { - const filteredInputs = filterHiddenInputs({ - formFields, - formInputs, - formLogics, - }) - const submissionContent = await createEncryptedSubmissionData({ - formFields, - formInputs: filteredInputs, - publicKey, - responseMetadata, - paymentReceiptEmail, - payments, - paymentProducts, - }) - - // Add captcha response to query string - const queryString = new URLSearchParams({ - captchaResponse: String(captchaResponse), - captchaType, - }).toString() - - const response = await fetch( - `${API_BASE_URL}${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/encrypt?${queryString}`, - { - method: 'POST', - body: JSON.stringify(submissionContent), - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }, - ) - - return processFetchResponse(response) -} - /** * Post feedback for a given form. * @param formId the id of the form to post feedback for diff --git a/frontend/src/features/public-form/mutations.ts b/frontend/src/features/public-form/mutations.ts index 1975644ce7..e39c2561f4 100644 --- a/frontend/src/features/public-form/mutations.ts +++ b/frontend/src/features/public-form/mutations.ts @@ -20,13 +20,9 @@ import { submitEmailModeFormWithFetch, submitFormFeedback, submitFormIssue, - SubmitStorageFormArgs, SubmitStorageFormClearArgs, - submitStorageModeClearForm, - submitStorageModeClearFormWithFetch, - submitStorageModeClearFormWithVirusScanning, - submitStorageModeForm, - submitStorageModeFormWithFetch, + submitStorageModeFormWithVirusScanning, + submitStorageModeFormWithVirusScanningWithFetch, uploadAttachmentToQuarantine, } from './PublicFormService' @@ -82,18 +78,6 @@ export const usePublicFormMutations = ( }, ) - const submitStorageModeFormMutation = useMutation( - (args: Omit) => { - return submitStorageModeForm({ ...args, formId }) - }, - ) - - const submitStorageModeClearFormMutation = useMutation( - (args: Omit) => { - return submitStorageModeClearForm({ ...args, formId }) - }, - ) - // TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved const submitEmailModeFormFetchMutation = useMutation( (args: Omit) => { @@ -101,15 +85,73 @@ export const usePublicFormMutations = ( }, ) - const submitStorageModeFormFetchMutation = useMutation( - (args: Omit) => { - return submitStorageModeFormWithFetch({ ...args, formId }) - }, - ) + const submitStorageModeFormWithVirusScanningFetchMutation = useMutation( + async (args: Omit) => { + const attachmentSizes = await getAttachmentSizes(args) + // If there are no attachments, submit form without virus scanning by passing in empty list + if (attachmentSizes.length === 0) { + return submitStorageModeFormWithVirusScanningWithFetch({ + ...args, + fieldIdToQuarantineKeyMap: [], + formId, + }) + } + + // Step 1: Get presigned post data for all attachment fields + return ( + getAttachmentPresignedPostData({ ...args, formId, attachmentSizes }) + .then( + // Step 2: Upload attachments to quarantine bucket asynchronously + (fieldToPresignedPostDataMap) => + Promise.all( + fieldToPresignedPostDataMap.map( + async (fieldToPresignedPostData) => { + const attachmentFile = + args.formInputs[fieldToPresignedPostData.id] - const submitStorageModeClearFormFetchMutation = useMutation( - (args: Omit) => { - return submitStorageModeClearFormWithFetch({ ...args, formId }) + // Check if response is a File object (from an attachment field) + if (!(attachmentFile instanceof File)) + throw new Error('Field is not attachment') + + const uploadResponse = await uploadAttachmentToQuarantine( + fieldToPresignedPostData.presignedPostData, + attachmentFile, + ) + + // If status code is not 200-299, throw error + if ( + uploadResponse.status < 200 || + uploadResponse.status > 299 + ) + throw new Error( + `Attachment upload failed - ${uploadResponse.statusText}`, + ) + + const quarantineBucketKey = + fieldToPresignedPostData.presignedPostData.fields.key + + if (!quarantineBucketKey) + throw new Error( + 'key is not defined in presigned post data', + ) + + return { + fieldId: fieldToPresignedPostData.id, + quarantineBucketKey, + } as FieldIdToQuarantineKeyType + }, + ), + ), + ) + // Step 3: Submit form with keys to quarantine bucket attachments + .then((fieldIdToQuarantineKeyMap) => { + return submitStorageModeFormWithVirusScanningWithFetch({ + ...args, + fieldIdToQuarantineKeyMap, + formId, + }) + }) + ) }, ) @@ -123,12 +165,12 @@ export const usePublicFormMutations = ( }, ) - const submitStorageModeClearFormWithVirusScanningMutation = useMutation( + const submitStorageModeFormWithVirusScanningMutation = useMutation( async (args: Omit) => { const attachmentSizes = await getAttachmentSizes(args) // If there are no attachments, submit form without virus scanning by passing in empty list if (attachmentSizes.length === 0) { - return submitStorageModeClearFormWithVirusScanning({ + return submitStorageModeFormWithVirusScanning({ ...args, fieldIdToQuarantineKeyMap: [], formId, @@ -182,7 +224,7 @@ export const usePublicFormMutations = ( ) // Step 3: Submit form with keys to quarantine bucket attachments .then((fieldIdToQuarantineKeyMap) => { - return submitStorageModeClearFormWithVirusScanning({ + return submitStorageModeFormWithVirusScanning({ ...args, fieldIdToQuarantineKeyMap, formId, @@ -194,13 +236,10 @@ export const usePublicFormMutations = ( return { submitEmailModeFormMutation, - submitStorageModeFormMutation, submitFormFeedbackMutation, - submitStorageModeFormFetchMutation, submitEmailModeFormFetchMutation, - submitStorageModeClearFormMutation, - submitStorageModeClearFormFetchMutation, - submitStorageModeClearFormWithVirusScanningMutation, + submitStorageModeFormWithVirusScanningFetchMutation, + submitStorageModeFormWithVirusScanningMutation, } } diff --git a/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx b/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx index f59e440a4f..5800d0e472 100644 --- a/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx +++ b/frontend/src/features/rollout-announcement/components/AnnouncementsFeatureList.tsx @@ -2,6 +2,7 @@ import { GUIDE_PAYMENTS_ENTRY } from '~constants/links' import { FeatureUpdateImage } from '~features/whats-new/FeatureUpdateList' +import myInfoStorageMode from '../../whats-new/assets/6-myinfo-storage.svg' import foldersDashboard from '../../whats-new/assets/folders_dashboard.svg' import PaymentsAnnouncementGraphic from '../assets/payments_announcement.svg' @@ -14,6 +15,16 @@ export interface NewFeature { // When updating this, remember to update the ROLLOUT_ANNOUNCEMENT_KEY_PREFIX with the new date // so admins will see new announcements. export const NEW_FEATURES: NewFeature[] = [ + { + // Announcement date: 2023-11-16 + title: 'Myinfo fields for Storage mode forms', + description: + 'Get verified data from respondents by adding Myinfo fields to your Storage mode form. To enable Myinfo fields, select one of our Myinfo-enabled authentication options in your form’s settings.', + image: { + url: myInfoStorageMode, + alt: 'Myinfo fields for Storage mode forms', + }, + }, { // Announcement date: 2023-10-31 title: 'Introducing Folders!', diff --git a/frontend/src/features/whats-new/FeatureUpdateList.ts b/frontend/src/features/whats-new/FeatureUpdateList.ts index 673801d6b4..60de1adb92 100644 --- a/frontend/src/features/whats-new/FeatureUpdateList.ts +++ b/frontend/src/features/whats-new/FeatureUpdateList.ts @@ -6,6 +6,7 @@ import { GUIDE_PAYMENTS_ENTRY } from '~constants/links' import Animation2 from './assets/2-payments.json' import Animation3 from './assets/3-search-and-filter.json' import Animation4 from './assets/4-dnd.json' +import MyInfoStorageMode from './assets/6-myinfo-storage.svg' import foldersDashboard from './assets/folders_dashboard.svg' // image can either be a static image (using url) or an animation (using animationData) @@ -34,6 +35,16 @@ export const FEATURE_UPDATE_LIST: FeatureUpdateList = { // Update version whenever a new feature is added. version: 4, features: [ + { + title: 'Myinfo fields for Storage mode forms', + date: new Date('16 Nov 2023 GMT+8'), + description: + 'Get verified data from respondents by adding Myinfo fields to your Storage mode form. To enable Myinfo fields, select one of our Myinfo-enabled authentication options in your form’s settings.', + image: { + url: MyInfoStorageMode, + alt: 'Myinfo fields for Storage mode forms', + }, + }, { title: 'Introducing Folders!', date: new Date('31 Oct 2023 GMT+8'), diff --git a/frontend/src/features/whats-new/assets/6-myinfo-storage.svg b/frontend/src/features/whats-new/assets/6-myinfo-storage.svg new file mode 100644 index 0000000000..3ba311231c --- /dev/null +++ b/frontend/src/features/whats-new/assets/6-myinfo-storage.svg @@ -0,0 +1,229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/CreateFormDetailsScreen.tsx b/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/CreateFormDetailsScreen.tsx index 9a5800bf43..f581e3a42c 100644 --- a/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/CreateFormDetailsScreen.tsx +++ b/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/CreateFormDetailsScreen.tsx @@ -17,7 +17,6 @@ import Button from '~components/Button' import FormErrorMessage from '~components/FormControl/FormErrorMessage' import FormFieldMessage from '~components/FormControl/FormFieldMessage' import FormLabel from '~components/FormControl/FormLabel' -import InlineMessage from '~components/InlineMessage' import Input from '~components/Input' import { useCreateFormWizard } from '../CreateFormWizardContext' @@ -35,7 +34,6 @@ export const CreateFormDetailsScreen = (): JSX.Element => { isLoading, isFetching, modalHeader, - containsMyInfoFields, } = useCreateFormWizard() const { register, @@ -79,23 +77,12 @@ export const CreateFormDetailsScreen = (): JSX.Element => { ( - - )} + render={({ field }) => } rules={{ required: 'Please select a form response mode' }} /> {errors.responseMode?.message} - {containsMyInfoFields && ( - - {`This form contains MyInfo fields. Only **Email** mode is supported at - this point.`} - - )} {responseModeValue === FormResponseMode.Email && ( void value: FormResponseMode } @@ -29,7 +28,7 @@ const OptionDescription = ({ listItems = [] }: { listItems: string[] }) => { export const FormResponseOptions = forwardRef< FormResponseOptionsProps, 'button' ->(({ value, onChange, containsMyInfoFields }, ref) => { +>(({ value, onChange }, ref) => { return ( Recommended} isActive={value === FormResponseMode.Encrypt} - isDisabled={containsMyInfoFields} onClick={() => onChange(FormResponseMode.Encrypt)} isFullWidth flex={1} diff --git a/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardContext.tsx b/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardContext.tsx index 1af1f663ed..11284dc68c 100644 --- a/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardContext.tsx +++ b/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardContext.tsx @@ -34,7 +34,6 @@ export type CreateFormWizardContextReturn = { isFetching: boolean isLoading: boolean modalHeader: string - containsMyInfoFields: boolean } export const CreateFormWizardContext = createContext< diff --git a/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardProvider.tsx b/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardProvider.tsx index 3b985d4a6e..bfe69cd1ea 100644 --- a/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardProvider.tsx +++ b/frontend/src/features/workspace/components/CreateFormModal/CreateFormWizardProvider.tsx @@ -105,8 +105,6 @@ const useCreateFormWizardContext = (): CreateFormWizardContextReturn => { handleDetailsSubmit, handleCreateStorageModeForm, modalHeader: 'Set up your form', - // Creation will never contain any fields. - containsMyInfoFields: false, } } diff --git a/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx b/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx index 5dd7d23be7..31d4cab2bb 100644 --- a/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx +++ b/frontend/src/features/workspace/components/DuplicateFormModal/DupeFormWizardProvider.tsx @@ -1,9 +1,8 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { FormResponseMode } from '~shared/types' import { usePreviewForm } from '~features/admin-form/common/queries' -import { isMyInfo } from '~features/myinfo/utils' import { useDuplicateFormMutations } from '~features/workspace/mutations' import { useDashboard } from '~features/workspace/queries' import { makeDuplicateFormTitle } from '~features/workspace/utils/createDuplicateFormTitle' @@ -27,11 +26,6 @@ export const useDupeFormWizardContext = (): CreateFormWizardContextReturn => { /* enabled= */ !!activeFormMeta, ) - const containsMyInfoFields = useMemo( - () => !!previewFormData?.form.form_fields.find((ff) => isMyInfo(ff)), - [previewFormData?.form.form_fields], - ) - const { formMethods, currentStep, direction, keypair, setCurrentStep } = useCommonFormWizardProvider() @@ -50,9 +44,6 @@ export const useDupeFormWizardContext = (): CreateFormWizardContextReturn => { reset({ ...getValues(), - responseMode: containsMyInfoFields - ? FormResponseMode.Email - : FormResponseMode.Encrypt, title: makeDuplicateFormTitle(previewFormData.form.title, dashboardForms), }) }, [ @@ -62,7 +53,6 @@ export const useDupeFormWizardContext = (): CreateFormWizardContextReturn => { isPreviewFormLoading, isWorkspaceLoading, dashboardForms, - containsMyInfoFields, ]) const { handleSubmit } = formMethods @@ -116,7 +106,6 @@ export const useDupeFormWizardContext = (): CreateFormWizardContextReturn => { formMethods, handleDetailsSubmit, handleCreateStorageModeForm, - containsMyInfoFields, modalHeader: 'Duplicate form', } } diff --git a/package-lock.json b/package-lock.json index bf836c7b5e..fba24f4723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.88.0", + "version": "6.89.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.88.0", + "version": "6.89.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", @@ -24,7 +24,7 @@ "abortcontroller-polyfill": "^1.7.5", "aws-info": "^1.2.0", "aws-sdk": "^2.1354.0", - "axios": "^1.2.1", + "axios": "^1.6.0", "bcrypt": "^5.1.0", "bluebird": "^3.5.2", "body-parser": "^1.20.1", @@ -9778,9 +9778,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", - "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -38955,9 +38955,9 @@ "dev": true }, "axios": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz", - "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", + "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", "requires": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", diff --git a/package.json b/package.json index 331c1b3531..ec2fe82397 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.88.0", + "version": "6.89.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " @@ -70,7 +70,7 @@ "abortcontroller-polyfill": "^1.7.5", "aws-info": "^1.2.0", "aws-sdk": "^2.1354.0", - "axios": "^1.2.1", + "axios": "^1.6.0", "bcrypt": "^5.1.0", "bluebird": "^3.5.2", "body-parser": "^1.20.1", diff --git a/shared/constants/form.ts b/shared/constants/form.ts index a6a26085e1..6b355aa7b6 100644 --- a/shared/constants/form.ts +++ b/shared/constants/form.ts @@ -68,5 +68,4 @@ export const PAYMENT_VARIABLE_INPUT_AMOUNT_FIELD_ID = export const E2EE_SUBMISSION_VERSION = 1 // Encryption boundary shift RFC: https://docs.google.com/document/d/1VmNXS_xYY2Yg30AwVqzdndBp5yRJGSDsyjBnH51ktyc/edit?usp=sharing // Encryption boundary shift implementation PR: https://github.com/opengovsg/FormSG/pull/6587 -export const ENCRYPTION_BOUNDARY_SHIFT_SUBMISSION_VERSION = 2 export const VIRUS_SCANNER_SUBMISSION_VERSION = 2.1 diff --git a/shared/package-lock.json b/shared/package-lock.json index 5bc2bed9d8..1c52d642de 100644 --- a/shared/package-lock.json +++ b/shared/package-lock.json @@ -102,9 +102,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.14.200", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.200.tgz", - "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==", + "version": "4.14.201", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.201.tgz", + "integrity": "sha512-y9euML0cim1JrykNxADLfaG0FgD1g/yTHwUs/Jg9ZIU7WKj2/4IW9Lbb1WZbvck78W/lfGXFfe+u2EGfIJXdLQ==", "dev": true }, "node_modules/@types/semver": { @@ -865,9 +865,9 @@ } }, "node_modules/type-fest": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.5.0.tgz", - "integrity": "sha512-diLQivFzddJl4ylL3jxSkEc39Tpw7o1QeEHIPxVwryDK2lpB7Nqhzhuo6v5/Ls08Z0yPSAhsyAWlv1/H0ciNmw==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.7.1.tgz", + "integrity": "sha512-iWr8RUmzAJRfhZugX9O7nZE6pCxDU8CZ3QxsLuTnGcBLJpCaP2ll3s4eMTBoFnU/CeXY/5rfQSuAEsTGJO4y8A==", "engines": { "node": ">=16" }, @@ -960,9 +960,9 @@ "dev": true }, "@types/lodash": { - "version": "4.14.200", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.200.tgz", - "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==", + "version": "4.14.201", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.201.tgz", + "integrity": "sha512-y9euML0cim1JrykNxADLfaG0FgD1g/yTHwUs/Jg9ZIU7WKj2/4IW9Lbb1WZbvck78W/lfGXFfe+u2EGfIJXdLQ==", "dev": true }, "@types/semver": { @@ -1478,9 +1478,9 @@ } }, "type-fest": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.5.0.tgz", - "integrity": "sha512-diLQivFzddJl4ylL3jxSkEc39Tpw7o1QeEHIPxVwryDK2lpB7Nqhzhuo6v5/Ls08Z0yPSAhsyAWlv1/H0ciNmw==" + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.7.1.tgz", + "integrity": "sha512-iWr8RUmzAJRfhZugX9O7nZE6pCxDU8CZ3QxsLuTnGcBLJpCaP2ll3s4eMTBoFnU/CeXY/5rfQSuAEsTGJO4y8A==" }, "util-deprecate": { "version": "1.0.2", diff --git a/shared/types/response.ts b/shared/types/response.ts index c42bd29e0a..de3ab7ad59 100644 --- a/shared/types/response.ts +++ b/shared/types/response.ts @@ -144,6 +144,16 @@ export type ChildBirthRecordsResponse = z.infer< typeof ChildBirthRecordsResponse > +// These fieldTypes are used for the child fields in MYINFO_ATTRIBUTE_MAP +export const SingleChildSubRecordResponse = MyInfoableSingleResponse.extend({ + fieldType: z.literal( + BasicField.Children || BasicField.ShortText || BasicField.Dropdown, + ), +}) +export type SingleChildSubRecordResponse = z.infer< + typeof SingleChildSubRecordResponse +> + export type FieldResponse = | HeaderResponse | EmailResponse @@ -165,3 +175,4 @@ export type FieldResponse = | TableResponse | UenResponse | ChildBirthRecordsResponse + | SingleChildSubRecordResponse diff --git a/src/app/loaders/express/session.ts b/src/app/loaders/express/session.ts index 707c3a199b..dd9eccb36b 100644 --- a/src/app/loaders/express/session.ts +++ b/src/app/loaders/express/session.ts @@ -6,6 +6,10 @@ import { Connection } from 'mongoose' import config from '../../config/config' +export const ADMIN_LOGIN_SESSION_COOKIE_NAME = config.isDev + ? 'formsg.connect.sid' + : 'connect.sid' + const sessionMiddlewares = (connection: Connection): RequestHandler[] => { // Configure express-session and connect to mongo const expressSession = session({ @@ -13,7 +17,8 @@ const sessionMiddlewares = (connection: Connection): RequestHandler[] => { resave: false, secret: config.sessionSecret, cookie: config.cookieSettings, - name: 'connect.sid', + // TODO: FRM-1512: Standardise cookie name across environments + name: ADMIN_LOGIN_SESSION_COOKIE_NAME, store: MongoStore.create({ client: connection.getClient(), }), diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index bd2e6bfe0c..4760c83612 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -694,31 +694,46 @@ describe('Form Model', () => { ) }) - it('should set authType to NIL when given authType is MyInfo', async () => { + // Ensure that encrypted sgID forms can be created since they could not before + it('should set authType to SGID when given authType is SGID', async () => { // Arrange - const malformedParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { + const encryptFormParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { + authType: FormAuthType.SGID, + }) + + // Act + const sgidForm = await EncryptedForm.create(encryptFormParams) + + // Assert + await expect(sgidForm.authType).toBe(FormAuthType.SGID) + }) + + // Ensure that encrypted MyInfo forms can be created since they could not before + it('should set authType to MyInfo when given authType is MyInfo', async () => { + // Arrange + const encryptFormParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { authType: FormAuthType.MyInfo, }) // Act - const invalidForm = await EncryptedForm.create(malformedParams) + const myInfoForm = await EncryptedForm.create(encryptFormParams) // Assert - await expect(invalidForm.authType).toBe(FormAuthType.NIL) + await expect(myInfoForm.authType).toBe(FormAuthType.MyInfo) }) - // Ensure that encrypted sgID forms can be created since they could not before - it('should set authType to SGID when given authType is SGID', async () => { + // Ensure that encrypted SGID MyInfo forms can be created since they could not before + it('should set authType to SGID MyInfo when given authType is SGID MyInfo', async () => { // Arrange const encryptFormParams = merge({}, MOCK_ENCRYPTED_FORM_PARAMS, { - authType: FormAuthType.SGID, + authType: FormAuthType.SGID_MyInfo, }) // Act - const sgidForm = await EncryptedForm.create(encryptFormParams) + const sgidMyInfoForm = await EncryptedForm.create(encryptFormParams) // Assert - await expect(sgidForm.authType).toBe(FormAuthType.SGID) + await expect(sgidMyInfoForm.authType).toBe(FormAuthType.SGID_MyInfo) }) it('should save with default payments settings', async () => { diff --git a/src/app/models/field/baseField.ts b/src/app/models/field/baseField.ts index 524f651135..444d6e8a81 100644 --- a/src/app/models/field/baseField.ts +++ b/src/app/models/field/baseField.ts @@ -1,11 +1,7 @@ import { Schema } from 'mongoose' import UIDGenerator from 'uid-generator' -import { - BasicField, - FormResponseMode, - MyInfoAttribute, -} from '../../../../shared/types' +import { BasicField, MyInfoAttribute } from '../../../../shared/types' import { IFieldSchema, IMyInfoSchema, ITableFieldSchema } from '../../../types' const uidgen3 = new UIDGenerator(256, UIDGenerator.BASE62) @@ -62,13 +58,6 @@ BaseFieldSchema.pre('validate', function (next) { return next(Error('Field type is incorrect or unspecified')) } - // Prevent MyInfo fields from being set in encrypt mode. - if (this.parent().responseMode === FormResponseMode.Encrypt) { - if (this.myInfo?.attr) { - return next(Error('MyInfo fields are not allowed for storage mode forms')) - } - } - // No errors. return next() }) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 0f4f887fa0..4526073c4c 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -311,13 +311,12 @@ const compileFormModel = (db: Mongoose): IFormModel => { return ( myInfoFieldCount === 0 || ((this.authType === FormAuthType.MyInfo || - this.authType == FormAuthType.SGID_MyInfo) && - this.responseMode === FormResponseMode.Email && + this.authType === FormAuthType.SGID_MyInfo) && myInfoFieldCount <= 30) ) }, message: - 'Check that your form is MyInfo-authenticated, is an email mode form and has 30 or fewer MyInfo fields.', + 'Check that your form is MyInfo-authenticated and has 30 or fewer MyInfo fields.', }, }, form_logics: { @@ -457,21 +456,6 @@ const compileFormModel = (db: Mongoose): IFormModel => { // Do not allow authType to be changed if form is published if (this.authType !== v && this.status === FormStatus.Public) { return this.authType - // Singpass/Corppass/SGID authentication is available for both email - // and storage mode - // Important - this case must come before the MyInfo + storage - // mode case, or else we may accidentally set Singpass/Corppass/SGID - // storage mode forms to FormAuthType.NIL - } else if ( - [FormAuthType.SP, FormAuthType.CP, FormAuthType.SGID].includes(v) - ) { - return v - } else if ( - this.responseMode === FormResponseMode.Encrypt && - // MyInfo is not available for storage mode - (v === FormAuthType.MyInfo || v === FormAuthType.SGID_MyInfo) - ) { - return FormAuthType.NIL } else { return v } diff --git a/src/app/modules/auth/auth.controller.ts b/src/app/modules/auth/auth.controller.ts index fb51f93fbd..edc996b9bd 100644 --- a/src/app/modules/auth/auth.controller.ts +++ b/src/app/modules/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { SendOtpResponseDto } from 'shared/types/user' import { SUPPORT_FORM_LINK } from '../../../../shared/constants/links' import { createLoggerWithLabel } from '../../config/logger' +import { ADMIN_LOGIN_SESSION_COOKIE_NAME } from '../../loaders/express/session' import MailService from '../../services/mail/mail.service' import { createReqMeta, getRequestIp } from '../../utils/request' import { ControllerHandler } from '../core/core.types' @@ -256,7 +257,7 @@ export const handleSignout: ControllerHandler = async (req, res) => { } // No error. - res.clearCookie('connect.sid') + res.clearCookie(ADMIN_LOGIN_SESSION_COOKIE_NAME) return res.status(StatusCodes.OK).json({ message: 'Sign out successful' }) }) } 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 198334e0f2..eab814517e 100644 --- a/src/app/modules/submission/email-submission/email-submission.types.ts +++ b/src/app/modules/submission/email-submission/email-submission.types.ts @@ -26,3 +26,8 @@ export interface IPopulatedEmailFormWithResponsesAndHash { parsedResponses: ParsedResponsesObject hashedFields?: Set } + +export interface IPopulatedStorageFormWithResponsesAndHash { + parsedResponses: ParsedResponsesObject + 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 5ac3609369..09f5074893 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -1,12 +1,7 @@ import { StatusCodes } from 'http-status-codes' import { compact } from 'lodash' -import { MYINFO_ATTRIBUTE_MAP } from '../../../../../shared/constants/field/myinfo' -import { - BasicField, - FormAuthType, - MyInfoAttribute, -} from '../../../../../shared/types' +import { BasicField, FormAuthType } from '../../../../../shared/types' import { EmailAdminDataField, EmailDataCollationToolField, @@ -58,7 +53,6 @@ import { MyInfoMissingLoginCookieError, } from '../../myinfo/myinfo.errors' import { MyInfoKey } from '../../myinfo/myinfo.types' -import { getMyInfoChildHashKey } from '../../myinfo/myinfo.util' import { SgidInvalidJwtError, SgidMissingJwtError, @@ -80,14 +74,13 @@ import { } from '../submission.errors' import { ProcessedCheckboxResponse, - ProcessedChildrenResponse, ProcessedFieldResponse, ProcessedTableResponse, } from '../submission.types' +import { getAnswersForChild, getMyInfoPrefix } from '../submission.utils' import { ATTACHMENT_PREFIX, - MYINFO_PREFIX, TABLE_PREFIX, VERIFIED_PREFIX, } from './email-submission.constants' @@ -96,22 +89,6 @@ import { ResponseFormattedForEmail } from './email-submission.types' const logger = createLoggerWithLabel(module) -/** - * Determines the prefix for a question based on whether it is verified - * by MyInfo. - * @param response - * @param hashedFields Field ids of hashed fields. - * @returns the prefix - */ -const getMyInfoPrefix = ( - response: ResponseFormattedForEmail, - hashedFields: Set, -): string => { - return !!response.myInfo?.attr && hashedFields.has(response._id) - ? MYINFO_PREFIX - : '' -} - /** * Determines the prefix for a question based on whether it was verified * by a user during form submission. @@ -214,44 +191,6 @@ export const getAnswerForCheckbox = ( } } -export const getAnswersForChild = ( - response: ProcessedChildrenResponse, -): ResponseFormattedForEmail[] => { - const subFields = response.childSubFieldsArray - const qnChildIdx = response.childIdx ?? 0 - 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) => { - const subfield = subFields[idx] - return { - _id: getMyInfoChildHashKey( - response._id, - subFields[idx], - childIdx, - childName, - ), - fieldType: response.fieldType, - // qnChildIdx represents the index of the MyInfo field - // childIdx represents the index of the child in this MyInfo field - // as there might be >1 child for each MyInfo child field if "Add another child" is used - question: `Child ${qnChildIdx + childIdx + 1} ${ - MYINFO_ATTRIBUTE_MAP[subfield].description - }`, - 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), diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index 1e5c77bdc2..88a8e09ab4 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -39,11 +39,12 @@ import * as TurnstileMiddleware from '../../../services/turnstile/turnstile.midd import { Pipeline } from '../../../utils/pipeline-middleware' import { createReqMeta } from '../../../utils/request' import { getFormAfterPermissionChecks } from '../../auth/auth.service' -import { MalformedParametersError } from '../../core/core.errors' import { ControllerHandler } from '../../core/core.types' import { setFormTags } from '../../datadog/datadog.utils' import { getFeatureFlag } from '../../feature-flags/feature-flags.service' import { PermissionLevel } from '../../form/admin-form/admin-form.types' +import { MyInfoService } from '../../myinfo/myinfo.service' +import { extractMyInfoLoginJwt } from '../../myinfo/myinfo.util' import { SgidService } from '../../sgid/sgid.service' import { getOidcService } from '../../spcp/spcp.oidc.service' import { getPopulatedUserById } from '../../user/user.service' @@ -146,19 +147,6 @@ const submitEncryptModeForm = async ( let userInfo const { authType } = formDef switch (authType) { - case FormAuthType.MyInfo: { - logger.error({ - message: - 'Storage mode form is not allowed to have MyInfo authorisation', - meta: logMeta, - }) - const { errorMessage, statusCode } = mapRouteError( - new MalformedParametersError( - 'Storage mode form is not allowed to have MyInfo authType', - ), - ) - return res.status(statusCode).json({ message: errorMessage }) - } case FormAuthType.SP: { const oidcService = getOidcService(FormAuthType.SP) const jwtPayloadResult = await oidcService @@ -204,6 +192,45 @@ const submitEncryptModeForm = async ( userInfo = jwtPayloadResult.value.userInfo break } + case FormAuthType.SGID_MyInfo: + case FormAuthType.MyInfo: { + const jwtPayloadResult = await extractMyInfoLoginJwt( + req.cookies, + authType, + ) + .andThen(MyInfoService.verifyLoginJwt) + .map(({ uinFin }) => { + return uinFin + }) + .mapErr((error) => { + logger.error({ + message: `Error verifying MyInfo${ + authType === FormAuthType.SGID_MyInfo ? '(over SGID)' : '' + } hashes`, + meta: logMeta, + error, + }) + return error + }) + if (jwtPayloadResult.isErr()) { + const { statusCode, errorMessage } = mapRouteError( + jwtPayloadResult.error, + ) + logger.error({ + message: `Failed to verify ${ + authType === FormAuthType.SGID_MyInfo ? 'SGID' : 'Singpass' + } JWT with auth client`, + meta: logMeta, + error: jwtPayloadResult.error, + }) + return res.status(statusCode).json({ + message: errorMessage, + spcpSubmissionFailure: true, + }) + } + uinFin = jwtPayloadResult.value + break + } case FormAuthType.SGID: { const jwtPayloadResult = SgidService.extractSgidSingpassJwtPayload( req.cookies.jwtSgid, @@ -232,7 +259,9 @@ const submitEncryptModeForm = async ( if ( form.authType === FormAuthType.SP || form.authType === FormAuthType.CP || - form.authType === FormAuthType.SGID + form.authType === FormAuthType.SGID || + form.authType === FormAuthType.MyInfo || + form.authType === FormAuthType.SGID_MyInfo ) { const encryptVerifiedContentResult = VerifiedContentService.getVerifiedContent({ diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts index 3ae11e7310..c9e2de26c8 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts @@ -11,6 +11,7 @@ import { } from '../../../../../shared/constants' import { BasicField, + FormAuthType, StorageModeAttachment, StorageModeAttachmentsMap, } from '../../../../../shared/types' @@ -29,6 +30,9 @@ import { createReqMeta } from '../../../utils/request' import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { JoiPaymentProduct } from '../../form/admin-form/admin-form.payments.constants' import * as FormService from '../../form/form.service' +import { MyInfoService } from '../../myinfo/myinfo.service' +import { extractMyInfoLoginJwt } from '../../myinfo/myinfo.util' +import { IPopulatedStorageFormWithResponsesAndHash } from '../email-submission/email-submission.types' import ParsedResponsesObject from '../ParsedResponsesObject.class' import { sharedSubmissionParams } from '../submission.constants' import * as SubmissionService from '../submission.service' @@ -58,7 +62,10 @@ import { StorageSubmissionMiddlewareHandlerType, ValidateSubmissionMiddlewareHandlerRequest, } from './encrypt-submission.types' -import { mapRouteError } from './encrypt-submission.utils' +import { + formatMyInfoStorageResponseData, + mapRouteError, +} from './encrypt-submission.utils' import IncomingEncryptSubmission from './IncomingEncryptSubmission.class' const logger = createLoggerWithLabel(module) @@ -388,6 +395,7 @@ export const validateStorageSubmission = async ( next: NextFunction, ) => { const formDef = req.formsg.formDef + let spcpSubmissionFailure: undefined | true const logMeta = { action: 'validateStorageSubmission', @@ -413,12 +421,14 @@ export const validateStorageSubmission = async ( else { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { filename: __, content: ___, ...restAttachments } = rest - responses.push({ ...restAttachments } as EncryptAttachmentResponse) + responses.push({ + ...restAttachments, + } as EncryptAttachmentResponse) } } } req.formsg.filteredResponses = responses - return next() + return { parsedResponses, form: formDef } }) .mapErr((error) => { // TODO(FRM-1318): Set DB flag to true to harden submission validation after validation has similar error rates as email mode forms. @@ -444,8 +454,68 @@ export const validateStorageSubmission = async ( error, }) req.formsg.filteredResponses = req.body.responses + return error + }) + .andThen(({ parsedResponses, form }) => { + // Validate MyInfo responses + const { authType } = form + switch (authType) { + case FormAuthType.SGID_MyInfo: + case FormAuthType.MyInfo: { + return extractMyInfoLoginJwt(req.cookies, authType) + .andThen(MyInfoService.verifyLoginJwt) + .asyncAndThen(({ uinFin }) => + MyInfoService.fetchMyInfoHashes(uinFin, form._id) + .andThen((hashes) => + MyInfoService.checkMyInfoHashes( + parsedResponses.responses, + hashes, + ), + ) + .map( + (hashedFields) => ({ + hashedFields, + parsedResponses, + }), + ), + ) + .mapErr((error) => { + spcpSubmissionFailure = true + logger.error({ + message: `Error verifying MyInfo${ + authType === FormAuthType.SGID_MyInfo ? '(over SGID)' : '' + } hashes`, + meta: logMeta, + error, + }) + return error + }) + } + default: + return ok({ + parsedResponses, + }) + } + }) + .map(({ parsedResponses, hashedFields }) => { + const storageFormData = formatMyInfoStorageResponseData( + parsedResponses.getAllResponses(), + hashedFields, + ) + req.body.responses = storageFormData return next() }) + .mapErr((error) => { + logger.error({ + message: 'Error saving responses in req.body', + meta: logMeta, + error, + }) + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + message: 'Error saving responses in req.body', + spcpSubmissionFailure, + }) + }) } const encryptAttachment = async ( diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts index 631d04e0d9..e31cde8c88 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts @@ -11,6 +11,7 @@ import { SubmissionType, } from '../../../../../shared/types' import { calculatePrice } from '../../../../../shared/utils/paymentProductPrice' +import { isProcessedChildResponse } from '../../../../app/utils/field-validation/field-validation.guards' import { IEncryptedSubmissionSchema, IPopulatedEncryptedForm, @@ -48,6 +49,7 @@ import { FormNotFoundError, PrivateFormError, } from '../../form/form.errors' +import { MyInfoKey } from '../../myinfo/myinfo.types' import { PaymentNotFoundError } from '../../payments/payments.errors' import { SgidInvalidJwtError, @@ -71,6 +73,8 @@ import { SubmissionNotFoundError, ValidateFieldError, } from '../submission.errors' +import { ProcessedFieldResponse } from '../submission.types' +import { getAnswersForChild, getMyInfoPrefix } from '../submission.utils' import { AttachmentSizeLimitExceededError, @@ -359,3 +363,27 @@ export const getPaymentIntentDescription = ( } } } + +export const formatMyInfoStorageResponseData = ( + parsedResponses: ProcessedFieldResponse[], + hashedFields?: Set, +) => { + if (!hashedFields) { + return parsedResponses + } else { + return parsedResponses.flatMap((response) => { + if (isProcessedChildResponse(response)) { + return getAnswersForChild(response).map((childField) => { + const myInfoPrefix = getMyInfoPrefix(childField, hashedFields) + childField.question = `${myInfoPrefix}${childField.question}` + return childField + }) + } else { + // Obtain prefix for question based on whether it is verified by MyInfo. + const myInfoPrefix = getMyInfoPrefix(response, hashedFields) + response.question = `${myInfoPrefix}${response.question}` + return response + } + }) + } +} diff --git a/src/app/modules/submission/submission.utils.ts b/src/app/modules/submission/submission.utils.ts index 1266ad634b..4e6c63b300 100644 --- a/src/app/modules/submission/submission.utils.ts +++ b/src/app/modules/submission/submission.utils.ts @@ -9,10 +9,12 @@ import { import { err, ok, Result } from 'neverthrow' import { FIELDS_TO_REJECT } from '../../../../shared/constants/field/basic' +import { MYINFO_ATTRIBUTE_MAP } from '../../../../shared/constants/field/myinfo' import { BasicField, FormField, FormResponseMode, + MyInfoAttribute, } from '../../../../shared/types' import * as FileValidation from '../../../../shared/utils/file-validation' import { @@ -26,9 +28,18 @@ import { ParsedClearFormFieldResponse, } from '../../../types/api' import { AutoReplyMailData } from '../../services/mail/mail.types' +import { MyInfoKey } from '../myinfo/myinfo.types' +import { getMyInfoChildHashKey } from '../myinfo/myinfo.util' +import { MYINFO_PREFIX } from './email-submission/email-submission.constants' +import { ResponseFormattedForEmail } from './email-submission/email-submission.types' import { ConflictError } from './submission.errors' -import { FilteredResponse } from './submission.types' +import { + FilteredResponse, + ProcessedChildrenResponse, + ProcessedFieldResponse, + ProcessedSingleAnswerResponse, +} from './submission.types' type ResponseModeFilterParam = { fieldType: BasicField @@ -266,3 +277,63 @@ export const mapAttachmentsFromResponses = ( content: response.content, })) } + +/** + * Determines the prefix for a question based on whether it is verified + * by MyInfo. + * @param response + * @param hashedFields Field ids of hashed fields. + * @returns the prefix + */ +export const getMyInfoPrefix = ( + response: ResponseFormattedForEmail | ProcessedFieldResponse, + hashedFields: Set, +): string => { + return !!response.myInfo?.attr && hashedFields.has(response._id) + ? MYINFO_PREFIX + : '' +} + +/** + * Expands child subfields into individual fields, so that they are no longer nested under + * 1 parent field. + * @param response + * @returns + */ +export const getAnswersForChild = ( + response: ProcessedChildrenResponse, +): ProcessedSingleAnswerResponse[] => { + const subFields = response.childSubFieldsArray + const qnChildIdx = response.childIdx ?? 0 + 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) => { + const subfield = subFields[idx] + return { + _id: getMyInfoChildHashKey( + response._id, + subFields[idx], + childIdx, + childName, + ), + fieldType: response.fieldType, + // qnChildIdx represents the index of the MyInfo field + // childIdx represents the index of the child in this MyInfo field + // as there might be >1 child for each MyInfo child field if "Add another child" is used + question: `Child ${qnChildIdx + childIdx + 1} ${ + MYINFO_ATTRIBUTE_MAP[subfield].description + }`, + myInfo: { + attr: subFields[idx] as unknown as MyInfoAttribute, + }, + isVisible: response.isVisible, + isUserVerified: response.isUserVerified, + answer, + } + }) + }) +} diff --git a/src/app/modules/verified-content/verified-content.service.ts b/src/app/modules/verified-content/verified-content.service.ts index 826cf00db8..dafded0e66 100644 --- a/src/app/modules/verified-content/verified-content.service.ts +++ b/src/app/modules/verified-content/verified-content.service.ts @@ -34,10 +34,12 @@ export const getVerifiedContent = ({ CpVerifiedContent | SpVerifiedContent | SgidVerifiedContent > => { switch (type) { + case FormAuthType.MyInfo: case FormAuthType.SP: return getSpVerifiedContent(data) case FormAuthType.CP: return getCpVerifiedContent(data) + case FormAuthType.SGID_MyInfo: case FormAuthType.SGID: return getSgidVerifiedContent(data) } diff --git a/src/app/modules/verified-content/verified-content.types.ts b/src/app/modules/verified-content/verified-content.types.ts index c7aecc77fb..896861bd60 100644 --- a/src/app/modules/verified-content/verified-content.types.ts +++ b/src/app/modules/verified-content/verified-content.types.ts @@ -41,6 +41,11 @@ export type EncryptVerificationContentParams = { } export type GetVerifiedContentParams = { - type: FormAuthType.SP | FormAuthType.CP | FormAuthType.SGID + type: + | FormAuthType.SP + | FormAuthType.CP + | FormAuthType.SGID + | FormAuthType.MyInfo + | FormAuthType.SGID_MyInfo data: Record } diff --git a/src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts b/src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts index 1c27619e83..63279c91bb 100644 --- a/src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts +++ b/src/app/routes/api/v3/auth/__tests__/auth.routes.spec.ts @@ -6,6 +6,7 @@ import { errAsync, okAsync } from 'neverthrow' import supertest, { Session } from 'supertest-session' import validator from 'validator' +import { ADMIN_LOGIN_SESSION_COOKIE_NAME } from 'src/app/loaders/express/session' import MailService from 'src/app/services/mail/mail.service' import { HashingError } from 'src/app/utils/hash' import * as OtpUtils from 'src/app/utils/otp' @@ -511,7 +512,7 @@ describe('auth.routes', () => { }) // Should have session cookie returned. const sessionCookie = request.cookies.find( - (cookie) => cookie.name === 'connect.sid', + (cookie) => cookie.name === ADMIN_LOGIN_SESSION_COOKIE_NAME, ) expect(sessionCookie).toBeDefined() }) @@ -538,7 +539,7 @@ describe('auth.routes', () => { }) // Should have session cookie returned. const sessionCookie = request.cookies.find( - (cookie) => cookie.name === 'connect.sid', + (cookie) => cookie.name === ADMIN_LOGIN_SESSION_COOKIE_NAME, ) expect(sessionCookie).toBeDefined() }) @@ -591,9 +592,9 @@ describe('auth.routes', () => { // Assert expect(response.status).toEqual(200) expect(response.body).toEqual({ message: 'Sign out successful' }) - // connect.sid should now be empty. + // Login cookie should now be empty. expect(response.header['set-cookie'][0]).toEqual( - expect.stringContaining('connect.sid=;'), + expect.stringContaining(`${ADMIN_LOGIN_SESSION_COOKIE_NAME}=;`), ) }) @@ -629,7 +630,7 @@ describe('auth.routes', () => { // Assert // Should have session cookie returned. const sessionCookie = request.cookies.find( - (cookie) => cookie.name === 'connect.sid', + (cookie) => cookie.name === ADMIN_LOGIN_SESSION_COOKIE_NAME, ) expect(sessionCookie).toBeDefined() return response.body