diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 355c326d44..ba57033877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: NODE_OPTIONS: '--max-old-space-size=4096 --openssl-legacy-provider' AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE: 1 - name: Upload build files - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 if: always() with: name: build-output @@ -144,17 +144,6 @@ jobs: # if: ${{ needs.changes.outputs.backend == 'true' }} runs-on: ubuntu-latest steps: - # prevent CI from failing when worker runs out of memory - # https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749 - - name: Increase swapfile - run: | - df -h - sudo swapoff -a - sudo fallocate -l 15G /swapfile - sudo chmod 600 /swapfile - sudo mkswap /swapfile - sudo swapon /swapfile - sudo swapon --show - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 62ce16cd9b..4409919f55 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -43,13 +43,11 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + build-mode: ${{ matrix.build-mode }} + config-file: opengovsg/codeql-config/codeql-config.yml@develop # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) @@ -68,4 +66,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 diff --git a/.sentryignore b/.sentryignore deleted file mode 100644 index 4b17e01fbb..0000000000 --- a/.sentryignore +++ /dev/null @@ -1,2 +0,0 @@ -.gitignore -node_modules/ diff --git a/.template-env b/.template-env index 2007b47f3e..64cd9517a0 100644 --- a/.template-env +++ b/.template-env @@ -53,8 +53,6 @@ FORMSG_SDK_MODE= # AWS_REGION= ## Google Services -## If the below variable exists, the [google-analytics] feature will be enabled. -# GA_TRACKING_ID= ## If the below variables exists, the [captcha] feature will be enabled. # GOOGLE_CAPTCHA= # GOOGLE_CAPTCHA_PUBLIC= @@ -110,6 +108,8 @@ FORMSG_SDK_MODE= ## Per-minute, per-IP request limits applied to specific endpoints # SUBMISSIONS_RATE_LIMIT= # SEND_AUTH_OTP_RATE_LIMIT= +# DOWNLOAD_FORM_WHITELIST_RATE_LIMIT= +# UPLOAD_FORM_WHITELIST_RATE_LIMIT= # Used to check if BE Server is currently running on local development environment # One of boolean: "true" | "false" diff --git a/CHANGELOG.md b/CHANGELOG.md index 257a95ba74..d8ac853a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,147 @@ 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.146.1](https://github.com/opengovsg/FormSG/compare/v6.146.0...v6.146.1) + +- fix(MRF): disable hasBeenScanned [`#7652`](https://github.com/opengovsg/FormSG/pull/7652) +- build: release v6.146.0 [`#7644`](https://github.com/opengovsg/FormSG/pull/7644) + +#### [v6.146.0](https://github.com/opengovsg/FormSG/compare/v6.145.0...v6.146.0) + +> 4 September 2024 + +- build: merge release to develop [`#7643`](https://github.com/opengovsg/FormSG/pull/7643) +- feat: email notifications for mrf completed workflows [`#7597`](https://github.com/opengovsg/FormSG/pull/7597) +- build: release v6.145.0 [`#7637`](https://github.com/opengovsg/FormSG/pull/7637) +- chore: bump version to v6.146.0 [`7cc7d92`](https://github.com/opengovsg/FormSG/commit/7cc7d9235636d7a6b55f32d6e7214ced74078ee2) + +#### [v6.145.0](https://github.com/opengovsg/FormSG/compare/v6.144.0...v6.145.0) + +> 2 September 2024 + +- feat(form-issue): add rate-limit [`#7633`](https://github.com/opengovsg/FormSG/pull/7633) +- fix: set disable props on field [`#7634`](https://github.com/opengovsg/FormSG/pull/7634) +- fix(deps): bump type-fest from 4.25.0 to 4.26.0 in /shared [`#7632`](https://github.com/opengovsg/FormSG/pull/7632) +- chore(deps-dev): bump @types/http-errors from 2.0.1 to 2.0.4 [`#7627`](https://github.com/opengovsg/FormSG/pull/7627) +- build: merge release v6.144.0 to develop [`#7624`](https://github.com/opengovsg/FormSG/pull/7624) +- docs: remove angular from CREDITS.md [`#7577`](https://github.com/opengovsg/FormSG/pull/7577) +- build: release v6.144.0 [`#7623`](https://github.com/opengovsg/FormSG/pull/7623) +- chore: bump version to v6.145.0 [`a498d4b`](https://github.com/opengovsg/FormSG/commit/a498d4bcf8003cc4eaffc1c6969b1cd3a596b1cf) + +#### [v6.144.0](https://github.com/opengovsg/FormSG/compare/v6.143.1...v6.144.0) + +> 26 August 2024 + +- feat(nric): remove override flag [`#7620`](https://github.com/opengovsg/FormSG/pull/7620) +- chore(nric): remove nric collection description [`#7621`](https://github.com/opengovsg/FormSG/pull/7621) +- feat(nric): set default to false [`#7619`](https://github.com/opengovsg/FormSG/pull/7619) +- build: merge release v6.143.1 to develop [`#7622`](https://github.com/opengovsg/FormSG/pull/7622) +- build: release v6.143.1 [`#7618`](https://github.com/opengovsg/FormSG/pull/7618) +- build: release v6.143.0 to develop [`#7616`](https://github.com/opengovsg/FormSG/pull/7616) +- chore(scripts): set isSubmitterIdCollectionEnabled to true [`#7615`](https://github.com/opengovsg/FormSG/pull/7615) +- chore(deps-dev): bump core-js from 3.28.0 to 3.38.1 [`#7613`](https://github.com/opengovsg/FormSG/pull/7613) +- fix(deps): bump jwk-to-pem from 2.0.5 to 2.0.6 [`#7612`](https://github.com/opengovsg/FormSG/pull/7612) +- chore(deps-dev): bump @types/bluebird from 3.5.38 to 3.5.42 [`#7611`](https://github.com/opengovsg/FormSG/pull/7611) +- chore: bump version to v6.144.0 [`65b1b25`](https://github.com/opengovsg/FormSG/commit/65b1b25e197bcea55c034c103bd1096b01d7a625) + +#### [v6.143.1](https://github.com/opengovsg/FormSG/compare/v6.143.0...v6.143.1) + +> 22 August 2024 + +- fix(mrf): ensure number of non-editable fields are still present [`#7617`](https://github.com/opengovsg/FormSG/pull/7617) +- build: release v6.143.0 [`#7610`](https://github.com/opengovsg/FormSG/pull/7610) +- chore: bump version to v6.143.0 [`9db06d4`](https://github.com/opengovsg/FormSG/commit/9db06d4f164b9d1848b5f871812ed1fec50416c0) + +#### [v6.143.0](https://github.com/opengovsg/FormSG/compare/v6.142.0...v6.143.0) + +> 20 August 2024 + +- feat: change nric mask toggle to nric collection toggle [`#7566`](https://github.com/opengovsg/FormSG/pull/7566) +- fix(deps): bump type-fest from 4.24.0 to 4.25.0 in /shared [`#7606`](https://github.com/opengovsg/FormSG/pull/7606) +- chore(deps-dev): bump elliptic from 6.5.4 to 6.5.7 in /frontend [`#7605`](https://github.com/opengovsg/FormSG/pull/7605) +- fix(deps): bump elliptic from 6.5.4 to 6.5.7 [`#7604`](https://github.com/opengovsg/FormSG/pull/7604) +- fix(deps): bump libphonenumber-js from 1.11.5 to 1.11.7 in /shared [`#7603`](https://github.com/opengovsg/FormSG/pull/7603) +- build: merge release v6.142.0 to develop [`#7602`](https://github.com/opengovsg/FormSG/pull/7602) +- build: release v6.142.0 [`#7601`](https://github.com/opengovsg/FormSG/pull/7601) +- chore: bump version to v6.143.0 [`377e808`](https://github.com/opengovsg/FormSG/commit/377e808ba4c62f687f798bcf1fe7831e45768512) + +#### [v6.142.0](https://github.com/opengovsg/FormSG/compare/v6.141.0...v6.142.0) + +> 15 August 2024 + +- chore(myinfo): update myinfo nationalities list 15 aug 2024 [`#7600`](https://github.com/opengovsg/FormSG/pull/7600) +- build: merge release v6.141.0 to develop [`#7599`](https://github.com/opengovsg/FormSG/pull/7599) +- chore(myinfo): update myinfo race list 15-aug-2024 [`#7598`](https://github.com/opengovsg/FormSG/pull/7598) +- build: release v6.141.0 [`#7592`](https://github.com/opengovsg/FormSG/pull/7592) +- fix(deps): bump axios from 1.6.5 to 1.7.4 [`#7596`](https://github.com/opengovsg/FormSG/pull/7596) +- chore: bump version to v6.142.0 [`1789ce0`](https://github.com/opengovsg/FormSG/commit/1789ce02f32735f1aafa68808a463e3503fabb0a) + +#### [v6.141.0](https://github.com/opengovsg/FormSG/compare/v6.140.0...v6.141.0) + +> 13 August 2024 + +- build: merge release v6.140.0 to develop [`#7591`](https://github.com/opengovsg/FormSG/pull/7591) +- feat: support nric whitelisting [`#7534`](https://github.com/opengovsg/FormSG/pull/7534) +- fix: add nonce to script-src directive [`#7578`](https://github.com/opengovsg/FormSG/pull/7578) +- chore(deps-dev): bump @babel/preset-env from 7.22.5 to 7.25.3 [`#7586`](https://github.com/opengovsg/FormSG/pull/7586) +- chore: remove unused props [`#7585`](https://github.com/opengovsg/FormSG/pull/7585) +- feat(admin-form): implement drag and drop functionality [`#7372`](https://github.com/opengovsg/FormSG/pull/7372) +- fix: settings page resets position glitch [`#7582`](https://github.com/opengovsg/FormSG/pull/7582) +- fix(deps): bump type-fest from 4.23.0 to 4.24.0 in /shared [`#7583`](https://github.com/opengovsg/FormSG/pull/7583) +- chore: move label to tab [`#7581`](https://github.com/opengovsg/FormSG/pull/7581) +- build: release v6.140.0 [`#7580`](https://github.com/opengovsg/FormSG/pull/7580) +- chore: bump version to v6.141.0 [`e9b60f7`](https://github.com/opengovsg/FormSG/commit/e9b60f7983dcd7e3a10a41268309930a7fecd12b) + +#### [v6.140.0](https://github.com/opengovsg/FormSG/compare/v6.139.0...v6.140.0) + +> 8 August 2024 + +- refactor: worker pool [`#7553`](https://github.com/opengovsg/FormSG/pull/7553) +- chore(admin): mrf v1.1 content [`#7579`](https://github.com/opengovsg/FormSG/pull/7579) +- build: merge release v6.139.0 to develop [`#7575`](https://github.com/opengovsg/FormSG/pull/7575) +- docs: remove Sentry references and .sentryignore file [`#7573`](https://github.com/opengovsg/FormSG/pull/7573) +- build: release v6.139.0 [`#7564`](https://github.com/opengovsg/FormSG/pull/7564) +- chore: bump version to v6.140.0 [`5ff8367`](https://github.com/opengovsg/FormSG/commit/5ff83673930cd9f63e967953b58bc22824e96cb1) + +#### [v6.139.0](https://github.com/opengovsg/FormSG/compare/v6.138.0...v6.139.0) + +> 6 August 2024 + +- fix: forbid stripe acc connection with pdf summary enabled [`#7570`](https://github.com/opengovsg/FormSG/pull/7570) +- fix: failing gh actions due to runner out of disk space [`#7565`](https://github.com/opengovsg/FormSG/pull/7565) +- fix: enable pdf attachment for encrypt mode forms [`#7523`](https://github.com/opengovsg/FormSG/pull/7523) +- build: merge release v6.138.0 to develop [`#7559`](https://github.com/opengovsg/FormSG/pull/7559) +- chore: remove unused sgid toggle [`#7563`](https://github.com/opengovsg/FormSG/pull/7563) +- build: release v6.138.0 [`#7558`](https://github.com/opengovsg/FormSG/pull/7558) +- chore: bump version to v6.139.0 [`90ff12e`](https://github.com/opengovsg/FormSG/commit/90ff12e2178e29bc351fe6ef7c4b054a647b4258) + +#### [v6.138.0](https://github.com/opengovsg/FormSG/compare/v6.137.0...v6.138.0) + +> 1 August 2024 + +- fix: statsD default route not found [`#7551`](https://github.com/opengovsg/FormSG/pull/7551) +- feat(fe-quality): updating outdated links and copy content [`#7552`](https://github.com/opengovsg/FormSG/pull/7552) +- fix: mock statsD client during dev/test env [`#7550`](https://github.com/opengovsg/FormSG/pull/7550) +- fix(deps): bump libphonenumber-js from 1.11.4 to 1.11.5 in /shared [`#7547`](https://github.com/opengovsg/FormSG/pull/7547) +- chore(deps-dev): bump eslint-plugin-simple-import-sort from 12.1.0 to 12.1.1 [`#7544`](https://github.com/opengovsg/FormSG/pull/7544) +- chore(ci): pipe test coverage results to datadog [`#7542`](https://github.com/opengovsg/FormSG/pull/7542) +- fix(deps): bump type-fest from 4.22.1 to 4.23.0 in /shared [`#7541`](https://github.com/opengovsg/FormSG/pull/7541) +- fix(deps): bump type-fest from 4.22.0 to 4.22.1 in /shared [`#7540`](https://github.com/opengovsg/FormSG/pull/7540) +- chore: add simple fix to date format for sample form submission api [`#7539`](https://github.com/opengovsg/FormSG/pull/7539) +- fix(deps): bump type-fest from 4.21.0 to 4.22.0 in /shared [`#7536`](https://github.com/opengovsg/FormSG/pull/7536) +- chore(deps-dev): bump eslint-plugin-prettier from 5.1.3 to 5.2.1 in /shared [`#7535`](https://github.com/opengovsg/FormSG/pull/7535) +- build: merge release v6.137.0 to develop [`#7533`](https://github.com/opengovsg/FormSG/pull/7533) +- build: release v6.137.0 [`#7532`](https://github.com/opengovsg/FormSG/pull/7532) +- chore: bump version to v6.138.0 [`c7fcadc`](https://github.com/opengovsg/FormSG/commit/c7fcadc8c6f29a379d57b54c9d795282837bcc99) + #### [v6.137.0](https://github.com/opengovsg/FormSG/compare/v6.136.0...v6.137.0) +> 17 July 2024 + - chore(frontend): remove isSingleSubmission toggle for email mode form… [`#7530`](https://github.com/opengovsg/FormSG/pull/7530) - build: merge release v6.136.0 to develop [`#7531`](https://github.com/opengovsg/FormSG/pull/7531) - build: release v6.136.0 [`#7528`](https://github.com/opengovsg/FormSG/pull/7528) +- chore: bump version to v6.137.0 [`fd05950`](https://github.com/opengovsg/FormSG/commit/fd05950516c2324bf2fdaa65b2a8a2349d707d41) - chore(Frontend): remove isSingleSubmission toggle for email mode form that have not yet enabled it [`de48cc0`](https://github.com/opengovsg/FormSG/commit/de48cc018e9d15d1109a7d09845fcaf6734b35c1) #### [v6.136.0](https://github.com/opengovsg/FormSG/compare/v6.135.0...v6.136.0) @@ -161,20 +297,19 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: add default values for postman env variables to docker compose [`#7424`](https://github.com/opengovsg/FormSG/pull/7424) - fix: email notifications with wrong submission id [`#7418`](https://github.com/opengovsg/FormSG/pull/7418) - build: release v6.128.0 [`#7419`](https://github.com/opengovsg/FormSG/pull/7419) -- fix(btn): form admin not fully resolved when retrieved from FormService [`#7420`](https://github.com/opengovsg/FormSG/pull/7420) - fix: fix issue where email notifs for storage forms have wrong submission id [`66b27ab`](https://github.com/opengovsg/FormSG/commit/66b27abcc08e7b71a10c80989e9626f963d4601c) -- chore: bump version to v6.128.0 [`8f91bf0`](https://github.com/opengovsg/FormSG/commit/8f91bf0cbe541487bc9cd2775dd4c3c135b13f70) - chore: bump version to v6.129.0 [`061dc4f`](https://github.com/opengovsg/FormSG/commit/061dc4fa0b106300764268f1b5f0671de75a7213) #### [v6.128.0](https://github.com/opengovsg/FormSG/compare/v6.127.1...v6.128.0) > 20 June 2024 +- fix(btn): form admin not fully resolved when retrieved from FormService [`#7420`](https://github.com/opengovsg/FormSG/pull/7420) - feat(btn): frm 1717 mop flow to postman [`#7342`](https://github.com/opengovsg/FormSG/pull/7342) - build: merge release v6.127.1 to develop [`#7416`](https://github.com/opengovsg/FormSG/pull/7416) - fix(deps): bump libphonenumber-js from 1.11.3 to 1.11.4 in /shared [`#7415`](https://github.com/opengovsg/FormSG/pull/7415) - hotfix: dupe form fail [`#7414`](https://github.com/opengovsg/FormSG/pull/7414) -- chore: bump version to v6.128.0 [`4b8cea2`](https://github.com/opengovsg/FormSG/commit/4b8cea2a46a258b96dc9b7be31b480481b8e30e8) +- chore: bump version to v6.128.0 [`8f91bf0`](https://github.com/opengovsg/FormSG/commit/8f91bf0cbe541487bc9cd2775dd4c3c135b13f70) #### [v6.127.1](https://github.com/opengovsg/FormSG/compare/v6.127.0...v6.127.1) diff --git a/CREDITS.md b/CREDITS.md index 036dda33fe..d1f50af894 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -645,70 +645,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- - -## Project -@sentry/browser - -### Source -https://github.com/getsentry/sentry-javascript - -### License -MIT License - -Copyright (c) 2022 Functional Software, Inc. dba Sentry - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -------------------------------------------------------------------------------- - -## Project -@sentry/integrations - -### Source -https://github.com/getsentry/sentry-javascript - -### License -MIT License - -Copyright (c) 2022 Functional Software, Inc. dba Sentry - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ------------------------------------------------------------------------------- ## Project @@ -5446,37 +5382,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- - -## Project -ui-select - -### Source -https://github.com/angular-ui/ui-select - -### License -The MIT License (MIT) - -Copyright (c) 2013-2014 AngularUI - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ------------------------------------------------------------------------------- ## Project diff --git a/__tests__/e2e/constants/settings.ts b/__tests__/e2e/constants/settings.ts index 7bfbc51a8f..b19770975f 100644 --- a/__tests__/e2e/constants/settings.ts +++ b/__tests__/e2e/constants/settings.ts @@ -8,14 +8,19 @@ export type Collaborator = { export type E2eSettingsOptions = { status: FormStatus collaborators: Collaborator[] + isSubmitterIdCollectionEnabled: boolean responseLimit?: number closedFormMessage?: string emails?: string[] authType: FormAuthType /** If authType is SPCP/MyInfo, eserviceId is required. */ esrvcId?: string - /** If authType is non-NIL, nric is required. */ + /** + * Represents singpass/corppass validated nric. + * If authType is non-NIL, nric is required. */ nric?: string - /** If authType is CP, uen is required */ + /** + * Represents corppass validated uen. + * If authType is CP, uen is required. */ uen?: string } diff --git a/__tests__/e2e/helpers/createForm.ts b/__tests__/e2e/helpers/createForm.ts index eb10c71c03..aa2964dc3f 100644 --- a/__tests__/e2e/helpers/createForm.ts +++ b/__tests__/e2e/helpers/createForm.ts @@ -195,7 +195,7 @@ const addSettings = async ( if (formResponseMode.responseMode === FormResponseMode.Encrypt) { // Upload the secret key and confirm to open the form. await page - .getByPlaceholder('Enter or upload your Secret Key to continue') + .getByPlaceholder('Enter or drop your Secret Key to continue') .fill(formResponseMode.secretKey) await page .locator('label') diff --git a/__tests__/e2e/helpers/verifySubmission.ts b/__tests__/e2e/helpers/verifySubmission.ts index a13dbe7437..89bc0592bf 100644 --- a/__tests__/e2e/helpers/verifySubmission.ts +++ b/__tests__/e2e/helpers/verifySubmission.ts @@ -15,6 +15,7 @@ import { import { expectAttachment, expectContains, + expectNotToContain, // expectToast, getAutoreplyEmail, getResponseArray, @@ -127,7 +128,10 @@ export const verifyEmailSubmission = async ( expectAttachment(field, submission.attachments) } - if (formSettings.authType !== FormAuthType.NIL) { + if ( + formSettings.isSubmitterIdCollectionEnabled && + formSettings.authType !== FormAuthType.NIL + ) { // Verify that form auth correctly returned NRIC (SPCP/SGID) and UEN (CP) if (!formSettings.nric) throw new Error('No nric provided!') switch (formSettings.authType) { @@ -216,7 +220,10 @@ export const verifyEncryptSubmission = async ( expectAttachment(field, submission.attachments) } - if (formSettings.authType !== FormAuthType.NIL) { + if ( + formSettings.isSubmitterIdCollectionEnabled && + formSettings.authType !== FormAuthType.NIL + ) { // Verify that form auth correctly returned NRIC (SPCP/SGID) and UEN (CP) if (!formSettings.nric) throw new Error('No nric provided!') switch (formSettings.authType) { @@ -234,6 +241,23 @@ export const verifyEncryptSubmission = async ( break } } + + const expectSubmissionNotToContain = expectNotToContain(submission.html) + if (!formSettings.isSubmitterIdCollectionEnabled) { + // Verify that the submission does not contain any singpass or corppass validated fields + expectSubmissionNotToContain([ + SPCPFieldTitle.SpNric, + SPCPFieldTitle.CpUid, + SPCPFieldTitle.CpUen, + SgidFieldTitle.SgidNric, + ]) + if (formSettings.nric) { + expectSubmissionNotToContain([formSettings.nric]) + } + if (formSettings.uen) { + expectSubmissionNotToContain([formSettings.uen]) + } + } } // Step 2: Download the response using secret key diff --git a/__tests__/e2e/utils/response.ts b/__tests__/e2e/utils/response.ts index 26b548b9fb..d51e39e876 100644 --- a/__tests__/e2e/utils/response.ts +++ b/__tests__/e2e/utils/response.ts @@ -149,6 +149,18 @@ export const expectContains = } } +/** + * Tests that container does not contain any of the values in contained. + * @param {string} container string in which to search + * @param {string[]} containedArray Array of values to search for + */ +export const expectNotToContain = + (container: string) => (containedArray: string[]) => { + for (const contained of containedArray) { + expect(container).not.toContain(contained) + } + } + /** * Checks that an attachment field's attachment is contained in the email. * @param {E2eFieldMetadata} field field used to create and fill form diff --git a/__tests__/e2e/utils/settings.ts b/__tests__/e2e/utils/settings.ts index 3a6664a241..41da254319 100644 --- a/__tests__/e2e/utils/settings.ts +++ b/__tests__/e2e/utils/settings.ts @@ -42,6 +42,7 @@ const _getSettings = ( status: FormStatus.Public, collaborators: [], authType: FormAuthType.NIL, + isSubmitterIdCollectionEnabled: false, // By default, if emails is undefined, only the admin (current user) will receive. ...custom, } diff --git a/docker-compose.yml b/docker-compose.yml index 26dcce4e89..460bb57c59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,8 +43,6 @@ services: - INTRANET_IP_LIST_PATH # This needs to be removed and replaced with a real tracking ID in a local .env file # in order to enable GA in a local environment - # TODO: remove after React rollout #4786 - - GA_TRACKING_ID=mockGATrackingId # Test credentials from reCAPTCHA docs # https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do - GOOGLE_CAPTCHA=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe @@ -136,6 +134,8 @@ services: - POSTMAN_INTERNAL_CAMPAIGN_ID=campaign_test_2 - POSTMAN_INTERNAL_CAMPAIGN_API_KEY=key_test_456 - POSTMAN_BASE_URL=https://test.postman.gov.sg/api/v2 + - DOWNLOAD_FORM_WHITELIST_RATE_LIMIT + - UPLOAD_FORM_WHITELIST_RATE_LIMIT mockpass: build: https://github.com/opengovsg/mockpass.git#v4.3.1 diff --git a/docs/DEPLOYMENT_SETUP.md b/docs/DEPLOYMENT_SETUP.md index 168a52642f..3fe0ef2f13 100644 --- a/docs/DEPLOYMENT_SETUP.md +++ b/docs/DEPLOYMENT_SETUP.md @@ -46,7 +46,6 @@ SMS Analytics and Monitoring -- Sentry.io - Google Analytics Spam protection @@ -270,16 +269,6 @@ Forms can be protected with [recaptcha](https://www.google.com/recaptcha/about/) | :------------------------ | ----------------------------- | | `VITE_APP_GA_TRACKING_ID` | Google Analytics tracking ID. | -#### Sentry.io - -Client-side error events are piped to [sentry.io](https://sentry.io/welcome/) for monitoring purposes. - -| Variable | Description | -| :------------------ | ----------------------------------------------------------------------------------------------------- | -| `CSP_REPORT_URI` | Reporting URL for Content Security Policy violdations. Can be configured to use a Sentry.io endpoint. | -| `SENTRY_CONFIG_URL` | Sentry.io URL for configuring the Raven SDK. | -| `CSP_REPORT_URI` | Reporting URL for Content Security Policy violdations. Can be configured to use a Sentry.io endpoint. | - #### SMS with Twilio The Mobile Number field supports form-fillers verifying their mobile numbers via a One-Time-Pin sent to their mobile phones. All messages are sent using [Twilio](https://www.twilio.com/) messaging APIs. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3abc21914d..3bc3892f89 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.137.0", + "version": "6.146.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.137.0", + "version": "6.146.1", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^2.8.2", @@ -51,6 +51,7 @@ "lottie-web": "^5.9.4", "node-stdlib-browser": "^1.2.0", "p-queue": "^7.2.0", + "papaparse": "^5.4.1", "polyfill-object.fromentries": "^1.0.1", "react": "^18.3.0", "react-csv": "^2.2.2", @@ -86,6 +87,7 @@ "stopword": "^2.0.8", "stripe": "^11.1.0", "timezone-mock": "^1.3.6", + "tweetnacl": "^1.0.3", "type-fest": "^4.17.0", "typescript": "^5.4.5", "use-debounce": "^7.0.1", @@ -117,6 +119,7 @@ "@types/file-saver": "^2.0.3", "@types/gtag.js": "0.0.10", "@types/loadable__component": "^5.13.9", + "@types/papaparse": "^5.3.14", "@types/react": "^18.3.0", "@types/react-csv": "^1.1.2", "@types/react-dom": "^18.3.0", @@ -9388,6 +9391,15 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -11244,9 +11256,9 @@ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dev": true, "dependencies": { "bytes": "3.1.2", @@ -11257,7 +11269,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -11291,6 +11303,21 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -14965,37 +14992,37 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.20.0.tgz", + "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", "dev": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.2.0", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -15021,6 +15048,15 @@ "ms": "2.0.0" } }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/express/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -15028,9 +15064,9 @@ "dev": true }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", "dev": true }, "node_modules/express/node_modules/safe-buffer": { @@ -18642,10 +18678,13 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -20340,6 +20379,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -22885,9 +22929,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dev": true, "dependencies": { "debug": "2.6.9", @@ -22930,9 +22974,9 @@ "dev": true }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.0.tgz", + "integrity": "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==", "dev": true, "dependencies": { "encodeurl": "~1.0.2", @@ -22944,6 +22988,51 @@ "node": ">= 0.8.0" } }, + "node_modules/serve-static/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/serve-static/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", @@ -32022,6 +32111,15 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -33388,9 +33486,9 @@ "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==" }, "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dev": true, "requires": { "bytes": "3.1.2", @@ -33401,7 +33499,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -33427,6 +33525,15 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true + }, + "qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "requires": { + "side-channel": "^1.0.6" + } } } }, @@ -36231,37 +36338,37 @@ } }, "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.20.0.tgz", + "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", "dev": true, "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.2.0", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -36284,6 +36391,12 @@ "ms": "2.0.0" } }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -36291,9 +36404,9 @@ "dev": true }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", "dev": true }, "safe-buffer": { @@ -38923,9 +39036,9 @@ } }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "dev": true }, "merge-stream": { @@ -40072,6 +40185,11 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -41945,9 +42063,9 @@ } }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dev": true, "requires": { "debug": "2.6.9", @@ -41991,15 +42109,61 @@ } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.0.tgz", + "integrity": "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==", "dev": true, "requires": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.18.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + } + } } }, "set-cookie-parser": { diff --git a/frontend/package.json b/frontend/package.json index aa57f5ae6b..e715a5a1fe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.137.0", + "version": "6.146.1", "homepage": ".", "type": "module", "private": true, @@ -47,6 +47,7 @@ "lottie-web": "^5.9.4", "node-stdlib-browser": "^1.2.0", "p-queue": "^7.2.0", + "papaparse": "^5.4.1", "polyfill-object.fromentries": "^1.0.1", "react": "^18.3.0", "react-csv": "^2.2.2", @@ -82,6 +83,7 @@ "stopword": "^2.0.8", "stripe": "^11.1.0", "timezone-mock": "^1.3.6", + "tweetnacl": "^1.0.3", "type-fest": "^4.17.0", "typescript": "^5.4.5", "use-debounce": "^7.0.1", @@ -140,6 +142,7 @@ "@types/file-saver": "^2.0.3", "@types/gtag.js": "0.0.10", "@types/loadable__component": "^5.13.9", + "@types/papaparse": "^5.3.14", "@types/react": "^18.3.0", "@types/react-csv": "^1.1.2", "@types/react-dom": "^18.3.0", diff --git a/frontend/src/app/HashRouterElement.tsx b/frontend/src/app/HashRouterElement.tsx index d771d73eb9..28dabf1005 100644 --- a/frontend/src/app/HashRouterElement.tsx +++ b/frontend/src/app/HashRouterElement.tsx @@ -46,11 +46,11 @@ const pathMapper = [ }, { regex: /^\/forms\/?$/, - getTarget: (m: FormRegExpMatchArray) => `${DASHBOARD_ROUTE}`, + getTarget: () => `${DASHBOARD_ROUTE}`, }, { regex: /^\/examples\/?$/, - getTarget: (m: FormRegExpMatchArray) => `/examples`, + getTarget: () => `/examples`, }, ] diff --git a/frontend/src/assets/svgrs/singpass/SingpassFullLogoSvgr.tsx b/frontend/src/assets/svgrs/singpass/SingpassFullLogoSvgr.tsx index 9ce0fc90f8..8733c2cc43 100644 --- a/frontend/src/assets/svgrs/singpass/SingpassFullLogoSvgr.tsx +++ b/frontend/src/assets/svgrs/singpass/SingpassFullLogoSvgr.tsx @@ -13,11 +13,11 @@ const MemoSingpassFullLogoSvgr = memo( diff --git a/frontend/src/components/Checkbox/Checkbox.stories.tsx b/frontend/src/components/Checkbox/Checkbox.stories.tsx index e3ec018321..f45bcda9af 100644 --- a/frontend/src/components/Checkbox/Checkbox.stories.tsx +++ b/frontend/src/components/Checkbox/Checkbox.stories.tsx @@ -77,8 +77,6 @@ export const Playground: StoryFn = ({ othersInputName = 'others-input', othersCheckboxName = 'others-checkbox', label, - isDisabled, - isRequired, ...args }) => { const options = useMemo(() => ['Option 1', 'Option 2', 'Option 3'], []) diff --git a/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx b/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx index 41b34ad409..3d8ea7f390 100644 --- a/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx +++ b/frontend/src/components/DateRangePicker/DateRangePickerContext.tsx @@ -96,7 +96,6 @@ const useProvideDateRangePicker = ({ allowManualInput = true, allowInvalidDates = true, closeCalendarOnChange = true, - onBlur, onClick, colorScheme = 'primary', monthsToDisplay, @@ -180,8 +179,8 @@ const useProvideDateRangePicker = ({ ...props, }) - const handleInputBlur: FocusEventHandler = useCallback( - (e) => { + const handleInputBlur: FocusEventHandler = + useCallback(() => { const startDate = parse(startInputDisplay, dateFormat, new Date()) const endDate = parse(endInputDisplay, dateFormat, new Date()) // Clear if input is invalid on blur if invalid dates are not allowed. @@ -192,15 +191,13 @@ const useProvideDateRangePicker = ({ setEndInputDisplay('') } handleUpdateInputs([startDate, endDate]) - }, - [ + }, [ startInputDisplay, dateFormat, endInputDisplay, allowInvalidDates, handleUpdateInputs, - ], - ) + ]) const handleInputClick: MouseEventHandler = useCallback( (e) => { diff --git a/frontend/src/components/Dropdown/MultiSelect/MultiSelectProvider.tsx b/frontend/src/components/Dropdown/MultiSelect/MultiSelectProvider.tsx index 9950c060ad..fc8473ad51 100644 --- a/frontend/src/components/Dropdown/MultiSelect/MultiSelectProvider.tsx +++ b/frontend/src/components/Dropdown/MultiSelect/MultiSelectProvider.tsx @@ -12,7 +12,7 @@ import { UseMultipleSelectionProps, } from 'downshift' -import { VIRTUAL_LIST_MAX_HEIGHT } from '../constants' +import { VIRTUAL_LIST_ITEM_HEIGHT, VIRTUAL_LIST_MAX_HEIGHT } from '../constants' import { useItems } from '../hooks/useItems' import { MultiSelectContext } from '../MultiSelectContext' import { SelectContext, SharedSelectContextReturnProps } from '../SelectContext' @@ -35,6 +35,7 @@ export interface MultiSelectProviderProps< values: string[] /** Controlled selection onChange handler */ onChange: (value: string[]) => void + onBlur: () => void /** Function based on which items in dropdown are filtered. Default filter filters by fuzzy match. */ filter?(items: Item[], value: string): Item[] /** Initial dropdown opened state. Defaults to `false`. */ @@ -62,12 +63,14 @@ export interface MultiSelectProviderProps< * Any props to override the default props of `downshift#useMultipleSelection` set by this component. */ downshiftMultiSelectProps?: Partial> + overrideVirtualListItemHeight?: number } export const MultiSelectProvider = ({ items: rawItems, values, onChange, + onBlur, name, filter = defaultFilter, nothingFoundLabel = 'No matching results', @@ -84,6 +87,7 @@ export const MultiSelectProvider = ({ downshiftComboboxProps = {}, downshiftMultiSelectProps = {}, inputAria, + overrideVirtualListItemHeight, // NOTE: for use when virtual list item height is not VIRTUAL_LIST_ITEM_HEIGHT e.g, when adding description line children, }: MultiSelectProviderProps): JSX.Element => { const { items, getItemByValue } = useItems({ rawItems }) @@ -282,11 +286,13 @@ export const MultiSelectProvider = ({ }) const virtualListHeight = useMemo(() => { - const totalHeight = filteredItems.length * 48 + const totalHeight = + filteredItems.length * + (overrideVirtualListItemHeight ?? VIRTUAL_LIST_ITEM_HEIGHT) // If the total height is less than the max height, just return the total height. // Otherwise, return the max height. return Math.min(totalHeight, VIRTUAL_LIST_MAX_HEIGHT) - }, [filteredItems.length]) + }, [filteredItems.length, overrideVirtualListItemHeight]) return ( {children} diff --git a/frontend/src/components/Dropdown/MultiSelectContext.tsx b/frontend/src/components/Dropdown/MultiSelectContext.tsx index 0b73054569..4fbe40fe72 100644 --- a/frontend/src/components/Dropdown/MultiSelectContext.tsx +++ b/frontend/src/components/Dropdown/MultiSelectContext.tsx @@ -17,6 +17,7 @@ interface MultiSelectContextReturn > { maxItems: number | null isSelectedItemFullWidth?: boolean + onBlur?: () => void } export const MultiSelectContext = createContext< diff --git a/frontend/src/components/Dropdown/components/MultiSelectCombobox/MultiSelectCombobox.tsx b/frontend/src/components/Dropdown/components/MultiSelectCombobox/MultiSelectCombobox.tsx index 8aa28f5040..118ec0bc7f 100644 --- a/frontend/src/components/Dropdown/components/MultiSelectCombobox/MultiSelectCombobox.tsx +++ b/frontend/src/components/Dropdown/components/MultiSelectCombobox/MultiSelectCombobox.tsx @@ -45,7 +45,7 @@ export const MultiSelectCombobox = forwardRef( getToggleButtonProps, } = useSelectContext() - const { getDropdownProps } = useMultiSelectContext() + const { getDropdownProps, onBlur } = useMultiSelectContext() const mergedRefs = useMergeRefs(inputRef, ref) @@ -77,6 +77,7 @@ export const MultiSelectCombobox = forwardRef( aria-readonly={isReadOnly} __css={styles.fieldwrapper} onClick={handleToggleMenu} + onBlur={onBlur} > diff --git a/frontend/src/components/Dropdown/components/MultiSelectItem/MultiSelectItem.tsx b/frontend/src/components/Dropdown/components/MultiSelectItem/MultiSelectItem.tsx index 705df163a6..d47db44d66 100644 --- a/frontend/src/components/Dropdown/components/MultiSelectItem/MultiSelectItem.tsx +++ b/frontend/src/components/Dropdown/components/MultiSelectItem/MultiSelectItem.tsx @@ -21,8 +21,15 @@ export const MultiSelectItem = ({ index, ...props }: MultiSelectItemProps): JSX.Element => { - const { isDisabled, isReadOnly, setIsFocused, closeMenu, isOpen, styles } = - useSelectContext() + const { + isDisabled, + isReadOnly, + setIsFocused, + inputRef, + closeMenu, + isOpen, + styles, + } = useSelectContext() const { getSelectedItemProps, removeSelectedItem } = useMultiSelectContext() const itemMeta = useMemo(() => { @@ -38,9 +45,12 @@ export const MultiSelectItem = ({ // stealing focus due to parent's onClick handler. e.stopPropagation() if (isDisabled || isReadOnly) return + inputRef?.current?.focus() + setIsFocused(true) + removeSelectedItem(item) }, - [isDisabled, isReadOnly, item, removeSelectedItem], + [isDisabled, isReadOnly, item, removeSelectedItem, setIsFocused, inputRef], ) const handleTagClick = useCallback( @@ -49,12 +59,14 @@ export const MultiSelectItem = ({ // stealing focus due to parent's onClick handler. e.stopPropagation() if (isDisabled || isReadOnly) return + inputRef?.current?.focus() setIsFocused(true) + if (isOpen) { closeMenu() } }, - [closeMenu, isDisabled, isOpen, isReadOnly, setIsFocused], + [closeMenu, isDisabled, isOpen, isReadOnly, setIsFocused, inputRef], ) return ( @@ -67,6 +79,12 @@ export const MultiSelectItem = ({ selectedItem: item, index, disabled: isDisabled, + onMouseDown: (event) => { + // What: Prevent default focus on tab when clicking remove tag, to avoid invoking MultiSelect's onBlur + // callback function. + // Why: This allows onBlur of MultiSelect to be invoked only when user clicks out of the MultiSelect component. + event.preventDefault() + }, onKeyDown: (event) => { if ( (isDisabled || isReadOnly) && diff --git a/frontend/src/components/Dropdown/constants.ts b/frontend/src/components/Dropdown/constants.ts index bb3072efe3..3f05ce1d46 100644 --- a/frontend/src/components/Dropdown/constants.ts +++ b/frontend/src/components/Dropdown/constants.ts @@ -1,2 +1,3 @@ export const VIRTUAL_LIST_MAX_HEIGHT = 12 * 16 export const VIRTUAL_LIST_OVERSCAN_HEIGHT = 4 * 16 +export const VIRTUAL_LIST_ITEM_HEIGHT = 48 diff --git a/frontend/src/components/Field/Attachment/Attachment.tsx b/frontend/src/components/Field/Attachment/Attachment.tsx index 82e97f6c03..1d9864a65c 100644 --- a/frontend/src/components/Field/Attachment/Attachment.tsx +++ b/frontend/src/components/Field/Attachment/Attachment.tsx @@ -19,6 +19,7 @@ import { ATTACHMENT_THEME_KEY } from '~theme/components/Field/Attachment' import { ThemeColorScheme } from '~theme/foundations/colours' import { AttachmentStylesProvider } from './AttachmentContext' +import { downloadFile } from './utils/downloadFile' import { AttachmentDropzone } from './AttachmentDropzone' import { AttachmentFileInfo } from './AttachmentFileInfo' import { @@ -72,12 +73,32 @@ export interface AttachmentProps extends UseFormControlProps { /** * Show attachment download button. */ - enableDownload?: boolean + showDownload?: boolean + + /** + * Disable download button. + */ + isDownloadDisabled?: boolean + + /** + * Disable remove button. + */ + isRemoveDisabled?: boolean /** * Show attachment removal button */ - enableRemove?: boolean + showRemove?: boolean + + /** + * Override callback function that is invoked when download button is clicked. + */ + handleDownloadFileOverride?: () => void + + /** + * Override callback function that is invoked when remove button is clicked. + */ + handleRemoveFileOverride?: () => void } export const Attachment = forwardRef( @@ -92,8 +113,12 @@ export const Attachment = forwardRef( name, colorScheme, title, - enableDownload, - enableRemove, + showDownload, + showRemove, + isDownloadDisabled, + isRemoveDisabled, + handleDownloadFileOverride, + handleRemoveFileOverride, ...props }, ref, @@ -220,22 +245,21 @@ export const Attachment = forwardRef( colorScheme, }) - const handleRemoveFile = useCallback(() => { + const _handleRemoveFile = useCallback(() => { onChange(null) rootRef.current?.focus() }, [onChange, rootRef]) - const handleDownloadFile = useCallback(() => { + const handleRemoveFile = handleRemoveFileOverride ?? _handleRemoveFile + + const _handleDownloadFile = useCallback(() => { if (value) { - const url = URL.createObjectURL(value) - const a = document.createElement('a') - a.href = url - a.download = value.name - a.click() - URL.revokeObjectURL(url) + downloadFile(value) } }, [value]) + const handleDownloadFile = handleDownloadFileOverride ?? _handleDownloadFile + // Bunch of memoization to avoid unnecessary re-renders. const processedRootProps = useMemo(() => { return getRootProps({ @@ -274,8 +298,10 @@ export const Attachment = forwardRef( file={value} handleRemoveFile={handleRemoveFile} handleDownloadFile={handleDownloadFile} - enableDownload={enableDownload} - enableRemove={enableRemove} + showDownload={showDownload} + showRemove={showRemove} + isDownloadDisabled={isDownloadDisabled} + isRemoveDisabled={isRemoveDisabled} /> ) : ( void handleDownloadFile: () => void } export const AttachmentFileInfo = ({ file, - enableDownload = false, - enableRemove = true, handleRemoveFile, handleDownloadFile, + showDownload = false, + showRemove = true, + isDownloadDisabled = false, + isRemoveDisabled = false, }: AttachmentFileInfoProps) => { const readableFileSize = useMemo( - () => getReadableFileSize(file.size), + () => (file.size ? getReadableFileSize(file.size) : null), [file.size], ) - const showDownloadButton = enableDownload && file + const showDownloadButton = showDownload && file return ( File attached: {file.name} with file size of {readableFileSize} - + - {enableRemove ? ( + {showRemove ? ( } onClick={handleRemoveFile} + isDisabled={isRemoveDisabled} /> ) : null} {showDownloadButton ? ( @@ -61,6 +66,7 @@ export const AttachmentFileInfo = ({ aria-label="Click to download file" icon={} onClick={handleDownloadFile} + isDisabled={isDownloadDisabled} /> ) : null} diff --git a/frontend/src/components/Field/Attachment/utils/downloadFile.ts b/frontend/src/components/Field/Attachment/utils/downloadFile.ts new file mode 100644 index 0000000000..e322f894ea --- /dev/null +++ b/frontend/src/components/Field/Attachment/utils/downloadFile.ts @@ -0,0 +1,8 @@ +export const downloadFile = (value: File) => { + const url = URL.createObjectURL(value) + const a = document.createElement('a') + a.href = url + a.download = value.name + a.click() + URL.revokeObjectURL(url) +} diff --git a/frontend/src/components/Field/Attachment/utils/getReadableFileSize.ts b/frontend/src/components/Field/Attachment/utils/getReadableFileSize.ts index 0744bdee0f..58aeb1cd66 100644 --- a/frontend/src/components/Field/Attachment/utils/getReadableFileSize.ts +++ b/frontend/src/components/Field/Attachment/utils/getReadableFileSize.ts @@ -8,6 +8,9 @@ import { DECIMAL_BYTE_UNITS } from '~shared/constants/file' * @returns the human-readable file size string */ export const getReadableFileSize = (fileSizeInBytes: number): string => { + if (fileSizeInBytes === 0) { + return '0 B' + } const i = Math.floor(Math.log(fileSizeInBytes) / Math.log(1000)) const size = Number((fileSizeInBytes / Math.pow(1000, i)).toFixed(2)) return size + ' ' + DECIMAL_BYTE_UNITS[i] diff --git a/frontend/src/components/FormControl/FormLabel/FormLabel.tsx b/frontend/src/components/FormControl/FormLabel/FormLabel.tsx index c4b0fa6d4f..7c8e3a702b 100644 --- a/frontend/src/components/FormControl/FormLabel/FormLabel.tsx +++ b/frontend/src/components/FormControl/FormLabel/FormLabel.tsx @@ -10,9 +10,11 @@ import { } from '@chakra-ui/react' import { BxsHelpCircle } from '~assets/icons/BxsHelpCircle' +import { BxsInfoCircle } from '~assets/icons/BxsInfoCircle' import { useMdComponents } from '~hooks/useMdComponents' import { MarkdownText } from '~components/MarkdownText' import Tooltip from '~components/Tooltip' +import { TooltipProps } from '~components/Tooltip/Tooltip' export interface FormLabelProps extends ChakraFormLabelProps { /** @@ -23,6 +25,14 @@ export interface FormLabelProps extends ChakraFormLabelProps { * Tooltip text to be postfixed at the end of each label, if any. */ tooltipText?: string + /** + * Tooltip placement for the tooltip text, if any. + */ + tooltipPlacement?: TooltipProps['placement'] + /** + * Determines Tooltip icon used for the tooltip text. Defaults to help. + */ + tooltipVariant?: 'info' | 'help' /** * Description text to be shown below the label text, if any. */ @@ -55,6 +65,8 @@ export interface FormLabelProps extends ChakraFormLabelProps { export const FormLabel = ({ isRequired, tooltipText, + tooltipPlacement, + tooltipVariant, questionNumber, description, useMarkdownForDescription = false, @@ -76,11 +88,15 @@ export const FormLabel = ({ {children} {tooltipText && ( - + @@ -140,7 +156,8 @@ const FormLabelDescription = ({ const mdComponents = useMdComponents({ styles: mdComponentsStyles, overrides: { - p: ({ node, ...mdProps }) => ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + p: ({ node: _, ...mdProps }) => ( ), }, diff --git a/frontend/src/components/Radio/Radio.stories.tsx b/frontend/src/components/Radio/Radio.stories.tsx index 99dd2c211f..d6977ddec3 100644 --- a/frontend/src/components/Radio/Radio.stories.tsx +++ b/frontend/src/components/Radio/Radio.stories.tsx @@ -76,7 +76,6 @@ const PlaygroundTemplate: StoryFn = ({ name = 'radio', othersInputName = 'others-input', label, - isDisabled, isRequired, hasOthers, ...args diff --git a/frontend/src/components/Radio/Radio.tsx b/frontend/src/components/Radio/Radio.tsx index 28ac0bf029..0470418dd5 100644 --- a/frontend/src/components/Radio/Radio.tsx +++ b/frontend/src/components/Radio/Radio.tsx @@ -153,6 +153,7 @@ export const Radio = forwardRef( const handleSelect = useCallback( (e: SyntheticEvent) => { + if (props.isDisabled) return if (isChecked && allowDeselect) { e.preventDefault() // Toggle off if onChange is given. @@ -161,17 +162,18 @@ export const Radio = forwardRef( onChange?.({ target: { value: '' } }) } }, - [allowDeselect, isChecked, onChange], + [allowDeselect, isChecked, onChange, props.isDisabled], ) const handleSpacebar = useCallback( (e: KeyboardEvent) => { + if (props.isDisabled) return if (e.key !== ' ') return if (isChecked && allowDeselect) { handleSelect(e) } }, - [allowDeselect, handleSelect, isChecked], + [allowDeselect, handleSelect, isChecked, props.isDisabled], ) // Update labelProps to include props to allow deselection of radio value if diff --git a/frontend/src/components/SecretKeyVerificationInput/SecretKeyVerificationInput.tsx b/frontend/src/components/SecretKeyVerificationInput/SecretKeyVerificationInput.tsx index ca73b4dfbc..4c3f4b47b0 100644 --- a/frontend/src/components/SecretKeyVerificationInput/SecretKeyVerificationInput.tsx +++ b/frontend/src/components/SecretKeyVerificationInput/SecretKeyVerificationInput.tsx @@ -1,5 +1,10 @@ -import { useCallback, useMemo, useRef } from 'react' -import { RegisterOptions, useForm } from 'react-hook-form' +import { useCallback, useMemo, useRef, useState } from 'react' +import { + RegisterOptions, + useForm, + UseFormSetError, + UseFormSetValue, +} from 'react-hook-form' import { BiUpload } from 'react-icons/bi' import { FormControl, @@ -57,6 +62,8 @@ export const SecretKeyVerificationInput = ({ defaultValues: { secretKey: prefillSecretKey }, }) + const [dragging, setDragging] = useState(false) + const fileUploadRef = useRef(null) const secretKeyValidationRules: RegisterOptions = useMemo(() => { @@ -76,6 +83,64 @@ export const SecretKeyVerificationInput = ({ return setSecretKey(secretKey.trim()) }) + const processFile = ( + file: File, + setError: UseFormSetError, + setValue: UseFormSetValue, + ) => { + const reader = new FileReader() + reader.onload = async (e) => { + if (!e.target) return + const text = e.target.result?.toString() + + if (!text || !SECRET_KEY_REGEX.test(text)) { + return setError( + SECRET_KEY_NAME, + { + type: 'invalidFile', + message: 'Selected file seems to be invalid', + }, + { shouldFocus: true }, + ) + } + + setValue(SECRET_KEY_NAME, text, { shouldValidate: true }) + } + reader.readAsText(file) + } + + const preventDefaults = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDrop = useCallback( + (e: React.DragEvent) => { + preventDefaults(e) + setDragging(false) + + const file = e.dataTransfer.files?.[0] + if (!file) return + + processFile(file, setError, setValue) + }, + [setError, setValue], + ) + + const handleDragEnter = (e: React.DragEvent) => { + preventDefaults(e) + setDragging(true) + } + + const handleDragOver = (e: React.DragEvent) => { + preventDefaults(e) + } + + const handleDragLeave = (e: React.DragEvent) => { + preventDefaults(e) + setDragging(false) + } + const handleFileSelect = useCallback( ({ target }: React.ChangeEvent) => { const file = target.files?.[0] @@ -87,25 +152,7 @@ export const SecretKeyVerificationInput = ({ if (!file) return - const reader = new FileReader() - reader.onload = async (e) => { - if (!e.target) return - const text = e.target.result?.toString().trim() - - if (!text || !SECRET_KEY_REGEX.test(text)) { - return setError( - SECRET_KEY_NAME, - { - type: 'invalidFile', - message: 'Selected file seems to be invalid', - }, - { shouldFocus: true }, - ) - } - - setValue(SECRET_KEY_NAME, text, { shouldValidate: true }) - } - reader.readAsText(file) + processFile(file, setError, setValue) }, [setError, setValue], ) @@ -131,6 +178,15 @@ export const SecretKeyVerificationInput = ({ data-testid="secretKey" type="password" isDisabled={isLoading} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + placeholder={ + dragging + ? 'Drop your Secret Key here' + : 'Enter or upload your Secret Key here to continue' + } {...register(SECRET_KEY_NAME, secretKeyValidationRules)} /> diff --git a/frontend/src/constants/links.ts b/frontend/src/constants/links.ts index ca5798a74a..caf980acdb 100644 --- a/frontend/src/constants/links.ts +++ b/frontend/src/constants/links.ts @@ -1,7 +1,7 @@ import { PRIVACY_POLICY_ROUTE, TOU_ROUTE } from './routes' export const CONTACT_US = 'https://go.gov.sg/formsg-support' -export const FEATURE_REQUEST = 'https://go.gov.sg/form-featurerequest' +export const FEATURE_REQUEST = 'https://go.gov.sg/formsg-featurerequest' export const REPORT_VULNERABILITY = 'https://go.gov.sg/report-vulnerability' export const OSS_README = 'https://go.gov.sg/formsg-thirdparty' @@ -10,18 +10,19 @@ export const SINGPASS_FAQ = 'https://www.singpass.gov.sg/main/html/faq.html' // FormSG guide links export const FORM_GUIDE = 'https://go.gov.sg/formsg-guides' export const GUIDE_WEBHOOKS = 'https://go.gov.sg/formsg-guide-webhooks' -export const GUIDE_EMAIL_MODE = 'https://go.gov.sg/formsg-guide-email-mode' export const GUIDE_STORAGE_MODE = 'https://go.gov.sg/formsg-guide-storage-mode' export const GUIDE_FORM_LOGIC = 'https://go.gov.sg/formsg-guide-logic' export const GUIDE_FORM_MRF = 'https://go.gov.sg/formsg-guide-mrf' export const GUIDE_MRF_MODE = 'http://go.gov.sg/formsg-mrf' +export const GUIDE_MYINFO_BUILDER_FIELD = + 'https://go.gov.sg/formsg-guide-singpass-myinfo' export const GUIDE_SPCP_ESRVCID = 'https://go.gov.sg/formsg-guide-singpass-myinfo' export const GUIDE_ENABLE_SPCP = 'https://go.gov.sg/formsg-guide-singpass-myinfo-enable' export const GUIDE_TWILIO = 'https://go.gov.sg/formsg-guide-verified-smses' export const GUIDE_ATTACHMENT_SIZE_LIMIT = - 'https://go.gov.sg/formsg-guide-attachment-size-increase' + 'https://go.gov.sg/formsg-guide-attachments' export const GUIDE_E2EE = 'https://go.gov.sg/formsg-guide-e2e' export const GUIDE_TRANSFER_OWNERSHIP = 'https://go.gov.sg/formsg-guide-transfer-ownership' @@ -38,7 +39,8 @@ export const GUIDE_PAYMENTS_INVOICE_DIFFERENCES = 'https://go.gov.sg/formsg-payments-invoice-differences' export const GUIDE_ENCRYPTION_BOUNDARY_SHIFT = 'https://guide.form.gov.sg/faq/faq/storage-mode-virus-scanning-and-content-validation' -export const ACCEPTED_FILETYPES_SPREADSHEET = 'https://go.gov.sg/formsg-cwl' +export const ACCEPTED_FILETYPES_SPREADSHEET = + 'https://go.gov.sg/formsg-guide-attachments' export const APP_FOOTER_LINKS = [ { label: 'Guide', href: FORM_GUIDE }, @@ -51,18 +53,6 @@ export const APP_FOOTER_LINKS = [ ] export const LANDING_PAGE_EXAMPLE_FORMS = [ - { - href: 'https://form.gov.sg/600c490b7c026600138d4ca9', - label: 'Register for COVID-19 Vaccination', - }, - { - href: 'https://form.gov.sg/5eb38e989bd7d80011066a02', - label: 'Daily Reporting Health Symptoms', - }, - { - href: 'https://form.gov.sg/6057667b248bbc0012ceda2f', - label: 'Gov.sg WhatsApp Subscription', - }, { href: 'https://form.gov.sg/6041e9f8bd47260012395250', label: 'Post-ICT Survey', @@ -78,13 +68,10 @@ export const LANDING_PAGE_EXAMPLE_FORMS = [ ] export const OGP_ALL_PRODUCTS = 'https://www.open.gov.sg/products/overview' -export const OGP_POSTMAN = 'https://postman.gov.sg' +export const OGP_POSTMAN = 'https://go.gov.sg/formsg-guide-postman' export const OGP_PLUMBER = 'https://plumber.gov.sg/' export const OGP_SGID = 'https://go.gov.sg/sgid-formsg' export const OGP_FORMSG_REPO = 'https://github.com/opengovsg/formsg' export const FORMSG_UAT = 'https://uat.form.gov.sg' - -export const GROWTHBOOK_DEV_PROXY = - 'https://proxy-growthbook-stg.formsg.workers.dev' diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 18d0ee6cf4..13cb2ecf10 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -54,6 +54,3 @@ export const ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX = new RegExp( export const PAYMENT_PAGE_SUBROUTE = 'payment/:paymentId' export const EDIT_SUBMISSION_PAGE_SUBROUTE = 'edit/:submissionId' - -// Path for growthbook api proxy, set up on cloudflare workers. Worker script: https://github.com/opengovsg/formsg-private/pull/171. -export const GROWTHBOOK_API_HOST_PATH = '/api/v1/proxy/growthbook' diff --git a/frontend/src/features/admin-form/AdminFormCreatePage.stories.tsx b/frontend/src/features/admin-form/AdminFormCreatePage.stories.tsx index 13fba82042..af8787c683 100644 --- a/frontend/src/features/admin-form/AdminFormCreatePage.stories.tsx +++ b/frontend/src/features/admin-form/AdminFormCreatePage.stories.tsx @@ -107,6 +107,7 @@ TabletAllFields.parameters = { export const TabletLoading = Template.bind({}) TabletLoading.parameters = { ...getTabletViewParameters(), + mockdate: new Date('2024-09-11T13:00:00.000Z'), msw: buildMswRoutes({}, 'infinite'), } @@ -166,8 +167,10 @@ FormWithPayment.parameters = { payments_field: { payment_type: PaymentType.Fixed, enabled: true, - amount_cents: 5000, description: 'Test event registration fee', + payment_type: PaymentType.Variable, + min_amount: 1000, + max_amount: 5000, }, }), } diff --git a/frontend/src/features/admin-form/common/AdminViewFormService.ts b/frontend/src/features/admin-form/common/AdminViewFormService.ts index 0550a11635..40534c038f 100644 --- a/frontend/src/features/admin-form/common/AdminViewFormService.ts +++ b/frontend/src/features/admin-form/common/AdminViewFormService.ts @@ -24,10 +24,7 @@ import { filterHiddenInputs, } from '~features/public-form/utils' -import { - PREVIEW_MASKED_MOCK_UINFIN, - PREVIEW_MOCK_UINFIN, -} from '../preview/constants' +import { PREVIEW_MOCK_UINFIN } from '../preview/constants' // endpoint exported for testing export const ADMIN_FORM_ENDPOINT = '/admin/forms' @@ -62,9 +59,7 @@ export const previewForm = async ( // and if server has not already sent back a mock authenticated state. if (data.form.authType !== FormAuthType.NIL && !data.spcpSession) { data.spcpSession = { - userName: data.form.isNricMaskEnabled - ? PREVIEW_MASKED_MOCK_UINFIN - : PREVIEW_MOCK_UINFIN, + userName: PREVIEW_MOCK_UINFIN, } } @@ -95,9 +90,7 @@ export const viewFormTemplate = async ( // and if server has not already sent back a mock authenticated state. if (data.form.authType !== FormAuthType.NIL && !data.spcpSession) { data.spcpSession = { - userName: data.form.isNricMaskEnabled - ? PREVIEW_MASKED_MOCK_UINFIN - : PREVIEW_MOCK_UINFIN, + userName: PREVIEW_MOCK_UINFIN, } } 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 3e9ef6f6b5..94b46641b5 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 @@ -456,14 +456,10 @@ const FieldRow = ({ field, ...rest }: FieldRowProps) => { case BasicField.Statement: return case BasicField.Attachment: { - const enableDownload = + const showDownload = rest.responseMode === FormResponseMode.Multirespondent return ( - + ) } case BasicField.Checkbox: diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx index 78bf0c033f..87c7dc52e3 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignContent/StartPageView.tsx @@ -6,10 +6,7 @@ import { FormAuthType, FormLogoState, FormStartPage } from '~shared/types' import { useIsMobile } from '~hooks/useIsMobile' -import { - PREVIEW_MASKED_MOCK_UINFIN as PREVIEW_MASKED_MOCK_UINFIN, - PREVIEW_MOCK_UINFIN, -} from '~features/admin-form/preview/constants' +import { PREVIEW_MOCK_UINFIN } from '~features/admin-form/preview/constants' import { useEnv } from '~features/env/queries' import { FormInstructions } from '~features/public-form/components/FormInstructions/FormInstructions' import { @@ -200,9 +197,7 @@ export const StartPageView = () => { showHeader loggedInId={ form && form.authType !== FormAuthType.NIL - ? form.isNricMaskEnabled - ? PREVIEW_MASKED_MOCK_UINFIN - : PREVIEW_MOCK_UINFIN + ? PREVIEW_MOCK_UINFIN : undefined } {...formHeaderProps} diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditEmail/EditEmail.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditEmail/EditEmail.tsx index f1a9bd0396..84afc56cb5 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditEmail/EditEmail.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditEmail/EditEmail.tsx @@ -3,6 +3,7 @@ import { RegisterOptions } from 'react-hook-form' import { Box, FormControl, useMergeRefs } from '@chakra-ui/react' import { extend, pick } from 'lodash' +import { PaymentChannel } from '~shared/types' import { EmailFieldBase } from '~shared/types/field' import { FormResponseMode } from '~shared/types/form' import { validateEmailDomains } from '~shared/utils/email-domain-validation' @@ -83,7 +84,6 @@ export const EditEmail = ({ field }: EditEmailProps): JSX.Element => { const watchedHasAllowedEmailDomains = watch('hasAllowedEmailDomains') const watchedHasAutoReply = watch('autoReplyOptions.hasAutoReply') - const includeFormSummary = watch('autoReplyOptions.includeFormSummary') const requiredValidationRule = useMemo( () => createBaseValidationRules({ required: true }), @@ -130,26 +130,23 @@ export const EditEmail = ({ field }: EditEmailProps): JSX.Element => { ) const { data: form } = useCreateTabForm() - const isPdfResponseEnabled = useMemo( - () => - form?.responseMode === FormResponseMode.Email || - (form?.responseMode === FormResponseMode.Encrypt && includeFormSummary), - [form, includeFormSummary], - ) - const pdfResponseToggleDescription = useMemo(() => { - if (!isPdfResponseEnabled) { - return 'For security reasons, PDF responses are not included in email confirmations for Storage mode forms' - } - }, [isPdfResponseEnabled]) + const isEncryptMode = form?.responseMode === FormResponseMode.Encrypt + const isPaymentDisabledForm = + isEncryptMode && + form.payments_channel.channel === PaymentChannel.Unconnected + + const isPdfResponseEnabled = + form?.responseMode === FormResponseMode.Email || isPaymentDisabledForm + + const pdfResponseToggleDescription = isPdfResponseEnabled + ? undefined + : 'PDF responses are not available for payment forms.' // email confirmation is not supported on MRF - const isToggleEmailConfirmationDisabled = useMemo( - () => - form?.responseMode === FormResponseMode.Multirespondent && - !field.autoReplyOptions.hasAutoReply, - [field.autoReplyOptions.hasAutoReply, form?.responseMode], - ) + const isToggleEmailConfirmationDisabled = + form?.responseMode === FormResponseMode.Multirespondent && + !field.autoReplyOptions.hasAutoReply return ( diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMobile/EditMobile.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMobile/EditMobile.tsx index f69f14182a..6f61eccbaa 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMobile/EditMobile.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/EditFieldDrawer/edit-fieldtype/EditMobile/EditMobile.tsx @@ -91,7 +91,7 @@ export const EditMobile = ({ field }: EditMobileProps): JSX.Element => { 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 dfeff7fb40..9149f6cb59 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 @@ -1,10 +1,9 @@ import { useCallback, useEffect, useMemo } from 'react' import { Link as ReactLink } from 'react-router-dom' import { Box, Text } from '@chakra-ui/react' -import { useFeatureIsOn, useGrowthBook } from '@growthbook/growthbook-react' import { Droppable } from '@hello-pangea/dnd' +import { useGrowthBook } from '@growthbook/growthbook-react' -import { featureFlags } from '~shared/constants' import { AdminFormDto, FormAuthType, @@ -12,7 +11,7 @@ import { MyInfoAttribute, } from '~shared/types' -import { GUIDE_EMAIL_MODE } from '~constants/links' +import { GUIDE_MYINFO_BUILDER_FIELD } from '~constants/links' import { ADMINFORM_SETTINGS_SINGPASS_SUBROUTE } from '~constants/routes' import InlineMessage from '~components/InlineMessage' import Link from '~components/Link' @@ -90,26 +89,20 @@ export const MyInfoFieldPanel = () => { } }, [growthbook, user]) - const showSgidMyInfoV2 = useFeatureIsOn(featureFlags.myinfoSgid) - - const sgidSupportedFinal = useMemo(() => { - return showSgidMyInfoV2 ? SGID_SUPPORTED_V2 : SGID_SUPPORTED_V1 - }, [showSgidMyInfoV2]) - /** * If sgID is used, checks if the corresponding * MyInfo field is supported by sgID. */ const sgIDUnSupported = useCallback( (form: AdminFormDto | undefined, fieldType: MyInfoAttribute): boolean => { - const sgidSupported: Set = new Set(sgidSupportedFinal) + const sgidSupported: Set = new Set(SGID_SUPPORTED_V2) return ( form?.authType === FormAuthType.SGID_MyInfo && !sgidSupported.has(fieldType) ) }, - [sgidSupportedFinal], + [], ) // myInfo should be disabled if @@ -269,7 +262,7 @@ const MyInfoText = ({ return ( {`Only 30 MyInfo fields are allowed (${numMyInfoFields}/30). `} - + Learn more diff --git a/frontend/src/features/admin-form/create/builder-and-design/DeleteFieldModal/DeleteFieldModal.tsx b/frontend/src/features/admin-form/create/builder-and-design/DeleteFieldModal/DeleteFieldModal.tsx index 19a44c6165..e874cb4e0a 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/DeleteFieldModal/DeleteFieldModal.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/DeleteFieldModal/DeleteFieldModal.tsx @@ -31,11 +31,11 @@ export const DeleteFieldModal = (): JSX.Element => { const { deleteFieldModalDisclosure: { onClose }, } = useBuilderAndDesignContext() - const { mapIdToField, logicedFieldIdsSet } = useAdminFormLogic() + const { idToFieldMap, logicedFieldIdsSet } = useAdminFormLogic() const { fieldIsInLogic, fieldIcon, fieldLabel } = useMemo(() => { if (stateData.state !== FieldBuilderState.EditingField) return {} - const questionNumber = mapIdToField?.[stateData.field._id].questionNumber + const questionNumber = idToFieldMap?.[stateData.field._id].questionNumber const fieldTitle = stateData.field.title return { fieldIsInLogic: logicedFieldIdsSet?.has(stateData.field._id), @@ -44,7 +44,7 @@ export const DeleteFieldModal = (): JSX.Element => { ? `${questionNumber}. ${fieldTitle}` : fieldTitle, } - }, [mapIdToField, stateData, logicedFieldIdsSet]) + }, [idToFieldMap, stateData, logicedFieldIdsSet]) const { deleteFieldMutation } = useDeleteFormField() diff --git a/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx b/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx index a7f4e07875..899ba8ccb8 100644 --- a/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx +++ b/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx @@ -13,10 +13,7 @@ import { PaymentsThankYouSvgr } from '~components/FormEndPage/PaymentsThankYouSv import { ThankYouSvgr } from '~components/FormEndPage/ThankYouSvgr' import { useAdminForm } from '~features/admin-form/common/queries' -import { - PREVIEW_MASKED_MOCK_UINFIN, - PREVIEW_MOCK_UINFIN, -} from '~features/admin-form/preview/constants' +import { PREVIEW_MOCK_UINFIN } from '~features/admin-form/preview/constants' import { useEnv } from '~features/env/queries' import { FormBannerLogo, @@ -93,9 +90,7 @@ export const EndPageContent = (): JSX.Element => { onLogout={undefined} loggedInId={ form && form.authType !== FormAuthType.NIL - ? form.isNricMaskEnabled - ? PREVIEW_MASKED_MOCK_UINFIN - : PREVIEW_MOCK_UINFIN + ? PREVIEW_MOCK_UINFIN : undefined } /> 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 dce8f2a5d4..214dab6e92 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 @@ -41,7 +41,7 @@ export interface EditConditionBlockProps { handleRemoveCondition?: (index?: number | number[] | undefined) => void formMethods: UseFormReturn logicableFields: Dictionary | null - mapIdToField: Record | null + idToFieldMap: Record | null } export const EditConditionBlock = ({ @@ -50,7 +50,7 @@ export const EditConditionBlock = ({ handleRemoveCondition, formMethods, logicableFields, - mapIdToField, + idToFieldMap, }: EditConditionBlockProps): JSX.Element => { const name = useMemo(() => `conditions.${index}` as const, [index]) @@ -73,23 +73,23 @@ export const EditConditionBlock = ({ const logicTypeValue = watch('logicType') const showValueWatch = useWatchDependency(watch, 'show') const currentSelectedField = useMemo(() => { - if (!ifFieldIdValue || !mapIdToField) return - return mapIdToField[ifFieldIdValue] - }, [ifFieldIdValue, mapIdToField]) + if (!ifFieldIdValue || !idToFieldMap) return + return idToFieldMap[ifFieldIdValue] + }, [ifFieldIdValue, idToFieldMap]) /** * Effect to set value and error if the user conditions on a deleted field. */ useEffect(() => { - if (!ifFieldIdValue || !mapIdToField) return - if (!(ifFieldIdValue in mapIdToField)) { + if (!ifFieldIdValue || !idToFieldMap) return + if (!(ifFieldIdValue in idToFieldMap)) { resetField(`${name}.field`) setError(`${name}.field`, { type: 'manual', message: 'This field was deleted, please select another field', }) } - }, [ifFieldIdValue, mapIdToField, name, resetField, setError]) + }, [ifFieldIdValue, idToFieldMap, name, resetField, setError]) /** * Effect to reset the field if the field to apply a condition on is changed. @@ -148,8 +148,8 @@ export const EditConditionBlock = ({ }, [currentSelectedField]) const conditionValueItems = useMemo(() => { - if (!ifFieldIdValue || !mapIdToField) return [] - const mappedField = mapIdToField[ifFieldIdValue] + if (!ifFieldIdValue || !idToFieldMap) return [] + const mappedField = idToFieldMap[ifFieldIdValue] if (!mappedField) return [] switch (mappedField.fieldType) { case BasicField.YesNo: @@ -167,7 +167,7 @@ export const EditConditionBlock = ({ default: return [] } - }, [ifFieldIdValue, mapIdToField]) + }, [ifFieldIdValue, idToFieldMap]) const logicTypeWrapperWidth = useMemo(() => { if (!currentSelectedField) return '9rem' diff --git a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/ThenShowBlock.tsx b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/ThenShowBlock.tsx index 01644a5488..eb252574ba 100644 --- a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/ThenShowBlock.tsx +++ b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/ThenShowBlock.tsx @@ -26,14 +26,14 @@ interface ThenShowBlockProps { isLoading: boolean formMethods: UseFormReturn formFields?: FormFieldDto[] - mapIdToField: Record | null + idToFieldMap: Record | null } export const ThenShowBlock = ({ isLoading, formMethods, formFields, - mapIdToField, + idToFieldMap, }: ThenShowBlockProps): JSX.Element => { const { watch, @@ -91,11 +91,11 @@ export const ThenShowBlock = ({ if ( logicTypeValue !== LogicType.ShowFields || !showValueWatch.value?.length || - !mapIdToField + !idToFieldMap ) return const filteredShowFields = showValueWatch.value.filter( - (field) => field in mapIdToField, + (field) => field in idToFieldMap, ) const deletedFieldsCount = showValueWatch.value.length - filteredShowFields.length @@ -112,7 +112,7 @@ export const ThenShowBlock = ({ else setDeletedFieldsCount(deletedFieldsCount) }, [ logicTypeValue, - mapIdToField, + idToFieldMap, resetField, setError, setValue, @@ -182,7 +182,7 @@ export const ThenShowBlock = ({ @@ -195,7 +195,7 @@ const ThenLogicInput = ({ isLoading, formMethods, formFields, - mapIdToField, + idToFieldMap, }: ThenShowBlockProps) => { const { watch, @@ -211,7 +211,7 @@ const ThenLogicInput = ({ const thenValueItems = useMemo(() => { // Return every field except fields that are already used in the logic. if (logicTypeValue === LogicType.ShowFields) { - if (!formFields || !mapIdToField) return [] + if (!formFields || !idToFieldMap) return [] const usedFieldIds = new Set( logicConditionsWatch.value.map((condition) => condition.field), ) @@ -219,14 +219,14 @@ const ThenLogicInput = ({ .filter((f) => !usedFieldIds.has(f._id)) .map((f) => ({ value: f._id, - label: getLogicFieldLabel(mapIdToField[f._id]), + label: getLogicFieldLabel(idToFieldMap[f._id]), icon: BASICFIELD_TO_DRAWER_META[f.fieldType].icon, })) } return [] // Watch entire <***>Watch variables since <***>Watch.value is a Proxy object // and will not update if <***>Watch.value is mutated. - }, [formFields, logicConditionsWatch, mapIdToField, logicTypeValue]) + }, [formFields, logicConditionsWatch, idToFieldMap, logicTypeValue]) if (logicTypeValue === LogicType.PreventSubmit) { return ( diff --git a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx index 2e8f737fe2..ec18c050b2 100644 --- a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx +++ b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx @@ -30,7 +30,7 @@ export const useEditLogicBlock = ({ onSubmit, }: UseEditLogicBlockProps) => { const setToInactive = useAdminLogicStore(setToInactiveSelector) - const { logicableFields, mapIdToField, formFields } = useAdminFormLogic() + const { logicableFields, idToFieldMap, formFields } = useAdminFormLogic() const formMethods = useForm({ defaultValues: merge({ conditions: [{}] }, defaultValues), @@ -86,7 +86,7 @@ export const useEditLogicBlock = ({ wrapperRef, setToInactive, logicableFields, - mapIdToField, + idToFieldMap, formFields, } } @@ -113,7 +113,7 @@ export const EditLogicBlock = ({ handleRemoveCondition, setToInactive, logicableFields, - mapIdToField, + idToFieldMap, formFields, } = useEditLogicBlock({ defaultValues, onSubmit }) @@ -129,7 +129,7 @@ export const EditLogicBlock = ({ return ( { - const { mapIdToField } = useAdminFormLogic() + const { idToFieldMap } = useAdminFormLogic() const setToEditing = useAdminLogicStore(setToEditingSelector) const stateData = useAdminLogicStore(createOrEditDataSelector) @@ -41,12 +41,12 @@ export const InactiveLogicBlock = ({ const isPreventEdit = useMemo(() => !!stateData, [stateData]) const renderThenContent = useMemo(() => { - if (!mapIdToField) return null + if (!idToFieldMap) return null switch (logic.logicType) { case LogicType.ShowFields: { const allInvalid = logic.show.every( - (fieldId) => !(fieldId in mapIdToField), + (fieldId) => !(fieldId in idToFieldMap), ) return ( <> @@ -64,7 +64,7 @@ export const InactiveLogicBlock = ({ logic.show.map((fieldId, index) => ( ) } - }, [logic, mapIdToField]) + }, [logic, idToFieldMap]) const handleClick = useCallback(() => { if (isPreventEdit) { @@ -94,7 +94,7 @@ export const InactiveLogicBlock = ({ setToEditing(logic._id) }, [isPreventEdit, logic._id, setToEditing]) - if (!mapIdToField) return null + if (!idToFieldMap) return null return ( @@ -137,7 +137,7 @@ export const InactiveLogicBlock = ({ {index === 0 ? 'If' : 'and'} ( - + { const { data: form, isLoading } = useAdminForm() - const mapIdToField = useMemo(() => { + const idToFieldMap = useMemo(() => { if (!form) return null const augmentedFormFields = augmentWithQuestionNo( @@ -22,9 +22,9 @@ export const useAdminFormLogic = () => { }, [form]) const logicableFields = useMemo(() => { - if (!mapIdToField) return null - return pickBy(mapIdToField, (f) => ALLOWED_LOGIC_FIELDS.has(f.fieldType)) - }, [mapIdToField]) + if (!idToFieldMap) return null + return pickBy(idToFieldMap, (f) => ALLOWED_LOGIC_FIELDS.has(f.fieldType)) + }, [idToFieldMap]) const logicedFieldIdsSet = useMemo( () => @@ -39,24 +39,24 @@ export const useAdminFormLogic = () => { ) const hasError = useMemo(() => { - if (!mapIdToField || !form?.form_logics) return false + if (!idToFieldMap || !form?.form_logics) return false return form.form_logics.some( (logic) => // Logic is errored if some condition does not exist, or all the // show fields do not exist. logic.conditions.some( - (condition) => !(condition.field in mapIdToField), + (condition) => !(condition.field in idToFieldMap), ) || (logic.logicType === LogicType.ShowFields && - logic.show.every((field) => !(field in mapIdToField))), + logic.show.every((field) => !(field in idToFieldMap))), ) - }, [form?.form_logics, mapIdToField]) + }, [form?.form_logics, idToFieldMap]) return { isLoading, formLogics: form?.form_logics, formFields: form?.form_fields, - mapIdToField, + idToFieldMap, logicableFields, logicedFieldIdsSet, hasError, diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/ActiveStepBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/ActiveStepBlock.tsx index e83856f225..8ed3e06040 100644 --- a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/ActiveStepBlock.tsx +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/ActiveStepBlock.tsx @@ -1,10 +1,6 @@ import { useCallback } from 'react' -import { - FormWorkflowStep, - FormWorkflowStepDto, - WorkflowType, -} from '~shared/types' +import { FormWorkflowStep, FormWorkflowStepDto } from '~shared/types' import { setToInactiveSelector, diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx index 08619e2e74..b93e4a5cec 100644 --- a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx @@ -57,6 +57,21 @@ export const EditStepBlock = ({ }, []) const handleSubmit = formMethods.handleSubmit((inputs: EditStepInputs) => { + if (isFirstStepByStepNumber(stepNumber)) { + if (inputs.field) { + return onSubmit({ + ...inputs, + workflow_type: WorkflowType.Dynamic, + field: inputs.field, + }) + } + return onSubmit({ + ...inputs, + workflow_type: WorkflowType.Static, + emails: inputs.emails ?? [], + }) + } + let step: FormWorkflowStep switch (inputs.workflow_type) { case WorkflowType.Static: { diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/QuestionsBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/QuestionsBlock.tsx index 66d3687120..ecf10ed0c7 100644 --- a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/QuestionsBlock.tsx +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/QuestionsBlock.tsx @@ -22,7 +22,7 @@ export const QuestionsBlock = ({ isLoading, formMethods, }: QuestionsBlockProps): JSX.Element => { - const { formFields = [], mapIdToField } = useAdminFormWorkflow() + const { formFields = [], idToFieldMap } = useAdminFormWorkflow() const { formState: { errors }, control, @@ -36,7 +36,7 @@ export const QuestionsBlock = ({ ) .map((f) => ({ value: f._id, - label: getLogicFieldLabel(mapIdToField[f._id]), + label: getLogicFieldLabel(idToFieldMap[f._id]), icon: BASICFIELD_TO_DRAWER_META[f.fieldType].icon, })) @@ -51,7 +51,10 @@ export const QuestionsBlock = ({ > Fields to fill - + diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/RespondentBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/RespondentBlock.tsx index 59c540c537..cf74984a8d 100644 --- a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/RespondentBlock.tsx +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/RespondentBlock.tsx @@ -1,17 +1,21 @@ import { Controller, UseFormReturn } from 'react-hook-form' -import { Flex, FormControl, Stack, Text } from '@chakra-ui/react' +import { As, Box, Flex, FormControl, Icon, Stack, Text } from '@chakra-ui/react' import { get } from 'lodash' import isEmail from 'validator/lib/isEmail' import { WorkflowType } from '~shared/types' +import { BxsInfoCircleAlt } from '~assets/icons/BxsInfoCircleAlt' import { SingleSelect } from '~components/Dropdown' import FormErrorMessage from '~components/FormControl/FormErrorMessage' +import FormLabel from '~components/FormControl/FormLabel' import Radio from '~components/Radio' import { TagInput } from '~components/TagInput' +import Tooltip from '~components/Tooltip' import { BASICFIELD_TO_DRAWER_META } from '~features/admin-form/create/constants' import { EditStepInputs } from '~features/admin-form/create/workflow/types' +import { useUser } from '~features/user/queries' import { useAdminFormWorkflow } from '../../../hooks/useAdminFormWorkflow' import { isFirstStepByStepNumber } from '../utils/isFirstStepByStepNumber' @@ -31,8 +35,24 @@ export const RespondentBlock = ({ formState: { errors }, register, getValues, + control, } = formMethods + // TODO: (MRF-email-notif) Remove isTest check when MRF email notifications is out of beta + const isTest = process.env.NODE_ENV === 'test' + const { user, isLoading: isUserLoading } = useUser() + isLoading = isLoading || isUserLoading + + const { emailFormFields = [] } = useAdminFormWorkflow() + + const emailFieldItems = emailFormFields.map( + ({ _id, questionNumber, title, fieldType }) => ({ + label: `${questionNumber}. ${title}`, + value: _id, + icon: BASICFIELD_TO_DRAWER_META[fieldType].icon, + }), + ) + const defaultWorkflowType = getValues('workflow_type') const isFirstStep = isFirstStepByStepNumber(stepNumber) @@ -44,12 +64,62 @@ export const RespondentBlock = ({ py="1.5rem" px={{ base: '1.5rem', md: '2rem' }} > - Respondent in this step - {isFirstStep ? ( - Anyone you share the form link with + <> + + Respondent in this step + + + + + {/* TODO: (MRF-email-notif) Remove isTest check when MRF email + notifications is out of beta */} + {isTest || user?.betaFlags?.mrfEmailNotifications ? ( + + + Add an email field for notifications to be sent to this + respondent + + + { + return ( + !selectedValue || + !emailFieldItems || + emailFieldItems.some( + ({ value: fieldValue }) => + fieldValue === selectedValue, + ) || + 'Field is not an email field' + ) + }, + }} + control={control} + render={({ field: { value = '', ...rest } }) => ( + + )} + /> + + {errors.field?.message} + + ) : ( + Anyone you share the form link with + )} + ) : ( <> + Respondent in this step {errors.workflow_type?.message} - + )} ) } -type RespondentInputProps = Omit - -const RespondentInput = ({ isLoading, formMethods }: RespondentInputProps) => { - const { emailFormFields = [] } = useAdminFormWorkflow() - - const emailFieldItems = emailFormFields.map( - ({ _id, questionNumber, title, fieldType }) => ({ - label: `${questionNumber}. ${title}`, - value: _id, - icon: BASICFIELD_TO_DRAWER_META[fieldType].icon, - }), - ) +interface RespondentInputProps + extends Omit { + emailFieldItems: { + label: string + value: string + icon?: As + }[] +} +const RespondentInput = ({ + isLoading, + formMethods, + emailFieldItems, +}: RespondentInputProps) => { const { formState: { errors }, control, @@ -171,9 +246,11 @@ const RespondentInput = ({ isLoading, formMethods }: RespondentInputProps) => { name="field" rules={{ required: 'Please select a field', - validate: (value) => - !emailFormFields || - emailFormFields.some(({ _id }) => _id === value) || + validate: (selectedValue) => + !emailFieldItems || + emailFieldItems.some( + ({ value: fieldValue }) => fieldValue === selectedValue, + ) || 'Field is not an email field', }} render={({ field: { value = '', ...rest } }) => ( diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/InactiveStepBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/InactiveStepBlock.tsx index 291e6b5659..32014419de 100644 --- a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/InactiveStepBlock.tsx +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/InactiveStepBlock.tsx @@ -1,13 +1,17 @@ import { useCallback, useMemo } from 'react' import { BiTrash } from 'react-icons/bi' import { Box, chakra, Flex, Stack, Text } from '@chakra-ui/react' +import { Dictionary } from 'lodash' +import { FormField } from '~shared/types' import { FormWorkflowStepDto, WorkflowType } from '~shared/types/form' import IconButton from '~components/IconButton' import { FieldLogicBadge } from '~features/admin-form/create/logic/components/LogicContent/InactiveLogicBlock/FieldLogicBadge' import { LogicBadge } from '~features/admin-form/create/logic/components/LogicContent/InactiveLogicBlock/LogicBadge' +import { FormFieldWithQuestionNo } from '~features/form/types' +import { useUser } from '~features/user/queries' import { createOrEditDataSelector, @@ -24,15 +28,66 @@ interface InactiveStepBlockProps { handleOpenDeleteModal: () => void } +interface RespondentBadgeProps { + step: FormWorkflowStepDto + idToFieldMap: Dictionary> +} +const FirstStepRespondentBadge = ({ + step, + idToFieldMap, +}: RespondentBadgeProps): JSX.Element | null => { + if ( + step.workflow_type === WorkflowType.Static || + (step.workflow_type === WorkflowType.Dynamic && !step.field) + ) { + return ( + + ) + } + return +} + +const SubsequentStepRespondentBadges = ({ + step, + idToFieldMap, +}: RespondentBadgeProps): JSX.Element => { + switch (step.workflow_type) { + case WorkflowType.Static: + return ( + <> + {step.emails.map((email) => ( + {email} + ))} + + ) + case WorkflowType.Dynamic: + return + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = step + throw new Error('Unexpected workflow type encountered') + } + } +} + export const InactiveStepBlock = ({ stepNumber, step, handleOpenDeleteModal, }: InactiveStepBlockProps): JSX.Element | null => { - const { mapIdToField } = useAdminFormWorkflow() + const { idToFieldMap } = useAdminFormWorkflow() const setToEditing = useAdminWorkflowStore(setToEditingSelector) const stateData = useAdminWorkflowStore(createOrEditDataSelector) + const { user } = useUser() + // TODO: (MRF-email-notif) Remove isTest check when MRF email notifications is out of beta + const isTest = process.env.NODE_ENV === 'test' + // Prevent editing step if some other step is being edited. const isPreventEdit = useMemo(() => !!stateData, [stateData]) @@ -45,20 +100,6 @@ export const InactiveStepBlock = ({ const isFirstStep = isFirstStepByStepNumber(stepNumber) - const respondentBadges = useMemo(() => { - switch (step.workflow_type) { - case WorkflowType.Static: - return step.emails.map((email) => {email}) - case WorkflowType.Dynamic: - return - default: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _: never = step - throw new Error('Unexpected workflow type encountered') - } - } - }, [mapIdToField, step]) - const questionBadges = useMemo(() => { if (step.edit.length === 0) { return ( @@ -71,7 +112,7 @@ export const InactiveStepBlock = ({ ) } - const allInvalid = step.edit.every((fieldId) => !(fieldId in mapIdToField)) + const allInvalid = step.edit.every((fieldId) => !(fieldId in idToFieldMap)) if (allInvalid) { return ( @@ -88,7 +129,7 @@ export const InactiveStepBlock = ({ return step.edit.map((fieldId, index) => ( )) - }, [mapIdToField, step.edit]) + }, [idToFieldMap, step.edit]) return ( @@ -129,8 +170,17 @@ export const InactiveStepBlock = ({ Respondent in this step + {/* TODO: (MRF-email-notif) Remove isTest and betaFlag check when MRF email + notifications is out of beta */} {isFirstStep ? ( - Anyone you share the form link with + isTest || user?.betaFlags?.mrfEmailNotifications ? ( + + ) : ( + Anyone you share the form link with + ) ) : ( - {respondentBadges} + )} diff --git a/frontend/src/features/admin-form/create/workflow/hooks/useAdminFormWorkflow.ts b/frontend/src/features/admin-form/create/workflow/hooks/useAdminFormWorkflow.ts index 07c791122a..ed374e0992 100644 --- a/frontend/src/features/admin-form/create/workflow/hooks/useAdminFormWorkflow.ts +++ b/frontend/src/features/admin-form/create/workflow/hooks/useAdminFormWorkflow.ts @@ -20,7 +20,7 @@ export const useAdminFormWorkflow = () => { form?.form_fields.map(augmentWithMyInfo) ?? [], ) - const mapIdToField = useMemo( + const idToFieldMap = useMemo( () => keyBy(augmentedFormFields, '_id'), [augmentedFormFields], ) @@ -43,7 +43,7 @@ export const useAdminFormWorkflow = () => { form?.responseMode !== FormResponseMode.Multirespondent ? undefined : form?.workflow, - mapIdToField, + idToFieldMap, emailFormFields, } } diff --git a/frontend/src/features/admin-form/preview/constants.ts b/frontend/src/features/admin-form/preview/constants.ts index e07557d54a..cad5f73ab2 100644 --- a/frontend/src/features/admin-form/preview/constants.ts +++ b/frontend/src/features/admin-form/preview/constants.ts @@ -1,2 +1 @@ export const PREVIEW_MOCK_UINFIN = 'S1234567A' -export const PREVIEW_MASKED_MOCK_UINFIN = '*****567A' diff --git a/frontend/src/features/admin-form/responses/FeedbackPage/utils/FeedbackCsvGenerator.test.ts b/frontend/src/features/admin-form/responses/FeedbackPage/utils/FeedbackCsvGenerator.test.ts index e91271b987..dc72dc9955 100644 --- a/frontend/src/features/admin-form/responses/FeedbackPage/utils/FeedbackCsvGenerator.test.ts +++ b/frontend/src/features/admin-form/responses/FeedbackPage/utils/FeedbackCsvGenerator.test.ts @@ -1,4 +1,5 @@ import { stringify } from 'csv-string' +import mockdate from 'mockdate' import moment from 'moment-timezone' import { DateString, FormFeedbackDto, FormId } from '~shared/types' @@ -8,6 +9,14 @@ import { FeedbackCsvGenerator } from './FeedbackCsvGenerator' const UTF8_BYTE_ORDER_MARK = '\uFEFF' describe('FeedbackCsvGenerator', () => { + beforeEach(() => { + mockdate.set(moment().toDate()) + }) + + afterEach(() => { + mockdate.reset() + }) + afterAll(() => { vi.clearAllMocks() }) diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts index e046bd3594..f5bc74d0f2 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/useDecryptionWorkers.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useMutation, UseMutationOptions } from 'react-query' import { datadogLogs } from '@datadog/browser-logs' +import { useFeatureIsOn } from '@growthbook/growthbook-react' import { waitForMs } from '~utils/waitForMs' @@ -14,7 +15,10 @@ import { } from '~features/analytics/AnalyticsService' import { useUser } from '~features/user/queries' -import { downloadResponseAttachment } from './utils/downloadCsv' +import { + downloadResponseAttachment, + downloadResponseAttachmentURL, +} from './utils/downloadCsv' import { EncryptedResponseCsvGenerator } from './utils/EncryptedResponseCsvGenerator' import { EncryptedResponsesStreamParams, @@ -55,6 +59,19 @@ interface UseDecryptionWorkersProps { > } +function timeout( + ms: number, + errorMessage = 'Operation timed out', +): Promise { + return new Promise((_, reject) => + setTimeout(() => reject(new Error(errorMessage)), ms), + ) +} + +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([promise, timeout(ms)]) +} + const useDecryptionWorkers = ({ onProgress, mutateProps, @@ -65,6 +82,10 @@ const useDecryptionWorkers = ({ const { data: adminForm } = useAdminForm() const { user } = useUser() + const isTest = process.env.NODE_ENV === 'test' + const isFasterDownloadsFeatureOn = useFeatureIsOn('faster-downloads') + const isFasterDownloadsEnabled = isTest || isFasterDownloadsFeatureOn + useEffect(() => { return () => killWorkers(workers) }, [workers]) @@ -161,13 +182,16 @@ const useDecryptionWorkers = ({ // round-robin scheduling const { workerApi } = workerPool[receivedRecordCount % numWorkers] - const decryptResult = await workerApi.decryptIntoCsv({ - line: result.value, - secretKey, - downloadAttachments, - formId: adminForm._id, - hostOrigin: window.location.origin, - }) + const decryptResult = await workerApi.decryptIntoCsv( + { + line: result.value, + secretKey, + downloadAttachments, + formId: adminForm._id, + hostOrigin: window.location.origin, + }, + isFasterDownloadsEnabled, + ) progress += 1 onProgress(progress) @@ -348,11 +372,320 @@ const useDecryptionWorkers = ({ }) }) }, - [adminForm, onProgress, user?._id, workers], + [adminForm, onProgress, user?._id, workers, isFasterDownloadsEnabled], + ) + + const downloadEncryptedResponsesFaster = useCallback( + async ({ + responsesCount, + downloadAttachments, + secretKey, + endDate, + startDate, + }: DownloadEncryptedParams) => { + if (!adminForm || !responsesCount) { + return Promise.resolve({ + expectedCount: 0, + successCount: 0, + errorCount: 0, + }) + } + + console.log('Faster downloads is enabled ⚡') + + abortControllerRef.current.abort() + const freshAbortController = new AbortController() + abortControllerRef.current = freshAbortController + + if (workers.length) killWorkers(workers) + + const numWorkers = window.navigator.hardwareConcurrency || 4 + let errorCount = 0 + let unverifiedCount = 0 + let attachmentErrorCount = 0 + let unknownStatusCount = 0 + + const logMeta = { + action: 'downloadEncryptedReponses', + formId: adminForm._id, + formTitle: adminForm.title, + downloadAttachments: downloadAttachments, + num_workers: numWorkers, + expectedNumSubmissions: NUM_OF_METADATA_ROWS, + adminId: user?._id, + } + // Trigger analytics here before starting decryption worker + trackDownloadResponseStart(adminForm, numWorkers, NUM_OF_METADATA_ROWS) + datadogLogs.logger.info('Download response start', { + meta: { + ...logMeta, + }, + }) + + const workerPool: CleanableDecryptionWorkerApi[] = [] + const idleWorkers: number[] = [] + + for (let i = workerPool.length; i < numWorkers; i++) { + workerPool.push(makeWorkerApiAndCleanup()) + idleWorkers.push(i) + } + + setWorkers(workerPool) + + const csvGenerator = new EncryptedResponseCsvGenerator( + responsesCount, + NUM_OF_METADATA_ROWS, + ) + + const stream = await getEncryptedResponsesStream( + adminForm._id, + { downloadAttachments, endDate, startDate }, + freshAbortController, + ) + + const processTask = async (value: string, workerIdx: number) => { + const { workerApi } = workerPool[workerIdx] + + const decryptResult = await workerApi.decryptIntoCsv( + { + line: value, + secretKey, + downloadAttachments, + formId: adminForm._id, + hostOrigin: window.location.origin, + }, + isFasterDownloadsEnabled, + ) + + switch (decryptResult.status) { + case CsvRecordStatus.Ok: + try { + csvGenerator.addRecord(decryptResult.submissionData) + } catch (e) { + errorCount++ + console.error('Error in getResponseInstance', e) + } + + // It's fine to hog on to the worker here while waiting for the browser + // rate limit to pass. If decryption is fast, we would wait regardless. + // If decryption is slow, we won't hit rate limits. + if (downloadAttachments && decryptResult.downloadBlobURL) { + await downloadResponseAttachmentURL( + decryptResult.downloadBlobURL, + decryptResult.id, + ) + URL.revokeObjectURL(decryptResult.downloadBlobURL) + } + break + case CsvRecordStatus.Unknown: + unknownStatusCount++ + break + case CsvRecordStatus.Error: + errorCount++ + break + case CsvRecordStatus.AttachmentError: + errorCount++ + attachmentErrorCount++ + break + case CsvRecordStatus.Unverified: + unverifiedCount++ + break + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = decryptResult.status + throw new Error('Invalid decryptResult status encountered.') + } + } + return workerIdx + } + + const readAndQueueTask = async () => { + const reader = stream.getReader() + let progress = 0 + let pendingTasks: Promise[] = [] + + try { + while (progress < responsesCount) { + const { done, value } = await reader.read() + if (done) break + + progress += 1 + onProgress(progress) + + while (idleWorkers.length === 0) { + const finishedTasks: number[] = [] + for (let i = 0; i < pendingTasks.length; i++) { + try { + const freedWorkerIdx = await withTimeout(pendingTasks[i], 50) + idleWorkers.push(freedWorkerIdx) + finishedTasks.push(i) + } catch (e) { + if ( + e instanceof Error && + e.message === 'Operation timed out' + ) { + continue + } + console.error(`Error in task ${i}`, e) + } + } + pendingTasks = pendingTasks.filter( + (_, i) => !finishedTasks.includes(i), + ) + } + + const workerIdx = idleWorkers.shift()! + pendingTasks.push(processTask(value, workerIdx)) + } + await Promise.all(pendingTasks) + } catch (e) { + console.error('Error reading stream', e) + } finally { + reader.releaseLock() + } + } + + const downloadStartTime = performance.now() + + return new Promise((resolve, reject) => { + readAndQueueTask() + .catch((err) => { + if (!downloadStartTime) { + // No start time, means did not even start http request. + datadogLogs.logger.info('Download network failure', { + meta: { + ...logMeta, + error: { + message: err.message, + name: err.name, + stack: err.stack, + }, + }, + }) + + trackDownloadNetworkFailure(adminForm, err) + } else { + const downloadFailedTime = performance.now() + const timeDifference = downloadFailedTime - downloadStartTime + + datadogLogs.logger.info('Download response failure', { + meta: { + ...logMeta, + duration: timeDifference, + error: { + message: err.message, + name: err.name, + stack: err.stack, + }, + }, + }) + + trackDownloadResponseFailure( + adminForm, + numWorkers, + NUM_OF_METADATA_ROWS, + timeDifference, + err, + ) + + console.error( + 'Failed to download data, is there a network issue?', + err, + ) + killWorkers(workerPool) + reject(err) + } + }) + .finally(() => { + const checkComplete = () => { + // If all the records could not be decrypted + if (errorCount + unverifiedCount === responsesCount) { + const failureEndTime = performance.now() + // todo: check the timedifference redeclaration + const timeDifference = failureEndTime - downloadStartTime + + datadogLogs.logger.info('Partial decryption failure', { + meta: { + ...logMeta, + duration: timeDifference, + error_count: errorCount, + unverified_count: unverifiedCount, + attachment_error_count: attachmentErrorCount, + unknown_status_count: unknownStatusCount, + }, + }) + + trackPartialDecryptionFailure( + adminForm, + numWorkers, + csvGenerator.length(), + timeDifference, + errorCount, + attachmentErrorCount, + ) + + killWorkers(workerPool) + resolve({ + expectedCount: responsesCount, + successCount: csvGenerator.length(), + errorCount, + unverifiedCount, + }) + } else if ( + // All results have been decrypted + csvGenerator.length() + errorCount + unverifiedCount >= + responsesCount + ) { + killWorkers(workerPool) + // Generate first three rows of meta-data before download + csvGenerator.addMetaDataFromSubmission( + errorCount, + unverifiedCount, + ) + csvGenerator.downloadCsv( + `${adminForm.title}-${adminForm._id}.csv`, + ) + + const downloadEndTime = performance.now() + const timeDifference = downloadEndTime - downloadStartTime + + datadogLogs.logger.info('Download response success', { + meta: { + ...logMeta, + duration: timeDifference, + }, + }) + + trackDownloadResponseSuccess( + adminForm, + numWorkers, + NUM_OF_METADATA_ROWS, + timeDifference, + ) + + resolve({ + expectedCount: responsesCount, + successCount: csvGenerator.length(), + errorCount, + unverifiedCount, + }) + } else { + setTimeout(checkComplete, 100) + } + } + + checkComplete() + }) + }) + }, + [adminForm, onProgress, user?._id, workers, isFasterDownloadsEnabled], ) const handleExportCsvMutation = useMutation( - (params: DownloadEncryptedParams) => downloadEncryptedResponses(params), + (params: DownloadEncryptedParams) => + isFasterDownloadsEnabled + ? downloadEncryptedResponsesFaster(params) + : downloadEncryptedResponses(params), mutateProps, ) diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts index 55c24ac92d..2d550aa81a 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/CsvRecord.class.ts @@ -13,6 +13,7 @@ import { /** @class CsvRecord represents the CSV data to be passed back, along with helper functions */ export class CsvRecord { downloadBlob?: Blob + downloadBlobURL?: string submissionData?: DecryptedSubmissionData #statusMessage: string diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/downloadCsv.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/downloadCsv.ts index 6c072d35b4..52173d49df 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/downloadCsv.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/utils/downloadCsv.ts @@ -6,3 +6,10 @@ export const downloadResponseAttachment = async ( ) => { return FileSaver.saveAs(blob, 'RefNo ' + submissionId + '.zip') } + +export const downloadResponseAttachmentURL = async ( + blobURL: string, + submissionId: string, +) => { + return FileSaver.saveAs(blobURL, 'RefNo ' + submissionId + '.zip') +} diff --git a/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts b/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts index 02cffa370f..86ac630891 100644 --- a/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts +++ b/frontend/src/features/admin-form/responses/ResponsesPage/storage/worker/decryption.worker.ts @@ -63,7 +63,10 @@ function verifySignature( * main thread. * @param data The data to decrypt into a csvRecord. */ -async function decryptIntoCsv(data: LineData): Promise { +async function decryptIntoCsv( + data: LineData, + isFasterDownloadsEnabled: boolean, +): Promise { // This needs to be dynamically imported due to sharing code between main app and worker code. // Fixes issue raised at https://stackoverflow.com/questions/66472945/referenceerror-refreshreg-is-not-defined // Something to do with babel-loader. @@ -186,7 +189,11 @@ async function decryptIntoCsv(data: LineData): Promise { CsvRecordStatus.Ok, 'Success (with Downloaded Attachment)', ) - csvRecord.setDownloadBlob(downloadBlob) + if (isFasterDownloadsEnabled) { + csvRecord.downloadBlobURL = URL.createObjectURL(downloadBlob) + } else { + csvRecord.setDownloadBlob(downloadBlob) + } } catch (error) { csvRecord.setStatus( CsvRecordStatus.AttachmentError, diff --git a/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx b/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx index 66fd0e54b3..12921fdf42 100644 --- a/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx +++ b/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx @@ -13,6 +13,7 @@ import { getAdminFormSettings, MOCK_FORM_FIELDS_WITH_MYINFO, patchAdminFormSettings, + putFormWhitelistSettingSimulateCsvStringValidationError, } from '~/mocks/msw/handlers/admin-form' import { StoryRouter, viewports } from '~utils/storybook' @@ -76,13 +77,13 @@ PublicStorageNilAuthForm.parameters = { }), } -// purpose: tests that isNricMaskEnabled should not affect setting options available -export const PublicStorageNilAuthFormNricMaskingEnabled = Template.bind({}) -PublicStorageNilAuthFormNricMaskingEnabled.parameters = { +export const PublicStorageNilAuthFormSubmitterIdCollectionEnabled = + Template.bind({}) +PublicStorageNilAuthFormSubmitterIdCollectionEnabled.parameters = { msw: buildEncryptModeMswRoutes({ responseMode: FormResponseMode.Encrypt, status: FormStatus.Public, - isNricMaskEnabled: true, + isSubmitterIdCollectionEnabled: true, }), } @@ -142,22 +143,25 @@ PublicEmailMyInfoForm.parameters = { ], } -export const PrivateEmailSingpassFormNricMaskingEnabled = Template.bind({}) -PrivateEmailSingpassFormNricMaskingEnabled.parameters = { +export const PrivateEmailSingpassFormSubmitterIdCollectionEnabled = + Template.bind({}) +PrivateEmailSingpassFormSubmitterIdCollectionEnabled.parameters = { msw: buildEmailModeMswRoutes({ status: FormStatus.Private, authType: FormAuthType.SGID, - isNricMaskEnabled: true, + isSubmitterIdCollectionEnabled: true, }), } -export const PrivateEmailMyInfoFormNricMaskingEnabled = Template.bind({}) -PrivateEmailMyInfoFormNricMaskingEnabled.parameters = { +export const PrivateEmailMyInfoFormSubmitterIdCollectionEnabled = Template.bind( + {}, +) +PrivateEmailMyInfoFormSubmitterIdCollectionEnabled.parameters = { msw: [ ...buildEmailModeMswRoutes({ status: FormStatus.Private, authType: FormAuthType.MyInfo, esrvcId: 'STORYBOOK-TEST', - isNricMaskEnabled: true, + isSubmitterIdCollectionEnabled: true, }), ...createFormBuilderMocks({ form_fields: MOCK_FORM_FIELDS_WITH_MYINFO }), ], @@ -173,22 +177,23 @@ PrivateEmailSingpassFormSingleSubmissionEnabled.parameters = { } // purpose: displays all available singpass settings in an enabled state -export const PrivateEmailSingpassFormAllTogglesEnabled = Template.bind({}) -PrivateEmailSingpassFormAllTogglesEnabled.parameters = { - msw: buildEmailModeMswRoutes({ +export const PrivateStorageSingpassFormAllTogglesEnabled = Template.bind({}) +PrivateStorageSingpassFormAllTogglesEnabled.parameters = { + msw: buildEncryptModeMswRoutes({ status: FormStatus.Private, authType: FormAuthType.SGID, isSingleSubmission: true, - isNricMaskEnabled: true, + isSubmitterIdCollectionEnabled: true, }), } -export const PublicStorageCorppassAllTogglesEnabledForm = Template.bind({}) -PublicStorageCorppassAllTogglesEnabledForm.parameters = { - msw: buildEncryptModeMswRoutes({ +export const PublicEmailCorppassAllTogglesEnabledForm = Template.bind({}) +PublicEmailCorppassAllTogglesEnabledForm.parameters = { + msw: buildEmailModeMswRoutes({ status: FormStatus.Public, authType: FormAuthType.CP, isSingleSubmission: true, + isSubmitterIdCollectionEnabled: true, }), } @@ -232,13 +237,50 @@ PrivateStorageSgidPaymentEnabledForm.parameters = { ], } +// stories for whitelist setting +export const PrivateStorageSgidWhitelistEnabledForm = Template.bind({}) +PrivateStorageSgidWhitelistEnabledForm.parameters = { + msw: [ + ...buildEncryptModeMswRoutes({ + status: FormStatus.Private, + authType: FormAuthType.SGID, + responseMode: FormResponseMode.Encrypt, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + }), + ], +} + +export const PrivateStorageMyInfoUpdateWhitelistValidationErrorForm = + Template.bind({}) +PrivateStorageMyInfoUpdateWhitelistValidationErrorForm.parameters = { + msw: [ + ...buildEncryptModeMswRoutes({ + status: FormStatus.Private, + authType: FormAuthType.MyInfo, + responseMode: FormResponseMode.Encrypt, + whitelistedSubmitterIds: { + isWhitelistEnabled: false, + }, + }), + putFormWhitelistSettingSimulateCsvStringValidationError('12345'), + ], + docs: { + description: { + story: + 'Uploading a valid CSV file should display a mock validation error. This story is used to simulate validation errors are displayed correctly in the UI.', + }, + }, +} + export const Tablet = Template.bind({}) Tablet.parameters = { viewport: { defaultViewport: 'tablet', }, chromatic: { viewports: [viewports.md] }, - msw: PrivateEmailSingpassFormAllTogglesEnabled.parameters.msw, + msw: PrivateStorageSingpassFormAllTogglesEnabled.parameters.msw, } export const Mobile = Template.bind({}) @@ -247,7 +289,7 @@ Mobile.parameters = { defaultViewport: 'mobile1', }, chromatic: { viewports: [viewports.xs] }, - msw: PrivateEmailSingpassFormAllTogglesEnabled.parameters.msw, + msw: PrivateStorageSingpassFormAllTogglesEnabled.parameters.msw, } export const Loading = Template.bind({}) diff --git a/frontend/src/features/admin-form/settings/SettingsEmailsPage.stories.tsx b/frontend/src/features/admin-form/settings/SettingsEmailsPage.stories.tsx index a000ccea3d..e2c72d91b2 100644 --- a/frontend/src/features/admin-form/settings/SettingsEmailsPage.stories.tsx +++ b/frontend/src/features/admin-form/settings/SettingsEmailsPage.stories.tsx @@ -1,10 +1,16 @@ import { Meta, StoryFn } from '@storybook/react' import { PaymentChannel, PaymentType } from '~shared/types' -import { FormResponseMode, FormSettings, FormStatus } from '~shared/types/form' +import { + FormResponseMode, + FormSettings, + FormStatus, + WorkflowType, +} from '~shared/types/form' import { getAdminFormSettings, + getAdminFormView, patchAdminFormSettings, } from '~/mocks/msw/handlers/admin-form' @@ -73,6 +79,7 @@ const buildMswRoutes = ({ mode?: FormResponseMode delay?: number | 'infinite' } = {}) => [ + getAdminFormView({ overrides, mode, delay }), getAdminFormSettings({ overrides, mode, delay }), patchAdminFormSettings({ overrides, mode, delay }), ] @@ -112,6 +119,32 @@ PrivateEmailForm.parameters = { }), } +export const PrivateMultiRespondentForm = Template.bind({}) +PrivateMultiRespondentForm.parameters = { + msw: buildMswRoutes({ + mode: FormResponseMode.Multirespondent, + overrides: { + status: FormStatus.Private, + emails: [], + stepsToNotify: [], + workflow: [ + { + _id: 'field_id_1', + workflow_type: WorkflowType.Dynamic, + field: 'email_field_id', + edit: [], + }, + { + _id: 'field_id_2', + workflow_type: WorkflowType.Static, + emails: [], + edit: [], + }, + ], + }, + }), +} + export const PublicForm = Template.bind({}) PublicForm.parameters = { msw: buildMswRoutes({ @@ -124,6 +157,33 @@ PublicForm.parameters = { }), } +export const PublicMultiRespondentForm = Template.bind({}) +PublicMultiRespondentForm.parameters = { + msw: buildMswRoutes({ + mode: FormResponseMode.Multirespondent, + overrides: { + status: FormStatus.Public, + emails: ['expected1@example.com', 'expected2@example.com'], + stepsToNotify: ['field_1_id'], + workflow: [ + { + _id: 'field_1_id', + workflow_type: WorkflowType.Dynamic, + field: 'email_field_id', + edit: [], + }, + { + _id: 'field_2_id', + workflow_type: WorkflowType.Static, + emails: [], + edit: [], + }, + ], + ...PAYMENTS_DISABLED, + }, + }), +} + export const PaymentForm = Template.bind({}) PaymentForm.parameters = { msw: buildMswRoutes({ diff --git a/frontend/src/features/admin-form/settings/SettingsEmailsPage.tsx b/frontend/src/features/admin-form/settings/SettingsEmailsPage.tsx index 63aa6cb622..2a01595b46 100644 --- a/frontend/src/features/admin-form/settings/SettingsEmailsPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsEmailsPage.tsx @@ -1,49 +1,75 @@ -import { FormControl, Skeleton } from '@chakra-ui/react' +import { Skeleton } from '@chakra-ui/react' -import { FormResponseMode } from '~shared/types' +import { FormResponseMode, FormSettings, FormStatus } from '~shared/types/form' +import { PaymentChannel } from '~shared/types/payment' import FormLabel from '~components/FormControl/FormLabel' import { TagInput } from '~components/TagInput' import { CategoryHeader } from './components/CategoryHeader' -import { EmailFormSection } from './components/EmailFormSection' +import { EmailNotificationsHeader } from './components/EmailNotificationsHeader' +import { FormEmailSection } from './components/FormEmailSection' +import { MrfFormEmailSection } from './components/MrfFormEmailSection' import { useAdminFormSettings } from './queries' -const AdminEmailSection = () => { - const { data: settings } = useAdminFormSettings() - - if (!settings) { - return - } +const FormEmailSectionSkeleton = (): JSX.Element => { + return ( + + Send an email copy of new responses + + + ) +} - const isEmailOrStorageMode = - settings?.responseMode === FormResponseMode.Email || - settings?.responseMode === FormResponseMode.Encrypt +interface FormEmailSectionContainerProps { + isDisabled: boolean + settings: FormSettings +} - // should render null - if (!isEmailOrStorageMode) { - return null +const FormEmailSectionContainer = ({ + isDisabled, + settings, +}: FormEmailSectionContainerProps): JSX.Element => { + if (settings.responseMode === FormResponseMode.Multirespondent) { + return } - - return + return } -const EmailFormSectionSkeleton = (): JSX.Element => { - return ( - - Send an email copy of new responses - - - - +export const SettingsEmailsPage = (): JSX.Element => { + const { data: settings } = useAdminFormSettings() + + const isFormPublic = settings?.status === FormStatus.Public + + const isPaymentsEnabled = !!( + settings && + settings.responseMode === FormResponseMode.Encrypt && + (settings.payments_channel.channel !== PaymentChannel.Unconnected || + settings.payments_field.enabled) ) -} -export const SettingsEmailsPage = (): JSX.Element => { + const isEmailMode = settings?.responseMode === FormResponseMode.Email + + const isDisabled = isFormPublic || isPaymentsEnabled + return ( <> Email notifications - + {settings ? ( + <> + + + + ) : ( + + )} ) } diff --git a/frontend/src/features/admin-form/settings/SettingsPage.tsx b/frontend/src/features/admin-form/settings/SettingsPage.tsx index 06f31f1986..d22d6b7b0d 100644 --- a/frontend/src/features/admin-form/settings/SettingsPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { BiCodeBlock, BiCog, @@ -20,11 +20,12 @@ import { } from '@chakra-ui/react' import { FormResponseMode } from '~shared/types' -import { isNonEmpty } from '~shared/utils/isNonEmpty' import { ADMINFORM_RESULTS_SUBROUTE, ADMINFORM_ROUTE } from '~constants/routes' import { useDraggable } from '~hooks/useDraggable' +import { useUser } from '~features/user/queries' + import { useAdminFormCollaborators } from '../common/queries' import { SettingsTab } from './components/SettingsTab' @@ -46,7 +47,9 @@ interface TabEntry { export const SettingsPage = (): JSX.Element => { const { formId, settingsTab } = useParams() - const { data: settings } = useAdminFormSettings() + const { data: formSettings, isLoading: isFormSettingLoading } = + useAdminFormSettings() + const { user, isLoading: isUserLoading } = useUser() if (!formId) throw new Error('No formId provided') @@ -60,55 +63,57 @@ export const SettingsPage = (): JSX.Element => { navigate(`${ADMINFORM_ROUTE}/${formId}/${ADMINFORM_RESULTS_SUBROUTE}`) }, [formId, hasEditAccess, isCollabLoading, navigate]) - const tabConfig = useMemo(() => { - const emailsNotificationsTab = - settings?.responseMode === FormResponseMode.Encrypt || - settings?.responseMode === FormResponseMode.Email - ? { - label: 'Email notifications', - icon: BiMailSend, - component: SettingsEmailsPage, - path: 'email-notifications', - showRedDot: true, - } - : null - - const baseConfig: (TabEntry | null)[] = [ - { - label: 'General', - icon: BiCog, - component: SettingsGeneralPage, - path: 'general', - }, - { - label: 'Singpass', - icon: BiKey, - component: SettingsAuthPage, - path: 'singpass', - }, - emailsNotificationsTab, - { - label: 'Twilio credentials', - icon: BiMessage, - component: SettingsTwilioPage, - path: 'twilio-credentials', - }, - { - label: 'Webhooks', - icon: BiCodeBlock, - component: SettingsWebhooksPage, - path: 'webhooks', - }, - { - label: 'Payments', - icon: BiDollar, - component: SettingsPaymentsPage, - path: 'payments', - }, - ] - - return baseConfig.filter(isNonEmpty) - }, [settings?.responseMode]) + const isTest = process.env.NODE_ENV === 'test' + // For beta flagging email notifications tab. + const emailNotificationsTab = + isUserLoading || + isFormSettingLoading || + // TODO: (MRF-email-notif) Remove isTest and betaFlag check when MRF email notifications is out of beta + (!isTest && + formSettings?.responseMode === FormResponseMode.Multirespondent && + !user?.betaFlags?.mrfEmailNotifications) + ? null + : { + label: 'Email notifications', + icon: BiMailSend, + component: SettingsEmailsPage, + path: 'email-notifications', + showRedDot: true, + } + + const tabConfig: TabEntry[] = [ + { + label: 'General', + icon: BiCog, + component: SettingsGeneralPage, + path: 'general', + }, + { + label: 'Singpass', + icon: BiKey, + component: SettingsAuthPage, + path: 'singpass', + }, + emailNotificationsTab, + { + label: 'Twilio credentials', + icon: BiMessage, + component: SettingsTwilioPage, + path: 'twilio-credentials', + }, + { + label: 'Webhooks', + icon: BiCodeBlock, + component: SettingsWebhooksPage, + path: 'webhooks', + }, + { + label: 'Payments', + icon: BiDollar, + component: SettingsPaymentsPage, + path: 'payments', + }, + ].filter(Boolean) as TabEntry[] const { ref, onMouseDown } = useDraggable() @@ -121,7 +126,19 @@ export const SettingsPage = (): JSX.Element => { } return ( - + = ( settingsToUpdate: StorageFormSettings[T], ) => Promise +export interface MrfEmailNotificationSettings { + emails: string[] + stepsToNotify: string[] +} + +type UpdateMultiRespondentFormFn< + T extends Partial, +> = (formId: string, settingsToUpdate: T) => Promise + +type UpdateStorageFormWhitelistSettingFn = ( + formId: string, + whitelistCsvString: Promise | null, +) => Promise + type UpdateFormFn = ( formId: string, settingsToUpdate: FormSettings[T], @@ -35,6 +51,18 @@ export const getFormSettings = async ( ).then(({ data }) => data) } +export const getFormEncryptedWhitelistedSubmitterIds = async ( + formId: string, +): Promise<{ + encryptedWhitelistedSubmitterIds: EncryptedStringsMessageContent | null +}> => { + return ApiService.get<{ + encryptedWhitelistedSubmitterIds: EncryptedStringsMessageContent | null + }>(`${ADMIN_FORM_ENDPOINT}/${formId}/settings/whitelist`, { + responseType: 'json', + }).then(({ data }) => data) +} + export const updateFormStatus: UpdateFormFn<'status'> = async ( formId, status, @@ -84,6 +112,12 @@ export const updateFormEmails: UpdateEmailFormFn<'emails'> = async ( return updateFormSettings(formId, { emails: newEmails }) } +export const updateMrfEmailNotifications: UpdateMultiRespondentFormFn< + MrfEmailNotificationSettings +> = async (formId, newSettings) => { + return updateFormSettings(formId, newSettings) +} + export const updateFormAuthType: UpdateFormFn<'authType'> = async ( formId, newAuthType, @@ -91,12 +125,11 @@ export const updateFormAuthType: UpdateFormFn<'authType'> = async ( return updateFormSettings(formId, { authType: newAuthType }) } -export const updateFormNricMask: UpdateFormFn<'isNricMaskEnabled'> = async ( - formId, - newIsNricMaskEnabled, -) => { +export const updateIsSubmitterIdCollectionEnabled: UpdateFormFn< + 'isSubmitterIdCollectionEnabled' +> = async (formId, nextIsSubmitterIdCollectionEnabled) => { return updateFormSettings(formId, { - isNricMaskEnabled: newIsNricMaskEnabled, + isSubmitterIdCollectionEnabled: nextIsSubmitterIdCollectionEnabled, }) } @@ -169,6 +202,17 @@ const updateFormSettings = async ( ).then(({ data }) => data) } +// TODO: update this to work with backend +export const updateFormWhitelistSetting: UpdateStorageFormWhitelistSettingFn = + async (formId: string, whitelistCsvString: Promise | null) => { + return ApiService.putForm( + `${ADMIN_FORM_ENDPOINT}/${formId}/settings/whitelist`, + { + whitelistCsvString: await whitelistCsvString, + }, + ).then(({ data }) => data) + } + export const updateTwilioCredentials = async ( formId: string, credentials: TwilioCredentials, diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx index bba97c37be..da8f6b8d35 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx @@ -8,7 +8,7 @@ interface AuthSettingsDisabledExplanationTextProps { } const CONTAINS_MYINFO_FIELDS_DISABLED_EXPLANATION_TEXT = - 'To change Singpass settings, close your form to new responses. For Singpass authentication changes, remove all existing Myinfo fields.' + 'To change any Singpass setting, close your form to new responses. For changes to your Singpass authentication mode, remove all existing Myinfo fields.' const FORM_IS_PUBLIC_DISABLED_EXPLANATION_TEXT = 'To change Singpass settings, close your form to new responses.' diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx index 40f947822f..d8701f43c1 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx @@ -2,8 +2,9 @@ import { Divider } from '@chakra-ui/react' import { FormResponseMode, FormSettings } from '~shared/types/form' -import { FormNricMaskToggle } from './FormNricMaskToggle' +import { FormSubmitterIdCollectionToggle } from './FormNricCollectionToggle' import { FormSingleSubmissionToggle } from './FormSingleSubmissionToggle' +import { FormWhitelistAttachmentField } from './FormWhitelistAttachmentField' import { SingpassAuthOptionsRadio } from './SingpassAuthOptionsRadio' export interface AuthSettingsSingpassSectionProps { @@ -17,30 +18,40 @@ export const AuthSettingsSingpassSection = ({ isFormPublic, containsMyInfoFields, }: AuthSettingsSingpassSectionProps): JSX.Element => { + const isSingpassSettingsDisabled = isFormPublic + const isSinglepassAuthOptionsDisabled = + isSingpassSettingsDisabled || containsMyInfoFields + const isEncryptMode = settings.responseMode === FormResponseMode.Encrypt + return ( <> - {/* Hide the NRIC mask toggle if they have not yet enabled it as part of - PMO circular */} - {settings.isNricMaskEnabled ? ( - <> - - - - ) : null} - {settings.isSingleSubmission || - settings.responseMode === FormResponseMode.Encrypt ? ( + <> + + + + {isEncryptMode || settings.isSingleSubmission ? ( <> ) : null} + + {isEncryptMode ? ( + + ) : null} ) } diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricCollectionToggle.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricCollectionToggle.tsx new file mode 100644 index 0000000000..01a5f487ed --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricCollectionToggle.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react' + +import { FormSettings } from '~shared/types/form' + +import Toggle from '~components/Toggle' + +import { useMutateFormSettings } from '../../mutations' + +interface FormSubmitterIdCollectionProps { + settings: FormSettings + isDisabled: boolean +} + +export const FormSubmitterIdCollectionToggle = ({ + settings, + isDisabled, +}: FormSubmitterIdCollectionProps): JSX.Element => { + const isSubmitterIdCollectionEnabled = + !!settings?.isSubmitterIdCollectionEnabled + + const { mutateIsSubmitterIdCollectionEnabled } = useMutateFormSettings() + + const handleToggleCollection = useCallback(() => { + if (!settings || mutateIsSubmitterIdCollectionEnabled.isLoading) return + const nextIsNricMaskEnabled = !settings.isSubmitterIdCollectionEnabled + return mutateIsSubmitterIdCollectionEnabled.mutate(nextIsNricMaskEnabled) + }, [mutateIsSubmitterIdCollectionEnabled, settings]) + + return ( + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.tsx deleted file mode 100644 index d0de58b2ae..0000000000 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback } from 'react' - -import { FormSettings } from '~shared/types/form' - -import Toggle from '~components/Toggle' - -import { useMutateFormSettings } from '../../mutations' - -interface FormNricMaskToggleProps { - settings: FormSettings - isDisabled: boolean -} - -export const FormNricMaskToggle = ({ - settings, - isDisabled, -}: FormNricMaskToggleProps): JSX.Element => { - const isNricMaskEnabled = !!settings?.isNricMaskEnabled - - const { mutateNricMask } = useMutateFormSettings() - - const handleToggleNricMask = useCallback(() => { - if (!settings || mutateNricMask.isLoading) return - const nextIsNricMaskEnabled = !settings.isNricMaskEnabled - return mutateNricMask.mutate(nextIsNricMaskEnabled) - }, [mutateNricMask, settings]) - - return ( - - ) -} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx index 7fcbdd25bd..75d5b71c4e 100644 --- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx @@ -29,7 +29,7 @@ export const FormSingpassAuthToggle = ({ ? DEFAULT_FORM_AUTH_TYPE : FormAuthType.NIL return mutateFormAuthType.mutate(nextAuthType) - }, [mutateFormAuthType, settings, DEFAULT_FORM_AUTH_TYPE]) + }, [mutateFormAuthType, settings]) return ( { + const { mutateFormWhitelistSetting } = useMutateFormSettings() + const { formId } = useParams() + + const isLoading = mutateFormWhitelistSetting.isLoading + const [isSecretKeyModalOpen, setIsSecretKeyModalOpen] = useState(false) + + const methods = useForm() + const { control, setValue, setError, clearErrors } = methods + + const standardCsvDownloadFileName = `whitelist_${formId}.csv` + + const fieldContainerSchema: AttachmentFieldSchema = { + _id: FormWhitelistAttachmentFieldContainerName, + title: 'Restrict form to eligible NRIC/FIN/UENs only', + description: + 'Only NRIC/FIN/UENs in this list are allowed to submit a response. CSV file should include all whitelisted NRIC/FIN/UENs in a single column with the "Respondent" header. ' + + '[Download a sample .csv file](https://go.gov.sg/formsg-whitelist-respondents-sample-csv)', + required: true, + disabled: isDisabled, + fieldType: BasicField.Attachment, + attachmentSize: AttachmentSize.TwentyMb, + } + + const { publicKey, whitelistedSubmitterIds } = settings + + const isWhitelistEnabled = whitelistedSubmitterIds?.isWhitelistEnabled + + useEffect(() => { + // Set the whitelist attachment field with a mock representation file + // if whitelist is enabled so actual file can be lazily downloaded. + if (isWhitelistEnabled) { + setValue(FormWhitelistAttachmentFieldName, { + name: standardCsvDownloadFileName, + size: null, + type: 'text/csv', + }) + } + }, [isWhitelistEnabled, setValue, standardCsvDownloadFileName]) + + const maxSizeInBytes = useMemo(() => { + if (!fieldContainerSchema.attachmentSize) { + return + } + return parseInt(fieldContainerSchema.attachmentSize) * MB + }, [fieldContainerSchema.attachmentSize]) + + const setWhitelistAttachmentFieldError = useCallback( + (errMsg: string) => { + setError(FormWhitelistAttachmentFieldContainerName, { + type: 'manual', + message: errMsg, + }) + }, + [setError], + ) + + const clearWhitelistAttachmentFieldError = useCallback(() => { + clearErrors(FormWhitelistAttachmentFieldContainerName) + }, [clearErrors]) + + const onFileSelect = useCallback( + (onChange: ControllerRenderProps['onChange']) => { + return (file: File | null) => { + if (!file) { + return + } + + const csvString = parseCsvFileToCsvString(file, (headerRow) => { + return { + isValid: + headerRow && + headerRow.length === 1 && + headerRow[0].replace(/(\r\n|\n|\r)/gm, '').toLowerCase() === + 'respondent', + invalidReason: + 'Your CSV file should only contain a single column with the "Respondent" header.', + } + }) + + mutateFormWhitelistSetting.mutate(csvString, { + onSuccess: () => { + clearWhitelistAttachmentFieldError() + onChange(file) + }, + onError: (error) => { + setWhitelistAttachmentFieldError(error.message) + }, + }) + } + }, + [ + setWhitelistAttachmentFieldError, + clearWhitelistAttachmentFieldError, + mutateFormWhitelistSetting, + ], + ) + + const triggerSecretKeyInputTransition = useCallback(() => { + setIsSecretKeyModalOpen(true) + }, []) + + const removeWhitelist = useCallback(() => { + mutateFormWhitelistSetting.mutate(null, { + onSuccess: () => { + setValue(FormWhitelistAttachmentFieldName, null) + }, + }) + }, [setValue, mutateFormWhitelistSetting]) + + return ( + <> + setIsSecretKeyModalOpen(false)} + publicKey={publicKey} + formId={formId!} + downloadFileName={standardCsvDownloadFileName} + /> + + + + ( + + + + )} + /> + + + + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SecretKeyDownloadWhitelistFileModal.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SecretKeyDownloadWhitelistFileModal.tsx new file mode 100644 index 0000000000..ac76b56c2f --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SecretKeyDownloadWhitelistFileModal.tsx @@ -0,0 +1,138 @@ +import { useCallback } from 'react' +import { useQueryClient } from 'react-query' +import { UseDisclosureReturn } from '@chakra-ui/react' +import Papa from 'papaparse' + +import { + decryptStringMessage, + EncryptedStringsMessageContent, +} from '~shared/utils/crypto' + +import { useToast } from '~hooks/useToast' +import { downloadFile } from '~components/Field/Attachment/utils/downloadFile' + +import { fetchAdminFormEncryptedWhitelistedSubmitterIds } from '../../queries' +import { SecretKeyFormModal } from '../SecretKeyFormModal' + +export interface SecretKeyDownloadWhitelistFileModalProps + extends Pick { + publicKey: string + formId: string + downloadFileName: string +} + +export const SecretKeyDownloadWhitelistFileModal = ({ + onClose, + isOpen, + publicKey, + downloadFileName, + formId, +}: SecretKeyDownloadWhitelistFileModalProps) => { + const toast = useToast({ status: 'success', isClosable: true }) + const errorToast = useToast({ status: 'danger', isClosable: true }) + + const queryClient = useQueryClient() + + const toastErrorMessage = useCallback( + (errorMessage: string) => { + errorToast.closeAll() + errorToast({ + description: errorMessage, + }) + }, + [errorToast], + ) + const toastSuccessMessage = useCallback( + (message) => { + toast.closeAll() + toast({ + description: message, + }) + }, + [toast], + ) + + const decryptSubmitterIds = useCallback( + ( + encryptedSubmitterIdContent: EncryptedStringsMessageContent, + secretKey: string, + ) => { + return decryptStringMessage(secretKey, encryptedSubmitterIdContent) + }, + [], + ) + const handleWhitelistCsvDownload = useCallback( + ({ secretKey }) => { + fetchAdminFormEncryptedWhitelistedSubmitterIds(formId, queryClient) + .then((data) => { + const { encryptedWhitelistedSubmitterIds } = data + if ( + encryptedWhitelistedSubmitterIds && + encryptedWhitelistedSubmitterIds.myPublicKey && + encryptedWhitelistedSubmitterIds.nonce && + encryptedWhitelistedSubmitterIds.cipherTexts && + encryptedWhitelistedSubmitterIds.cipherTexts.length > 0 + ) { + const decryptedSubmitterIds = decryptSubmitterIds( + encryptedWhitelistedSubmitterIds, + secretKey, + ) + const submitterIds = decryptedSubmitterIds.filter( + (id) => id !== null, + ) + if (!submitterIds || submitterIds.length === 0) { + return + } + + // generate and download csv file + const csvData = submitterIds.map((submitterId) => ({ + Respondent: submitterId, + })) + const csvString = Papa.unparse(csvData, { + header: true, + delimiter: ',', + skipEmptyLines: 'greedy', + }) + const csvBlob = new Blob([csvString], { + type: 'text/csv', + }) + const csvFile = new File([csvBlob], downloadFileName, { + type: 'text/csv', + }) + downloadFile(csvFile) + onClose() + toastSuccessMessage( + 'Whitelist setting file downloaded successfully', + ) + } else { + toastErrorMessage('Whitelist settings could not be decrypted') + } + }) + .catch((error: Error) => { + toastErrorMessage(error.message) + }) + }, + [ + formId, + queryClient, + decryptSubmitterIds, + downloadFileName, + onClose, + toastSuccessMessage, + toastErrorMessage, + ], + ) + + return ( + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/EmailNotificationsHeader.tsx b/frontend/src/features/admin-form/settings/components/EmailNotificationsHeader.tsx new file mode 100644 index 0000000000..25ac202893 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/EmailNotificationsHeader.tsx @@ -0,0 +1,54 @@ +import { BiBulb } from 'react-icons/bi' +import { Flex, Icon } from '@chakra-ui/react' + +import { GUIDE_FORM_MRF, OGP_PLUMBER } from '~constants/links' +import { useMdComponents } from '~hooks/useMdComponents' +import InlineMessage from '~components/InlineMessage' +import { MarkdownText } from '~components/MarkdownText' + +const MRFAdvertisingInfobox = () => { + const mdComponents = useMdComponents() + + return ( + + + {`Require routing and approval? [Check out our new feature: Multi-respondent forms!](${GUIDE_FORM_MRF})`} + + ) +} + +interface EmailNotificationsHeaderProps { + isFormPublic: boolean + isPaymentsEnabled: boolean + isFormResponseModeEmail: boolean +} + +export const EmailNotificationsHeader = ({ + isFormPublic, + isPaymentsEnabled, + isFormResponseModeEmail, +}: EmailNotificationsHeaderProps) => { + if (isFormPublic) { + return ( + + To change email recipients, close your form to new responses. + + ) + } + + if (isPaymentsEnabled) { + return ( + + {`Email notifications for payment forms are not available in FormSG. You can configure them using [Plumber](${OGP_PLUMBER}).`} + + ) + } + + if (isFormResponseModeEmail) { + return + } + + return null +} diff --git a/frontend/src/features/admin-form/settings/components/EmailFormSection.tsx b/frontend/src/features/admin-form/settings/components/FormEmailSection.tsx similarity index 56% rename from frontend/src/features/admin-form/settings/components/EmailFormSection.tsx rename to frontend/src/features/admin-form/settings/components/FormEmailSection.tsx index 5aba509a7e..b90aa191ef 100644 --- a/frontend/src/features/admin-form/settings/components/EmailFormSection.tsx +++ b/frontend/src/features/admin-form/settings/components/FormEmailSection.tsx @@ -5,43 +5,84 @@ import { useForm, useFormContext, } from 'react-hook-form' -import { BiBulb } from 'react-icons/bi' -import { Flex, FormControl, Icon } from '@chakra-ui/react' +import { FormControl } from '@chakra-ui/react' import { get, isEmpty, isEqual } from 'lodash' import isEmail from 'validator/lib/isEmail' -import { PaymentChannel } from '~shared/types' import { EmailFormSettings, FormResponseMode, - FormStatus, StorageFormSettings, } from '~shared/types/form' -import { - GUIDE_FORM_MRF, - GUIDE_PREVENT_EMAIL_BOUNCE, - OGP_PLUMBER, -} from '~constants/links' -import { useMdComponents } from '~hooks/useMdComponents' +import { GUIDE_PREVENT_EMAIL_BOUNCE } from '~constants/links' import { OPTIONAL_ADMIN_EMAIL_VALIDATION_RULES, REQUIRED_ADMIN_EMAIL_VALIDATION_RULES, } from '~utils/formValidation' import FormErrorMessage from '~components/FormControl/FormErrorMessage' import FormLabel from '~components/FormControl/FormLabel' -import InlineMessage from '~components/InlineMessage' -import { MarkdownText } from '~components/MarkdownText' import { TagInput } from '~components/TagInput' import { useMutateFormSettings } from '../mutations' import { useAdminFormSettings } from '../queries' interface EmailFormSectionProps { + isDisabled: boolean settings: EmailFormSettings | StorageFormSettings } -export const EmailFormSection = ({ +interface AdminEmailRecipientsInputProps { + onSubmit: (params: { emails: string[] }) => void +} + +const EMAILS_FIELD_NAME = 'emails' + +const AdminEmailRecipientsInput = ({ + onSubmit, +}: AdminEmailRecipientsInputProps): JSX.Element => { + const { getValues, setValue, control, handleSubmit } = useFormContext<{ + emails: string[] + isRequired: boolean + }>() + + const { data: settings } = useAdminFormSettings() + + const handleBlur = useCallback(() => { + // Get rid of bad tags before submitting. + setValue( + 'emails', + (getValues('emails') || []).filter((email) => isEmail(email)), + ) + handleSubmit(onSubmit)() + }, [getValues, handleSubmit, onSubmit, setValue]) + + const emailsFieldPlaceholder = + getValues(EMAILS_FIELD_NAME)?.length > 0 ? undefined : 'me@example.com' + + return ( + ( + + )} + /> + ) +} + +export const FormEmailSection = ({ + isDisabled, settings, }: EmailFormSectionProps): JSX.Element => { const initialEmailSet = useMemo( @@ -60,16 +101,6 @@ export const EmailFormSection = ({ const { mutateFormEmails } = useMutateFormSettings() - const isFormPublic = settings.status === FormStatus.Public - - const isPaymentsEnabled = - settings && - settings.responseMode === FormResponseMode.Encrypt && - (settings.payments_channel.channel !== PaymentChannel.Unconnected || - settings.payments_field.enabled) - - const isEmailsDisabled = isFormPublic || isPaymentsEnabled - const handleSubmitEmails = useCallback( ({ emails }: { emails: string[] }) => { if (isEqual(new Set(emails.filter(Boolean)), initialEmailSet)) return @@ -87,13 +118,8 @@ export const EmailFormSection = ({ return ( <> - - + Send an email copy of new responses - + {get(errors, 'emails.message')} {isEmpty(errors) ? ( Separate multiple email addresses with a comma @@ -123,101 +146,3 @@ export const EmailFormSection = ({ ) } - -const MRFAdvertisingInfobox = () => { - const mdComponents = useMdComponents() - - return ( - - - {`Require routing and approval? [Check out our new feature: Multi-respondent forms!](${GUIDE_FORM_MRF})`} - - ) -} - -interface EmailNotificationsHeaderProps { - isFormPublic: boolean - isPaymentsEnabled: boolean - isFormResponseModeEmail: boolean -} - -const EmailNotificationsHeader = ({ - isFormPublic, - isPaymentsEnabled, - isFormResponseModeEmail, -}: EmailNotificationsHeaderProps) => { - if (isFormPublic) { - return ( - - To change admin email recipients, close your form to new responses. - - ) - } - - if (isPaymentsEnabled) { - return ( - - {`Email notifications for payment forms are not available in FormSG. You can configure them using [Plumber](${OGP_PLUMBER}).`} - - ) - } - - if (isFormResponseModeEmail) { - return - } - - return null -} - -interface AdminEmailRecipientsInputProps { - onSubmit: (params: { emails: string[] }) => void - isEmailsDisabled: boolean -} - -const AdminEmailRecipientsInput = ({ - onSubmit, -}: AdminEmailRecipientsInputProps): JSX.Element => { - const { getValues, setValue, control, handleSubmit } = useFormContext<{ - emails: string[] - isRequired: boolean - }>() - - const { data: settings } = useAdminFormSettings() - - const tagValidation = useMemo(() => isEmail, []) - - const handleBlur = useCallback(() => { - // Get rid of bad tags before submitting. - setValue( - 'emails', - (getValues('emails') || []).filter((email) => tagValidation(email)), - ) - handleSubmit(onSubmit)() - }, [getValues, handleSubmit, onSubmit, setValue, tagValidation]) - - return ( - ( - 0 - ? {} - : { - placeholder: 'me@example.com', - })} - {...field} - tagValidation={tagValidation} - onBlur={handleBlur} - /> - )} - /> - ) -} diff --git a/frontend/src/features/admin-form/settings/components/MrfFormEmailSection.tsx b/frontend/src/features/admin-form/settings/components/MrfFormEmailSection.tsx new file mode 100644 index 0000000000..74b5844ce1 --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/MrfFormEmailSection.tsx @@ -0,0 +1,196 @@ +import { useCallback } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { Box, FormControl, FormErrorMessage, Skeleton } from '@chakra-ui/react' +import { get, isEmpty, isEqual, uniq } from 'lodash' +import isEmail from 'validator/lib/isEmail' + +import { MultirespondentFormSettings } from '~shared/types/form' + +import { OPTIONAL_ADMIN_EMAIL_VALIDATION_RULES } from '~utils/formValidation' +import { MultiSelect } from '~components/Dropdown' +import FormLabel from '~components/FormControl/FormLabel' +import { TagInput } from '~components/TagInput' + +import { useAdminFormWorkflow } from '~features/admin-form/create/workflow/hooks/useAdminFormWorkflow' + +import { useMutateFormSettings } from '../mutations' + +interface MrfEmailNotificationsFormProps { + settings: MultirespondentFormSettings + isDisabled: boolean +} + +const WORKFLOW_EMAIL_MULTISELECT_NAME = 'email-multi-select' +const OTHER_PARTIES_EMAIL_INPUT_NAME = 'other-parties-email-input' + +interface FormData { + [WORKFLOW_EMAIL_MULTISELECT_NAME]: string[] + [OTHER_PARTIES_EMAIL_INPUT_NAME]: string[] +} + +const MrfEmailNotificationsForm = ({ + settings, + isDisabled, +}: MrfEmailNotificationsFormProps) => { + const { isLoading, formWorkflow } = useAdminFormWorkflow() + + const formWorkflowStepsWithStepNumber = + formWorkflow?.map((step, index) => ({ + ...step, + stepNumber: index + 1, + })) ?? [] + + const filterInvalidEmails = useCallback((emails: string[]) => { + if (!emails) return [] + return emails.filter((email) => isEmail(email)) + }, []) + + const { stepsToNotify, emails } = settings + + const { + handleSubmit, + control, + setValue, + getValues, + formState: { errors }, + } = useForm<{ + [WORKFLOW_EMAIL_MULTISELECT_NAME]: string[] + [OTHER_PARTIES_EMAIL_INPUT_NAME]: string[] + }>({ + defaultValues: { + [WORKFLOW_EMAIL_MULTISELECT_NAME]: stepsToNotify, + [OTHER_PARTIES_EMAIL_INPUT_NAME]: emails, + }, + }) + + const { mutateMrfEmailNotifications } = useMutateFormSettings() + + const handleSubmitEmailNotificationSettings = ({ + nextStaticEmails, + nextStepsToNotify, + }: { + nextStaticEmails: string[] + nextStepsToNotify: string[] + }) => { + if ( + isEqual(nextStaticEmails, emails) && + isEqual(nextStepsToNotify, stepsToNotify) + ) { + return + } + return mutateMrfEmailNotifications.mutate({ + emails: nextStaticEmails, + stepsToNotify: nextStepsToNotify, + }) + } + + const onSubmit = (formData: FormData) => { + const selectedSteps = formData[WORKFLOW_EMAIL_MULTISELECT_NAME] + const selectedEmails = formData[OTHER_PARTIES_EMAIL_INPUT_NAME] + + return handleSubmitEmailNotificationSettings({ + nextStepsToNotify: selectedSteps, + nextStaticEmails: selectedEmails, + }) + } + + const handleOtherPartiesEmailInputBlur = () => { + const uniqueValidEmails = uniq( + filterInvalidEmails(getValues(OTHER_PARTIES_EMAIL_INPUT_NAME)), + ) + setValue(OTHER_PARTIES_EMAIL_INPUT_NAME, uniqueValidEmails) + handleSubmit(onSubmit)() + } + + const otherPartiesEmailInputPlaceholder = + getValues(OTHER_PARTIES_EMAIL_INPUT_NAME)?.length > 0 + ? undefined + : 'me@example.com' + + return ( +
+ + Notify respondents in your workflow + + + ( + ({ + label: `Respondent(s) in Step ${step.stepNumber}`, + value: step._id, + }))} + values={values} + onChange={onChange} + onBlur={handleSubmit(onSubmit)} + placeholder="Select respondents from your form" + isSelectedItemFullWidth + isDisabled={isLoading || isDisabled} + {...rest} + /> + )} + /> + + + + + + + Notify other parties + + ( + + )} + /> + {isEmpty(errors[OTHER_PARTIES_EMAIL_INPUT_NAME]) ? ( + + Separate multiple email addresses with a comma + + ) : ( + + {get(errors, `${OTHER_PARTIES_EMAIL_INPUT_NAME}.message`)} + + )} + + +
+ ) +} + +interface MrfFormEmailSectionProps { + settings: MultirespondentFormSettings + isDisabled: boolean +} + +export const MrfFormEmailSection = ({ + settings, + isDisabled, +}: MrfFormEmailSectionProps): JSX.Element => { + return ( + + + + ) +} diff --git a/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx index 5314c0433b..30ec838bb2 100644 --- a/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx +++ b/frontend/src/features/admin-form/settings/components/PaymentSettingsSection/PaymentSettingsSection.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { Link as ReactLink, useSearchParams } from 'react-router-dom' +import { Link as ReactLink, useParams, useSearchParams } from 'react-router-dom' import { As, Box, @@ -7,8 +7,10 @@ import { Flex, FormControl, Icon, + ListItem, Skeleton, Text, + UnorderedList, VStack, } from '@chakra-ui/react' import { get, isEmpty } from 'lodash' @@ -17,7 +19,7 @@ import { DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT, ERROR_QUERY_PARAM_KEY, } from '~shared/constants' -import { FormResponseMode, PaymentChannel } from '~shared/types' +import { EmailFieldBase, FormResponseMode, PaymentChannel } from '~shared/types' import { BxsCheckCircle, BxsError, BxsInfoCircle } from '~assets/icons' import { GUIDE_STRIPE_ONBOARDING } from '~constants/links' @@ -27,6 +29,7 @@ import InlineMessage from '~components/InlineMessage' import Input from '~components/Input' import Link from '~components/Link' +import { useAdminForm } from '~features/admin-form/common/queries' import { useEnv } from '~features/env/queries' import { useAdminFormPayments, useAdminFormSettings } from '../../queries' @@ -40,28 +43,55 @@ import { } from './StripeConnectButton' const PaymentsDisabledRationaleText = ({ - isEmailsPresent, + isAdminEmailsPresent, isSingleSubmission, + isPDFResponseEnabled, }: { - isEmailsPresent: boolean + isAdminEmailsPresent: boolean isSingleSubmission: boolean + isPDFResponseEnabled: boolean }): JSX.Element => { - if (isEmailsPresent && isSingleSubmission) { + const disabledCount = [ + isAdminEmailsPresent, + isSingleSubmission, + isPDFResponseEnabled, + ].filter(Boolean).length + + const { formId } = useParams() + if (!formId) return <> + + if (disabledCount > 1) { return ( - To enable payment fields, remove all recipients from{' '} - - email notifications - {' '} - and disable{' '} - - only one submission per NRIC/FIN/UEN - - . + To enable payment fields, + + {isAdminEmailsPresent ? ( + + + Remove all recipients from email notifications + + + ) : undefined} + {isPDFResponseEnabled ? ( + + + Turn off "Include PDF responses" in all email fields + + + ) : undefined} + {isSingleSubmission ? ( + + + Disable only one submission per NRIC/FIN/UEN + + + ) : undefined} + ) } - if (isEmailsPresent) { + + if (isAdminEmailsPresent) { return ( To enable payment fields, remove all recipients from{' '} @@ -83,6 +113,16 @@ const PaymentsDisabledRationaleText = ({ ) } + if (isPDFResponseEnabled) { + return ( + + To enable payment fields,{' '} + + turn off "Include PDF Responses" in all email fields. + + + ) + } return <> } @@ -95,13 +135,14 @@ const BeforeConnectionInstructions = ({ const { data: paymentGuideLink } = usePaymentGuideLink() const [searchParams] = useSearchParams() const { data: settings } = useAdminFormSettings() + const { data: formDef } = useAdminForm() const queryParams = Object.fromEntries([...searchParams]) const isInvalidDomain = queryParams[ERROR_QUERY_PARAM_KEY] === DISALLOW_CONNECT_NON_WHITELIST_STRIPE_ACCOUNT - const isEmailsPresent = useMemo(() => { + const isAdminEmailsPresent = useMemo(() => { return ( (settings?.responseMode === FormResponseMode.Email || settings?.responseMode === FormResponseMode.Encrypt) && @@ -109,9 +150,20 @@ const BeforeConnectionInstructions = ({ ) }, [settings]) + const isPDFResponseEnabled = useMemo(() => { + return ( + formDef?.form_fields + .filter((field) => field.fieldType === 'email') + .map((field) => field as EmailFieldBase) + .map((field) => field.autoReplyOptions.includeFormSummary) + .some((x) => x) ?? false + ) + }, [formDef?.form_fields]) + const isSingleSubmission = !!settings?.isSingleSubmission - const isPaymentsDisabled = isEmailsPresent || isSingleSubmission + const isPaymentsDisabled = + isAdminEmailsPresent || isSingleSubmission || isPDFResponseEnabled if (isInvalidDomain) { return ( @@ -134,8 +186,9 @@ const BeforeConnectionInstructions = ({ @@ -186,8 +239,9 @@ const BeforeConnectionInstructions = ({ diff --git a/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx b/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx index 03ca10b8fd..d2801e97b3 100644 --- a/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx +++ b/frontend/src/features/admin-form/settings/components/SecretKeyActivationModal.tsx @@ -1,245 +1,38 @@ -import { useCallback, useMemo, useRef } from 'react' -import { useForm } from 'react-hook-form' -import { BiRightArrowAlt, BiUpload } from 'react-icons/bi' -import { - Container, - FormControl, - Modal, - ModalBody, - ModalContent, - ModalHeader, - Stack, - useBreakpointValue, - UseDisclosureReturn, -} from '@chakra-ui/react' +import { UseDisclosureReturn } from '@chakra-ui/react' import { FormStatus } from '~shared/types/form/form' -import { isKeypairValid, SECRET_KEY_REGEX } from '~utils/secretKeyValidation' -import Button from '~components/Button' -import Checkbox from '~components/Checkbox' -import FormErrorMessage from '~components/FormControl/FormErrorMessage' -import FormLabel from '~components/FormControl/FormLabel' -import IconButton from '~components/IconButton' -import Input from '~components/Input' -import { ModalCloseButton } from '~components/Modal' - import { useMutateFormSettings } from '../mutations' -import { FormActivationSvg } from './FormActivationSvg' +import { SecretKeyFormModal } from './SecretKeyFormModal' export interface SecretKeyActivationModalProps extends Pick { publicKey: string } -const SECRET_KEY_NAME = 'secretKey' - -interface SecretKeyFormInputs { - [SECRET_KEY_NAME]: string - ack: boolean -} - -const useSecretKeyActivationModal = ({ - publicKey, - onClose, -}: Pick) => { - const { - formState: { errors }, - setError, - register, - watch, - setValue, - reset, - handleSubmit, - } = useForm() - - const fileUploadRef = useRef(null) - - const { mutateFormStatus } = useMutateFormSettings() - - const handleVerifyKeypair = handleSubmit(({ secretKey }) => { - const isValid = isKeypairValid(publicKey, secretKey) - - if (!isValid) { - return setError( - SECRET_KEY_NAME, - { - type: 'invalidKey', - message: 'The secret key provided is invalid', - }, - { shouldFocus: true }, - ) - } - - // Valid, process to activate form. - return mutateFormStatus.mutate(FormStatus.Public, { onSuccess: onClose }) - }) - - const handleFileSelect = useCallback( - ({ target }: React.ChangeEvent) => { - const file = target.files?.[0] - // Reset file input so the same file selected will trigger this onChange - // function. - if (fileUploadRef.current) { - fileUploadRef.current.value = '' - } - - if (!file) return - - const reader = new FileReader() - reader.onload = async (e) => { - if (!e.target) return - const text = e.target.result?.toString() - - if (!text || !SECRET_KEY_REGEX.test(text)) { - return setError( - SECRET_KEY_NAME, - { - type: 'invalidFile', - message: 'Selected file seems to be invalid', - }, - { shouldFocus: true }, - ) - } - - setValue(SECRET_KEY_NAME, text, { shouldValidate: true }) - } - reader.readAsText(file) - }, - [setError, setValue], - ) - - // Reset form before closing. - const handleOnClose = useCallback(() => { - reset() - return onClose() - }, [onClose, reset]) - - const watchedSecretKey = watch(SECRET_KEY_NAME) - const watchedAck = watch('ack') - - const secretKeyNotUploaded = useMemo( - () => !watchedSecretKey, - [watchedSecretKey], - ) - - const activateDisabled = useMemo( - () => !watchedSecretKey || !watchedAck, - [watchedSecretKey, watchedAck], - ) - - return { - fileUploadRef, - handleFileSelect, - handleVerifyKeypair, - register, - secretKeyNotUploaded, - activateDisabled, - errors, - isLoading: mutateFormStatus.isLoading, - handleOnClose, - } -} - export const SecretKeyActivationModal = ({ onClose, isOpen, publicKey, }: SecretKeyActivationModalProps): JSX.Element => { - const { - fileUploadRef, - handleFileSelect, - handleVerifyKeypair, - register, - secretKeyNotUploaded, - activateDisabled, - errors, - isLoading, - handleOnClose, - } = useSecretKeyActivationModal({ publicKey, onClose }) + const { mutateFormStatus } = useMutateFormSettings() - const modalSize = useBreakpointValue({ - base: 'mobile', - xs: 'mobile', - md: 'full', - }) + const onSubmit = () => { + return mutateFormStatus.mutate(FormStatus.Public, { onSuccess: onClose }) + } + const isLoading = mutateFormStatus.isLoading return ( - - - - {/* Hidden input field to trigger file selector, can be anywhere in the DOM */} - - - Activate your form - - - - -
- - Enter or upload Secret Key - - - } - onClick={() => fileUploadRef.current?.click()} - /> - - {errors.secretKey?.message} - - - -
-
-
-
-
+ ) } diff --git a/frontend/src/features/admin-form/settings/components/SecretKeyFormModal.tsx b/frontend/src/features/admin-form/settings/components/SecretKeyFormModal.tsx new file mode 100644 index 0000000000..cec4d3f05e --- /dev/null +++ b/frontend/src/features/admin-form/settings/components/SecretKeyFormModal.tsx @@ -0,0 +1,166 @@ +import { BiRightArrowAlt, BiUpload } from 'react-icons/bi' +import { + Container, + FormControl, + Modal, + ModalBody, + ModalContent, + ModalHeader, + Stack, + useBreakpointValue, + UseDisclosureReturn, +} from '@chakra-ui/react' + +import { SECRET_KEY_REGEX } from '~utils/secretKeyValidation' +import Button from '~components/Button' +import Checkbox from '~components/Checkbox' +import FormErrorMessage from '~components/FormControl/FormErrorMessage' +import FormLabel from '~components/FormControl/FormLabel' +import IconButton from '~components/IconButton' +import Input from '~components/Input' +import { ModalCloseButton } from '~components/Modal' + +import { + ACK_NAME, + SECRET_KEY_NAME, + SecretKeyFormInputs, + useSecretKeyForm, +} from '../hooks/useSecretKeyForm' + +import { FormActivationSvg } from './FormActivationSvg' + +export interface SecretKeyFormModalProps + extends Pick { + isLoading: boolean + publicKey: string + modalActionText: string + submitButtonText: string + onSubmit: (secretKeyFormInputs: SecretKeyFormInputs) => void + hasAck?: boolean +} + +export const SecretKeyFormModal = ({ + isLoading, + onClose, + isOpen, + publicKey, + modalActionText, + submitButtonText, + onSubmit, + hasAck = false, +}: SecretKeyFormModalProps): JSX.Element => { + const { + dragging, + errors, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleSecretKeyFileChange, + handleSecretKeyFormClose, + handleVerifyKeyPairAndSubmit, + isSecretKeyFormCompleted, + isSecretKeyUploaded, + register, + secretKeyFileUploadRef, + } = useSecretKeyForm({ + publicKey, + onClose, + onSubmit, + hasAck, + }) + + const modalSize = useBreakpointValue({ + base: 'mobile', + xs: 'mobile', + md: 'full', + }) + + return ( + + + + {/* Hidden input field to trigger file selector, can be anywhere in the DOM */} + + + {modalActionText} + + + + +
+ + Enter or upload Secret Key + + + } + onClick={() => secretKeyFileUploadRef.current?.click()} + /> + + {errors.secretKey?.message} + + {hasAck ? ( + + ) : null} + +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/admin-form/settings/hooks/useSecretKeyForm.tsx b/frontend/src/features/admin-form/settings/hooks/useSecretKeyForm.tsx new file mode 100644 index 0000000000..1f3edb303c --- /dev/null +++ b/frontend/src/features/admin-form/settings/hooks/useSecretKeyForm.tsx @@ -0,0 +1,164 @@ +import { useCallback, useRef, useState } from 'react' +import { useForm, UseFormSetError, UseFormSetValue } from 'react-hook-form' + +import { isKeypairValid, SECRET_KEY_REGEX } from '~utils/secretKeyValidation' + +export const SECRET_KEY_NAME = 'secretKey' +export const ACK_NAME = 'ack' +export interface SecretKeyFormInputs { + [SECRET_KEY_NAME]: string + [ACK_NAME]?: boolean +} + +export interface UseSecretKeyFormProps { + publicKey: string + onSubmit: ({ secretKey, ack }: SecretKeyFormInputs) => void + onClose: () => void + hasAck: boolean +} + +/** + * Reusable hook for secret key form logic. + * Handles validation of secret key and supports various secret key selection methods such as file upload. + */ +export const useSecretKeyForm = ({ + publicKey, + onClose, + onSubmit, + hasAck = false, +}: UseSecretKeyFormProps) => { + const { + formState: { errors }, + setError, + setValue, + register, + reset, + watch, + handleSubmit, + } = useForm() + + const [dragging, setDragging] = useState(false) + + const preventDefaults = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const processFile = ( + file: File, + setError: UseFormSetError, + setValue: UseFormSetValue, + ) => { + const reader = new FileReader() + reader.onload = async (e) => { + if (!e.target) return + const text = e.target.result?.toString() + + if (!text || !SECRET_KEY_REGEX.test(text)) { + return setError( + SECRET_KEY_NAME, + { + type: 'invalidFile', + message: 'Selected file seems to be invalid', + }, + { shouldFocus: true }, + ) + } + + setValue(SECRET_KEY_NAME, text, { shouldValidate: true }) + } + reader.readAsText(file) + } + + const handleDrop = useCallback( + (e: React.DragEvent) => { + preventDefaults(e) + setDragging(false) + + const file = e.dataTransfer.files?.[0] + if (!file) return + + processFile(file, setError, setValue) + }, + [setError, setValue], + ) + + const handleDragEnter = (e: React.DragEvent) => { + preventDefaults(e) + setDragging(true) + } + + const handleDragOver = (e: React.DragEvent) => { + preventDefaults(e) + } + + const handleDragLeave = (e: React.DragEvent) => { + preventDefaults(e) + setDragging(false) + } + + const watchedSecretKey = watch(SECRET_KEY_NAME) + const watchedAck = watch(ACK_NAME) + + const isSecretKeyUploaded = !!watchedSecretKey + + const isSecretKeyFormCompleted = + !!watchedSecretKey && (!hasAck || !!watchedAck) + + const secretKeyFileUploadRef = useRef(null) + + const handleVerifyKeyPairAndSubmit = handleSubmit( + ({ secretKey, ack }: SecretKeyFormInputs) => { + if (!isKeypairValid(publicKey, secretKey)) { + return setError( + SECRET_KEY_NAME, + { + type: 'invalidKey', + message: 'The secret key provided is invalid', + }, + { shouldFocus: true }, + ) + } + + onSubmit({ secretKey, ack }) + }, + ) + + const handleSecretKeyFileChange = useCallback( + ({ target }: React.ChangeEvent) => { + const file = target.files?.[0] + // Reset file input so the same file selected will trigger this onChange + // function. + if (secretKeyFileUploadRef.current) { + secretKeyFileUploadRef.current.value = '' + } + + if (!file) return + + processFile(file, setError, setValue) + }, + [setError, setValue], + ) + + // Reset form before closing. + const handleSecretKeyFormClose = useCallback(() => { + reset() + return onClose() + }, [onClose, reset]) + + return { + dragging, + errors, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleSecretKeyFileChange, + handleSecretKeyFormClose, + handleVerifyKeyPairAndSubmit, + isSecretKeyFormCompleted, + isSecretKeyUploaded, + register, + secretKeyFileUploadRef, + } +} diff --git a/frontend/src/features/admin-form/settings/mutations.ts b/frontend/src/features/admin-form/settings/mutations.ts index e0b8699bcf..d6024a871a 100644 --- a/frontend/src/features/admin-form/settings/mutations.ts +++ b/frontend/src/features/admin-form/settings/mutations.ts @@ -27,6 +27,7 @@ import { adminFormSettingsKeys } from './queries' import { createStripeAccount, deleteTwilioCredentials, + MrfEmailNotificationSettings, unlinkStripeAccount, updateBusinessInfo, updateFormAuthType, @@ -36,13 +37,15 @@ import { updateFormInactiveMessage, updateFormIssueNotification, updateFormLimit, - updateFormNricMask, updateFormStatus, updateFormTitle, updateFormWebhookRetries, updateFormWebhookUrl, + updateFormWhitelistSetting, updateGstEnabledFlag, updateIsSingleSubmission, + updateIsSubmitterIdCollectionEnabled, + updateMrfEmailNotifications, updateTwilioCredentials, } from './SettingsService' @@ -72,6 +75,27 @@ export const useMutateFormSettings = () => { [formId, queryClient], ) + const generateErrorToast = useCallback( + (message) => { + toast.closeAll() + toast({ + description: message, + status: 'danger', + }) + }, + [toast], + ) + + const generateSuccessToast = useCallback( + (message) => { + toast.closeAll() + toast({ + description: message, + }) + }, + [toast], + ) + const handleSuccess = useCallback( ({ newData, @@ -80,24 +104,17 @@ export const useMutateFormSettings = () => { newData: FormSettings toastDescription: string }) => { - toast.closeAll() updateFormData(newData) - toast({ - description: toastDescription, - }) + generateSuccessToast(toastDescription) }, - [toast, updateFormData], + [updateFormData, generateSuccessToast], ) const handleError = useCallback( (error: Error) => { - toast.closeAll() - toast({ - description: error.message, - status: 'danger', - }) + generateErrorToast(error.message) }, - [toast], + [generateErrorToast], ) const mutateFormStatus = useMutation( @@ -218,6 +235,20 @@ export const useMutateFormSettings = () => { }, ) + const mutateMrfEmailNotifications = useMutation( + (MrfEmailNotificationSettings: MrfEmailNotificationSettings) => + updateMrfEmailNotifications(formId, MrfEmailNotificationSettings), + { + onSuccess: (newData) => { + handleSuccess({ + newData, + toastDescription: 'Emails successfully updated.', + }) + }, + onError: handleError, + }, + ) + const mutateFormEsrvcId = useMutation( (nextEsrvcId?: string) => updateFormEsrvcId(formId, nextEsrvcId), { @@ -306,16 +337,19 @@ export const useMutateFormSettings = () => { }, ) - const mutateNricMask = useMutation( - (nextIsNricMaskEnabled: boolean) => - updateFormNricMask(formId, nextIsNricMaskEnabled), + const mutateIsSubmitterIdCollectionEnabled = useMutation( + (nextIsSubmitterIdCollectionEnabled: boolean) => + updateIsSubmitterIdCollectionEnabled( + formId, + nextIsSubmitterIdCollectionEnabled, + ), { onSuccess: (newData) => { handleSuccess({ newData, - toastDescription: newData.isNricMaskEnabled - ? 'NRIC masking is now enabled on your form.' - : 'NRIC masking is now disabled on your form.', + toastDescription: newData.isSubmitterIdCollectionEnabled + ? 'NRIC/FIN/UEN collection is now enabled on your form.' + : 'NRIC/FIN/UEN collection is now disabled on your form.', }) }, onError: handleError, @@ -342,6 +376,24 @@ export const useMutateFormSettings = () => { }, ) + const mutateFormWhitelistSetting = useMutation( + (whitelistCsvString: Promise | null) => { + return updateFormWhitelistSetting(formId, whitelistCsvString) + }, + { + onSuccess: (_newData, variable) => { + generateSuccessToast( + variable + ? 'Your CSV has been uploaded successfully.' + : 'Your CSV has been removed successfully.', + ) + }, + onError: (error: Error) => { + generateErrorToast(error.message) + }, + }, + ) + const mutateFormWebhookUrl = useMutation( (nextUrl?: string) => updateFormWebhookUrl(formId, nextUrl), { @@ -409,10 +461,12 @@ export const useMutateFormSettings = () => { mutateFormCaptcha, mutateFormIssueNotification, mutateFormEmails, + mutateMrfEmailNotifications, mutateFormTitle, mutateFormAuthType, - mutateNricMask, + mutateIsSubmitterIdCollectionEnabled, mutateIsSingleSubmission, + mutateFormWhitelistSetting, mutateFormEsrvcId, mutateFormBusiness, mutateGST, diff --git a/frontend/src/features/admin-form/settings/queries.ts b/frontend/src/features/admin-form/settings/queries.ts index 283d99d1ee..621fdd0048 100644 --- a/frontend/src/features/admin-form/settings/queries.ts +++ b/frontend/src/features/admin-form/settings/queries.ts @@ -1,12 +1,17 @@ import { useState } from 'react' -import { useQuery, UseQueryResult } from 'react-query' +import { QueryClient, useQuery, UseQueryResult } from 'react-query' import { useParams } from 'react-router-dom' import { FormSettings } from '~shared/types/form/form' +import { EncryptedStringsMessageContent } from '~shared/utils/crypto' import { adminFormKeys } from '../common/queries' -import { getFormSettings, validateStripeAccount } from './SettingsService' +import { + getFormEncryptedWhitelistedSubmitterIds, + getFormSettings, + validateStripeAccount, +} from './SettingsService' export const adminFormSettingsKeys = { base: [...adminFormKeys.base, 'settings'] as const, @@ -15,6 +20,8 @@ export const adminFormSettingsKeys = { [...adminFormSettingsKeys.id(id), 'payment_channel'] as const, payment_field: (id: string) => [...adminFormSettingsKeys.id(id), 'payment_field'] as const, + whitelist: (id: string) => + [...adminFormSettingsKeys.id(id), 'whitelist'] as const, } /** @@ -31,6 +38,22 @@ export const useAdminFormSettings = (): UseQueryResult => { ) } +export const fetchAdminFormEncryptedWhitelistedSubmitterIds = ( + formId: string, + queryClient: QueryClient, +): Promise<{ + encryptedWhitelistedSubmitterIds: EncryptedStringsMessageContent | null +}> => { + if (!formId) throw new Error('No formId provided') + + return queryClient.fetchQuery( + adminFormSettingsKeys.whitelist(formId), + () => getFormEncryptedWhitelistedSubmitterIds(formId), + // Disable caching by setting stale time to 0. + { staleTime: 0 }, + ) +} + export const useAdminFormPayments = () => { const { formId } = useParams() if (!formId) throw new Error('No formId provided') diff --git a/frontend/src/features/admin-form/share/ShareFormModal.tsx b/frontend/src/features/admin-form/share/ShareFormModal.tsx index dc7a7eaeb6..3611e5e05e 100644 --- a/frontend/src/features/admin-form/share/ShareFormModal.tsx +++ b/frontend/src/features/admin-form/share/ShareFormModal.tsx @@ -105,13 +105,42 @@ export interface ShareFormModalProps { isFormPrivate: boolean | undefined } +const FormActivationMessage = ({ + isFormPrivate, + formId, + onClose, +}: { + isFormPrivate: boolean | undefined + onClose: () => void + formId: string | undefined +}) => { + const navigate = useNavigate() + const handleRedirectToSettings = useCallback(() => { + onClose() + navigate(`${ADMINFORM_ROUTE}/${formId}/${ADMINFORM_SETTINGS_SUBROUTE}`) + }, [formId, navigate, onClose]) + + if (!isFormPrivate) return null + + return ( + + + This form is currently closed to new responses. Activate your form in{' '} + {' '} + to allow new responses or to share it as a template. + + + ) +} + export const ShareFormModal = ({ isOpen, onClose, formId, isFormPrivate, }: ShareFormModalProps): JSX.Element => { - const navigate = useNavigate() const modalSize = useBreakpointValue({ base: 'mobile', xs: 'mobile', @@ -179,11 +208,6 @@ export const ShareFormModal = ({ `) }, [shareLink]) - const handleRedirectToSettings = useCallback(() => { - onClose() - navigate(`${ADMINFORM_ROUTE}/${formId}/${ADMINFORM_SETTINGS_SUBROUTE}`) - }, [formId, navigate, onClose]) - const { data: goLinkSuffixData } = useGoLink(formId ?? '') const [goLinkSuffixInput, setGoLinkSuffixInput] = useState('') const [goLinkSaved, setGoLinkSaved] = useState(false) @@ -344,22 +368,6 @@ export const ShareFormModal = ({ Share form - {isFormPrivate ? ( - - - This form is currently closed to new responses. Activate your - form in{' '} - {' '} - to allow new responses or to share it as a template. - - - ) : null} + {/* GoLinkSection */} {(displayGoLink && whitelisted) || @@ -435,9 +448,19 @@ export const ShareFormModal = ({ ) : null} + + diff --git a/frontend/src/features/public-form/PublicFormContext.tsx b/frontend/src/features/public-form/PublicFormContext.tsx index d021041b0d..b3a757026a 100644 --- a/frontend/src/features/public-form/PublicFormContext.tsx +++ b/frontend/src/features/public-form/PublicFormContext.tsx @@ -68,6 +68,7 @@ export interface PublicFormContextProps hasSingleSubmissionValidationError: boolean setHasSingleSubmissionValidationError?: Dispatch> + hasRespondentNotWhitelistedError: boolean encryptedPreviousSubmission?: MultirespondentSubmissionDto previousSubmission?: ReturnType diff --git a/frontend/src/features/public-form/PublicFormPage.stories.tsx b/frontend/src/features/public-form/PublicFormPage.stories.tsx index 6a7ba3edb6..296d4504ee 100644 --- a/frontend/src/features/public-form/PublicFormPage.stories.tsx +++ b/frontend/src/features/public-form/PublicFormPage.stories.tsx @@ -2,6 +2,7 @@ import { Meta, StoryFn } from '@storybook/react' import { expect, userEvent, waitFor, within } from '@storybook/test' import dedent from 'dedent' +import { ErrorCode } from '~shared/types' import { BasicField } from '~shared/types/field' import { FormAuthType, @@ -282,6 +283,30 @@ SingpassUnauthorized.parameters = { ], } +export const SingpassUnauthorizedSubmitterIdCollectionEnabled = Template.bind( + {}, +) +SingpassUnauthorizedSubmitterIdCollectionEnabled.storyName = + 'Singpass/Unauthorized/Submitter ID Collection Enabled' +SingpassUnauthorizedSubmitterIdCollectionEnabled.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'Singpass login form', + authType: FormAuthType.SP, + startPage: { + colorTheme: FormColorTheme.Grey, + }, + isSubmitterIdCollectionEnabled: true, + }, + }, + }), + ], +} + export const UnauthedMobile = Template.bind({}) UnauthedMobile.parameters = { ...SingpassUnauthorized.parameters, @@ -325,6 +350,27 @@ CorppassUnauthorized.parameters = { ], } +export const CorppassUnauthorizedSubmitterIdCollectionEnabled = Template.bind( + {}, +) +CorppassUnauthorizedSubmitterIdCollectionEnabled.storyName = + 'Corppass/Unauthorized/Submitter ID Collection Enabled' +CorppassUnauthorizedSubmitterIdCollectionEnabled.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'Corppass login form', + authType: FormAuthType.CP, + isSubmitterIdCollectionEnabled: true, + }, + }, + }), + ], +} + export const CorppassAuthorized = Template.bind({}) CorppassAuthorized.storyName = 'Corppass/Authorized' CorppassAuthorized.parameters = { @@ -362,6 +408,25 @@ SgidUnauthorized.parameters = { ], } +export const SgidUnauthorizedSubmitterIdCollectionEnabled = Template.bind({}) +SgidUnauthorizedSubmitterIdCollectionEnabled.storyName = + 'SGID/Unauthorized/Submitter ID Collection Enabled' +SgidUnauthorizedSubmitterIdCollectionEnabled.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'SGID login form', + authType: FormAuthType.SGID, + isSubmitterIdCollectionEnabled: true, + }, + }, + }), + ], +} + export const SgidAuthorized = Template.bind({}) SgidAuthorized.storyName = 'SGID/Authorized' SgidAuthorized.parameters = { @@ -382,6 +447,81 @@ SgidAuthorized.parameters = { ], } +export const SgidMyInfoUnauthorized = Template.bind({}) +SgidMyInfoUnauthorized.storyName = 'SGID_MyInfo/Unauthorized' +SgidMyInfoUnauthorized.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'SGID_MyInfo login form', + authType: FormAuthType.SGID_MyInfo, + }, + }, + }), + ], +} + +export const SgidMyInfoUnauthorizedSubmitterIdCollectionEnabled = Template.bind( + {}, +) +SgidMyInfoUnauthorizedSubmitterIdCollectionEnabled.storyName = + 'SGID_MyInfo/Unauthorized/Submitter ID Collection Enabled' +SgidMyInfoUnauthorizedSubmitterIdCollectionEnabled.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'SGID_MyInfo login form', + authType: FormAuthType.SGID_MyInfo, + isSubmitterIdCollectionEnabled: true, + }, + }, + }), + ], +} + +export const SingpassMyInfoUnauthorized = Template.bind({}) +SingpassMyInfoUnauthorized.storyName = 'SP_MyInfo/Unauthorized' +SingpassMyInfoUnauthorized.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'SP_MyInfo login form', + authType: FormAuthType.MyInfo, + }, + }, + }), + ], +} + +export const SingpassMyInfoUnauthorizedSubmitterIdCollectionEnabled = + Template.bind({}) +SingpassMyInfoUnauthorizedSubmitterIdCollectionEnabled.storyName = + 'SP_MyInfo/Unauthorized/Submitter ID Collection Enabled' +SingpassMyInfoUnauthorizedSubmitterIdCollectionEnabled.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'SP_MyInfo login form', + authType: FormAuthType.MyInfo, + isSubmitterIdCollectionEnabled: true, + }, + }, + }), + ], +} + export const SgIdSingleSubmissionFailureMessage = Template.bind({}) SgIdSingleSubmissionFailureMessage.storyName = 'SGID/Single Submission Per NRIC/FIN/UEN Failure Sign In Screen Message' @@ -396,7 +536,7 @@ SgIdSingleSubmissionFailureMessage.parameters = { authType: FormAuthType.SGID, isSingleSubmission: true, }, - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }, }), ], @@ -416,16 +556,16 @@ SingpassSingleSubmissionFailureMessage.parameters = { authType: FormAuthType.SP, isSingleSubmission: true, }, - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }, }), ], } -export const CorppassSingleSubmissionFailuredMessage = Template.bind({}) -CorppassSingleSubmissionFailuredMessage.storyName = +export const CorppassSingleSubmissionFailureMessage = Template.bind({}) +CorppassSingleSubmissionFailureMessage.storyName = 'Corppass/Single Submission Per NRIC/FIN/UEN Failure Sign In Screen Message' -CorppassSingleSubmissionFailuredMessage.parameters = { +CorppassSingleSubmissionFailureMessage.parameters = { msw: [ ...envHandlers, getPublicFormResponse({ @@ -436,7 +576,7 @@ CorppassSingleSubmissionFailuredMessage.parameters = { authType: FormAuthType.CP, isSingleSubmission: true, }, - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }, }), ], @@ -459,7 +599,7 @@ SgIdSingleSubmissionFailureModalAfterSubmit.parameters = { spcpSession: { userName: 'S1234567A', }, - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }, }), ], @@ -482,7 +622,51 @@ CpSingleSubmissionFailureModalAfterSubmit.parameters = { spcpSession: { userName: 'uen-123456789A', }, - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], + }, + }), + ], +} + +export const SgIdRespondentNotWhitelistedFailureMessage = Template.bind({}) +SgIdRespondentNotWhitelistedFailureMessage.storyName = + 'SGID/Respondent Not Whitelisted Failure Sign In Screen Message' +SgIdRespondentNotWhitelistedFailureMessage.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'SGID login form', + authType: FormAuthType.SGID, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + }, + errorCodes: [ErrorCode.respondentNotWhitelisted], + }, + }), + ], +} + +export const MyInfoRespondentNotWhitelistedFailureMessage = Template.bind({}) +MyInfoRespondentNotWhitelistedFailureMessage.storyName = + 'MyInfo/Respondent Not Whitelisted Failure Sign In Screen Message' +MyInfoRespondentNotWhitelistedFailureMessage.parameters = { + msw: [ + ...envHandlers, + getPublicFormResponse({ + delay: 0, + overrides: { + form: { + title: 'MyInfo login form', + authType: FormAuthType.MyInfo, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + }, + errorCodes: [ErrorCode.respondentNotWhitelisted], }, }), ], diff --git a/frontend/src/features/public-form/PublicFormProvider.tsx b/frontend/src/features/public-form/PublicFormProvider.tsx index 77b4f9f83f..d920c9447e 100644 --- a/frontend/src/features/public-form/PublicFormProvider.tsx +++ b/frontend/src/features/public-form/PublicFormProvider.tsx @@ -24,13 +24,13 @@ import { } from '~shared/constants' import { BasicField, PaymentType } from '~shared/types' import { CaptchaTypes } from '~shared/types/captcha' +import { ErrorCode } from '~shared/types/errorCodes' import { FormAuthType, FormResponseMode, ProductItem, PublicFormDto, } from '~shared/types/form' -import { maskNric } from '~shared/utils/nric-mask' import { dollarsToCents } from '~shared/utils/payments' import { MONGODB_ID_REGEX } from '~constants/routes' @@ -133,11 +133,6 @@ export const PublicFormProvider = ({ // Once form has been submitted, submission data will be set here. const [submissionData, setSubmissionData] = useState() - const [numVisibleFields, setNumVisibleFields] = useState(0) - const [ - hasSingleSubmissionValidationError, - setHasSingleSubmissionValidationError, - ] = useState(false) const { data, @@ -150,22 +145,41 @@ export const PublicFormProvider = ({ /* enabled= */ !submissionData, ) - // Mask Nric if isNricMaskEnabled is true - if (data?.form.isNricMaskEnabled && data.spcpSession?.userName) { - data.spcpSession.userName = maskNric(data.spcpSession.userName) - } + const [numVisibleFields, setNumVisibleFields] = useState(0) + + // Respondent access error states + const [ + hasSingleSubmissionValidationError, + setHasSingleSubmissionValidationError, + ] = useState(false) + const [ + hasRespondentNotWhitelistedError, + setHasRespondentNotWhitelistedError, + ] = useState(false) + + const clearRespondentAccessErrors = useCallback(() => { + setHasRespondentNotWhitelistedError(false) + setHasSingleSubmissionValidationError(false) + }, []) useEffect(() => { if ( - data?.form.isSingleSubmission && - data.hasSingleSubmissionValidationFailure + data?.errorCodes?.find( + (errorCode) => + errorCode === ErrorCode.respondentSingleSubmissionValidationFailure, + ) ) { setHasSingleSubmissionValidationError(true) } - }, [ - data?.form.isSingleSubmission, - data?.hasSingleSubmissionValidationFailure, - ]) + + if ( + data?.errorCodes?.find( + (errorCode) => errorCode === ErrorCode.respondentNotWhitelisted, + ) + ) { + setHasRespondentNotWhitelistedError(true) + } + }, [data?.errorCodes]) const { isNotFormId, toast, vfnToastIdRef, expiryInMs, ...commonFormValues } = useCommonFormProvider(formId) @@ -369,14 +383,18 @@ export const PublicFormProvider = ({ data?.form.responseMode === FormResponseMode.Encrypt && data.form.payments_field.enabled + const hasMyInfoError = !!data?.errorCodes?.find( + (errorCode) => errorCode === ErrorCode.myInfo, + ) + useEffect(() => { - if (data?.myInfoError) { + if (hasMyInfoError) { toast({ status: 'danger', description: t('features.publicForm.errors.myinfo'), }) } - }, [data, toast, t]) + }, [hasMyInfoError, toast, t]) const showErrorToast = useCallback( (error: unknown, form: PublicFormDto) => { @@ -551,7 +569,6 @@ export const PublicFormProvider = ({ ) { data.spcpSession = undefined } - setHasSingleSubmissionValidationError(false) setSubmissionData({ id: submissionId, timestamp, @@ -713,7 +730,7 @@ export const PublicFormProvider = ({ ) { data.spcpSession = undefined } - setHasSingleSubmissionValidationError(false) + clearRespondentAccessErrors() setSubmissionData({ id: submissionId, timestamp, @@ -776,7 +793,7 @@ export const PublicFormProvider = ({ ) { data.spcpSession = undefined } - setHasSingleSubmissionValidationError(false) + clearRespondentAccessErrors() setSubmissionData({ id: submissionId, timestamp, @@ -848,6 +865,7 @@ export const PublicFormProvider = ({ navigate, formId, storePaymentMemory, + clearRespondentAccessErrors, ], ) @@ -883,6 +901,7 @@ export const PublicFormProvider = ({ setNumVisibleFields, hasSingleSubmissionValidationError, setHasSingleSubmissionValidationError, + hasRespondentNotWhitelistedError, encryptedPreviousSubmission, previousSubmission, previousAttachments, diff --git a/frontend/src/features/public-form/components/FormAuth/FormAuth.tsx b/frontend/src/features/public-form/components/FormAuth/FormAuth.tsx index 68997ae313..81e9269628 100644 --- a/frontend/src/features/public-form/components/FormAuth/FormAuth.tsx +++ b/frontend/src/features/public-form/components/FormAuth/FormAuth.tsx @@ -1,8 +1,11 @@ import { useMemo } from 'react' import { BiLogInCircle } from 'react-icons/bi' -import { Box, Stack, Text } from '@chakra-ui/react' +import { Box, Stack } from '@chakra-ui/react' -import { FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE } from '~shared/constants' +import { + FORM_RESPONDENT_NOT_WHITELISTED_ERROR_MESSAGE, + FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE, +} from '~shared/constants' import { FormAuthType } from '~shared/types/form' import InlineMessage from '~/components/InlineMessage' @@ -14,15 +17,35 @@ import { usePublicAuthMutations } from '~features/public-form/mutations' import { usePublicFormContext } from '~features/public-form/PublicFormContext' import { AuthImageSvgr } from './AuthImageSvgr' +import { FormAuthMessage } from './FormAuthMessage' + +const getDispayedAuthTypeText = ( + authType: Exclude, +) => { + switch (authType) { + case FormAuthType.SP: + case FormAuthType.MyInfo: + return 'Singpass' + case FormAuthType.CP: + return 'Singpass (Corporate)' + case FormAuthType.SGID: + case FormAuthType.SGID_MyInfo: + return 'Singpass app' + } +} export interface FormAuthProps { authType: Exclude + isSubmitterIdCollectionEnabled: boolean hasSingleSubmissionValidationError: boolean + hasRespondentNotWhitelistedError: boolean } export const FormAuth = ({ authType, + isSubmitterIdCollectionEnabled, hasSingleSubmissionValidationError, + hasRespondentNotWhitelistedError, }: FormAuthProps): JSX.Element => { const { formId, form } = usePublicFormContext() @@ -32,33 +55,8 @@ export const FormAuth = ({ }, [form]) const isMobile = useIsMobile() - - const displayedInfo = useMemo(() => { - switch (authType) { - case FormAuthType.SP: - case FormAuthType.MyInfo: - return { - authType: 'Singpass', - helpText: - 'Sign in with Singpass to access this form.\nYour Singpass ID will be included with your form submission.', - } - case FormAuthType.CP: - return { - authType: 'Singpass (Corporate)', - helpText: - 'Corporate entity login is required for this form.\nYour Singpass ID and corporate Entity ID will be included with your form submission.', - } - case FormAuthType.SGID: - case FormAuthType.SGID_MyInfo: - return { - authType: 'Singpass app', - helpText: - 'Sign in with the Singpass app to access this form.\nYour Singpass ID will be included with your form submission.', - } - } - }, [authType]) - const { handleLoginMutation } = usePublicAuthMutations(formId) + const displayedAuthTypeText = getDispayedAuthTypeText(authType) return ( handleLoginMutation.mutate()} isLoading={handleLoginMutation.isLoading} > - Log in with {displayedInfo.authType} + Log in with {displayedAuthTypeText} - - {displayedInfo.helpText} - + {hasSingleSubmissionValidationError ? ( {FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE} ) : null} + {hasRespondentNotWhitelistedError ? ( + + {FORM_RESPONDENT_NOT_WHITELISTED_ERROR_MESSAGE} + + ) : null} ) diff --git a/frontend/src/features/public-form/components/FormAuth/FormAuthMessage.tsx b/frontend/src/features/public-form/components/FormAuth/FormAuthMessage.tsx new file mode 100644 index 0000000000..46c5dd4621 --- /dev/null +++ b/frontend/src/features/public-form/components/FormAuth/FormAuthMessage.tsx @@ -0,0 +1,105 @@ +import { Text } from '@chakra-ui/react' + +import { FormAuthType } from '~shared/types/form' + +interface FormAuthMessageProps { + authType: Exclude + isSubmitterIdCollectionEnabled: boolean +} + +const SubmitterIdCollectionInfoText = ({ + authType, + isSubmitterIdCollectionEnabled, +}: FormAuthMessageProps): JSX.Element => { + if (isSubmitterIdCollectionEnabled) { + switch (authType) { + case FormAuthType.SP: + case FormAuthType.MyInfo: + case FormAuthType.SGID: + case FormAuthType.SGID_MyInfo: + return ( + + Your Singpass login ID will be included with + your form submission. + + ) + case FormAuthType.CP: + return ( + + Your Singpass and Corppass login ID{' '} + will be included with your form submission. + + ) + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = authType + throw new Error('Invalid auth type') + } + } + } else { + switch (authType) { + case FormAuthType.SP: + case FormAuthType.MyInfo: + case FormAuthType.SGID: + case FormAuthType.SGID_MyInfo: + return ( + + Your Singpass login ID will not be included with + your form submission. + + ) + case FormAuthType.CP: + return ( + + Your Singpass and Corppass login ID will{' '} + not be included with your form submission. + + ) + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = authType + throw new Error('Invalid auth type') + } + } + } +} + +const getSignInText = (authType: Exclude) => { + switch (authType) { + case FormAuthType.SP: + case FormAuthType.MyInfo: + return 'Sign in with Singpass to access this form.\n' + case FormAuthType.CP: + return 'Corporate entity login is required for this form.\n' + case FormAuthType.SGID: + case FormAuthType.SGID_MyInfo: + return 'Sign in with the Singpass app to access this form.\n' + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = authType + throw new Error('Invalid auth type') + } + } +} + +export const FormAuthMessage = ({ + authType, + isSubmitterIdCollectionEnabled, +}: FormAuthMessageProps) => { + const signInText = getSignInText(authType) + + return ( + + {signInText} + + + ) +} diff --git a/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx b/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx index a2ff05cd52..2c433a3e44 100644 --- a/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx +++ b/frontend/src/features/public-form/components/FormFields/FieldFactory.tsx @@ -79,13 +79,13 @@ export const FieldFactory = memo( case BasicField.Uen: return case BasicField.Attachment: { - const enableDownload = + const showDownload = form?.responseMode === FormResponseMode.Multirespondent return ( ) } diff --git a/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx b/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx index 8c66afc032..d013a44e6c 100644 --- a/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx +++ b/frontend/src/features/public-form/components/FormFields/FormFieldsContainer.tsx @@ -15,6 +15,7 @@ export const FormFieldsContainer = (): JSX.Element | null => { form, isAuthRequired, hasSingleSubmissionValidationError, + hasRespondentNotWhitelistedError, isLoading, handleSubmitForm, submissionData, @@ -40,9 +41,11 @@ export const FormFieldsContainer = (): JSX.Element | null => { return ( ) } @@ -75,6 +78,7 @@ export const FormFieldsContainer = (): JSX.Element | null => { workflowStep, handleSubmitForm, hasSingleSubmissionValidationError, + hasRespondentNotWhitelistedError, ]) if (submissionData) return null diff --git a/frontend/src/features/public-form/utils/createSubmission.ts b/frontend/src/features/public-form/utils/createSubmission.ts index 952ca76dec..bff672d035 100644 --- a/frontend/src/features/public-form/utils/createSubmission.ts +++ b/frontend/src/features/public-form/utils/createSubmission.ts @@ -354,7 +354,7 @@ const createResponsesV3 = ( returnedInputs[ff._id] = { fieldType: ff.fieldType, answer: { - hasBeenScanned: false, //TODO(MRF/FRM-1590): conditionally set to true if not replaced by respondent 2 onwards + hasBeenScanned: false, //TODO: FRM-1839 + FRM-1590 conditionally set to true if not replaced by respondent 2 onwards answer: fieldIdToQuarantineKeyEntry.quarantineBucketKey, }, } diff --git a/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx b/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx index 0db77633ba..3b7d881fd0 100644 --- a/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx +++ b/frontend/src/features/workspace/components/CreateFormModal/CreateFormModalContent/FormResponseOptions.tsx @@ -36,13 +36,15 @@ export const FormResponseOptions = forwardRef< Recommended} isActive={value === FormResponseMode.Encrypt} onClick={() => onChange(FormResponseMode.Encrypt)} flex={1} > Storage mode form - View or download responses in FormSG + + View and download responses in FormSG or receive responses in your + inbox + Email mode form - Receive responses in your inbox + Receive responses in your inbox only Multi-respondent form - Create a workflow to collect responses from multiple respondents in - the same form submission + Collect responses from multiple people in the same submission diff --git a/frontend/src/growthbook.ts b/frontend/src/growthbook.ts index 95ad9c140d..cdd89cfb5a 100644 --- a/frontend/src/growthbook.ts +++ b/frontend/src/growthbook.ts @@ -1,7 +1,7 @@ import { GrowthBook } from '@growthbook/growthbook-react' -import { GROWTHBOOK_DEV_PROXY } from '~constants/links' -import { GROWTHBOOK_API_HOST_PATH } from '~constants/routes' +import { GROWTHBOOK_DEV_PROXY } from '~shared/constants/links' +import { GROWTHBOOK_API_HOST_PATH } from '~shared/constants/routes' export const createGrowthbookInstance = (clientKey: string) => { const isDev = import.meta.env.NODE_ENV === 'development' diff --git a/frontend/src/hooks/useMdComponents.tsx b/frontend/src/hooks/useMdComponents.tsx index f7d363e8fb..37248efa67 100644 --- a/frontend/src/hooks/useMdComponents.tsx +++ b/frontend/src/hooks/useMdComponents.tsx @@ -20,9 +20,13 @@ type MdComponentStyles = { */ text?: SystemStyleObject /** - * If exists, will be used for styling text + * If exists, will be used for styling lists */ list?: SystemStyleObject + /** + * If exists, will be used for styling ordered lists + */ + listItem?: SystemStyleObject } type UseMdComponentsProps = { @@ -55,6 +59,15 @@ export const useMdComponents = ({ [styles.link], ) + const listItemStyle = useMemo( + () => ({ + sx: { + ...(styles.listItem ?? {}), + }, + }), + [styles.listItem], + ) + const listStyles = useMemo( () => ({ sx: { @@ -76,7 +89,9 @@ export const useMdComponents = ({ /> ), ul: ({ node, ...props }) => , - li: ({ node, ...props }) => , + li: ({ node, ...props }) => ( + + ), a: ({ node, ...props }) => { const { href } = props const isExternal = @@ -87,7 +102,7 @@ export const useMdComponents = ({ p: ({ node, ...props }) => , ...overrides, }), - [linkStyles, overrides, textStyles, listStyles], + [linkStyles, overrides, textStyles, listStyles, listItemStyle], ) return mdComponents diff --git a/frontend/src/i18n/locales/features/public-form/en-sg.ts b/frontend/src/i18n/locales/features/public-form/en-sg.ts index e77678a330..ef40420660 100644 --- a/frontend/src/i18n/locales/features/public-form/en-sg.ts +++ b/frontend/src/i18n/locales/features/public-form/en-sg.ts @@ -6,13 +6,13 @@ export const enSG: PublicForm = { notFound: 'Form not found', deleted: 'This form is no longer active', private: - 'If you think this is a mistake, please contact the agency that gave you the form link.', + 'If you require further assistance, please contact the agency that gave you the form link.', submissionSecretKeyInvalid: { title: 'Invalid form link', header: 'This form link is no longer valid.', message: - 'A submission may have already been made using this link. If you think this is a mistake, please contact the agency that gave you the form link.', + 'A submission may have already been made using this link. If you require further assistance, please contact the agency that gave you the form link.', }, myinfo: diff --git a/frontend/src/mocks/msw/handlers/admin-form/form.ts b/frontend/src/mocks/msw/handlers/admin-form/form.ts index 66a37bff5b..2b356ec0c0 100644 --- a/frontend/src/mocks/msw/handlers/admin-form/form.ts +++ b/frontend/src/mocks/msw/handlers/admin-form/form.ts @@ -604,7 +604,7 @@ export const createMockForm = ( authType: FormAuthType.NIL, status: FormStatus.Public, inactiveMessage: - 'If you think this is a mistake, please contact the agency that gave you the form link!', + 'If you require further assistance, please contact the agency that gave you the form link.', submissionLimit: null, form_fields: [], form_logics: [], diff --git a/frontend/src/mocks/msw/handlers/admin-form/preview-form.ts b/frontend/src/mocks/msw/handlers/admin-form/preview-form.ts index 82a6191623..2e497b69bd 100644 --- a/frontend/src/mocks/msw/handlers/admin-form/preview-form.ts +++ b/frontend/src/mocks/msw/handlers/admin-form/preview-form.ts @@ -41,7 +41,7 @@ export const getPreviewFormResponse = ({ export const getPreviewFormErrorResponse = ({ delay = 0, status = 404, - message = 'If you think this is a mistake, please contact the agency that gave you the form link.', + message = 'If you require further assistance, please contact the agency that gave you the form link.', }: { delay?: number | 'infinite' status?: number diff --git a/frontend/src/mocks/msw/handlers/admin-form/settings.ts b/frontend/src/mocks/msw/handlers/admin-form/settings.ts index 2d3ad82414..c06d0bbf47 100644 --- a/frontend/src/mocks/msw/handlers/admin-form/settings.ts +++ b/frontend/src/mocks/msw/handlers/admin-form/settings.ts @@ -3,12 +3,45 @@ import { rest } from 'msw' import { EMAIL_FORM_SETTINGS_FIELDS, + MULTIRESPONDENT_FORM_SETTINGS_FIELDS, STORAGE_FORM_SETTINGS_FIELDS, } from '~shared/constants/form' -import { FormId, FormResponseMode, FormSettings } from '~shared/types/form/form' +import { + AdminFormDto, + FormId, + FormResponseMode, + FormSettings, +} from '~shared/types/form/form' import { createMockForm } from './form' +export const getAdminFormView = ({ + delay = 0, + overrides, + mode = FormResponseMode.Email, +}: { + delay?: number | 'infinite' + overrides?: Partial + mode?: FormResponseMode +} = {}) => { + return rest.get( + '/api/v3/admin/forms/:formId', + (req, res, ctx) => { + return res( + ctx.delay(delay), + ctx.status(200), + ctx.json( + createMockForm({ + _id: req.params.formId as FormId, + responseMode: mode, + ...overrides, + }), + ), + ) + }, + ) +} + export const getAdminFormSettings = ({ delay = 0, overrides, @@ -18,6 +51,12 @@ export const getAdminFormSettings = ({ overrides?: Partial mode?: FormResponseMode } = {}) => { + const MODE_TO_SETTINGS_FIELDS_MAP = { + [FormResponseMode.Email]: EMAIL_FORM_SETTINGS_FIELDS, + [FormResponseMode.Encrypt]: STORAGE_FORM_SETTINGS_FIELDS, + [FormResponseMode.Multirespondent]: MULTIRESPONDENT_FORM_SETTINGS_FIELDS, + } + return rest.get( '/api/v3/admin/forms/:formId/settings', (req, res, ctx) => { @@ -31,9 +70,7 @@ export const getAdminFormSettings = ({ responseMode: mode, ...overrides, }).form, - mode === FormResponseMode.Email - ? EMAIL_FORM_SETTINGS_FIELDS - : STORAGE_FORM_SETTINGS_FIELDS, + MODE_TO_SETTINGS_FIELDS_MAP[mode], ), ), ) @@ -73,3 +110,19 @@ export const patchAdminFormSettings = ({ }, ) } + +export const putFormWhitelistSettingSimulateCsvStringValidationError = ( + formId: string, +) => { + return rest.put>( + `/api/v3/admin/forms/${formId}/settings/whitelist`, + (req, res, ctx) => { + return res( + ctx.status(422), + ctx.json({ + message: 'Storybook whitelist update mock validation error', + }), + ) + }, + ) +} diff --git a/frontend/src/mocks/msw/handlers/admin-form/template-form.ts b/frontend/src/mocks/msw/handlers/admin-form/template-form.ts index 704a58c7d9..d6eb3ca3ea 100644 --- a/frontend/src/mocks/msw/handlers/admin-form/template-form.ts +++ b/frontend/src/mocks/msw/handlers/admin-form/template-form.ts @@ -43,7 +43,7 @@ export const getTemplateFormResponse = ({ export const getTemplateFormErrorResponse = ({ delay = 0, status = 403, - message = 'If you think this is a mistake, please contact the agency that gave you the form link.', + message = 'If you require further assistance, please contact the agency that gave you the form link.', }: { delay?: number | 'infinite' status?: number diff --git a/frontend/src/mocks/msw/handlers/public-form.ts b/frontend/src/mocks/msw/handlers/public-form.ts index cb1f44e0fd..ccecba1e9f 100644 --- a/frontend/src/mocks/msw/handlers/public-form.ts +++ b/frontend/src/mocks/msw/handlers/public-form.ts @@ -489,7 +489,7 @@ export const getPublicFormWithoutSectionsResponse = ({ export const getPublicFormErrorResponse = ({ delay = 0, status = 404, - message = 'If you think this is a mistake, please contact the agency that gave you the form link.', + message = 'If you require further assistance, please contact the agency that gave you the form link.', }: { delay?: number | 'infinite' status?: number diff --git a/frontend/src/pages/Landing/Home/LandingPage.tsx b/frontend/src/pages/Landing/Home/LandingPage.tsx index fcf916b573..dcb2af4469 100644 --- a/frontend/src/pages/Landing/Home/LandingPage.tsx +++ b/frontend/src/pages/Landing/Home/LandingPage.tsx @@ -84,7 +84,12 @@ import { useLanding } from './queries' export const LandingPage = (): JSX.Element => { const { data } = useLanding() const isMobile = useIsMobile() - const mdComponents = useMdComponents() + const mdComponents = useMdComponents({ + styles: { + text: { whiteSpace: 'initial' }, + listItem: { marginBottom: '1rem' }, + }, + }) return ( <> @@ -452,7 +457,8 @@ export const LandingPage = (): JSX.Element => { - Download your responses as a CSV + Download your responses as a CSV and collect responses at your + email address diff --git a/frontend/src/services/ApiService.ts b/frontend/src/services/ApiService.ts index ea34d69635..e4c50470a2 100644 --- a/frontend/src/services/ApiService.ts +++ b/frontend/src/services/ApiService.ts @@ -2,6 +2,8 @@ import { datadogLogs } from '@datadog/browser-logs' import axios, { AxiosError } from 'axios' import { StatusCodes } from 'http-status-codes' +import { ErrorCode } from '~shared/types/errorCodes' + import { ApiError } from '~typings/core' import { LOCAL_STORAGE_EVENT, LOGGED_IN_KEY } from '~constants/localStorage' @@ -30,7 +32,12 @@ export const transformAxiosError = (error: Error): ApiError => { if (axios.isAxiosError(error)) { if (error.response) { const statusCode = error.response.status - if (error.response.data?.hasSingleSubmissionValidationFailure) { + if ( + error.response.data?.errorCodes?.find( + (errorCode: ErrorCode) => + errorCode === ErrorCode.respondentSingleSubmissionValidationFailure, + ) + ) { return new SingleSubmissionValidationError() } if (statusCode === StatusCodes.TOO_MANY_REQUESTS) { @@ -39,6 +46,13 @@ export const transformAxiosError = (error: Error): ApiError => { if (typeof error.response.data === 'string') { return new HttpError(error.response.data, statusCode) } + // handle celebrate errors + if (error.response.data?.validation?.body?.message) { + return new HttpError( + error.response.data.validation.body.message, + statusCode, + ) + } if (error.response.data?.message) { return new HttpError(error.response.data.message, statusCode) } diff --git a/frontend/src/templates/Field/Attachment/AttachmentField.stories.tsx b/frontend/src/templates/Field/Attachment/AttachmentField.stories.tsx index c3967e3170..2174772f5e 100644 --- a/frontend/src/templates/Field/Attachment/AttachmentField.stories.tsx +++ b/frontend/src/templates/Field/Attachment/AttachmentField.stories.tsx @@ -104,13 +104,13 @@ ValidationOptional.args = { export const DownloadEnabled = Template.bind({}) DownloadEnabled.args = { schema: { ...baseSchema, required: false }, - enableDownload: true, + showDownload: true, defaultValue: new File(['examplebtyes'], 'example.txt'), } export const DownloadEnabledWithDisabledUpload = Template.bind({}) DownloadEnabledWithDisabledUpload.args = { schema: { ...baseSchema, disabled: true }, - enableDownload: true, + showDownload: true, defaultValue: new File(['examplebtyes'], 'example.txt'), } diff --git a/frontend/src/templates/Field/Attachment/AttachmentField.tsx b/frontend/src/templates/Field/Attachment/AttachmentField.tsx index 3d20d22bf5..0158e98162 100644 --- a/frontend/src/templates/Field/Attachment/AttachmentField.tsx +++ b/frontend/src/templates/Field/Attachment/AttachmentField.tsx @@ -20,7 +20,7 @@ import { AttachmentFieldInput, AttachmentFieldSchema } from '../types' export interface AttachmentFieldProps extends BaseFieldProps { schema: AttachmentFieldSchema disableRequiredValidation?: boolean - enableDownload?: boolean + showDownload?: boolean } /** @@ -29,7 +29,7 @@ export interface AttachmentFieldProps extends BaseFieldProps { export const AttachmentField = ({ schema, disableRequiredValidation, - enableDownload, + showDownload, colorTheme = FormColorTheme.Blue, }: AttachmentFieldProps): JSX.Element => { const fieldName = schema._id @@ -42,6 +42,9 @@ export const AttachmentField = ({ useFormContext() const maxSizeInBytes = useMemo(() => { + if (!schema.attachmentSize) { + return + } return parseInt(schema.attachmentSize) * MB }, [schema.attachmentSize]) @@ -115,8 +118,8 @@ export const AttachmentField = ({ onChange={handleFileChange(onChange)} onError={setErrorMessage} title={`${schema.questionNumber}. ${schema.title}`} - enableDownload={enableDownload} - enableRemove={!schema.disabled} + showDownload={showDownload} + showRemove={!schema.disabled} /> )} name={fieldName} diff --git a/frontend/src/templates/Field/Radio/RadioField.tsx b/frontend/src/templates/Field/Radio/RadioField.tsx index 011625d277..73a0c17c24 100644 --- a/frontend/src/templates/Field/Radio/RadioField.tsx +++ b/frontend/src/templates/Field/Radio/RadioField.tsx @@ -100,6 +100,7 @@ export const RadioField = ({ {...(idx === 0 ? { ref } : {})} // Required should apply to radio group rather than individual radio. isRequired={false} + isDisabled={schema.disabled} > {option} diff --git a/frontend/src/theme/components/Radio.ts b/frontend/src/theme/components/Radio.ts index 8b81e42168..ec2d62eee3 100644 --- a/frontend/src/theme/components/Radio.ts +++ b/frontend/src/theme/components/Radio.ts @@ -85,6 +85,7 @@ export const Radio: ComponentMultiStyleConfig = { _disabled: { borderColor: 'neutral.500', bg: 'white', + cursor: 'not-allowed', _checked: { borderColor: 'neutral.500', color: 'neutral.500', diff --git a/frontend/src/utils/parseCsvFileToCsvString.ts b/frontend/src/utils/parseCsvFileToCsvString.ts new file mode 100644 index 0000000000..06d51f8ec5 --- /dev/null +++ b/frontend/src/utils/parseCsvFileToCsvString.ts @@ -0,0 +1,39 @@ +import Papa from 'papaparse' + +export const parseCsvFileToCsvString = ( + file: File, + validateHeader?: (headerRow: string[]) => { + isValid: boolean + invalidReason: string + }, +): Promise => { + return new Promise((resolve, reject) => { + Papa.parse(file, { + delimiter: ',', + complete: ({ data }: { data: string[][] }) => { + const hasHeader = !!validateHeader + const headerRow = hasHeader ? data[0] : null + const contentRows = hasHeader ? data.slice(1) : data + if (validateHeader && headerRow) { + const { isValid, invalidReason } = validateHeader(headerRow) + if (!isValid) { + reject(new Error(invalidReason)) + } + } + const csvString = Papa.unparse(contentRows, { + newline: '\r\n', + }) + // strip quotes to account for mixed CRLF and LF line endings. + // strip newline/empty spaces at the end of string to account for invisible trailing newlines and empty last rows. + const strippedCsvString = csvString.replaceAll('"', '').trim() + if (!strippedCsvString) { + reject(new Error('Your CSV file body cannot be empty.')) + } + resolve(strippedCsvString) + }, + error: (error) => { + reject(error) + }, + }) + }) +} diff --git a/package-lock.json b/package-lock.json index eef60af561..bed01927a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "FormSG", - "version": "6.137.0", + "version": "6.146.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.137.0", + "version": "6.146.1", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.536.0", "@aws-sdk/client-lambda": "^3.414.0", "@babel/runtime": "^7.24.7", "@faker-js/faker": "^8.4.1", + "@growthbook/growthbook": "^1.1.0", "@joi/date": "^2.1.0", "@opengovsg/formsg-sdk": "^0.12.0-alpha.1", "@opengovsg/myinfo-gov-client": "^4.1.2", @@ -23,10 +24,10 @@ "abortcontroller-polyfill": "^1.7.5", "aws-info": "^1.2.0", "aws-sdk": "^2.1659.0", - "axios": "^1.6.4", + "axios": "^1.7.4", "bcrypt": "^5.1.1", "bluebird": "^3.5.2", - "body-parser": "^1.20.1", + "body-parser": "^1.20.3", "boxicons": "1.8.0", "bson": "^4.7.2", "busboy": "^1.6.0", @@ -40,11 +41,11 @@ "csv-string": "^4.1.1", "cuid": "^2.1.8", "date-fns": "^2.30.0", - "dd-trace": "^3.36.0", + "dd-trace": "^5.22.0", "dedent-js": "~1.0.1", "dotenv": "^16.0.3", "ejs": "^3.1.10", - "express": "^4.19.2", + "express": "^4.20.0", "express-rate-limit": "^7.2.0", "express-request-id": "^1.4.1", "express-session": "^1.18.0", @@ -67,13 +68,14 @@ "JSONStream": "^1.3.5", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", - "jwk-to-pem": "^2.0.5", + "jwk-to-pem": "^2.0.6", "libphonenumber-js": "^1.10.59", "lodash": "^4.17.21", "moment-timezone": "0.5.41", "mongodb-memory-server-core": "^9.1.7", "mongodb-uri": "^0.9.7", "mongoose": "^6.12.0", + "multer": "^1.4.5-lts.1", "multiparty": ">=4.2.3", "nan": "^2.19.0", "neverthrow": "^6.1.0", @@ -109,18 +111,18 @@ "whatwg-fetch": "^3.6.2", "winston": "^3.13.0", "winston-cloudwatch": "^6.2.0", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@babel/core": "^7.24.3", "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-env": "^7.22.5", + "@babel/preset-env": "^7.25.3", "@opengovsg/credits-generator": "^1.0.6", "@opengovsg/mockpass": "^4.3.2", "@playwright/test": "^1.45.1", "@stoplight/prism-cli": "^5.5.4", "@types/bcrypt": "^5.0.0", - "@types/bluebird": "^3.5.38", + "@types/bluebird": "^3.5.42", "@types/busboy": "^1.5.3", "@types/compression": "^1.7.5", "@types/connect-datadog": "0.0.6", @@ -133,7 +135,7 @@ "@types/express-session": "^1.18.0", "@types/helmet": "4.0.0", "@types/html-escaper": "^3.0.2", - "@types/http-errors": "^2.0.1", + "@types/http-errors": "^2.0.4", "@types/ip": "^1.1.0", "@types/jest": "^29.5.1", "@types/json-stringify-safe": "^5.0.3", @@ -142,6 +144,7 @@ "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.6", "@types/mongodb-uri": "^0.9.4", + "@types/multer": "^1.4.11", "@types/node": "^14.18.23", "@types/nodemailer": "^6.4.15", "@types/opossum": "^6.2.3", @@ -161,9 +164,8 @@ "axios-mock-adapter": "^1.22.0", "concurrently": "^7.6.0", "copyfiles": "^2.4.1", - "core-js": "^3.28.0", + "core-js": "^3.38.1", "coveralls": "^3.1.1", - "csv-parse": "^5.3.6", "env-cmd": "^10.1.0", "eslint": "^8.57.0", "eslint-config-prettier": "^8.10.0", @@ -2432,9 +2434,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz", + "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==", "engines": { "node": ">=6.9.0" } @@ -2505,11 +2507,11 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.8.tgz", - "integrity": "sha512-47DG+6F5SzOi0uEvK4wMShmn5yY0mVjVJoWTphdY2B4Rx9wHgjK7Yhtr0ru6nE+sn0v38mzrWOlah0p/YlHHOQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", + "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", "dependencies": { - "@babel/types": "^7.24.8", + "@babel/types": "^7.25.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -2532,37 +2534,38 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", + "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.5.tgz", - "integrity": "sha512-m1EP3lVOPptR+2DwD125gziZNcmoNSHGmJROKoy87loWUQyJaVXDgpmruWqDARZSmtYQ+Dl25okU8+qhVzuykw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz", + "integrity": "sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -2587,20 +2590,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.5.tgz", - "integrity": "sha512-xkb58MyOYIslxu3gKmVXmjTtUPvBU4odYzbiIQbWwLKIHCsx6UGZGX6F1IznMFVnDdirseUZopzN+ZRt8Xb33Q==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.5", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.5", - "semver": "^6.3.0" + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.0.tgz", + "integrity": "sha512-GYM6BxeQsETc9mnct+nIIpf63SAyzvyYN7UB/IlTyd+MBg06afFGp0mIeUqGyWgS2mxad6vqbMrHVlaL3m70sQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-member-expression-to-functions": "^7.24.8", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/helper-replace-supers": "^7.25.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/traverse": "^7.25.0", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -2619,14 +2620,14 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.5.tgz", - "integrity": "sha512-1VpEFOIbMRaXyDeUwUfmTIxExLwQ+zkW+Bh5zXpApA3oQedBx9v/updixWxnx/bZpKw7u8VxWjb/qWpIcmPq8A==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.2.tgz", + "integrity": "sha512-+wqVGP+DFmqwFD3EH6TMTfUNeqDehV3E/dl+Sd54eaXqm17tEUNbEIn4sVivVowbvUpOtIGxdo3GoXyDH9N/9g==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-annotate-as-pure": "^7.24.7", "regexpu-core": "^5.3.1", - "semver": "^6.3.0" + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -2660,47 +2661,14 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", - "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", - "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", - "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", - "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz", - "integrity": "sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", + "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.8", + "@babel/types": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -2719,15 +2687,14 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.6.tgz", - "integrity": "sha512-Y/YMPm83mV2HJTbX1Qh2sjgjqcacvOlhbzdCCsSlblOKjSYmQqEbO6rUniWQyRo9ncyfjT8hnUjlG06RXDEmcA==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "dependencies": { - "@babel/helper-environment-visitor": "^7.24.6", - "@babel/helper-module-imports": "^7.24.6", - "@babel/helper-simple-access": "^7.24.6", - "@babel/helper-split-export-declaration": "^7.24.6", - "@babel/helper-validator-identifier": "^7.24.6" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" }, "engines": { "node": ">=6.9.0" @@ -2737,12 +2704,12 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", + "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -2758,15 +2725,14 @@ } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.5.tgz", - "integrity": "sha512-cU0Sq1Rf4Z55fgz7haOakIyM7+x/uCFwXpLPaeRzfoUtAEAuUZjZvFPjL/rk5rW693dIgn2hng1W7xbT7lWT4g==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.0.tgz", + "integrity": "sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-wrap-function": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-wrap-function": "^7.25.0", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -2776,50 +2742,41 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.5.tgz", - "integrity": "sha512-aLdNM5I3kdI/V9xGNyKSF3X/gTyMUBohTZ+/3QdQKAA9vxIiy12E+8E2HoOP1/DjeqU+g6as35QHJNMDDYpuCg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz", + "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.5", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/helper-member-expression-to-functions": "^7.24.8", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-simple-access": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.6.tgz", - "integrity": "sha512-nZzcMMD4ZhmB35MOOzQuiGO5RzL6tJbsT37Zx8M5L/i9KSrukGXWTjLe1knIbb/RmxoJE9GON9soq0c0VEMM5g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", "dependencies": { - "@babel/types": "^7.24.6" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", + "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", + "dev": true, "dependencies": { + "@babel/traverse": "^7.24.7", "@babel/types": "^7.24.7" }, "engines": { @@ -2843,23 +2800,22 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.5.tgz", - "integrity": "sha512-bYqLIBSEshYcYQyfks8ewYA8S30yaGSeRslcvKMvoUk6HHPySbxHq9YRi6ghhzEU+yhQv9bP/jXnygkStOcqZw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.0.tgz", + "integrity": "sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==", "dev": true, "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.5", - "@babel/traverse": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -2902,13 +2858,44 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz", + "integrity": "sha512-wUrcsxZg6rqBXG05HG1FPYgsP6EvwF4WpBbxIpWIIYnH8wG0gzx3yZY3dtEHas4sTAOGkbTsc9EGPxwff8lRoA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.0.tgz", + "integrity": "sha512-Bm4bH2qsX880b/3ziJ8KD711LT7z4u8CFudmjqle65AZj/HNUFhEf90dqYv6O86buWvSBmeQDjv0Tn2aF/bIBA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz", - "integrity": "sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.0.tgz", + "integrity": "sha512-lXwdNZtTmeVOOFtwM/WDe7yg1PL8sYhRk/XH0FzbR2HDQ0xC+EnQ/JHeoMYSavtU115tnUk0q9CDyq8si+LMAA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -2918,14 +2905,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.5.tgz", - "integrity": "sha512-31Bb65aZaUwqCbWMnZPduIZxCBngHFlzyN6Dq6KAJjtx+lx6ohKHubc61OomYi7XwVD4Ol0XCVz4h+pYFR048g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz", + "integrity": "sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -2934,6 +2921,22 @@ "@babel/core": "^7.13.0" } }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.0.tgz", + "integrity": "sha512-tggFrk1AIShG/RUQbEwt2Tr/E+ObkfwrPjR6BjbRvsx24+PSjK8zrq0GWPNCjo8qpRx4DuJzlcvWJqlm+0h3kw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-proposal-function-sent": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-function-sent/-/plugin-proposal-function-sent-7.18.6.tgz", @@ -2963,21 +2966,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-unicode-property-regex": { - "version": "7.18.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "dev": true, @@ -3030,8 +3018,9 @@ }, "node_modules/@babel/plugin-syntax-dynamic-import": { "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -3067,12 +3056,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz", - "integrity": "sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.7.tgz", + "integrity": "sha512-Ec3NRUMoi8gskrkBe3fNmEQfxDvY8bgfQpz6jlk/41kX9eUjvpyqWU7PBP/pLAvMaSQjbMNKJmvX57jP+M6bPg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3082,12 +3071,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz", - "integrity": "sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", + "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3260,12 +3249,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz", - "integrity": "sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz", + "integrity": "sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3275,15 +3264,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.5.tgz", - "integrity": "sha512-gGOEvFzm3fWoyD5uZq7vVTD57pPJ3PczPUD/xCFGjzBpUosnklmXyKnGQbbbGs1NPNPskFex0j93yKbHt0cHyg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.0.tgz", + "integrity": "sha512-uaIi2FdqzjpAMvVqvB51S42oC2JEVgh0LDsGfZVDysWE8LrJtQC2jvKmOqEYThKyB7bDEb7BP1GYWDm7tABA0Q==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.5", - "@babel/plugin-syntax-async-generators": "^7.8.4" + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-remap-async-to-generator": "^7.25.0", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -3293,14 +3282,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz", - "integrity": "sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz", + "integrity": "sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.5" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-remap-async-to-generator": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3310,12 +3299,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz", - "integrity": "sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz", + "integrity": "sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3325,12 +3314,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.5.tgz", - "integrity": "sha512-EcACl1i5fSQ6bt+YGuU/XGCeZKStLmyVGytWkpyhCLeQVA0eu6Wtiw92V+I1T/hnezUv7j74dA/Ro69gWcU+hg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.0.tgz", + "integrity": "sha512-yBQjYoOjXlFv9nlXb3f1casSHOZkWr29NX+zChVanLg5Nc157CrbEX9D7hxxtTpuFy7Q0YzmmWfJxzvps4kXrQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -3340,13 +3329,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz", - "integrity": "sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.7.tgz", + "integrity": "sha512-vKbfawVYayKcSeSR5YYzzyXvsDFWU2mD8U5TFeXtbCPLFUqe7GyCgvO6XDHzje862ODrOwy6WCPmKeWHbCFJ4w==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3356,13 +3345,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.5.tgz", - "integrity": "sha512-SPToJ5eYZLxlnp1UzdARpOGeC2GbHvr9d/UV0EukuVx8atktg194oe+C5BqQ8jRTkgLRVOPYeXRSBg1IlMoVRA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz", + "integrity": "sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-class-static-block": "^7.14.5" }, "engines": { @@ -3373,19 +3362,16 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.5.tgz", - "integrity": "sha512-2edQhLfibpWpsVBx2n/GKOz6JdGQvLruZQfGr9l1qes2KQaWswjBzhQF7UDUZMNaMMQeYnQzxwOMPsbYF7wqPQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.0.tgz", + "integrity": "sha512-xyi6qjr/fYU304fiRwFbekzkqVJZ6A7hOjWZd+89FVcBqPV3S9Wuozz82xdpLspckeaafntbzglaW4pqpzvtSw==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.5", + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-replace-supers": "^7.25.0", + "@babel/traverse": "^7.25.0", "globals": "^11.1.0" }, "engines": { @@ -3396,13 +3382,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz", - "integrity": "sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz", + "integrity": "sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/template": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3412,12 +3398,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.5.tgz", - "integrity": "sha512-GfqcFuGW8vnEqTUBM7UtPd5A4q797LTvvwKxXTgRsFjoqaJiEg9deBG6kWeQYkVEL569NpnmpC0Pkr/8BLKGnQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz", + "integrity": "sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -3427,13 +3413,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz", - "integrity": "sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz", + "integrity": "sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3443,12 +3429,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz", - "integrity": "sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz", + "integrity": "sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3457,13 +3443,29 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.0.tgz", + "integrity": "sha512-YLpb4LlYSc3sCUa35un84poXoraOiQucUTTu8X1j18JV+gNa8E0nyUf/CjZ171IRGr4jEguF+vzJU66QZhn29g==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.0", + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.5.tgz", - "integrity": "sha512-0MC3ppTB1AMxd8fXjSrbPa7LT9hrImt+/fcj+Pg5YMD7UQyWp/02+JWpdnCymmsXwIx5Z+sYn1bwCn4ZJNvhqQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz", + "integrity": "sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-dynamic-import": "^7.8.3" }, "engines": { @@ -3474,13 +3476,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz", - "integrity": "sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz", + "integrity": "sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==", "dev": true, "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3490,12 +3492,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.5.tgz", - "integrity": "sha512-X4hhm7FRnPgd4nDA4b/5V280xCx6oL7Oob5+9qVS5C13Zq4bh1qq7LU0GgRU6b5dBWBvhGaXYVB4AcN6+ol6vg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz", + "integrity": "sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-export-namespace-from": "^7.8.3" }, "engines": { @@ -3506,12 +3508,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.5.tgz", - "integrity": "sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz", + "integrity": "sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3521,14 +3524,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz", - "integrity": "sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==", + "version": "7.25.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.1.tgz", + "integrity": "sha512-TVVJVdW9RKMNgJJlLtHsKDTydjZAbwIsn6ySBPQaEAUU5+gVvlJt/9nRmqVbsV/IBanRjzWoaAQKLoamWVOUuA==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-compilation-targets": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.1" }, "engines": { "node": ">=6.9.0" @@ -3538,12 +3541,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.5.tgz", - "integrity": "sha512-DuCRB7fu8MyTLbEQd1ew3R85nx/88yMoqo2uPSjevMj3yoN7CDM8jkgrY0wmVxfJZyJ/B9fE1iq7EQppWQmR5A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz", + "integrity": "sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-json-strings": "^7.8.3" }, "engines": { @@ -3554,12 +3557,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz", - "integrity": "sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.2.tgz", + "integrity": "sha512-HQI+HcTbm9ur3Z2DkO+jgESMAMcYLuN/A7NRw9juzxAezN9AvqvUTnpKP/9kkYANz6u7dFlAyOu44ejuGySlfw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -3569,12 +3572,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.5.tgz", - "integrity": "sha512-MQQOUW1KL8X0cDWfbwYP+TbVbZm16QmQXJQ+vndPtH/BoO0lOKpVoEDMI7+PskYxH+IiE0tS8xZye0qr1lGzSA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz", + "integrity": "sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" }, "engines": { @@ -3585,12 +3588,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz", - "integrity": "sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz", + "integrity": "sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3600,13 +3603,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz", - "integrity": "sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz", + "integrity": "sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3616,14 +3619,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.5.tgz", - "integrity": "sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz", + "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-simple-access": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-simple-access": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3633,15 +3636,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.5.tgz", - "integrity": "sha512-emtEpoaTMsOs6Tzz+nbmcePl6AKVtS1yC4YNAeMun9U8YCsgadPNxnOPQ8GhHFB2qdx+LZu9LgoC0Lthuu05DQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.0.tgz", + "integrity": "sha512-YPJfjQPDXxyQWg/0+jHKj1llnY5f/R6a0p/vP4lPymxLu7Lvl4k2WMitqi08yxwQcCVUUdG9LCUj4TNEgAp3Jw==", "dev": true, "dependencies": { - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5" + "@babel/helper-module-transforms": "^7.25.0", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -3651,13 +3654,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz", - "integrity": "sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz", + "integrity": "sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3667,13 +3670,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", - "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz", + "integrity": "sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3683,12 +3686,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz", - "integrity": "sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz", + "integrity": "sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3698,12 +3701,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.5.tgz", - "integrity": "sha512-6CF8g6z1dNYZ/VXok5uYkkBBICHZPiGEl7oDnAx2Mt1hlHVHOSIKWJaXHjQJA5VB43KZnXZDIexMchY4y2PGdA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz", + "integrity": "sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" }, "engines": { @@ -3714,12 +3717,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.5.tgz", - "integrity": "sha512-NbslED1/6M+sXiwwtcAB/nieypGw02Ejf4KtDeMkCEpP6gWFMX1wI9WKYua+4oBneCCEmulOkRpwywypVZzs/g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz", + "integrity": "sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-numeric-separator": "^7.10.4" }, "engines": { @@ -3730,16 +3733,15 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.5.tgz", - "integrity": "sha512-Kk3lyDmEslH9DnvCDA1s1kkd3YWQITiBOHngOtDL9Pt6BZjzqb6hiOlb8VfjiiQJ2unmegBqZu0rx5RxJb5vmQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz", + "integrity": "sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.22.5" + "@babel/plugin-transform-parameters": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3749,13 +3751,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz", - "integrity": "sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz", + "integrity": "sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-replace-supers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3765,12 +3767,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.5.tgz", - "integrity": "sha512-pH8orJahy+hzZje5b8e2QIlBWQvGpelS76C63Z+jhZKsmzfNaPQ+LaW6dcJ9bxTpo1mtXbgHwy765Ro3jftmUg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz", + "integrity": "sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" }, "engines": { @@ -3781,13 +3783,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.5.tgz", - "integrity": "sha512-AconbMKOMkyG+xCng2JogMCDcqW8wedQAqpVIL4cOSescZ7+iW8utC6YDZLMCSUIReEA733gzRSaOSXMAt/4WQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz", + "integrity": "sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, "engines": { @@ -3798,12 +3800,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.5.tgz", - "integrity": "sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz", + "integrity": "sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3813,13 +3815,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz", - "integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.7.tgz", + "integrity": "sha512-COTCOkG2hn4JKGEKBADkA8WNb35TGkkRbI5iT845dB+NyqgO8Hn+ajPbSnIQznneJTa3d30scb6iz/DhH8GsJQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3829,14 +3831,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.5.tgz", - "integrity": "sha512-/9xnaTTJcVoBtSSmrVyhtSvO3kbqS2ODoh2juEU72c3aYonNF0OMGiaz2gjukyKM2wBBYJP38S4JiE0Wfb5VMQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz", + "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", "@babel/plugin-syntax-private-property-in-object": "^7.14.5" }, "engines": { @@ -3847,12 +3849,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz", - "integrity": "sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz", + "integrity": "sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3862,13 +3864,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.5.tgz", - "integrity": "sha512-rR7KePOE7gfEtNTh9Qw+iO3Q/e4DEsoQ+hdvM6QUDH7JRJ5qxq5AA52ZzBWbI5i9lfNuvySgOGP8ZN7LAmaiPw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz", + "integrity": "sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "regenerator-transform": "^0.15.1" + "@babel/helper-plugin-utils": "^7.24.7", + "regenerator-transform": "^0.15.2" }, "engines": { "node": ">=6.9.0" @@ -3878,12 +3880,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz", - "integrity": "sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz", + "integrity": "sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3922,12 +3924,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz", - "integrity": "sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz", + "integrity": "sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3937,13 +3939,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz", - "integrity": "sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz", + "integrity": "sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3953,12 +3955,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz", - "integrity": "sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz", + "integrity": "sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3968,12 +3970,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz", - "integrity": "sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz", + "integrity": "sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -3983,12 +3985,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz", - "integrity": "sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz", + "integrity": "sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -3998,12 +4000,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.5.tgz", - "integrity": "sha512-biEmVg1IYB/raUO5wT1tgfacCef15Fbzhkx493D3urBI++6hpJ+RFG4SrWMn0NEZLfvilqKf3QDrRVZHo08FYg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz", + "integrity": "sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -4013,13 +4015,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz", - "integrity": "sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz", + "integrity": "sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -4029,13 +4031,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz", - "integrity": "sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz", + "integrity": "sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -4045,13 +4047,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz", - "integrity": "sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.7.tgz", + "integrity": "sha512-2G8aAvF4wy1w/AGZkemprdGMRg5o6zPNhbHVImRz3lss55TYCBd6xStN19rt8XJHq20sqV0JbyWjOWwQRwV/wg==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -4061,25 +4063,28 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.5.tgz", - "integrity": "sha512-fj06hw89dpiZzGZtxn+QybifF07nNiZjZ7sazs2aVDcysAZVGjW7+7iFYxg6GLNM47R/thYfLdrXc+2f11Vi9A==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.22.5", - "@babel/helper-compilation-targets": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.5", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.5", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.5", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.3.tgz", + "integrity": "sha512-QsYW7UeAaXvLPX9tdVliMJE7MD7M6MLYVTovRTIwhoYQVFHR1rM4wO8wqAezYi3/BpSD+NzVCZ69R6smWiIi8g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.2", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-validator-option": "^7.24.8", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.3", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.0", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.0", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.0", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.22.5", - "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-assertions": "^7.24.7", + "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", @@ -4091,61 +4096,61 @@ "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.22.5", - "@babel/plugin-transform-async-generator-functions": "^7.22.5", - "@babel/plugin-transform-async-to-generator": "^7.22.5", - "@babel/plugin-transform-block-scoped-functions": "^7.22.5", - "@babel/plugin-transform-block-scoping": "^7.22.5", - "@babel/plugin-transform-class-properties": "^7.22.5", - "@babel/plugin-transform-class-static-block": "^7.22.5", - "@babel/plugin-transform-classes": "^7.22.5", - "@babel/plugin-transform-computed-properties": "^7.22.5", - "@babel/plugin-transform-destructuring": "^7.22.5", - "@babel/plugin-transform-dotall-regex": "^7.22.5", - "@babel/plugin-transform-duplicate-keys": "^7.22.5", - "@babel/plugin-transform-dynamic-import": "^7.22.5", - "@babel/plugin-transform-exponentiation-operator": "^7.22.5", - "@babel/plugin-transform-export-namespace-from": "^7.22.5", - "@babel/plugin-transform-for-of": "^7.22.5", - "@babel/plugin-transform-function-name": "^7.22.5", - "@babel/plugin-transform-json-strings": "^7.22.5", - "@babel/plugin-transform-literals": "^7.22.5", - "@babel/plugin-transform-logical-assignment-operators": "^7.22.5", - "@babel/plugin-transform-member-expression-literals": "^7.22.5", - "@babel/plugin-transform-modules-amd": "^7.22.5", - "@babel/plugin-transform-modules-commonjs": "^7.22.5", - "@babel/plugin-transform-modules-systemjs": "^7.22.5", - "@babel/plugin-transform-modules-umd": "^7.22.5", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.22.5", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.5", - "@babel/plugin-transform-numeric-separator": "^7.22.5", - "@babel/plugin-transform-object-rest-spread": "^7.22.5", - "@babel/plugin-transform-object-super": "^7.22.5", - "@babel/plugin-transform-optional-catch-binding": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.5", - "@babel/plugin-transform-parameters": "^7.22.5", - "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/plugin-transform-private-property-in-object": "^7.22.5", - "@babel/plugin-transform-property-literals": "^7.22.5", - "@babel/plugin-transform-regenerator": "^7.22.5", - "@babel/plugin-transform-reserved-words": "^7.22.5", - "@babel/plugin-transform-shorthand-properties": "^7.22.5", - "@babel/plugin-transform-spread": "^7.22.5", - "@babel/plugin-transform-sticky-regex": "^7.22.5", - "@babel/plugin-transform-template-literals": "^7.22.5", - "@babel/plugin-transform-typeof-symbol": "^7.22.5", - "@babel/plugin-transform-unicode-escapes": "^7.22.5", - "@babel/plugin-transform-unicode-property-regex": "^7.22.5", - "@babel/plugin-transform-unicode-regex": "^7.22.5", - "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", - "@babel/preset-modules": "^0.1.5", - "@babel/types": "^7.22.5", - "babel-plugin-polyfill-corejs2": "^0.4.3", - "babel-plugin-polyfill-corejs3": "^0.8.1", - "babel-plugin-polyfill-regenerator": "^0.5.0", - "core-js-compat": "^3.30.2", - "semver": "^6.3.0" + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.0", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoped-functions": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.24.7", + "@babel/plugin-transform-classes": "^7.25.0", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-dotall-regex": "^7.24.7", + "@babel/plugin-transform-duplicate-keys": "^7.24.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.0", + "@babel/plugin-transform-dynamic-import": "^7.24.7", + "@babel/plugin-transform-exponentiation-operator": "^7.24.7", + "@babel/plugin-transform-export-namespace-from": "^7.24.7", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-json-strings": "^7.24.7", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-member-expression-literals": "^7.24.7", + "@babel/plugin-transform-modules-amd": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-modules-systemjs": "^7.25.0", + "@babel/plugin-transform-modules-umd": "^7.24.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-new-target": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-object-super": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-property-literals": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-reserved-words": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-template-literals": "^7.24.7", + "@babel/plugin-transform-typeof-symbol": "^7.24.8", + "@babel/plugin-transform-unicode-escapes": "^7.24.7", + "@babel/plugin-transform-unicode-property-regex": "^7.24.7", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.24.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.4", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.37.1", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -4154,48 +4159,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-env/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.0.tgz", - "integrity": "sha512-RnanLx5ETe6aybRi1cO/edaRH+bNYWaryCEmjDDYyNr4wnSzyOp8T0dWipmqVHKEY3AbVKUom50AKSlj1zmKbg==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.17.7", - "@babel/helper-plugin-utils": "^7.16.7", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2", - "semver": "^6.1.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.1.tgz", - "integrity": "sha512-ikFrZITKg1xH6pLND8zT14UPgjKHiGLqex7rGEZCH2EvhsneJaJPemmpQaIZV5AL03II+lXylw3UmddDK8RU5Q==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.0", - "core-js-compat": "^3.30.1" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.0.tgz", - "integrity": "sha512-hDJtKjMLVa7Z+LwnTCxoDLQj6wdc+B8dun7ayF2fYieI6OzfuvcLMB32ihJZ4UhCBwNYGl5bg/x/P9cMdnkc2g==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4206,18 +4169,17 @@ } }, "node_modules/@babel/preset-modules": { - "version": "0.1.5", + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", - "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", - "@babel/plugin-transform-dotall-regex": "^7.4.4", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/regjsgen": { @@ -4243,13 +4205,13 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -4268,9 +4230,12 @@ } }, "node_modules/@babel/template/node_modules/@babel/parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.8.tgz", - "integrity": "sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", + "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", + "dependencies": { + "@babel/types": "^7.25.2" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -4279,18 +4244,15 @@ } }, "node_modules/@babel/traverse": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.8.tgz", - "integrity": "sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz", + "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==", "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.8", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.8", - "@babel/types": "^7.24.8", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.2", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -4311,9 +4273,12 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.8.tgz", - "integrity": "sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", + "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", + "dependencies": { + "@babel/types": "^7.25.2" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -4322,9 +4287,9 @@ } }, "node_modules/@babel/types": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.8.tgz", - "integrity": "sha512-SkSBEHwwJRU52QEVZBmMBnE5Ux2/6WU1grdYyOhpbCNxbmJrDuDCphBzKZSO3taf0zztp+qkWlymE5tVL5l0TA==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "dependencies": { "@babel/helper-string-parser": "^7.24.8", "@babel/helper-validator-identifier": "^7.24.7", @@ -4378,21 +4343,21 @@ } }, "node_modules/@datadog/native-appsec": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@datadog/native-appsec/-/native-appsec-4.0.0.tgz", - "integrity": "sha512-myTguXJ3VQHS2E1ylNsSF1avNpDmq5t+K4Q47wdzeakGc3sDIDDyEbvuFTujl9c9wBIkup94O1mZj5DR37ajzA==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@datadog/native-appsec/-/native-appsec-8.1.1.tgz", + "integrity": "sha512-mf+Ym/AzET4FeUTXOs8hz0uLOSsVIUnavZPUx8YoKWK5lKgR2L+CLfEzOpjBwgFpDgbV8I1/vyoGelgGpsMKHA==", "hasInstallScript": true, "dependencies": { "node-gyp-build": "^3.9.0" }, "engines": { - "node": ">=12" + "node": ">=16" } }, "node_modules/@datadog/native-iast-rewriter": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@datadog/native-iast-rewriter/-/native-iast-rewriter-2.1.3.tgz", - "integrity": "sha512-4oxMFz5ZEpOK3pRc9KjquMgkRP6D+oPQVIzOk4dgG8fl2iepHtCa3gna/fQBfdWIiX5a2j65O3R1zNp2ckk8JA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@datadog/native-iast-rewriter/-/native-iast-rewriter-2.4.1.tgz", + "integrity": "sha512-j3auTmyyn63e2y+SL28CGNy/l+jXQyh+pxqoGTacWaY5FW/dvo5nGQepAismgJ3qJ8VhQfVWRdxBSiT7wu9clw==", "dependencies": { "lru-cache": "^7.14.0", "node-gyp-build": "^4.5.0" @@ -4410,9 +4375,9 @@ } }, "node_modules/@datadog/native-iast-rewriter/node_modules/node-gyp-build": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.1.tgz", - "integrity": "sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", + "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -4420,9 +4385,10 @@ } }, "node_modules/@datadog/native-iast-taint-tracking": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@datadog/native-iast-taint-tracking/-/native-iast-taint-tracking-1.5.0.tgz", - "integrity": "sha512-SOWIk1M6PZH0osNB191Voz2rKBPoF5hISWVSK9GiJPrD40+xjib1Z/bFDV7EkDn3kjOyordSBdNPG5zOqZJdyg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@datadog/native-iast-taint-tracking/-/native-iast-taint-tracking-3.1.0.tgz", + "integrity": "sha512-rw6qSjmxmu1yFHVvZLXFt/rVq2tUZXocNogPLB8n7MPpA0jijNGb109WokWw5ITImiW91GcGDuBW6elJDVKouQ==", + "hasInstallScript": true, "dependencies": { "node-gyp-build": "^3.9.0" } @@ -4446,19 +4412,19 @@ "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" }, "node_modules/@datadog/pprof": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-3.2.0.tgz", - "integrity": "sha512-kOhWHCWB80djnMCr5KNKBAy1Ih/jK/PIj6yqnZwL1Wqni/h6IBPRUMhtIxcYJMRgsZVYrFXUV20AVXTZCzFokw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@datadog/pprof/-/pprof-5.3.0.tgz", + "integrity": "sha512-53z2Q3K92T6Pf4vz4Ezh8kfkVEvLzbnVqacZGgcbkP//q0joFzO8q00Etw1S6NdnCX0XmX08ULaF4rUI5r14mw==", "hasInstallScript": true, "dependencies": { "delay": "^5.0.0", "node-gyp-build": "<4.0", "p-limit": "^3.1.0", - "pprof-format": "^2.0.7", + "pprof-format": "^2.1.0", "source-map": "^0.7.4" }, "engines": { - "node": ">=12" + "node": ">=14" } }, "node_modules/@datadog/pprof/node_modules/p-limit": { @@ -5030,6 +4996,17 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz", "integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==" }, + "node_modules/@growthbook/growthbook": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook/-/growthbook-1.1.0.tgz", + "integrity": "sha512-TBgUnMcYxOVo4dDDZJ8J2Z9DjDy9PlL+J2GpLMMD6SPpw66gSDcnnwGk9H6diHS/IuUmDXNkU+24+L/bXulWww==", + "dependencies": { + "dom-mutator": "^0.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@hapi/hoek": { "version": "9.2.1", "license": "BSD-3-Clause" @@ -9133,9 +9110,9 @@ } }, "node_modules/@types/bluebird": { - "version": "3.5.38", - "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.38.tgz", - "integrity": "sha512-yR/Kxc0dd4FfwtEoLZMoqJbM/VE/W7hXn/MIjb+axcwag0iFmSPK7OBUZq1YWLynJUoWQkfUrI7T0HDqGApNSg==", + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.42.tgz", + "integrity": "sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==", "dev": true }, "node_modules/@types/body-parser": { @@ -9328,9 +9305,10 @@ "dev": true }, "node_modules/@types/http-errors": { - "version": "2.0.1", - "dev": true, - "license": "MIT" + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true }, "node_modules/@types/ip": { "version": "1.1.0", @@ -9440,6 +9418,15 @@ "integrity": "sha512-VmSX8yXZwS80/sDSlXJfGVMj5Q/fRlYxQWndOPRa7TIDfdKnXDjbwZxQ8h+rWgk/7HXA4Ga2c+w+X2U5jRDPnw==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "14.18.23", "license": "MIT" @@ -10529,6 +10516,14 @@ "acorn": "^8" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -10702,6 +10697,11 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" + }, "node_modules/aproba": { "version": "1.2.0", "license": "ISC" @@ -11192,11 +11192,11 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -11705,9 +11705,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -11717,7 +11717,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -11749,10 +11749,11 @@ } }, "node_modules/body-parser/node_modules/qs": { - "version": "6.11.0", - "license": "BSD-3-Clause", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -11906,9 +11907,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "funding": [ { "type": "opencollective", @@ -11924,10 +11925,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -12085,9 +12086,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001588", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz", - "integrity": "sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "funding": [ { "type": "opencollective", @@ -12239,6 +12240,14 @@ "devtools-protocol": "*" } }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ci-info": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.7.0.tgz", @@ -12685,7 +12694,6 @@ }, "node_modules/concat-stream": { "version": "1.6.2", - "dev": true, "engines": [ "node >= 0.8" ], @@ -13053,9 +13061,9 @@ } }, "node_modules/core-js": { - "version": "3.28.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.28.0.tgz", - "integrity": "sha512-GiZn9D4Z/rSYvTeg1ljAIsEqFm0LaN9gVtwDCrKL80zHtS31p9BAjmTxVqTQDMpwlMolJZOFntUG2uwyj7DAqw==", + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz", + "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==", "dev": true, "hasInstallScript": true, "funding": { @@ -13177,7 +13185,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -13191,7 +13198,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -13280,12 +13286,6 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, - "node_modules/csv-parse": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.3.6.tgz", - "integrity": "sha512-WI330GjCuEioK/ii8HM2YE/eV+ynpeLvU+RXw4R8bRU8R0laK5zO3fDsc4gH8s472e3Ga38rbIjCAiQh+tEHkw==", - "dev": true - }, "node_modules/csv-string": { "version": "4.1.1", "license": "MIT", @@ -13435,54 +13435,52 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, + "node_modules/dc-polyfill": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.6.tgz", + "integrity": "sha512-UV33cugmCC49a5uWAApM+6Ev9ZdvIUMTrtCO9fj96TPGOQiea54oeO3tiEVdVeo3J9N2UdJEmbS4zOkkEA35uQ==", + "engines": { + "node": ">=12.17" + } + }, "node_modules/dd-trace": { - "version": "3.37.0", - "resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-3.37.0.tgz", - "integrity": "sha512-0F/mM+T3ayNxu//Cfqh+NajC4F/6Hn7QMVPIR4Gsn2gK7DfaOHXh0/cPI5mWNANA04vES73GVpUkDhGQAtFnVw==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/dd-trace/-/dd-trace-5.22.0.tgz", + "integrity": "sha512-0Ofd2i9JFOquAGe/y0hoYjMikn8rmrkxXiA1heMZ3H/Y87QrI6FOvAGn+zCFJemN1CXvfJny7lU3gDzZVgLrWg==", "hasInstallScript": true, "dependencies": { - "@datadog/native-appsec": "^4.0.0", - "@datadog/native-iast-rewriter": "2.1.3", - "@datadog/native-iast-taint-tracking": "1.5.0", + "@datadog/native-appsec": "8.1.1", + "@datadog/native-iast-rewriter": "2.4.1", + "@datadog/native-iast-taint-tracking": "3.1.0", "@datadog/native-metrics": "^2.0.0", - "@datadog/pprof": "3.2.0", + "@datadog/pprof": "5.3.0", "@datadog/sketches-js": "^2.1.0", - "@opentelemetry/api": "^1.0.0", + "@opentelemetry/api": ">=1.0.0 <1.9.0", "@opentelemetry/core": "^1.14.0", "crypto-randomuuid": "^1.0.0", - "diagnostics_channel": "^1.1.0", + "dc-polyfill": "^0.1.4", "ignore": "^5.2.4", - "import-in-the-middle": "^1.4.2", + "import-in-the-middle": "^1.8.1", "int64-buffer": "^0.1.9", - "ipaddr.js": "^2.1.0", "istanbul-lib-coverage": "3.2.0", + "jest-docblock": "^29.7.0", "koalas": "^1.0.2", - "limiter": "^1.1.4", - "lodash.kebabcase": "^4.1.1", - "lodash.pick": "^4.4.0", + "limiter": "1.1.5", "lodash.sortby": "^4.7.0", - "lodash.uniq": "^4.5.0", "lru-cache": "^7.14.0", - "methods": "^1.1.2", "module-details-from-path": "^1.0.3", "msgpack-lite": "^0.1.26", - "node-abort-controller": "^3.1.1", "opentracing": ">=0.12.1", "path-to-regexp": "^0.1.2", - "protobufjs": "^7.2.4", + "pprof-format": "^2.1.0", + "protobufjs": "^7.2.5", "retry": "^0.13.1", - "semver": "^7.5.4" + "semver": "^7.5.4", + "shell-quote": "^1.8.1", + "tlhunter-sorted-set": "^0.1.0" }, "engines": { - "node": ">=14" - } - }, - "node_modules/dd-trace/node_modules/ipaddr.js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", - "engines": { - "node": ">= 10" + "node": ">=18" } }, "node_modules/dd-trace/node_modules/lru-cache": { @@ -13800,7 +13798,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, "engines": { "node": ">=8" } @@ -13845,13 +13842,6 @@ "wrappy": "1" } }, - "node_modules/diagnostics_channel": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -13912,6 +13902,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-mutator": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/dom-mutator/-/dom-mutator-0.6.0.tgz", + "integrity": "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg==", + "engines": { + "node": ">=10" + } + }, "node_modules/dom-serializer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", @@ -14087,13 +14085,14 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.673", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.673.tgz", - "integrity": "sha512-zjqzx4N7xGdl5468G+vcgzDhaHkaYgVcf9MqgexcTqsl2UHSCmOj/Bi3HAprg4BZCpC7HyD8a6nZl6QAZf72gw==" + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", + "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==" }, "node_modules/elliptic": { - "version": "6.5.4", - "license": "MIT", + "version": "6.5.7", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", + "integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", @@ -14454,8 +14453,9 @@ } }, "node_modules/escalade": { - "version": "3.1.1", - "license": "MIT", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "engines": { "node": ">=6" } @@ -15497,7 +15497,8 @@ }, "node_modules/etag": { "version": "1.8.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "engines": { "node": ">= 0.6" } @@ -15660,36 +15661,36 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.20.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.20.0.tgz", + "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.2.0", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -15810,6 +15811,14 @@ "ms": "2.0.0" } }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/express/node_modules/ms": { "version": "2.0.0", "license": "MIT" @@ -16370,46 +16379,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreground-child/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/foreground-child/node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/foreground-child/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/foreground-child/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, "node_modules/foreground-child/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -16421,20 +16390,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreground-child/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/forever-agent": { "version": "0.6.1", "dev": true, @@ -16556,7 +16511,8 @@ }, "node_modules/fresh": { "version": "0.5.2", - "license": "MIT", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "engines": { "node": ">= 0.6" } @@ -17624,20 +17580,20 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", - "integrity": "sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", + "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", "dependencies": { "acorn": "^8.8.2", - "acorn-import-assertions": "^1.9.0", + "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "node_modules/import-in-the-middle/node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "bin": { "acorn": "bin/acorn" }, @@ -18966,10 +18922,9 @@ } }, "node_modules/jest-docblock": { - "version": "29.4.3", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.4.3.tgz", - "integrity": "sha512-fzdTftThczeSD9nZ3fzA/4KkHtnmllawWrXO69vtI+L9WjEIuXWs4AmyME7lN5hU7dB0sHhuPfcKofRsUb/2Fg==", - "dev": true, + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dependencies": { "detect-newline": "^3.0.0" }, @@ -21030,11 +20985,12 @@ } }, "node_modules/jwk-to-pem": { - "version": "2.0.5", - "license": "Apache-2.0", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.6.tgz", + "integrity": "sha512-zPC/5vjyR08TpknpTGW6Z3V3lDf9dU92oHbf0jJlG8tGOzslF9xk2UiO/seSx2llCUrNAe+AvmuGTICSXiYU7A==", "dependencies": { "asn1.js": "^5.3.0", - "elliptic": "^6.5.4", + "elliptic": "^6.5.7", "safe-buffer": "^5.0.1" } }, @@ -21607,10 +21563,6 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, - "node_modules/lodash.kebabcase": { - "version": "4.1.1", - "license": "MIT" - }, "node_modules/lodash.map": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz", @@ -21634,6 +21586,7 @@ }, "node_modules/lodash.pick": { "version": "4.4.0", + "dev": true, "license": "MIT" }, "node_modules/lodash.reduce": { @@ -21663,10 +21616,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "license": "MIT" - }, "node_modules/log-driver": { "version": "1.2.7", "dev": true, @@ -22351,8 +22300,12 @@ "optional": true }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "license": "MIT" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -22417,7 +22370,8 @@ }, "node_modules/mime": { "version": "1.6.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "bin": { "mime": "cli.js" }, @@ -22481,7 +22435,6 @@ }, "node_modules/minimist": { "version": "1.2.6", - "dev": true, "license": "MIT" }, "node_modules/minipass": { @@ -22570,7 +22523,6 @@ }, "node_modules/mkdirp": { "version": "0.5.5", - "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.5" @@ -22586,7 +22538,8 @@ }, "node_modules/module-details-from-path": { "version": "1.0.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", + "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" }, "node_modules/moment": { "version": "2.29.4", @@ -22922,6 +22875,23 @@ "msgpack": "bin/msgpack" } }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/multiparty": { "version": "4.2.3", "license": "MIT", @@ -23146,7 +23116,8 @@ "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", - "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true }, "node_modules/node-addon-api": { "version": "5.1.0", @@ -23377,9 +23348,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" }, "node_modules/node-rsa": { "version": "1.1.1", @@ -24165,7 +24136,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -24199,8 +24169,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "license": "MIT" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/path-type": { "version": "4.0.0", @@ -24244,9 +24215,9 @@ "license": "MIT" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -24690,9 +24661,9 @@ } }, "node_modules/pprof-format": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.0.7.tgz", - "integrity": "sha512-1qWaGAzwMpaXJP9opRa23nPnt2Egi7RMNoNBptEE/XwHbcn4fC2b/4U4bKc5arkGkIh2ZabpF2bEb+c5GNHEKA==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.1.0.tgz", + "integrity": "sha512-0+G5bHH0RNr8E5hoZo/zJYsL92MhkZjwrHp3O2IxmY8RJL9ooKeuZ8Tm0ZNBw5sGZ9TiM71sthTjWoR2Vf5/xw==" }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -25119,7 +25090,8 @@ }, "node_modules/range-parser": { "version": "1.2.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "engines": { "node": ">= 0.6" } @@ -25681,9 +25653,9 @@ "dev": true }, "node_modules/regenerator-transform": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", - "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", "dev": true, "dependencies": { "@babel/runtime": "^7.8.4" @@ -26270,8 +26242,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "license": "MIT", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -26293,22 +26266,26 @@ }, "node_modules/send/node_modules/debug": { "version": "2.6.9", - "license": "MIT", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dependencies": { "ms": "2.0.0" } }, "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/send/node_modules/ms": { "version": "2.1.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/send/node_modules/on-finished": { "version": "2.4.1", - "license": "MIT", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dependencies": { "ee-first": "1.1.1" }, @@ -26325,8 +26302,9 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "license": "MIT", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.0.tgz", + "integrity": "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==", "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", @@ -26337,6 +26315,58 @@ "node": ">= 0.8.0" } }, + "node_modules/serve-static/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/serve-static/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "license": "ISC" @@ -26433,7 +26463,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -26445,15 +26474,14 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } }, "node_modules/shell-quote": { - "version": "1.7.4", - "dev": true, - "license": "MIT", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -26494,12 +26522,17 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "license": "MIT", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -28037,6 +28070,11 @@ "node": ">=0.6.0" } }, + "node_modules/tlhunter-sorted-set": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/tlhunter-sorted-set/-/tlhunter-sorted-set-0.1.0.tgz", + "integrity": "sha512-eGYW4bjf1DtrHzUYxYfAcSytpOkA44zsr7G2n3PV7yOUR23vmkGe3LL4R+1jL9OsXtbsFOwe8XtbCrabeaEFnw==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -28705,7 +28743,6 @@ }, "node_modules/typedarray": { "version": "0.0.6", - "dev": true, "license": "MIT" }, "node_modules/typescript": { @@ -28961,9 +28998,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "funding": [ { "type": "opencollective", @@ -28979,8 +29016,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -30350,7 +30387,6 @@ }, "node_modules/xtend": { "version": "4.0.2", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4" @@ -30434,9 +30470,9 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index ee85ba673b..31070e8708 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.137.0", + "version": "6.146.1", "homepage": "https://form.gov.sg", "authors": [ "FormSG " @@ -59,6 +59,7 @@ "@aws-sdk/client-lambda": "^3.414.0", "@babel/runtime": "^7.24.7", "@faker-js/faker": "^8.4.1", + "@growthbook/growthbook": "^1.1.0", "@joi/date": "^2.1.0", "@opengovsg/formsg-sdk": "^0.12.0-alpha.1", "@opengovsg/myinfo-gov-client": "^4.1.2", @@ -69,10 +70,10 @@ "abortcontroller-polyfill": "^1.7.5", "aws-info": "^1.2.0", "aws-sdk": "^2.1659.0", - "axios": "^1.6.4", + "axios": "^1.7.4", "bcrypt": "^5.1.1", "bluebird": "^3.5.2", - "body-parser": "^1.20.1", + "body-parser": "^1.20.3", "boxicons": "1.8.0", "bson": "^4.7.2", "busboy": "^1.6.0", @@ -86,11 +87,11 @@ "csv-string": "^4.1.1", "cuid": "^2.1.8", "date-fns": "^2.30.0", - "dd-trace": "^3.36.0", + "dd-trace": "^5.22.0", "dedent-js": "~1.0.1", "dotenv": "^16.0.3", "ejs": "^3.1.10", - "express": "^4.19.2", + "express": "^4.20.0", "express-rate-limit": "^7.2.0", "express-request-id": "^1.4.1", "express-session": "^1.18.0", @@ -113,13 +114,14 @@ "JSONStream": "^1.3.5", "jsonwebtoken": "^9.0.2", "jszip": "^3.10.1", - "jwk-to-pem": "^2.0.5", + "jwk-to-pem": "^2.0.6", "libphonenumber-js": "^1.10.59", "lodash": "^4.17.21", "moment-timezone": "0.5.41", "mongodb-memory-server-core": "^9.1.7", "mongodb-uri": "^0.9.7", "mongoose": "^6.12.0", + "multer": "^1.4.5-lts.1", "multiparty": ">=4.2.3", "nan": "^2.19.0", "neverthrow": "^6.1.0", @@ -155,18 +157,18 @@ "whatwg-fetch": "^3.6.2", "winston": "^3.13.0", "winston-cloudwatch": "^6.2.0", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@babel/core": "^7.24.3", "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-env": "^7.22.5", + "@babel/preset-env": "^7.25.3", "@opengovsg/credits-generator": "^1.0.6", "@opengovsg/mockpass": "^4.3.2", "@playwright/test": "^1.45.1", "@stoplight/prism-cli": "^5.5.4", "@types/bcrypt": "^5.0.0", - "@types/bluebird": "^3.5.38", + "@types/bluebird": "^3.5.42", "@types/busboy": "^1.5.3", "@types/compression": "^1.7.5", "@types/connect-datadog": "0.0.6", @@ -179,7 +181,7 @@ "@types/express-session": "^1.18.0", "@types/helmet": "4.0.0", "@types/html-escaper": "^3.0.2", - "@types/http-errors": "^2.0.1", + "@types/http-errors": "^2.0.4", "@types/ip": "^1.1.0", "@types/jest": "^29.5.1", "@types/json-stringify-safe": "^5.0.3", @@ -188,6 +190,7 @@ "@types/jwk-to-pem": "^2.0.3", "@types/lodash": "^4.17.6", "@types/mongodb-uri": "^0.9.4", + "@types/multer": "^1.4.11", "@types/node": "^14.18.23", "@types/nodemailer": "^6.4.15", "@types/opossum": "^6.2.3", @@ -207,9 +210,8 @@ "axios-mock-adapter": "^1.22.0", "concurrently": "^7.6.0", "copyfiles": "^2.4.1", - "core-js": "^3.28.0", + "core-js": "^3.38.1", "coveralls": "^3.1.1", - "csv-parse": "^5.3.6", "env-cmd": "^10.1.0", "eslint": "^8.57.0", "eslint-config-prettier": "^8.10.0", diff --git a/playwright.config.ts b/playwright.config.ts index c80a07d944..308660ae54 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ * Maximum time expect() should wait for the condition to be met. * For example in `await expect(locator).toHaveText();` */ - timeout: 3000, + timeout: 5 * 1000, }, /* Run tests in files in parallel */ fullyParallel: true, diff --git a/scripts/20240821_set_isSubmitterIdCollectionEnabled_true_for_all_existing_forms/set_isSubmitterIdCollectionEnabled_true_for_all_existing_forms.js b/scripts/20240821_set_isSubmitterIdCollectionEnabled_true_for_all_existing_forms/set_isSubmitterIdCollectionEnabled_true_for_all_existing_forms.js new file mode 100644 index 0000000000..116c5dbfa1 --- /dev/null +++ b/scripts/20240821_set_isSubmitterIdCollectionEnabled_true_for_all_existing_forms/set_isSubmitterIdCollectionEnabled_true_for_all_existing_forms.js @@ -0,0 +1,39 @@ +/* eslint-disable */ + +/** What: This script sets isSubmitterIdCollectionEnabled to true for all existing forms + * where isSubmitterIdCollectionEnabled does not exist. + * Why: This was done so as to maintain the existing behaviour of the forms where + * nric/fin/uen is collected for all Singpass forms before the addition of Singpass + * Id Collection Opt-in/out feature added in https://github.com/opengovsg/FormSG/pull/7566. +*/ + +// COUNT # total number of forms +db.forms.countDocuments() + +// STEP 1: set isSubmitterIdCollectionEnabled to true for all existing forms where isSubmitterIdCollectionEnabled does not exist +// COUNT +// # forms where isSubmitterIdCollectionEnabled does not exist, which is # of forms to update +db.forms.countDocuments({ + isSubmitterIdCollectionEnabled: { $exists: false }, +}) + +// UPDATE +// Set isSubmitterIdCollectionEnabled: true for forms where isSubmitterIdCollectionEnabled does not exist +db.forms.updateMany( + { + // Used for updating the forms in batches by created timestamp + created: { + $gte: ISODate( + "2023-01-01T00:00:00.000+00:00" + ), + $lte: ISODate("2024-01-01T00:00:00.000+00:00") + }, + isSubmitterIdCollectionEnabled: { $exists: false } }, + { $set: { isSubmitterIdCollectionEnabled: true } }, +) + +// VERIFY +// Ensure # forms where isSubmitterIdCollectionEnabled does not exist = 0 +db.forms.countDocuments({ + isSubmitterIdCollectionEnabled: { $exists: false }, +}) \ No newline at end of file diff --git a/shared/constants/errors.ts b/shared/constants/errors.ts index a87eb44af8..dae6390895 100644 --- a/shared/constants/errors.ts +++ b/shared/constants/errors.ts @@ -10,4 +10,16 @@ export const GO_VALIDATION_ERROR_MESSAGE = export const GO_ALREADY_EXIST_ERROR_MESSAGE = 'GoGov link already exists' export const FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE = - 'You have already submitted a response using this NRIC/FIN/UEN. If you think this is a mistake, please contact the agency that gave you the form link.' + 'You have already submitted a response using this NRIC/FIN/UEN. If you require further assistance, please contact the agency that gave you the form link.' + +export const FORM_RESPONDENT_NOT_WHITELISTED_ERROR_MESSAGE = + 'You do not have access to this form. If you require further assistance, please contact the agency that gave you the form link.' + +export const FORM_WHITELIST_SETTING_CONTAINS_DUPLICATES_ERROR_MESSAGE = + 'Your CSV contains duplicate entries.' + +export const FORM_WHITELIST_SETTING_CONTAINS_INVALID_FORMAT_SUBMITTERID_ERROR_MESSAGE = + (exampleInvalidSubmitterId: string) => + `Your CSV contains NRIC/FIN/UEN(s) that are not in the correct format. (e.g, ${exampleInvalidSubmitterId})` + +export const FORM_WHITELIST_CONTAINS_EMPTY_ROWS_ERROR_MESSAGE = `Your CSV contains empty row(s).` diff --git a/shared/constants/feature-flags.ts b/shared/constants/feature-flags.ts index 8adae9c8c0..9198731886 100644 --- a/shared/constants/feature-flags.ts +++ b/shared/constants/feature-flags.ts @@ -3,6 +3,10 @@ export const featureFlags = { goLinks: 'goLinks' as const, turnstile: 'turnstile' as const, validateStripeEmailDomain: 'validateStripeEmailDomain' as const, + /** + * @deprecated since 2024-Aug-02 + * On growthbook, kept permenently ON for all ENV + * */ myinfoSgid: 'myinfo-sgid' as const, chartsMaxResponseCount: 'charts-max-response-count' as const, addingTwilioDisabled: 'adding-twilio-disabled' as const, diff --git a/shared/constants/field/myinfo/myinfo-nationalities.ts b/shared/constants/field/myinfo/myinfo-nationalities.ts index 24e53945a5..09575bfcde 100644 --- a/shared/constants/field/myinfo/myinfo-nationalities.ts +++ b/shared/constants/field/myinfo/myinfo-nationalities.ts @@ -2,9 +2,12 @@ export const myInfoNationalities = [ 'AFGHAN', 'ALBANIAN', 'ALGERIAN', + 'AMERICAN SAMOAN', 'AMERICAN', 'ANDORRAN', 'ANGOLAN', + 'ANGUILLAN', + 'ANTARCTICA', 'ANTIGUAN', 'ARGENTINIAN', 'ARMENIAN', @@ -24,12 +27,12 @@ export const myInfoNationalities = [ 'BOSNIAN', 'BOTSWANA', 'BRAZILIAN', - 'BRITISH', 'BRITISH NATIONAL OVERSEAS', 'BRITISH OVERSEAS CITIZEN', 'BRITISH OVERSEAS TERRITORIES CITIZEN', 'BRITISH PROTECTED PERSON', 'BRITISH SUBJECT', + 'BRITISH', 'BRUNEIAN', 'BULGARIAN', 'BURKINABE', @@ -42,19 +45,25 @@ export const myInfoNationalities = [ 'CHADIAN', 'CHILEAN', 'CHINESE', + 'CHINESE/HONGKONG SAR', + 'CHINESE/MACAO SAR', 'COLOMBIAN', 'COMORAN', 'CONGOLESE', + 'COOK ISLANDER', 'COSTA RICAN', 'CROATIAN', 'CUBAN', 'CYPRIOT', 'CZECH', + 'CZECHOSLOVAK', + 'D.P.R. KOREAN', 'DANISH', 'DEMOCRATIC REPUBLIC OF THE CONGO', 'DJIBOUTIAN', - 'DOMINICAN', 'DOMINICAN (REPUBLIC)', + 'DOMINICAN', + 'DUTCH', 'EAST TIMORESE', 'ECUADORIAN', 'EGYPTIAN', @@ -65,21 +74,27 @@ export const myInfoNationalities = [ 'FIJIAN', 'FILIPINO', 'FINNISH', + 'FRENCE SOUTHERN TERRITORIES', + 'FRENCH GUIANESE', + 'FRENCH POLYNESIAN', 'FRENCH', 'GABON', 'GAMBIAN', 'GEORGIAN', 'GERMAN', + 'GERMAN, EAST', + 'GERMAN, WEST', 'GHANAIAN', 'GREEK', 'GRENADIAN', + 'GUADELOUPIAN', + 'GUAMANIAN', 'GUATEMALAN', - 'GUINEAN', 'GUINEAN (BISSAU)', + 'GUINEAN', 'GUYANESE', 'HAITIAN', 'HONDURAN', - 'HONG KONG', 'HUNGARIAN', 'ICELANDER', 'INDIAN', @@ -93,13 +108,16 @@ export const myInfoNationalities = [ 'JAMAICAN', 'JAPANESE', 'JORDANIAN', + 'KAMPUCHEAN', 'KAZAKHSTANI', 'KENYAN', + 'KIRGHIZ', 'KIRIBATI', 'KITTIAN & NEVISIAN', - 'KOREAN, NORTH', 'KOREAN, SOUTH', + 'KOSOVAR', 'KUWAITI', + 'KYRGHIS', 'KYRGYZSTAN', 'LAOTIAN', 'LATVIAN', @@ -110,7 +128,6 @@ export const myInfoNationalities = [ 'LIECHTENSTEINER', 'LITHUANIAN', 'LUXEMBOURGER', - 'MACAO', 'MACEDONIAN', 'MADAGASY', 'MALAWIAN', @@ -119,6 +136,7 @@ export const myInfoNationalities = [ 'MALIAN', 'MALTESE', 'MARSHALLESE', + 'MARTINIQUAIS', 'MAURITANEAN', 'MAURITIAN', 'MEXICAN', @@ -127,20 +145,26 @@ export const myInfoNationalities = [ 'MONACAN', 'MONGOLIAN', 'MONTENEGRIN', + 'MONTENEGRIN', 'MOROCCAN', 'MOZAMBICAN', 'MYANMAR', 'NAMIBIAN', 'NAURUAN', 'NEPALESE', + 'NETHERLANDS ANTILILLES', 'NETHERLANDS', + 'NEW CALEDONIA', 'NEW ZEALANDER', 'NI-VANUATU', 'NICARAGUAN', 'NIGER', 'NIGERIAN', + 'NIUEAN', 'NORWEGIAN', 'OMANI', + 'OTHERS', + 'PACIFIC ISLAND TRUST TERRITORY', 'PAKISTANI', 'PALAUAN', 'PALESTINIAN', @@ -148,14 +172,19 @@ export const myInfoNationalities = [ 'PAPUA NEW GUINEAN', 'PARAGUAYAN', 'PERUVIAN', + 'PITCAIRN', 'POLISH', 'PORTUGUESE', + 'PUERTO RICAN', 'QATARI', 'REFUGEE (OTHER THAN XXB)', 'REFUGEE (XXB)', + 'REUNIONESE', 'ROMANIAN', 'RUSSIAN', + 'RUSSIAN', 'RWANDAN', + 'SAHRAWI', 'SALVADORAN', 'SAMMARINESE', 'SAMOAN', @@ -171,6 +200,7 @@ export const myInfoNationalities = [ 'SOLOMON ISLANDER', 'SOMALI', 'SOUTH AFRICAN', + 'SOUTH SUDANESE', 'SPANISH', 'SRI LANKAN', 'ST. LUCIA', @@ -182,11 +212,14 @@ export const myInfoNationalities = [ 'SWEDISH', 'SWISS', 'SYRIAN', + 'TADZHIK', 'TAIWANESE', 'TAJIKISTANI', 'TANZANIAN', 'THAI', + 'TIMORENSE', 'TOGOLESE', + 'TOKELAUAN', 'TONGAN', 'TRINIDADIAN & TOBAGONIAN', 'TUNISIAN', @@ -197,12 +230,18 @@ export const myInfoNationalities = [ 'UKRAINIAN', 'UNITED ARAB EMIRATES', 'UNKNOWN', + 'UPPER VOLTA', 'URUGUAYAN', 'UZBEKISTAN', 'VATICAN CITY STATE (HOLY SEE)', 'VENEZUELAN', 'VIETNAMESE', + 'WALLIS AND FUTUNA ISLANDERS', + 'YEMEN ARAB REP', + 'YEMEN, SOUTH', 'YEMENI', + 'YUGOSLAVIANS', + 'ZAIRAN', 'ZAMBIAN', 'ZIMBABWEAN', ] diff --git a/shared/constants/field/myinfo/myinfo-races.ts b/shared/constants/field/myinfo/myinfo-races.ts index 26fef01f77..f46296c3ea 100644 --- a/shared/constants/field/myinfo/myinfo-races.ts +++ b/shared/constants/field/myinfo/myinfo-races.ts @@ -126,6 +126,7 @@ export const myInfoRaces = [ 'LATVIAN', 'LEBANESE', 'LIBYAN', + 'LISU', 'LITHUANIAN', 'LUXEMBOURGER', 'MADURESE', @@ -178,6 +179,7 @@ export const myInfoRaces = [ 'POLISH', 'POLYNESIAN', 'PORTUGUESE', + 'PUNAN', 'PUNJABI', 'RAJPUT', 'RAKHINE', diff --git a/shared/constants/form.ts b/shared/constants/form.ts index 1f6cefdbd6..44f97869c9 100644 --- a/shared/constants/form.ts +++ b/shared/constants/form.ts @@ -1,7 +1,7 @@ const PUBLIC_FORM_FIELDS = [ 'admin', 'authType', - 'isNricMaskEnabled', + 'isSubmitterIdCollectionEnabled', 'isSingleSubmission', 'endPage', 'esrvcId', @@ -21,7 +21,9 @@ export const STORAGE_PUBLIC_FORM_FIELDS = [ ...PUBLIC_FORM_FIELDS, 'payments_field', 'publicKey', + 'whitelistedSubmitterIds', ] as const + export const MULTIRESPONDENT_PUBLIC_FORM_FIELDS = [ ...PUBLIC_FORM_FIELDS, 'publicKey', @@ -31,7 +33,7 @@ export const MULTIRESPONDENT_PUBLIC_FORM_FIELDS = [ const FORM_SETTINGS_FIELDS = [ 'responseMode', 'authType', - 'isNricMaskEnabled', + 'isSubmitterIdCollectionEnabled', 'isSingleSubmission', 'esrvcId', 'hasCaptcha', @@ -54,12 +56,15 @@ export const STORAGE_FORM_SETTINGS_FIELDS = [ 'publicKey', 'business', 'emails', + 'whitelistedSubmitterIds', ] as const export const MULTIRESPONDENT_FORM_SETTINGS_FIELDS = [ ...FORM_SETTINGS_FIELDS, 'publicKey', 'workflow', + 'emails', + 'stepsToNotify', ] as const export const WEBHOOK_SETTINGS_FIELDS = ['responseMode', 'webhook'] as const diff --git a/shared/constants/links.ts b/shared/constants/links.ts index 568100f2d7..07c8865d61 100644 --- a/shared/constants/links.ts +++ b/shared/constants/links.ts @@ -6,3 +6,7 @@ export const SINGPASS_PARTNER_SUPPORT_LINK = 'https://go.gov.sg/formsg-singpass-contact' export const SGID_VALID_ORG_PAGE = 'https://docs.id.gov.sg/faq-users#as-a-government-officer-why-am-i-not-able-to-login-to-my-work-tool-using-sgid' + +// Growthbook proxy set up on cloudflare workers. For more info, see Worker script: https://github.com/opengovsg/formsg-private/pull/171. +export const GROWTHBOOK_DEV_PROXY = + 'https://proxy-growthbook-stg.formsg.workers.dev' diff --git a/shared/constants/routes.ts b/shared/constants/routes.ts new file mode 100644 index 0000000000..e7763f2ea5 --- /dev/null +++ b/shared/constants/routes.ts @@ -0,0 +1,2 @@ +// Path for growthbook api proxy +export const GROWTHBOOK_API_HOST_PATH = '/api/v1/proxy/growthbook' diff --git a/shared/package-lock.json b/shared/package-lock.json index 9c856ea081..9af07a1062 100644 --- a/shared/package-lock.json +++ b/shared/package-lock.json @@ -590,9 +590,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.5.tgz", - "integrity": "sha512-TwHR5BZxGRODtAfz03szucAkjT5OArXr+94SMtAM2pYXIlQNVMrxvb6uSCbnaJJV6QXEyICk7+l6QPgn72WHhg==" + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.8.tgz", + "integrity": "sha512-0fv/YKpJBAgXKy0kaS3fnqoUVN8901vUYAKIGD/MWZaDfhJt1nZjPL3ZzdZBt/G8G8Hw2J1xOIrXWdNHFHPAvg==" }, "node_modules/lie": { "version": "3.3.0", @@ -872,9 +872,9 @@ "dev": true }, "node_modules/type-fest": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.23.0.tgz", - "integrity": "sha512-ZiBujro2ohr5+Z/hZWHESLz3g08BBdrdLMieYFULJO+tWc437sn8kQsWLJoZErY8alNhxre9K4p3GURAG11n+w==", + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", + "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", "engines": { "node": ">=16" }, diff --git a/shared/types/core.ts b/shared/types/core.ts index 819d5a624e..58aba9a350 100644 --- a/shared/types/core.ts +++ b/shared/types/core.ts @@ -24,8 +24,6 @@ export type ClientEnvVars = { isSPMaintenance: string // Singpass maintenance message isCPMaintenance: string // Corppass maintenance message myInfoBannerContent: string // MyInfo maintenance message - // TODO: remove after React rollout #4786 - GATrackingID: string | null spcpCookieDomain: string // Cookie domain used for removing spcp cookies stripePublishableKey: string diff --git a/shared/types/errorCodes.ts b/shared/types/errorCodes.ts new file mode 100644 index 0000000000..2090205204 --- /dev/null +++ b/shared/types/errorCodes.ts @@ -0,0 +1,6 @@ +// Client error codes +export enum ErrorCode { + myInfo = 'MY_INFO_ERROR', + respondentSingleSubmissionValidationFailure = 'SINGLE_SUBMISSION_VALIDATION_FAILURE', + respondentNotWhitelisted = 'RESPONDENT_NOT_WHITELISTED', +} diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index 2f23e04bad..0e4ba95d61 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -16,7 +16,8 @@ import { DateString } from '../generic' import { FormLogic, LogicDto } from './form_logic' import { PaymentChannel, PaymentMethodType, PaymentType } from '../payment' import { Product } from './product' -import { FormWorkflow, FormWorkflowDto } from './workflow' +import { FormWorkflow, FormWorkflowDto, FormWorkflowStepDto } from './workflow' +import { ErrorCode } from '../errorCodes' export type FormId = Tagged @@ -145,7 +146,7 @@ export interface FormBase { hasCaptcha: boolean hasIssueNotification: boolean authType: FormAuthType - isNricMaskEnabled: boolean + isSubmitterIdCollectionEnabled: boolean isSingleSubmission: boolean status: FormStatus @@ -170,6 +171,15 @@ export interface EmailFormBase extends FormBase { emails: string[] } +export interface WhitelistedSubmitterIds { + isWhitelistEnabled: boolean +} + +export interface WhitelistedSubmitterIdsWithReferenceOid + extends WhitelistedSubmitterIds { + encryptedWhitelistedSubmitterIds: string // Object id of the encrypted whitelist +} + export interface StorageFormBase extends FormBase { responseMode: FormResponseMode.Encrypt publicKey: string @@ -177,12 +187,15 @@ export interface StorageFormBase extends FormBase { payments_channel: FormPaymentsChannel payments_field: FormPaymentsField business?: FormBusinessField + whitelistedSubmitterIds?: WhitelistedSubmitterIds | null } export interface MultirespondentFormBase extends FormBase { responseMode: FormResponseMode.Multirespondent publicKey: string workflow: FormWorkflow + emails: string[] + stepsToNotify: FormWorkflowStepDto['_id'][] } /** @@ -302,9 +315,8 @@ export type PublicFormViewDto = { form: PublicFormDto spcpSession?: SpcpSession isIntranetUser?: boolean - myInfoError?: true myInfoChildrenBirthRecords?: MyInfoChildData - hasSingleSubmissionValidationFailure?: true + errorCodes?: ErrorCode[] } export type PreviewFormViewDto = Pick diff --git a/shared/types/index.ts b/shared/types/index.ts index cb3e25ee0e..33682be9d4 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -13,3 +13,4 @@ export * from './payment' export * from './admin_feedback' export * from './converter' export * from './response-v3' +export * from './errorCodes' diff --git a/shared/types/submission.ts b/shared/types/submission.ts index b99ca9a554..aca2f5a74c 100644 --- a/shared/types/submission.ts +++ b/shared/types/submission.ts @@ -8,6 +8,7 @@ import { DateString } from './generic' import { EmailResponse, FieldResponse, MobileResponse } from './response' import { PaymentStatus } from './payment' import { FormWorkflowDto, LogicDto, ProductItem } from './form' +import { ErrorCode } from './errorCodes' export type SubmissionId = Opaque export const SubmissionId = z.string() as unknown as z.Schema @@ -237,7 +238,7 @@ export type SubmissionResponseDto = { export type SubmissionErrorDto = ErrorDto & { spcpSubmissionFailure?: true - hasSingleSubmissionValidationFailure?: true + errorCodes?: ErrorCode[] } export type SubmissionCountQueryDto = diff --git a/shared/types/user.ts b/shared/types/user.ts index fe4381299b..54928ff0d4 100644 --- a/shared/types/user.ts +++ b/shared/types/user.ts @@ -21,6 +21,8 @@ export const UserBase = z.object({ payment: z.boolean().optional(), children: z.boolean().optional(), postmanSms: z.boolean().optional(), + // TODO: (MRF-email-notif) Remove betaFlag when MRF email notifications is out of beta + mrfEmailNotifications: z.boolean().optional(), }) .optional(), flags: z.record(z.nativeEnum(SeenFlags), z.number()).optional(), diff --git a/shared/utils/__tests__/nric-mask.spec.ts b/shared/utils/__tests__/nric-mask.spec.ts deleted file mode 100644 index 5954255937..0000000000 --- a/shared/utils/__tests__/nric-mask.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { maskNric } from '../nric-mask' - -describe('Nric masking utility', () => { - it('should mask the nric', () => { - const unmasked_nric = 'S1234567A' - const masked_nric = '*****567A' - - expect(maskNric(unmasked_nric)).toEqual(masked_nric) - }) -}) diff --git a/shared/utils/crypto.ts b/shared/utils/crypto.ts new file mode 100644 index 0000000000..215ef603b5 --- /dev/null +++ b/shared/utils/crypto.ts @@ -0,0 +1,138 @@ +import nacl from 'tweetnacl' +import { + encodeUTF8, + decodeUTF8, + encodeBase64, + decodeBase64, +} from 'tweetnacl-util' + +const _generateKeyPair = () => { + const keyPair = nacl.box.keyPair() + return { + publicKey: encodeBase64(keyPair.publicKey), + privateKey: encodeBase64(keyPair.secretKey), + } +} + +type EncryptedStringContent = { + myPublicKey: string + nonce: string + cipherText: string +} + +export type EncryptedStringsMessageContent = { + myPublicKey: string + nonce: string + cipherTexts: string[] +} + +// Should only be used for encryption which requires lookup as well. +// WARNING: By storing private key, confidentiality is still enforced but authenticity is compromised. +// NOTE: my private key should not be passed to client and should be kept in server only. +// Rationale: This tradeoff is necessary for lookup functionality ie. generate same ciphertext for given plaintext. +export type EncryptedStringsMessageContentWithMyPrivateKey = + EncryptedStringsMessageContent & { + myPrivateKey: string + } + +export const encryptStringsMessage = ( + plainTexts: string[], + peerPublicKey: string, +): EncryptedStringsMessageContentWithMyPrivateKey => { + const nonce = nacl.randomBytes(24) + const generatedKeyPair = _generateKeyPair() + + return { + myPublicKey: generatedKeyPair.publicKey, + myPrivateKey: generatedKeyPair.privateKey, + nonce: encodeBase64(nonce), + cipherTexts: encryptStrings( + plainTexts, + peerPublicKey, + nonce, + generatedKeyPair, + ).map((content) => content.cipherText), + } +} + +/** + * Encrypts a list of strings with the given nonce and publicKey. + * Note: each nonce should be unique per message. + * In this case, this list of strings is considered a single message. + * @param plainTexts + * @param nonce + * @param peerPublicKey + * @returns + */ +const encryptStrings = ( + plainTexts: string[], + peerPublicKey: string, + nonce?: Uint8Array, + myKeyPair?: { publicKey: string; privateKey: string }, +): EncryptedStringContent[] => { + return plainTexts.map((plainText) => + encryptString(plainText, peerPublicKey, nonce, myKeyPair), + ) +} + +export const encryptString = ( + plainText: string, + peerPublicKey: string, + nonce?: Uint8Array, + myKeyPair?: { publicKey: string; privateKey: string }, +): EncryptedStringContent => { + if (!myKeyPair) { + myKeyPair = _generateKeyPair() + } + if (!nonce) { + nonce = nacl.randomBytes(24) + } + const plainTextBinary = decodeUTF8(plainText) + + return { + myPublicKey: myKeyPair.publicKey, + nonce: encodeBase64(nonce), + cipherText: encodeBase64( + nacl.box( + plainTextBinary, + nonce, + decodeBase64(peerPublicKey), + decodeBase64(myKeyPair.privateKey), + ), + ), + } +} + +export const decryptStringMessage = ( + peerPrivateKey: string, + encryptedStringsMessageContent: EncryptedStringsMessageContent, +): (string | null)[] => { + const nonce = encryptedStringsMessageContent.nonce + const myPublicKey = encryptedStringsMessageContent.myPublicKey + + return encryptedStringsMessageContent.cipherTexts.map((cipherText) => + decryptString(peerPrivateKey, { + myPublicKey, + nonce, + cipherText, + }), + ) +} + +const decryptString = ( + peerPrivateKey: string, + encryptStringContent: EncryptedStringContent, +): string | null => { + const decryptedBinary = nacl.box.open( + decodeBase64(encryptStringContent.cipherText), + decodeBase64(encryptStringContent.nonce), + decodeBase64(encryptStringContent.myPublicKey), + decodeBase64(peerPrivateKey), + ) + + if (!decryptedBinary) { + return null + } + + return encodeUTF8(decryptedBinary) +} diff --git a/shared/utils/file-validation.ts b/shared/utils/file-validation.ts index cbdf076b30..16db86c8cd 100644 --- a/shared/utils/file-validation.ts +++ b/shared/utils/file-validation.ts @@ -74,6 +74,8 @@ export const VALID_EXTENSIONS = [ '.zip', ] +export const VALID_WHITELIST_FILE_EXTENSIONS = ['.csv'] + /** * Extracts the file extension of a given filename. * diff --git a/shared/utils/nric-mask.ts b/shared/utils/nric-mask.ts deleted file mode 100644 index 9bb3743a73..0000000000 --- a/shared/utils/nric-mask.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Replaces all characters but the last `numCharsShown` characters of a given string with a mask string. - * @param str string to mask - * @param numCharsShown number of characters to display at the end - * @param maskString string to replace masked characters with - * @returns masked string - */ -const maskString = ( - str: string, - numCharsShown: number, - maskString: string = '*', -) => { - return ( - str.slice(0, -numCharsShown).replace(/./g, maskString) + - str.slice(-numCharsShown) - ) -} - -/** - * Masks all characters but the last 4 characters of a given nric. - * @param nric NRIC e.g. S1234567A - * @returns masked NRIC e.g. S*****567A - */ -export const maskNric = (nric: string): string => { - return maskString(nric, 4, '*') -} diff --git a/src/app/config/config.ts b/src/app/config/config.ts index 1a3ef47420..7384b34456 100644 --- a/src/app/config/config.ts +++ b/src/app/config/config.ts @@ -38,16 +38,16 @@ const compulsoryVars = convict(compulsoryVarsSchema) // Deep merge nested objects optionalVars and compulsoryVars const basicVars = merge(optionalVars, compulsoryVars) -const isDev = - basicVars.core.nodeEnv === Environment.Dev || - basicVars.core.nodeEnv === Environment.Test -const nodeEnv = isDev ? basicVars.core.nodeEnv : Environment.Prod +const isDev = basicVars.core.nodeEnv === Environment.Dev +const isTest = basicVars.core.nodeEnv === Environment.Test +const isDevOrTest = isDev || isTest +const nodeEnv = isDevOrTest ? basicVars.core.nodeEnv : Environment.Prod // Load and validate configuration values which are compulsory only in production // If environment variables are not present, an error will be thrown // They may still be referenced in development let prodOnlyVars -if (isDev) { +if (isDevOrTest) { prodOnlyVars = convict(prodOnlyVarsSchema).getProperties() } else { // Perform validation before accessing ses config @@ -63,7 +63,7 @@ if (isDev) { // Perform validation before accessing s3 Bucket Urls const s3BucketUrlSchema = loadS3BucketUrlSchema({ - isDev, + isDev: isDevOrTest, region: basicVars.awsConfig.region, }) const awsEndpoint = convict(s3BucketUrlSchema).getProperties().endPoint @@ -89,8 +89,8 @@ const s3 = new aws.S3({ // Unset and use default if not in development mode // Endpoint and path style overrides are needed only in development mode // for localstack to work, or for Cloudflare R2. - endpoint: isDev || hasR2Buckets ? s3BucketUrlVars.endPoint : undefined, - s3ForcePathStyle: isDev || hasR2Buckets ? true : undefined, + endpoint: isDevOrTest || hasR2Buckets ? s3BucketUrlVars.endPoint : undefined, + s3ForcePathStyle: isDevOrTest || hasR2Buckets ? true : undefined, }) // using aws-sdk v3 (FRM-993) @@ -99,7 +99,7 @@ const virusScannerLambda = new Lambda({ // For dev mode or where specified, endpoint is set to point to the separate docker container running the lambda function. // host.docker.internal is a special DNS name which resolves to the internal IP address used by the host. // Reference: https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host - ...(isDev || basicVars.awsConfig.virusScannerLambdaEndpoint + ...(isDevOrTest || basicVars.awsConfig.virusScannerLambdaEndpoint ? { endpoint: basicVars.awsConfig.virusScannerLambdaEndpoint || @@ -116,7 +116,7 @@ const awsConfig: AwsConfig = { } let dbUri: string | undefined -if (isDev) { +if (isDevOrTest) { if (basicVars.core.nodeEnv === Environment.Dev && prodOnlyVars.dbHost) { dbUri = prodOnlyVars.dbHost } else if (basicVars.core.nodeEnv === Environment.Test) { @@ -134,7 +134,7 @@ const dbConfig: DbConfig = { user: '', pass: '', // Only create indexes in dev env to avoid adverse production impact. - autoIndex: isDev, + autoIndex: isDevOrTest, promiseLibrary: global.Promise, }, } @@ -148,7 +148,7 @@ const mailConfig: MailConfig = (function () { // Creating mail transport let transporter: Mail - if (!isDev) { + if (!isDevOrTest) { const options: SMTPPool.Options = { host: prodOnlyVars.host, auth: { @@ -189,7 +189,7 @@ const mailConfig: MailConfig = (function () { // Cookie settings needed for express-session configuration const cookieSettings: SessionOptions['cookie'] = { httpOnly: true, // JavaScript will not be able to read the cookie in case of XSS exploitation - secure: !isDev, // true prevents cookie from being accessed over http + secure: !isDevOrTest, // true prevents cookie from being accessed over http maxAge: 24 * 60 * 60 * 1000, // 24 hours sameSite: 'strict', // Cookie will not be sent if navigating from another domain } @@ -198,7 +198,7 @@ const cookieSettings: SessionOptions['cookie'] = { * Fetches AWS credentials */ const configureAws = async () => { - if (!isDev) { + if (!isDevOrTest) { const getCredentials = () => { return new Promise((resolve, reject) => { aws.config.getCredentials((err) => { @@ -220,7 +220,7 @@ const configureAws = async () => { } } -const apiEnv = isDev ? 'test' : 'live' +const apiEnv = isDevOrTest ? 'test' : 'live' const publicApiConfig: PublicApiConfig = { apiEnv, apiKeyVersion: basicVars.publicApi.apiKeyVersion, @@ -233,6 +233,8 @@ const config: Config = { mail: mailConfig, cookieSettings, isDev, + isTest, + isDevOrTest, useMockTwilio: basicVars.core.useMockTwilio, useMockPostmanSms: basicVars.core.useMockPostmanSms, nodeEnv, diff --git a/src/app/config/datadog-statsd-client.ts b/src/app/config/datadog-statsd-client.ts index 79cedb16ea..e2674bf8bf 100644 --- a/src/app/config/datadog-statsd-client.ts +++ b/src/app/config/datadog-statsd-client.ts @@ -1,3 +1,7 @@ import { StatsD } from 'hot-shots' -export const statsdClient = new StatsD({ useDefaultRoute: true }) +import config from './config' + +export const statsdClient = new StatsD({ + useDefaultRoute: !config.isDevOrTest, +}) diff --git a/src/app/config/features/google-analytics.config.ts b/src/app/config/features/google-analytics.config.ts deleted file mode 100644 index 17db3895f1..0000000000 --- a/src/app/config/features/google-analytics.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -// TODO: remove after React rollout #4786 -import convict, { Schema } from 'convict' - -export interface IGoogleAnalytics { - GATrackingID: string | null -} - -const googleAnalyticsSchema: Schema = { - GATrackingID: { - doc: 'Google Analytics tracking ID', - format: String, - default: null, - env: 'GA_TRACKING_ID', - }, -} - -export const googleAnalyticsConfig = convict(googleAnalyticsSchema) - .validate({ allowed: 'strict' }) - .getProperties() diff --git a/src/app/config/features/growthbook.config.ts b/src/app/config/features/growthbook.config.ts index bc3f65e30c..a52064d448 100644 --- a/src/app/config/features/growthbook.config.ts +++ b/src/app/config/features/growthbook.config.ts @@ -1,5 +1,7 @@ import convict, { Schema } from 'convict' +import { isTest } from '../config' + export interface IGrowthbook { growthbookClientKey: string } @@ -13,6 +15,6 @@ const growthbookSchema: Schema = { }, } -export const growthbookConfig = convict(growthbookSchema) - .validate({ allowed: 'strict' }) - .getProperties() +export const growthbookConfig = isTest + ? convict(growthbookSchema).getProperties() + : convict(growthbookSchema).validate({ allowed: 'strict' }).getProperties() diff --git a/src/app/config/schema.ts b/src/app/config/schema.ts index 73f104ae20..7c1b784a06 100644 --- a/src/app/config/schema.ts +++ b/src/app/config/schema.ts @@ -356,12 +356,30 @@ export const optionalVarsSchema: Schema = { default: 60, env: 'SEND_AUTH_OTP_RATE_LIMIT', }, + publicFormIssueFeedback: { + doc: 'Per-minute, per-IP, per form request limit for public form issue feedback endpoints', + format: 'int', + default: 3, + env: 'PUBLIC_FORM_ISSUE_FEEDBACK_RATE_LIMIT', + }, downloadPaymentReceipt: { doc: 'Per-minute, per-IP request limit to download the payment receipt from Stripe', format: 'int', default: 10, env: 'DOWNLOAD_PAYMENT_RECEIPT_RATE_LIMIT', }, + downloadFormWhitelist: { + doc: 'Per-minute, per-IP request limit to download the form whitelist', + format: 'int', + default: 10, + env: 'DOWNLOAD_FORM_WHITELIST_RATE_LIMIT', + }, + uploadFormWhitelist: { + doc: 'Per-minute, per-IP request limit to upload the form whitelist', + format: 'int', + default: 10, + env: 'UPLOAD_FORM_WHITELIST_RATE_LIMIT', + }, publicApi: { doc: 'Per-minute, per-IP, per-instance request limit for public APIs', format: 'int', diff --git a/src/app/loaders/express/__tests__/helmet.spec.ts b/src/app/loaders/express/__tests__/helmet.spec.ts index 1dca8c8eaf..da37033992 100644 --- a/src/app/loaders/express/__tests__/helmet.spec.ts +++ b/src/app/loaders/express/__tests__/helmet.spec.ts @@ -47,14 +47,30 @@ describe('helmetMiddlewares', () => { expect(mockHelmet.contentSecurityPolicy).toHaveBeenCalled() }) + it('should call generateNonceMiddleware before contentSecurityPolicyMiddleware', () => { + const generateNonceMiddlewareFnIdx = helmetMiddlewares().findIndex( + (result) => + typeof result === 'function' && + result.name === 'generateNonceMiddleware', + ) + + const contentSecurityPolicyIdx = helmetMiddlewares().findIndex( + (result) => + typeof result === 'string' && result === 'contentSecurityPolicy', + ) + + // generateNonceMiddleware should be called before contentSecurityPolicyMiddleware + expect(generateNonceMiddlewareFnIdx).toBeLessThan(contentSecurityPolicyIdx) + }) + it('should call helmet.hsts() if req.secure', () => { const mockReq = expressHandler.mockRequest({ secure: true }) const mockRes = expressHandler.mockResponse() const mockNext = jest.fn() - // Find works for helmet.hsts() because the other functions are mocked to return a string const hstsFn = helmetMiddlewares().find( - (result) => typeof result === 'function', + (result) => + typeof result === 'function' && result.name === 'hstsMiddleware', ) // Necessary to check for hstsFn because find() returns undefined by default, otherwise // will throw TypeError @@ -82,7 +98,7 @@ describe('helmetMiddlewares', () => { }) it('should call helmet.contentSecurityPolicy() with the correct directives if cspReportUri and !isDev', () => { - mockConfig.isDev = false + mockConfig.isDevOrTest = false helmetMiddlewares() expect(mockHelmet.contentSecurityPolicy).toHaveBeenCalledWith({ useDefaults: true, @@ -93,7 +109,7 @@ describe('helmetMiddlewares', () => { }) it('should call helmet.contentSecurityPolicy() with the correct directives if !cspReportUri and isDev', () => { - mockConfig.isDev = true + mockConfig.isDevOrTest = true helmetMiddlewares() expect(mockHelmet.contentSecurityPolicy).toHaveBeenCalledWith({ useDefaults: true, diff --git a/src/app/loaders/express/constants.ts b/src/app/loaders/express/constants.ts index 588f73b1a5..0bd5830d7a 100644 --- a/src/app/loaders/express/constants.ts +++ b/src/app/loaders/express/constants.ts @@ -1,3 +1,5 @@ +import { RequestHandler } from 'express' + import config from '../../config/config' export const CSP_CORE_DIRECTIVES = { @@ -30,7 +32,9 @@ export const CSP_CORE_DIRECTIVES = { 'https://*.googletagmanager.com/gtag/', 'https://*.cloudflareinsights.com/', // Cloudflare web analytics https://developers.cloudflare.com/analytics/types-of-analytics/#web-analytics 'https://www.gstatic.com/charts/', // React Google Charts for FormSG charts - 'https://www.gstatic.cn', + 'https://www.gstatic.cn/recaptcha/releases/', + (_req: Parameters[0], res: Parameters[1]) => + `'nonce-${res.locals.nonce}'`, ], connectSrc: [ "'self'", diff --git a/src/app/loaders/express/growthbook.ts b/src/app/loaders/express/growthbook.ts new file mode 100644 index 0000000000..ed497dfe63 --- /dev/null +++ b/src/app/loaders/express/growthbook.ts @@ -0,0 +1,25 @@ +import { GrowthBook } from '@growthbook/growthbook' +import { RequestHandler } from 'express' + +import { GROWTHBOOK_DEV_PROXY } from '../../../../shared/constants/links' +import { GROWTHBOOK_API_HOST_PATH } from '../../../../shared/constants/routes' +import config from '../../config/config' +import { growthbookConfig } from '../../config/features/growthbook.config' + +const growthbookMiddleware: RequestHandler = async (req, res, next) => { + req.growthbook = new GrowthBook({ + apiHost: `${config.isDev ? GROWTHBOOK_DEV_PROXY : config.app.appUrl}${GROWTHBOOK_API_HOST_PATH}`, + clientKey: growthbookConfig.growthbookClientKey, + enableDevMode: config.isDev, + }) + + res.on('close', () => { + if (req.growthbook) { + req.growthbook.destroy() + } + }) + + await req.growthbook.init({ timeout: 1000 }).then(() => next()) +} + +export default growthbookMiddleware diff --git a/src/app/loaders/express/helmet.ts b/src/app/loaders/express/helmet.ts index a81f6c96ac..1967681ab3 100644 --- a/src/app/loaders/express/helmet.ts +++ b/src/app/loaders/express/helmet.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto' import { RequestHandler } from 'express' import helmet from 'helmet' @@ -13,6 +14,12 @@ const helmetMiddlewares = () => { } else next() } + const generateNonceMiddleware: RequestHandler = (req, res, next) => { + res.locals.nonce = + res.locals.nonce || crypto.randomBytes(32).toString('hex') + + next() + } const xssFilterMiddleware = helmet.xssFilter() const noSniffMiddleware = helmet.noSniff() @@ -29,7 +36,7 @@ const helmetMiddlewares = () => { const cspCoreDirectives = CSP_CORE_DIRECTIVES - const cspOptionalDirectives = config.isDev + const cspOptionalDirectives = config.isDevOrTest ? // Remove upgradeInsecureRequest CSP header if config.isDev // See https://github.com/helmetjs/helmet for use of null to disable default { upgradeInsecureRequests: null } @@ -37,12 +44,14 @@ const helmetMiddlewares = () => { const contentSecurityPolicyMiddleware = helmet.contentSecurityPolicy({ useDefaults: true, + // @ts-expect-error: cspCoreDirectives types are not properly exported by helmet directives: { ...cspCoreDirectives, ...cspOptionalDirectives, }, }) return [ + generateNonceMiddleware, xssFilterMiddleware, noSniffMiddleware, ieNoOpenMiddlware, diff --git a/src/app/loaders/express/index.ts b/src/app/loaders/express/index.ts index 63454adb5d..26a81284af 100644 --- a/src/app/loaders/express/index.ts +++ b/src/app/loaders/express/index.ts @@ -20,6 +20,7 @@ import { catchNonExistentStaticRoutesMiddleware, errorHandlerMiddlewares, } from './error-handler' +import growthbookMiddleware from './growthbook' import helmetMiddlewares from './helmet' import appLocals from './locals' import loggingMiddleware from './logging' @@ -105,6 +106,11 @@ const loadExpressApp = async (connection: Connection) => { // Log intranet usage app.use(IntranetMiddleware.logIntranetUsage) + // Growthbook should not be enabled in test environment to avoid flakiness + if (!config.isTest) { + app.use(growthbookMiddleware) + } + // jwks endpoint for SP OIDC app.use('/singpass/.well-known/jwks.json', SpOidcJwksRouter) // Registered routes with sgID diff --git a/src/app/loaders/express/locals.ts b/src/app/loaders/express/locals.ts index ee577cb0ae..f7d3d6a2d2 100644 --- a/src/app/loaders/express/locals.ts +++ b/src/app/loaders/express/locals.ts @@ -2,7 +2,6 @@ import ejs from 'ejs' import config from '../../config/config' import { captchaConfig } from '../../config/features/captcha.config' -import { googleAnalyticsConfig } from '../../config/features/google-analytics.config' import { paymentConfig } from '../../config/features/payment.config' import { spcpMyInfoConfig } from '../../config/features/spcp-myinfo.config' @@ -18,8 +17,6 @@ const frontendVars = { isSPMaintenance: spcpMyInfoConfig.isSPMaintenance, // Singpass maintenance message isCPMaintenance: spcpMyInfoConfig.isCPMaintenance, // Corppass maintenance message myInfoBannerContent: spcpMyInfoConfig.myInfoBannerContent, // MyInfo maintenance message - // TODO: remove after React rollout #4786 - GATrackingID: googleAnalyticsConfig.GATrackingID, spcpCookieDomain: spcpMyInfoConfig.spcpCookieDomain, // Cookie domain used for removing spcp cookies // payment variables reactMigrationUseFetchForSubmissions: @@ -40,9 +37,7 @@ const environment = ejs.render( var isGeneralMaintenance = "<%- isGeneralMaintenance %>" var isLoginBanner = "<%- isLoginBanner %>" var siteBannerContent = "<%- siteBannerContent %>" - var adminBannerContent = "<%- adminBannerContent %>" - // Google Analytics - var GATrackingID = "<%= GATrackingID%>" + var adminBannerContent = "<%- adminBannerContent %>" // Recaptcha var captchaPublicKey = "<%= captchaPublicKey %>" // S3 bucket diff --git a/src/app/loaders/express/logging.ts b/src/app/loaders/express/logging.ts index abf358317d..36608926ef 100644 --- a/src/app/loaders/express/logging.ts +++ b/src/app/loaders/express/logging.ts @@ -26,7 +26,7 @@ const loggingMiddleware = () => { format: winston.format.combine( winston.format.label({ label: LOGGER_LABEL }), winston.format.timestamp(), - config.isDev + config.isDevOrTest ? winston.format.combine(winston.format.colorize(), customFormat) : winston.format.json(), ), diff --git a/src/app/loaders/express/session.ts b/src/app/loaders/express/session.ts index 8e97981f77..9cebeb4b95 100644 --- a/src/app/loaders/express/session.ts +++ b/src/app/loaders/express/session.ts @@ -6,7 +6,7 @@ import { Connection } from 'mongoose' import config from '../../config/config' -export const ADMIN_LOGIN_SESSION_COOKIE_NAME = config.isDev +export const ADMIN_LOGIN_SESSION_COOKIE_NAME = config.isDevOrTest ? 'formsg.connect.sid' : 'connect.sid' diff --git a/src/app/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts index e37fddabf1..6b6fe3e011 100644 --- a/src/app/models/__tests__/form.server.model.spec.ts +++ b/src/app/models/__tests__/form.server.model.spec.ts @@ -24,6 +24,8 @@ import { LogicType, PaymentChannel, PaymentType, + StorageFormSettings, + WhitelistedSubmitterIdsWithReferenceOid, WorkflowType, } from 'shared/types' @@ -72,10 +74,10 @@ const MOCK_MULTIRESPONDENT_FORM_PARAMS = { const FORM_DEFAULTS = { authType: 'NIL', - isNricMaskEnabled: false, + isSubmitterIdCollectionEnabled: false, isSingleSubmission: false, inactiveMessage: - 'If you think this is a mistake, please contact the agency that gave you the form link.', + 'If you require further assistance, please contact the agency that gave you the form link.', isListed: true, startPage: { colorTheme: 'blue', @@ -103,6 +105,13 @@ const FORM_DEFAULTS = { goLinkSuffix: '', } +const ENCRYPT_MODE_SETTINGS_DEFAULTS = { + emails: [], + whitelistedSubmitterIds: { + isWhitelistEnabled: false, + }, +} + const PAYMENTS_DEFAULTS = { payments_channel: { channel: PaymentChannel.Unconnected, @@ -445,8 +454,9 @@ describe('Form Model', () => { describe('Encrypted form schema', () => { const ENCRYPT_FORM_DEFAULTS = merge( - { responseMode: 'encrypt', emails: [] }, + { responseMode: 'encrypt' }, FORM_DEFAULTS, + ENCRYPT_MODE_SETTINGS_DEFAULTS, PAYMENTS_DEFAULTS, ) @@ -876,11 +886,37 @@ describe('Form Model', () => { '`null` is not a valid enum value for path `payments_field.payment_type`', ) }) + + it('should not get full list of whitelisted submitter id when getSettings', async () => { + // Arrange + const whitelistData = { + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + encryptedWhitelistedSubmitterIds: new ObjectId(), + }, + } + const MOCK_ENCRYPTED_FORM_PARAMS_WITH_WHITELIST = { + ...MOCK_ENCRYPTED_FORM_PARAMS, + ...whitelistData, + } + + const validForm = new EncryptedForm( + MOCK_ENCRYPTED_FORM_PARAMS_WITH_WHITELIST, + ) + + // Act + const settings = validForm.getSettings() as StorageFormSettings + + // Assert + expect(settings.whitelistedSubmitterIds).toEqual( + pick(whitelistData.whitelistedSubmitterIds, 'isWhitelistEnabled'), + ) + }) }) describe('Multirespondent form schema', () => { const MULTIRESPONDENT_FORM_DEFAULTS = merge( - { responseMode: 'multirespondent' }, + { responseMode: 'multirespondent', stepsToNotify: [], emails: [] }, FORM_DEFAULTS, ) @@ -2635,7 +2671,7 @@ describe('Form Model', () => { title: 'Test Form', admin: MOCK_ADMIN_OBJ_ID, authType: FormAuthType.SP, - isNricMaskEnabled: true, + isSubmitterIdCollectionEnabled: true, isSingleSubmission: true, inactiveMessage: 'inactive_test', responseMode: FormResponseMode.Encrypt, @@ -2659,8 +2695,8 @@ describe('Form Model', () => { MOCK_ALL_OVERRIDE_PARAMS.submissionLimit, ) expect(duplicatedForm.authType).toEqual(MOCK_ALL_FORM_PARAMS.authType) - expect(duplicatedForm.isNricMaskEnabled).toEqual( - MOCK_ALL_FORM_PARAMS.isNricMaskEnabled, + expect(duplicatedForm.isSubmitterIdCollectionEnabled).toEqual( + MOCK_ALL_FORM_PARAMS.isSubmitterIdCollectionEnabled, ) expect(duplicatedForm.inactiveMessage).toEqual( MOCK_ALL_FORM_PARAMS.inactiveMessage, @@ -2669,6 +2705,29 @@ describe('Form Model', () => { MOCK_ALL_FORM_PARAMS.isSingleSubmission, ) }) + + it('should not duplicate unwanted fields', () => { + const whitelistedSubmitterIdsParam: WhitelistedSubmitterIdsWithReferenceOid = + { + isWhitelistEnabled: true, + encryptedWhitelistedSubmitterIds: 'some object id', + } + const MOCK_ALL_FORM_PARAMS = { + whitelistedSubmitterIds: whitelistedSubmitterIdsParam, + } + const MOCK_ALL_OVERRIDE_PARAMS = { + admin: 'duplicated admin', + title: 'duplicated title', + responseMode: FormResponseMode.Encrypt, + } + + const sourceForm = new Form(MOCK_ALL_FORM_PARAMS) + const duplicatedForm = sourceForm.getDuplicateParams( + MOCK_ALL_OVERRIDE_PARAMS, + ) + + expect(duplicatedForm).not.toHaveProperty('whitelistedSubmitterIds') + }) }) describe('insertFormField', () => { diff --git a/src/app/models/field/__tests__/emailField.spec.ts b/src/app/models/field/__tests__/emailField.spec.ts index f589279bee..d839123f47 100644 --- a/src/app/models/field/__tests__/emailField.spec.ts +++ b/src/app/models/field/__tests__/emailField.spec.ts @@ -101,4 +101,33 @@ describe('models.fields.emailField', () => { }) expect(actual.field.toObject()).toEqual(expected) }) + + it('should set includeFormSummary to false on ResponseMode.Multirespondent forms', async () => { + // Arrange + const mockEmailField = { + autoReplyOptions: { + hasAutoReply: true, + autoReplySubject: 'some subject', + autoReplySender: 'some sender', + autoReplyMessage: 'This is a test message', + // Set includeFormSummary to true. + includeFormSummary: true, + }, + } + // Act + const actual = await MockParent.create({ + responseMode: FormResponseMode.Multirespondent, + field: mockEmailField, + }) + + // Assert + const expected = merge(EMAIL_FIELD_DEFAULTS, mockEmailField, { + _id: expect.anything(), + autoReplyOptions: { + // Should be always set to false for MRF forms + includeFormSummary: false, + }, + }) + expect(actual.field.toObject()).toEqual(expected) + }) }) diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index e5abb63f31..4cc092e13b 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -109,6 +109,7 @@ import LogicSchema, { ShowFieldsLogicSchema, } from './form_logic.server.schema' import { CustomFormLogoSchema, FormLogoSchema } from './form_logo.server.schema' +import { FORM_WHITELISTED_SUBMITTER_IDS_ID } from './form_whitelist.server.model' import WorkflowStepSchema, { WorkflowStepDynamicSchema, WorkflowStepStaticSchema, @@ -192,6 +193,21 @@ export const formPaymentsFieldSchema = { }, } +const whitelistedSubmitterIdNestedPath = { + isWhitelistEnabled: { + type: Boolean, + required: true, + default: false, + }, + encryptedWhitelistedSubmitterIds: { + type: Schema.Types.ObjectId, + ref: FORM_WHITELISTED_SUBMITTER_IDS_ID, + required: false, + default: undefined, + }, + _id: { id: false }, +} + const EncryptedFormSchema = new Schema({ publicKey: { type: String, @@ -218,6 +234,16 @@ const EncryptedFormSchema = new Schema({ // TODO: Make this required after all forms have been migrated required: false, }, + whitelistedSubmitterIds: { + type: whitelistedSubmitterIdNestedPath, + get: (v: { isWhitelistEnabled: boolean }) => ({ + // remove the ObjectId link to whitelist collection's document by default unless asked for. + isWhitelistEnabled: v.isWhitelistEnabled, + }), + default: () => ({ + isWhitelistEnabled: false, + }), + }, payments_channel: { channel: { type: String, @@ -253,7 +279,13 @@ const EncryptedFormSchema = new Schema({ const EncryptedFormDocumentSchema = EncryptedFormSchema as unknown as Schema -EncryptedFormDocumentSchema.methods.addPaymentAccountId = async function ({ +EncryptedFormDocumentSchema.methods.getWhitelistedSubmitterIds = function () { + return this.get('whitelistedSubmitterIds', null, { + getters: false, + }) +} + +EncryptedFormDocumentSchema.methods.addPaymentAccountId = function ({ accountId, publishableKey, }: { @@ -316,6 +348,38 @@ const MultirespondentFormSchema = new Schema({ workflow: { type: [WorkflowStepSchema], }, + emails: { + type: [ + { + type: String, + trim: true, + }, + ], + set: transformEmails, + validate: [ + (v: string[]) => { + if (!Array.isArray(v)) return false + if (v.length === 0) return true + return v.every((email) => validator.isEmail(email)) + }, + 'Please provide valid email addresses', + ], + required: true, + }, + stepsToNotify: { + type: [{ type: String }], + validate: [ + { + validator: (v: string[]) => { + if (!Array.isArray(v)) return false + if (v.length === 0) return true + return v.every((fieldId) => ObjectId.isValid(fieldId)) + }, + message: 'Please provide valid form field ids', + }, + ], + required: true, + }, }) const MultirespondentFormWorkflowPath = MultirespondentFormSchema.path( @@ -517,7 +581,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { }, }, - isNricMaskEnabled: { + isSubmitterIdCollectionEnabled: { type: Boolean, default: false, }, @@ -560,7 +624,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { inactiveMessage: { type: String, default: - 'If you think this is a mistake, please contact the agency that gave you the form link.', + 'If you require further assistance, please contact the agency that gave you the form link.', }, isListed: { @@ -710,7 +774,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { 'startPage', 'endPage', 'authType', - 'isNricMaskEnabled', + 'isSubmitterIdCollectionEnabled', 'isSingleSubmission', 'inactiveMessage', 'responseMode', diff --git a/src/app/models/form_whitelist.server.model.ts b/src/app/models/form_whitelist.server.model.ts new file mode 100644 index 0000000000..799b7f5b2c --- /dev/null +++ b/src/app/models/form_whitelist.server.model.ts @@ -0,0 +1,87 @@ +import { Mongoose, Schema } from 'mongoose' + +import { + IFormWhitelistedSubmitterIdsModel, + IFormWhitelistedSubmitterIdsSchema, +} from 'src/types' + +import { FORM_SCHEMA_ID } from './form.server.model' + +export const FORM_WHITELISTED_SUBMITTER_IDS_ID = 'FormWhitelistedSubmitterIds' + +const formWhitelistedSubmitterIdsSchema = new Schema< + IFormWhitelistedSubmitterIdsSchema, + IFormWhitelistedSubmitterIdsModel +>({ + formId: { + type: Schema.Types.ObjectId, + ref: () => FORM_SCHEMA_ID, + required: true, + }, + myPublicKey: { + type: String, + required: true, + }, + myPrivateKey: { + type: String, + required: true, + select: false, + }, + nonce: { + type: String, + required: true, + }, + cipherTexts: { + type: [{ type: String, required: true }], + required: true, + validate: [ + (v: string[]) => { + return Array.isArray(v) && v.length > 0 + }, + 'cipherTexts must be non-empty array', + ], + }, +}) + +formWhitelistedSubmitterIdsSchema.statics.checkIfSubmitterIdIsWhitelisted = + async function (whitelistId: string, submitterId: string) { + return this.exists({ + _id: whitelistId, + cipherTexts: submitterId, + }).exec() + } + +formWhitelistedSubmitterIdsSchema.statics.findEncryptionPropertiesById = + async function (whitelistId: string) { + const encryptionProperties = 'myPublicKey myPrivateKey nonce' + return await this.findById(whitelistId) + .select(encryptionProperties) + .lean() + .exec() + } + +const compileFormWhitelistedSubmitterIdsModel = ( + db: Mongoose, +): IFormWhitelistedSubmitterIdsModel => { + const FormWhitelistedSubmitterIdsModel = db.model< + IFormWhitelistedSubmitterIdsSchema, + IFormWhitelistedSubmitterIdsModel + >(FORM_WHITELISTED_SUBMITTER_IDS_ID, formWhitelistedSubmitterIdsSchema) + + return FormWhitelistedSubmitterIdsModel +} + +const getFormWhitelistSubmitterIdsModel = ( + db: Mongoose, +): IFormWhitelistedSubmitterIdsModel => { + try { + return db.model< + IFormWhitelistedSubmitterIdsSchema, + IFormWhitelistedSubmitterIdsModel + >(FORM_WHITELISTED_SUBMITTER_IDS_ID) + } catch { + return compileFormWhitelistedSubmitterIdsModel(db) + } +} + +export default getFormWhitelistSubmitterIdsModel diff --git a/src/app/models/user.server.model.ts b/src/app/models/user.server.model.ts index c5cef65291..6fad04bcb6 100644 --- a/src/app/models/user.server.model.ts +++ b/src/app/models/user.server.model.ts @@ -75,6 +75,8 @@ const compileUserModel = (db: Mongoose) => { payment: Boolean, children: Boolean, postmanSms: Boolean, + // TODO: (MRF-email-notif) Remove betaFlag when MRF email notifications is out of beta + mrfEmailNotifications: Boolean, }, flags: { type: Schema.Types.Map, // of SeenFlags diff --git a/src/app/modules/core/core.errors.ts b/src/app/modules/core/core.errors.ts index d73eec917a..733c533443 100644 --- a/src/app/modules/core/core.errors.ts +++ b/src/app/modules/core/core.errors.ts @@ -24,6 +24,10 @@ export enum ErrorCodes { FORM_LOGIC_NOT_FOUND = 100005, FORM_AUTH_TYPE_MISMATCH = 100006, FORM_AUTH_NO_ESRVC_ID = 100007, + FORM_RESPONDENT_NOT_WHITELISTED = 100008, + FORM_RESPONDENT_SINGLE_SUBMISSION_VALIDATION_FAILED = 100009, + FORM_UNEXPECTED_WHITELIST_SETTING_NOT_FOUND = 100010, + // [100100 - 100199] Admin Form Errors (/modules/form/admin-form) ADMIN_FORM_INVALID_FILE_TYPE = 100100, ADMIN_FORM_EDIT_FIELD = 100101, ADMIN_FORM_FIELD_NOT_FOUND = 100102, diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index a11630bccb..74c50f2e1e 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -76,6 +76,7 @@ import { FormStatus, LogicDto, } from '../../../../../../shared/types' +import * as CryptoUtil from '../../../../../../shared/utils/crypto' import { smsConfig } from '../../../../config/features/sms.config' import * as SmsService from '../../../../services/sms/sms.service' import ParsedResponsesObject from '../../../submission/ParsedResponsesObject.class' @@ -4792,6 +4793,399 @@ describe('admin-form.controller', () => { }) }) + describe('handleUpdateWhitelistSetting', () => { + const MOCK_VALID_UEN = '53244311W' + const MOCK_LOWERCASE_UEN = '53244311w' + const MOCK_VALID_FIN = 'F1612366T' + const MOCK_VALID_NRIC = 'S7101844Z' + const MOCK_LOWERCASE_NRIC = 's7101844z' + + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + + const MOCK_STORAGE_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + title: 'mock title', + publicKey: 'some public key', + } as IPopulatedEncryptedForm + + const MOCK_UPDATED_SETTINGS = { + authType: FormAuthType.NIL, + hasCaptcha: false, + inactiveMessage: 'some inactive message', + status: FormStatus.Private, + submissionLimit: 42069, + title: 'new title', + webhook: { + isRetryEnabled: true, + url: '', + }, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + } as FormSettings + + const MOCK_BASE_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + + const updateFormWhitelistSettingSpy = jest.spyOn( + AdminFormService, + 'updateFormWhitelistSetting', + ) + + const MOCK_ENCRYPTED_WHITELISTED_SUBMITTER_IDS = { + cipherTexts: ['abc'], + nonce: 'some nonce', + myPrivateKey: 'some private key', + myPublicKey: 'some public key', + } + const encryptStringsMessageSpy = jest + .spyOn(CryptoUtil, 'encryptStringsMessage') + .mockReturnValue(MOCK_ENCRYPTED_WHITELISTED_SUBMITTER_IDS) + + beforeEach(() => { + MockAdminFormService.checkIsWhitelistSettingValid.mockReturnValue({ + isValid: true, + }) + }) + + it('should return 403 if user does not have write permissions for the form', async () => { + // Arrange + const MOCK_VALID_UPDATE_WHITELIST_REQ = assignIn( + cloneDeep(MOCK_BASE_REQ), + { + body: { + whitelistCsvString: `${MOCK_LOWERCASE_NRIC}\r\n${MOCK_VALID_FIN}\r\n${MOCK_VALID_UEN}`, + }, + }, + ) + + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + + const expectedErrorString = 'no write permissions' + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + errAsync(new ForbiddenFormError(expectedErrorString)), + ) + + // Act + await AdminFormController._handleUpdateWhitelistSettingForTest( + MOCK_VALID_UPDATE_WHITELIST_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(updateFormWhitelistSettingSpy).not.toHaveBeenCalled() + }) + + it('should uppercase all whitelist strings before encrypting them for consistency in matching submitterIds', async () => { + // Arrange + const MOCK_VALID_UPDATE_WHITELIST_REQ = assignIn( + cloneDeep(MOCK_BASE_REQ), + { + body: { + whitelistCsvString: `${MOCK_VALID_FIN}\r\n${MOCK_LOWERCASE_NRIC}\r\n${MOCK_LOWERCASE_UEN}`, + }, + }, + ) + + const mockRes = expressHandler.mockResponse() + + const expectedFormSettings = MOCK_UPDATED_SETTINGS + updateFormWhitelistSettingSpy.mockReturnValueOnce( + okAsync(expectedFormSettings), + ) + + // Mock services to return success results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + okAsync(MOCK_STORAGE_FORM), + ) + + // Act + await AdminFormController._handleUpdateWhitelistSettingForTest( + MOCK_VALID_UPDATE_WHITELIST_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.json).toHaveBeenCalledWith(expectedFormSettings) + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(encryptStringsMessageSpy).toHaveBeenCalledWith( + [MOCK_VALID_FIN, MOCK_VALID_NRIC, MOCK_VALID_UEN], + MOCK_STORAGE_FORM.publicKey, + ) + expect(updateFormWhitelistSettingSpy).toHaveBeenCalledTimes(1) + }) + + it('should return 200 ok with form settings and allow admin with form write permissions to update whitelist setting successfully', async () => { + // Arrange + const MOCK_VALID_UPDATE_WHITELIST_REQ = assignIn( + cloneDeep(MOCK_BASE_REQ), + { + body: { + whitelistCsvString: `${MOCK_VALID_NRIC}\r\n${MOCK_VALID_FIN}\r\n${MOCK_VALID_UEN}`, + }, + }, + ) + + const mockRes = expressHandler.mockResponse() + + const expectedFormSettings = MOCK_UPDATED_SETTINGS + updateFormWhitelistSettingSpy.mockReturnValueOnce( + okAsync(expectedFormSettings), + ) + + // Mock services to return success results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + okAsync(MOCK_STORAGE_FORM), + ) + + // Act + await AdminFormController._handleUpdateWhitelistSettingForTest( + MOCK_VALID_UPDATE_WHITELIST_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.json).toHaveBeenCalledWith(expectedFormSettings) + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(encryptStringsMessageSpy).toHaveBeenCalledWith( + [MOCK_VALID_NRIC, MOCK_VALID_FIN, MOCK_VALID_UEN], + MOCK_STORAGE_FORM.publicKey, + ) + expect(updateFormWhitelistSettingSpy).toHaveBeenCalledTimes(1) + }) + + it('should return 200 ok with form settings and allow admin with form write permissions to delete whitelist setting successfully', async () => { + // Arrange + const MOCK_VALID_DISABLE_WHITELIST_REQ = assignIn( + cloneDeep(MOCK_BASE_REQ), + { + body: { + whitelistCsvString: null, + }, + }, + ) + + const mockRes = expressHandler.mockResponse() + + const expectedFormSettings = { + ...MOCK_UPDATED_SETTINGS, + whitelistedSubmitterIds: { + isWhitelistEnabled: false, + }, + } + updateFormWhitelistSettingSpy.mockReturnValueOnce( + okAsync(expectedFormSettings), + ) + + // Mock services to return success results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce( + okAsync(MOCK_STORAGE_FORM), + ) + + // Act + await AdminFormController._handleUpdateWhitelistSettingForTest( + MOCK_VALID_DISABLE_WHITELIST_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.json).toHaveBeenCalledWith(expectedFormSettings) + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(encryptStringsMessageSpy).not.toHaveBeenCalledWith() // since no whitelist, no need to encrypt + expect(updateFormWhitelistSettingSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('handleGetWhitelistSetting', () => { + const MOCK_FORM_SETTINGS = { + authType: FormAuthType.NIL, + hasCaptcha: false, + inactiveMessage: 'some inactive message', + status: FormStatus.Private, + submissionLimit: 42069, + title: 'mock title', + webhook: { + isRetryEnabled: true, + url: '', + }, + } as FormSettings + const MOCK_USER_ID = new ObjectId().toHexString() + const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_USER = { + _id: MOCK_USER_ID, + email: 'somerandom@example.com', + } as IPopulatedUser + const MOCK_FORM = { + admin: MOCK_USER, + _id: MOCK_FORM_ID, + getSettings: () => MOCK_FORM_SETTINGS, + } as IPopulatedForm + + const MOCK_REQ = expressHandler.mockRequest({ + params: { + formId: MOCK_FORM_ID, + }, + session: { + user: { + _id: MOCK_USER_ID, + }, + }, + }) + + it('should return 200 ok with encrypted whitelist settings if admin has read permissions for the form', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + // Mock various services to return expected results. + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + const adminCheck = jest.fn( + ({ + form, + }: { + form: IPopulatedForm + }): Result => + ok(form), + ) + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + MockEncryptSubmissionService.checkFormIsEncryptMode.mockReturnValueOnce( + ok(MOCK_FORM as IPopulatedEncryptedForm), + ) + + const getForWhitelistSettingSpy = jest.spyOn( + AdminFormService, + 'getFormWhitelistSetting', + ) + + const MOCK_ENCRYPTED_WHITELIST_SETTING: CryptoUtil.EncryptedStringsMessageContent = + { + myPublicKey: 'some public key', + cipherTexts: ['some encrypted string'], + nonce: 'some nonce', + } + + getForWhitelistSettingSpy.mockReturnValueOnce( + okAsync(MOCK_ENCRYPTED_WHITELIST_SETTING), + ) + + // Act + await AdminFormController.handleGetWhitelistSetting( + MOCK_REQ, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.json).toHaveBeenCalledWith({ + encryptedWhitelistedSubmitterIds: MOCK_ENCRYPTED_WHITELIST_SETTING, + }) + expect(mockRes.status).toHaveBeenCalledWith(200) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).toHaveBeenCalledWith({ + user: MOCK_USER, + form: MOCK_FORM, + }) + expect(getForWhitelistSettingSpy).toHaveBeenCalledWith(MOCK_FORM) + }) + + it('should return 403 when current admin does not have permissions to view whitelist settings', async () => { + // Arrange + const mockRes = expressHandler.mockResponse() + + MockUserService.getPopulatedUserById.mockReturnValueOnce( + okAsync(MOCK_USER), + ) + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(MOCK_FORM), + ) + + const expectedErrorString = 'no write permissions' + const adminCheck = jest.fn( + (): Result => + err(new ForbiddenFormError(expectedErrorString)), + ) + MockAuthService.checkFormForPermissions.mockReturnValueOnce(adminCheck) + + const getForWhitelistSettingSpy = jest.spyOn( + AdminFormService, + 'getFormWhitelistSetting', + ) + + // Act + await AdminFormController.handleGetWhitelistSetting( + MOCK_REQ, + mockRes, + jest.fn(), + ) + // Assert + expect(mockRes.json).toHaveBeenCalledWith({ + message: expectedErrorString, + }) + expect(mockRes.status).toHaveBeenCalledWith(403) + expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( + MOCK_USER_ID, + ) + expect(MockFormService.retrieveFullFormById).toHaveBeenCalledWith( + MOCK_FORM_ID, + ) + expect(MockAuthService.checkFormForPermissions).toHaveBeenCalledWith( + PermissionLevel.Read, + ) + expect(adminCheck).toHaveBeenCalledWith({ + user: MOCK_USER, + form: MOCK_FORM, + }) + expect(getForWhitelistSettingSpy).not.toHaveBeenCalled() + }) + }) + describe('handleUpdateSettings', () => { const MOCK_USER_ID = new ObjectId().toHexString() const MOCK_FORM_ID = new ObjectId().toHexString() diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts index bf79bc7421..27954370f6 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.service.spec.ts @@ -6,6 +6,10 @@ import { assignIn, cloneDeep, merge, omit, pick } from 'lodash' import mongoose, { ClientSession } from 'mongoose' import { err, errAsync, ok, okAsync } from 'neverthrow' import { Workspace } from 'shared/types/workspace' +import { + EncryptedStringsMessageContent, + EncryptedStringsMessageContentWithMyPrivateKey, +} from 'shared/utils/crypto' import config, { aws } from 'src/app/config/config' import getAgencyModel from 'src/app/models/agency.server.model' @@ -13,6 +17,7 @@ import getFormModel, { getEmailFormModel, getEncryptedFormModel, } from 'src/app/models/form.server.model' +import getFormWhitelistSubmitterIdsModel from 'src/app/models/form_whitelist.server.model' import { getWorkspaceModel } from 'src/app/models/workspace.server.model' import { ApplicationError, @@ -33,12 +38,18 @@ import { IEmailFormSchema, IFormDocument, IFormSchema, + IPopulatedEncryptedForm, IPopulatedForm, IUserSchema, PickDuplicateForm, } from 'src/types' import { EditFormFieldParams } from 'src/types/api' +import { + FORM_WHITELIST_CONTAINS_EMPTY_ROWS_ERROR_MESSAGE, + FORM_WHITELIST_SETTING_CONTAINS_DUPLICATES_ERROR_MESSAGE, + FORM_WHITELIST_SETTING_CONTAINS_INVALID_FORMAT_SUBMITTERID_ERROR_MESSAGE, +} from '../../../../../../shared/constants/errors' import { VALID_UPLOAD_FILE_TYPES } from '../../../../../../shared/constants/file' import { AdminDashboardFormMetaDto, @@ -84,6 +95,8 @@ const EmailFormModel = getEmailFormModel(mongoose) const EncryptFormModel = getEncryptedFormModel(mongoose) const AgencyModel = getAgencyModel(mongoose) const WorkspaceModel = getWorkspaceModel(mongoose) +const FormWhitelistedSubmitterIdsModel = + getFormWhitelistSubmitterIdsModel(mongoose) jest.mock('src/app/modules/user/user.service') const MockUserService = jest.mocked(UserService) @@ -2877,4 +2890,186 @@ describe('admin-form.service', () => { expect(twilioCacheSpy).toHaveBeenCalledWith(MSG_SRVC_NAME) }) }) + + describe('checkIsWhitelistSettingValid', () => { + const MOCK_VALID_UEN = '53244311W' + const MOCK_VALID_FIN = 'F1612366T' + const MOCK_VALID_NRIC = 'S7101844Z' + + it('should return isValid as true without invalidReason if whitelist setting contains valid submitterIds', () => { + // Arrange + const MOCK_WHITELIST_SETTING = [ + MOCK_VALID_NRIC, + MOCK_VALID_FIN, + MOCK_VALID_UEN, + ] + + // Act + const result = AdminFormService.checkIsWhitelistSettingValid( + MOCK_WHITELIST_SETTING, + ) + + // Assert + expect(result).toEqual({ + isValid: true, + }) + }) + + it('should return isValid as true without invalidReason if whitelist setting contains no submitterIds', () => { + // Arrange + const MOCK_WHITELIST_SETTING: string[] = [] + + // Act + const result = AdminFormService.checkIsWhitelistSettingValid( + MOCK_WHITELIST_SETTING, + ) + + // Assert + expect(result).toEqual({ + isValid: true, + }) + }) + + it('should return isValid as true without invalidReason if whitelist setting is null which means setting as isWhitelistEnabled to false', () => { + // Arrange + const MOCK_WHITELIST_SETTING = null + + // Act + const result = AdminFormService.checkIsWhitelistSettingValid( + MOCK_WHITELIST_SETTING, + ) + + // Assert + expect(result).toEqual({ + isValid: true, + }) + }) + + it('should return duplicate submitter id error message if whitelist csv string has duplicate submitter ids', () => { + // Arrange + const MOCK_WHITELIST_SETTING = [ + MOCK_VALID_NRIC, + MOCK_VALID_FIN, + MOCK_VALID_UEN, + MOCK_VALID_UEN, + ] + + // Act + const result = AdminFormService.checkIsWhitelistSettingValid( + MOCK_WHITELIST_SETTING, + ) + + // Assert + expect(result).toEqual({ + isValid: false, + invalidReason: FORM_WHITELIST_SETTING_CONTAINS_DUPLICATES_ERROR_MESSAGE, + }) + }) + + it('should return empty row not allowed error message if whitelist csv string has empty rows', () => { + // Arrange + const MOCK_WHITELIST_SETTING = [ + MOCK_VALID_NRIC, + MOCK_VALID_FIN, + '', + MOCK_VALID_UEN, + ] + + // Act + const result = AdminFormService.checkIsWhitelistSettingValid( + MOCK_WHITELIST_SETTING, + ) + + // Assert + expect(result).toEqual({ + isValid: false, + invalidReason: FORM_WHITELIST_CONTAINS_EMPTY_ROWS_ERROR_MESSAGE, + }) + }) + + it('should return invalid submitter id error message if whitelist csv string has invalid submitter ids', () => { + // Arrange + const invalidSubmitterId = 'invalid' + const MOCK_WHITELIST_SETTING = [ + MOCK_VALID_NRIC, + MOCK_VALID_FIN, + MOCK_VALID_UEN, + invalidSubmitterId, + ] + + // Act + const result = AdminFormService.checkIsWhitelistSettingValid( + MOCK_WHITELIST_SETTING, + ) + + // Assert + expect(result).toEqual({ + isValid: false, + invalidReason: + FORM_WHITELIST_SETTING_CONTAINS_INVALID_FORMAT_SUBMITTERID_ERROR_MESSAGE( + invalidSubmitterId, + ), + }) + }) + }) + + describe('getFormWhitelistSetting', () => { + it('should not include myPrivateKey when fetching the whitelist setting', async () => { + // Arrange + const MOCK_FORM_ID = new ObjectId() + + const MOCK_WHITELISTED_SUBMITTER_IDS_CONTENT: EncryptedStringsMessageContent = + { + myPublicKey: 'some public key', + cipherTexts: ['abc', 'def'], + nonce: 'some nonce', + } + const MOCK_WHITELISTED_SUBMITTER_IDS_CONTENT_WITH_PK: EncryptedStringsMessageContentWithMyPrivateKey = + { + ...MOCK_WHITELISTED_SUBMITTER_IDS_CONTENT, + myPrivateKey: 'some private key', + } + + const LEAN_WHITELISTED_SUBMITTER_IDS_DOC = { + formId: MOCK_FORM_ID, + ...MOCK_WHITELISTED_SUBMITTER_IDS_CONTENT_WITH_PK, + } + + const MOCK_WHITELISTED_SUBMITTER_ID_QUERY = { + exec: jest.fn().mockResolvedValue(LEAN_WHITELISTED_SUBMITTER_IDS_DOC), + } + + const formWhitelistedSubmitterIdsModelCreateSpy = jest + .spyOn(FormWhitelistedSubmitterIdsModel, 'findById') + .mockReturnValueOnce({ + lean: jest + .fn() + .mockReturnValue(MOCK_WHITELISTED_SUBMITTER_ID_QUERY) as any, + } as any) + + const MOCK_ENCRYPTED_WHITELIST_DOCUMENT_ID = new ObjectId() + + const MOCK_FORM_DOCUMENT = { + getWhitelistedSubmitterIds: jest.fn().mockReturnValue({ + isWhitelistEnabled: true, + encryptedWhitelistedSubmitterIds: + MOCK_ENCRYPTED_WHITELIST_DOCUMENT_ID, + }), + } as unknown as IPopulatedEncryptedForm + + // Act + const whitelistedSettingResult = + await AdminFormService.getFormWhitelistSetting(MOCK_FORM_DOCUMENT) + + // Assert no error occurred + expect(formWhitelistedSubmitterIdsModelCreateSpy).toHaveBeenCalledWith( + MOCK_ENCRYPTED_WHITELIST_DOCUMENT_ID, + ) + expect(whitelistedSettingResult.isOk()).toEqual(true) + // Assert that the myPrivateKey is not included in the fetched whitelist settings + expect(whitelistedSettingResult._unsafeUnwrap()).toEqual( + MOCK_WHITELISTED_SUBMITTER_IDS_CONTENT, + ) + }) + }) }) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index f699b732a0..1b85a96d76 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -5,6 +5,7 @@ import { celebrate, Joi as BaseJoi, Segments } from 'celebrate' import { AuthedSessionData } from 'express-session' import { StatusCodes } from 'http-status-codes' import JSONStream from 'JSONStream' +import multer from 'multer' import { ResultAsync } from 'neverthrow' import { @@ -45,6 +46,10 @@ import { SubmissionCountQueryDto, WebhookSettingsUpdateDto, } from '../../../../../shared/types' +import { + EncryptedStringsMessageContent, + encryptStringsMessage, +} from '../../../../../shared/utils/crypto' import { IFormDocument, IPopulatedForm } from '../../../../types' import { EncryptSubmissionDto, @@ -1482,6 +1487,147 @@ export const handleUpdateSettings = [ _handleUpdateSettings, ] as ControllerHandler[] +const TWENTY_MB_IN_BYTES = 20 * 1024 * 1024 +const handleWhitelistSettingMultipartBody = multer({ + limits: { + fieldSize: TWENTY_MB_IN_BYTES, + fields: 1, // only allow csv string field + files: 0, + }, +}) + +const _handleUpdateWhitelistSettingValidator = celebrate({ + [Segments.PARAMS]: { + formId: Joi.string() + .required() + .pattern(/^[a-fA-F0-9]{24}$/) + .message('Your form ID is invalid.'), + }, + [Segments.BODY]: { + whitelistCsvString: Joi.string() + .pattern(/^[a-zA-Z0-9,\r\n]+$/) + .messages({ + 'string.empty': 'Your csv is empty.', + 'string.pattern.base': 'Your csv has one or more invalid characters.', + }), + }, +}) + +const _parseWhitelistCsvString = (whitelistCsvString: string | null) => { + if (!whitelistCsvString) { + return null + } + return whitelistCsvString.split('\r\n').map((entry: string) => entry.trim()) +} + +const _handleUpdateWhitelistSetting: ControllerHandler< + { formId: string }, + object, + { whitelistCsvString: string | null } +> = async (req, res) => { + const { formId } = req.params + const sessionUserId = (req.session as AuthedSessionData).user._id + + const logMeta = { + action: '_handleUpdateWhitelistSetting', + ...createReqMeta(req), + userId: sessionUserId, + formId, + } + + // Step 1: Retrieve form only if currently logged in user has write permissions for form. + const formResult = await UserService.getPopulatedUserById( + sessionUserId, + ).andThen((user) => + AuthService.getFormAfterPermissionChecks({ + user, + formId, + level: PermissionLevel.Write, + }), + ) + + if (formResult.isErr()) { + const { error } = formResult + logger.error({ + message: 'Error occurred when updating form settings', + meta: logMeta, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + } + + const form = formResult.value + + const { whitelistCsvString } = req.body + const whitelistedSubmitterIds = _parseWhitelistCsvString(whitelistCsvString) + + const upperCaseWhitelistedSubmitterIds = + whitelistedSubmitterIds && whitelistedSubmitterIds.length > 0 + ? whitelistedSubmitterIds.map((id) => id.toUpperCase()) + : null + + // Step 2: perform validation on submitted whitelist setting + const isWhitelistSettingValid = AdminFormService.checkIsWhitelistSettingValid( + upperCaseWhitelistedSubmitterIds, + ) + if (!isWhitelistSettingValid.isValid) { + logger.error({ + message: 'Invalid whitelist setting', + meta: logMeta, + }) + return res.status(StatusCodes.UNPROCESSABLE_ENTITY).json({ + message: isWhitelistSettingValid.invalidReason, + }) + } + + // Step 3: Encrypt whitelist settings + if (!form.publicKey) { + logger.error({ + message: 'Form does not have a public key', + meta: logMeta, + }) + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + message: 'Form does not have a public key', + }) + } + const formPublicKey = form.publicKey + const encryptedWhitelistSubmitterIdsContent = upperCaseWhitelistedSubmitterIds + ? encryptStringsMessage(upperCaseWhitelistedSubmitterIds, formPublicKey) + : null + + // Step 4: Update form with encrypted whitelist settings + return AdminFormService.updateFormWhitelistSetting( + form, + encryptedWhitelistSubmitterIdsContent, + ) + .map((updatedSettings) => res.status(StatusCodes.OK).json(updatedSettings)) + .mapErr((error) => { + logger.error({ + message: 'Error occurred when updating form settings', + meta: { + action: 'handleUpdateSettings', + ...createReqMeta(req), + userId: sessionUserId, + formId, + // do not log the whitelist setting as it may contain sensitive data and be large in size + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) +} + +export const _handleUpdateWhitelistSettingForTest = + _handleUpdateWhitelistSetting + +export const handleUpdateWhitelistSetting = [ + handleWhitelistSettingMultipartBody.none(), // expecting string field + _handleUpdateWhitelistSettingValidator, + _handleUpdateWhitelistSetting, +] as ControllerHandler[] + /** * Handler for PATCH api/public/v1/admin/forms/:formId/webhooksettings. * @security session @@ -1599,6 +1745,51 @@ export const handleGetSettings: ControllerHandler< }) } +export const handleGetWhitelistSetting: ControllerHandler< + { + formId: string + }, + | { + encryptedWhitelistedSubmitterIds: EncryptedStringsMessageContent | null + } + | ErrorDto +> = (req, res) => { + const { formId } = req.params + const sessionUserId = (req.session as AuthedSessionData).user._id + + return UserService.getPopulatedUserById(sessionUserId) + .andThen((user) => + // Retrieve form for settings as well as for permissions checking + FormService.retrieveFullFormById(formId).map((form) => ({ + form, + user, + })), + ) + .andThen(AuthService.checkFormForPermissions(PermissionLevel.Read)) + .andThen((form) => EncryptSubmissionService.checkFormIsEncryptMode(form)) + .map(async (form) => AdminFormService.getFormWhitelistSetting(form)) + .andThen((formWhitelistedSubmitterIds) => formWhitelistedSubmitterIds) + .map((formWhitelistedSubmitterIds) => { + return res.status(StatusCodes.OK).json({ + encryptedWhitelistedSubmitterIds: formWhitelistedSubmitterIds, + }) + }) + .mapErr((error: Error) => { + logger.error({ + message: 'Error occurred when retrieving form whitelist settings', + meta: { + action: 'handleGetWhitelistSetting', + ...createReqMeta(req), + userId: sessionUserId, + formId, + }, + error, + }) + const { errorMessage, statusCode } = mapRouteError(error) + return res.status(statusCode).json({ message: errorMessage }) + }) +} + /** * Handler for POST api/public/v1/admin/forms/:formId/webhooksettings. * diff --git a/src/app/modules/form/admin-form/admin-form.middlewares.ts b/src/app/modules/form/admin-form/admin-form.middlewares.ts index a8fd543fcd..094e0c72e4 100644 --- a/src/app/modules/form/admin-form/admin-form.middlewares.ts +++ b/src/app/modules/form/admin-form/admin-form.middlewares.ts @@ -21,12 +21,13 @@ const webhookSettingsValidator = Joi.object({ export const updateSettingsValidator = celebrate({ [Segments.BODY]: Joi.object({ authType: Joi.string().valid(...Object.values(FormAuthType)), - isNricMaskEnabled: Joi.boolean(), + isSubmitterIdCollectionEnabled: Joi.boolean(), isSingleSubmission: Joi.boolean(), emails: Joi.alternatives().try( Joi.array().items(Joi.string().email()), Joi.string().email({ multiple: true }), ), + stepsToNotify: Joi.array().items(Joi.string()), esrvcId: Joi.string().allow(''), hasCaptcha: Joi.boolean(), hasIssueNotification: Joi.boolean(), diff --git a/src/app/modules/form/admin-form/admin-form.service.ts b/src/app/modules/form/admin-form/admin-form.service.ts index 1e02f4595f..5398739fd3 100644 --- a/src/app/modules/form/admin-form/admin-form.service.ts +++ b/src/app/modules/form/admin-form/admin-form.service.ts @@ -5,14 +5,22 @@ import { DeleteSecretRequest, PutSecretValueRequest, } from 'aws-sdk/clients/secretsmanager' -import { assignIn, last, omit } from 'lodash' +import { assignIn, last, omit, pick } from 'lodash' import mongoose, { ClientSession } from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' +import { + EncryptedStringsMessageContent, + EncryptedStringsMessageContentWithMyPrivateKey, +} from 'shared/utils/crypto' import type { Except, Merge } from 'type-fest' import { + FORM_WHITELIST_CONTAINS_EMPTY_ROWS_ERROR_MESSAGE, + FORM_WHITELIST_SETTING_CONTAINS_DUPLICATES_ERROR_MESSAGE, + FORM_WHITELIST_SETTING_CONTAINS_INVALID_FORMAT_SUBMITTERID_ERROR_MESSAGE, MAX_UPLOAD_FILE_SIZE, VALID_UPLOAD_FILE_TYPES, + WHITELISTED_SUBMITTER_ID_DECRYPTION_FIELDS, } from '../../../../../shared/constants' import { MYINFO_ATTRIBUTE_MAP } from '../../../../../shared/constants/field/myinfo' import { @@ -32,6 +40,11 @@ import { StartPageUpdateDto, StorageFormSettings, } from '../../../../../shared/types' +import { + isMFinSeriesValid, + isNricValid, +} from '../../../../../shared/utils/nric-validation' +import { isUenValid } from '../../../../../shared/utils/uen-validation' import { EditFieldActions } from '../../../../shared/constants' import { FormFieldSchema, @@ -47,6 +60,7 @@ import config, { aws as AwsConfig } from '../../../config/config' import { createLoggerWithLabel } from '../../../config/logger' import getAgencyModel from '../../../models/agency.server.model' import getFormModel from '../../../models/form.server.model' +import getFormWhitelistSubmitterIdsModel from '../../../models/form_whitelist.server.model' import { getWorkspaceModel } from '../../../models/workspace.server.model' import { twilioClientCache } from '../../../services/sms/sms.service' import { @@ -74,6 +88,7 @@ import * as UserService from '../../user/user.service' import { removeFormsFromAllWorkspaces } from '../../workspace/workspace.service' import { FormNotFoundError, + FormWhitelistSettingNotFoundError, LogicNotFoundError, TransferOwnershipError, } from '../form.errors' @@ -108,6 +123,8 @@ const logger = createLoggerWithLabel(module) const FormModel = getFormModel(mongoose) const AgencyModel = getAgencyModel(mongoose) const WorkspaceModel = getWorkspaceModel(mongoose) +const FormWhitelistedSubmitterIdsModel = + getFormWhitelistSubmitterIdsModel(mongoose) export const secretsManager = new SecretsManager({ region: config.aws.region, @@ -1030,6 +1047,203 @@ export const updateFormCollaborators = ( ) } +export const checkIsWhitelistSettingValid = ( + whitelistedSubmitterIds: string[] | null, +): { isValid: boolean; invalidReason?: string } => { + if (!whitelistedSubmitterIds || whitelistedSubmitterIds.length <= 0) { + return { + isValid: true, + } + } + + // check for empty rows/entries + const emptyRowIndex = whitelistedSubmitterIds.findIndex( + (entry: string) => entry === '', + ) + if (emptyRowIndex !== -1) { + return { + isValid: false, + invalidReason: FORM_WHITELIST_CONTAINS_EMPTY_ROWS_ERROR_MESSAGE, + } + } + + // check for invalid NRIC/FIN/UEN format + const invalidEntries = whitelistedSubmitterIds.filter((entry: string) => { + return !( + isNricValid(entry) || + isMFinSeriesValid(entry) || + isUenValid(entry) + ) + }) + // check for invalid entries + if (invalidEntries.length > 0) { + return { + isValid: false, + invalidReason: + FORM_WHITELIST_SETTING_CONTAINS_INVALID_FORMAT_SUBMITTERID_ERROR_MESSAGE( + invalidEntries[0], + ), + } + } + + // check for duplicates + if ( + new Set(whitelistedSubmitterIds).size !== whitelistedSubmitterIds.length + ) { + return { + isValid: false, + invalidReason: FORM_WHITELIST_SETTING_CONTAINS_DUPLICATES_ERROR_MESSAGE, + } + } + + return { + isValid: true, + } +} + +/** + * Fetches the whitelist setting document without myPrivateKey for the client to use for decryption. + */ +export const getFormWhitelistSetting = ( + form: IPopulatedForm, +): ResultAsync< + EncryptedStringsMessageContent | null, + FormWhitelistSettingNotFoundError | PossibleDatabaseError +> => { + const { isWhitelistEnabled, encryptedWhitelistedSubmitterIds } = + form.getWhitelistedSubmitterIds() + + if (!isWhitelistEnabled) { + return okAsync(null) + } + + if (isWhitelistEnabled && !encryptedWhitelistedSubmitterIds) { + return errAsync(new FormWhitelistSettingNotFoundError()) + } + + return ResultAsync.fromPromise( + FormWhitelistedSubmitterIdsModel.findById(encryptedWhitelistedSubmitterIds) + .lean() + .exec() + .then((whitelistSetting) => + pick(whitelistSetting, WHITELISTED_SUBMITTER_ID_DECRYPTION_FIELDS), + ) as Promise, + (error) => { + logger.error({ + message: 'Error encountered while retrieving form whitelist setting', + meta: { + action: 'getFormWhitelistSetting', + formId: form._id, + }, + error, + }) + return transformMongoError(error) + }, + ).andThen((whitelistSetting) => { + if (!whitelistSetting) { + return errAsync(new FormWhitelistSettingNotFoundError()) + } + return okAsync(whitelistSetting) + }) +} + +export const updateFormWhitelistSetting = ( + originalForm: IPopulatedForm, + encryptedWhitelistedSubmitterIdsContent: EncryptedStringsMessageContentWithMyPrivateKey | null, +) => { + if (originalForm.responseMode !== FormResponseMode.Encrypt) { + return errAsync( + new MalformedParametersError( + 'Whitelist setting does not exist for non-encrypt mode forms', + ), + ) + } + + const FormModelToUse = getFormModelByResponseMode(originalForm.responseMode) + + const updateFormWhitelistSettingPromise = async () => { + const session = await FormModelToUse.startSession() + session.startTransaction() + + if (encryptedWhitelistedSubmitterIdsContent) { + // create whitelisted submitter id collection document and update reference to it + const createdWhitelistedSubmitterIdsDocument = + await FormWhitelistedSubmitterIdsModel.create({ + formId: originalForm._id, + ...encryptedWhitelistedSubmitterIdsContent, + }) + const updatedForm = await FormModelToUse.findByIdAndUpdate( + originalForm._id, + { + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + encryptedWhitelistedSubmitterIds: + createdWhitelistedSubmitterIdsDocument._id, + }, + }, + { + new: true, + runValidators: true, + }, + ).exec() + + if (!updateForm) { + await session.abortTransaction() + return + } + + await session.commitTransaction() + await session.endSession() + + return updatedForm + } else { + // delete whitelisted submitter id collection document and update reference to null + await FormWhitelistedSubmitterIdsModel.deleteMany({ + formId: originalForm._id, + }) + const updatedForm = await FormModelToUse.findByIdAndUpdate( + originalForm._id, + { + whitelistedSubmitterIds: { + isWhitelistEnabled: false, + encryptedWhitelistedSubmitterIds: undefined, + }, + }, + { new: true, runValidators: true }, + ).exec() + + if (!updatedForm) { + await session.abortTransaction() + return + } + await session.commitTransaction() + await session.endSession() + return updatedForm + } + } + + return ResultAsync.fromPromise( + updateFormWhitelistSettingPromise(), + (error) => { + logger.error({ + message: 'Error encountered while updating form whitelist setting', + meta: { + action: 'updateFormWhitelistSetting', + formId: originalForm._id, + // Body is not logged in case sensitive data such as emails are stored. + }, + error, + }) + return transformMongoError(error) + }, + ).andThen((updatedForm) => { + if (!updatedForm) { + return errAsync(new FormNotFoundError()) + } + return okAsync(updatedForm.getSettings()) + }) +} + /** * Updates form settings. * @param originalForm The original form to update settings for diff --git a/src/app/modules/form/form.errors.ts b/src/app/modules/form/form.errors.ts index e7cfc87e68..a0e20cdf16 100644 --- a/src/app/modules/form/form.errors.ts +++ b/src/app/modules/form/form.errors.ts @@ -1,3 +1,7 @@ +import { + FORM_RESPONDENT_NOT_WHITELISTED_ERROR_MESSAGE, + FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE, +} from '../../../../shared/constants/errors' import { FormAuthType } from '../../../../shared/types' import { ApplicationError, ErrorCodes } from '../core/core.errors' @@ -26,7 +30,7 @@ export class PrivateFormError extends ApplicationError { * @param formTitle Extra meta for form title */ constructor( - message = 'If you think this is a mistake, please contact the agency that gave you the form link.', + message = 'If you require further assistance, please contact the agency that gave you the form link.', formTitle: string, ) { super(message, undefined, ErrorCodes.FORM_PRIVATE_FORM) @@ -88,3 +92,33 @@ export class FormAuthNoEsrvcIdError extends ApplicationError { ) } } + +export class FormRespondentNotWhitelistedError extends ApplicationError { + constructor() { + super( + FORM_RESPONDENT_NOT_WHITELISTED_ERROR_MESSAGE, + undefined, + ErrorCodes.FORM_RESPONDENT_NOT_WHITELISTED, + ) + } +} + +export class FormWhitelistSettingNotFoundError extends ApplicationError { + constructor(message = 'Whitelist setting not found') { + super( + message, + undefined, + ErrorCodes.FORM_UNEXPECTED_WHITELIST_SETTING_NOT_FOUND, + ) + } +} + +export class FormRespondentSingleSubmissionValidationError extends ApplicationError { + constructor() { + super( + FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE, + undefined, + ErrorCodes.FORM_RESPONDENT_SINGLE_SUBMISSION_VALIDATION_FAILED, + ) + } +} diff --git a/src/app/modules/form/form.service.ts b/src/app/modules/form/form.service.ts index 0226e5b3ea..d0968525e6 100644 --- a/src/app/modules/form/form.service.ts +++ b/src/app/modules/form/form.service.ts @@ -1,6 +1,7 @@ import { faker } from '@faker-js/faker' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' +import { decodeBase64 } from 'tweetnacl-util' import { BasicField, @@ -11,6 +12,7 @@ import { FormStatus, PublicFormDto, } from '../../../../shared/types' +import { encryptString } from '../../../../shared/utils/crypto' import { IEmailFormModel, IEncryptedFormModel, @@ -25,6 +27,7 @@ import getFormModel, { getEncryptedFormModel, getMultirespondentFormModel, } from '../../models/form.server.model' +import getFormWhitelistSubmitterIdsModel from '../../models/form_whitelist.server.model' import getSubmissionModel from '../../models/submission.server.model' import { getMongoErrorMessage, @@ -41,6 +44,7 @@ import { getMyInfoFieldOptions } from '../myinfo/myinfo.util' import { FormDeletedError, FormNotFoundError, + FormWhitelistSettingNotFoundError, PrivateFormError, } from './form.errors' @@ -50,6 +54,8 @@ const EmailFormModel = getEmailFormModel(mongoose) const EncryptedFormModel = getEncryptedFormModel(mongoose) const MultirespondentFormModel = getMultirespondentFormModel(mongoose) const SubmissionModel = getSubmissionModel(mongoose) +const FormWhitelistSubmitterIdsModel = + getFormWhitelistSubmitterIdsModel(mongoose) /** * Deactivates a given form by its id @@ -275,6 +281,81 @@ export const checkFormSubmissionLimitAndDeactivateForm = ( }) } +export const checkHasRespondentNotWhitelistedFailure = ( + form: IPopulatedForm, + submitterId: string, +): ResultAsync => { + // check since whitelist is only for encrypt mode forms + if (form.responseMode !== FormResponseMode.Encrypt) { + return okAsync(false) + } + if (form.authType === FormAuthType.NIL) { + return okAsync(false) + } + + const { isWhitelistEnabled, encryptedWhitelistedSubmitterIds: whitelistId } = + form.getWhitelistedSubmitterIds() + + if (!isWhitelistEnabled) { + return okAsync(false) + } + if (isWhitelistEnabled && !whitelistId) { + return errAsync(new FormWhitelistSettingNotFoundError()) + } + + const formPublicKey = form.publicKey + if (!formPublicKey) { + logger.error({ + message: 'Encrypt mode form does not have a public key', + meta: { + action: 'checkHasRespondentNotWhitelistedFailure', + formId: form._id, + }, + }) + return errAsync( + new ApplicationError('Encrypt mode form does not have a public key'), + ) + } + return ResultAsync.fromPromise( + FormWhitelistSubmitterIdsModel.findEncryptionPropertiesById( + whitelistId, + ).then(({ myPublicKey, myPrivateKey, nonce }) => { + const myKeyPair = { + publicKey: myPublicKey, + privateKey: myPrivateKey, + } + const usedNonce = decodeBase64(nonce) + + const upperCaseSubmitterId = submitterId.toUpperCase() + + const submitterIdForLookup = encryptString( + upperCaseSubmitterId, + formPublicKey, + usedNonce, + myKeyPair, + ).cipherText + + return FormWhitelistSubmitterIdsModel.checkIfSubmitterIdIsWhitelisted( + whitelistId, + submitterIdForLookup, + ).then((isWhitelisted) => !isWhitelisted) + }), + (err) => { + logger.error({ + message: 'Error while checking if submitterId is whitelisted', + meta: { + action: 'checkHasRespondentNotWhitelistedFailure', + formId: form._id, + err, + }, + }) + return new ApplicationError( + 'Error while checking if submitterId is whitelisted', + ) + }, + ) +} + /** * Verify that if the form is a single submission per submitterId form, the submitterId does not exist. * @param form the form to check for diff --git a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts index 4bb09eb616..e96359efa0 100644 --- a/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts +++ b/src/app/modules/form/public-form/__tests__/public-form.controller.spec.ts @@ -22,12 +22,17 @@ import { MOCK_LOGIN_DOC } from 'src/app/modules/spcp/__tests__/spcp.test.constan import { JwtPayload, SpcpForm } from 'src/app/modules/spcp/spcp.types' import { IFormDocument, + IPopulatedEncryptedForm, IPopulatedForm, IPopulatedUser, PublicForm, } from 'src/types' -import { FormAuthType, MyInfoAttribute } from '../../../../../../shared/types' +import { + ErrorCode, + FormAuthType, + MyInfoAttribute, +} from '../../../../../../shared/types' import * as AuthService from '../../../auth/auth.service' import * as BillingService from '../../../billing/billing.service' import { @@ -43,6 +48,7 @@ import { import { CpOidcServiceClass } from '../../../spcp/spcp.oidc.service/spcp.oidc.service.cp' import { SpOidcServiceClass } from '../../../spcp/spcp.oidc.service/spcp.oidc.service.sp' import { JwtName } from '../../../spcp/spcp.types' +import { generateHashedSubmitterId } from '../../../submission/submission.utils' import { AuthTypeMismatchError, FormAuthNoEsrvcIdError, @@ -137,6 +143,9 @@ describe('public-form.controller', () => { MockFormService.checkHasSingleSubmissionValidationFailure.mockReturnValue( okAsync(false), ) + MockFormService.checkHasRespondentNotWhitelistedFailure.mockReturnValue( + okAsync(false), + ) }) it('should return 200 when there is no FormAuthType on the request', async () => { @@ -298,6 +307,68 @@ describe('public-form.controller', () => { }) }) + describe('success cases for submitterId whitelisting', () => { + it('should return 200 ok without any failure flags when user is whitelisted', async () => { + // Arrange + MockFormService.checkHasRespondentNotWhitelistedFailure.mockReturnValueOnce( + okAsync(false), + ) + const MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED = { + ...BASE_FORM, + esrvcId: 'mockEsrvcId', + authType: FormAuthType.MyInfo, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + toJSON: jest.fn().mockReturnValue(BASE_FORM), + } as unknown as IPopulatedEncryptedForm + const MOCK_MYINFO_DATA = new MyInfoData({ + uinFin: 'mockUinFin', + } as IPersonResponse) + const MOCK_SPCP_SESSION = { userName: MOCK_MYINFO_DATA.getUinFin() } + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + }) + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED), + ) + MockMyInfoService.retrieveAccessToken.mockReturnValueOnce( + okAsync(MOCK_ACCESS_TOKEN), + ) + MockMyInfoService.getMyInfoDataForForm.mockReturnValueOnce( + okAsync(MOCK_MYINFO_DATA), + ) + MockBillingService.recordLoginByForm.mockReturnValueOnce( + okAsync(MOCK_LOGIN_DOC), + ) + MockMyInfoService.prefillAndSaveMyInfoFields.mockReturnValueOnce( + okAsync([]), + ) + + // Act + await PublicFormController.handleGetPublicForm( + mockReqWithCookies, + mockRes, + jest.fn(), + ) + + // Assert + expect(mockRes.cookie).toHaveBeenCalled() + expect(mockRes.json).toHaveBeenCalledWith({ + form: { + ...MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED.getPublicView(), + form_fields: [], + }, + spcpSession: MOCK_SPCP_SESSION, + isIntranetUser: false, + }) + }) + }) + // Errors describe('errors in myInfo', () => { const MOCK_MYINFO_FORM = { @@ -377,7 +448,7 @@ describe('public-form.controller', () => { expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], }) }) @@ -413,7 +484,7 @@ describe('public-form.controller', () => { expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], }) }) @@ -441,7 +512,7 @@ describe('public-form.controller', () => { expect(mockRes.clearCookie).toHaveBeenCalled() expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser: false, }) }) @@ -469,7 +540,7 @@ describe('public-form.controller', () => { expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], }) }) @@ -496,7 +567,7 @@ describe('public-form.controller', () => { expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], }) }) @@ -529,7 +600,7 @@ describe('public-form.controller', () => { expect(mockRes.json).toHaveBeenCalledWith({ form: MOCK_MYINFO_FORM.getPublicView(), isIntranetUser: false, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], }) }) }) @@ -608,6 +679,65 @@ describe('public-form.controller', () => { }) }) + describe('errors due to submitterId whitelisting', () => { + it('should return 200 but with respondent not whitelisted failure flag when submitterId is not in whitelist', async () => { + // Arrange + MockFormService.checkHasRespondentNotWhitelistedFailure.mockReturnValueOnce( + okAsync(true), + ) + const MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED = { + ...BASE_FORM, + esrvcId: 'mockEsrvcId', + authType: FormAuthType.MyInfo, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + toJSON: jest.fn().mockReturnValue(BASE_FORM), + } as unknown as IPopulatedEncryptedForm + const MOCK_MYINFO_DATA = new MyInfoData({ + uinFin: 'mockUinFin', + } as IPersonResponse) + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + }) + MockAuthService.getFormIfPublic.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED), + ) + MockMyInfoService.retrieveAccessToken.mockReturnValueOnce( + okAsync(MOCK_ACCESS_TOKEN), + ) + MockMyInfoService.getMyInfoDataForForm.mockReturnValueOnce( + okAsync(MOCK_MYINFO_DATA), + ) + MockBillingService.recordLoginByForm.mockReturnValueOnce( + okAsync(MOCK_LOGIN_DOC), + ) + MockMyInfoService.prefillAndSaveMyInfoFields.mockReturnValueOnce( + okAsync([]), + ) + + // Act + await PublicFormController.handleGetPublicForm( + mockReqWithCookies, + mockRes, + jest.fn(), + ) + + // Assert that user is logged out and myInfo fields are not passed + expect(mockRes.json).toHaveBeenCalledWith({ + form: { + ...MOCK_MYINFO_AUTH_FORM_WITH_WHITELIST_ENABLED.getPublicView(), + }, + isIntranetUser: false, + errorCodes: [ErrorCode.respondentNotWhitelisted], + }) + }) + }) + describe('errors due to single submission per submitterId violation', () => { const MOCK_SP_FORM = { ...BASE_FORM, @@ -643,11 +773,14 @@ describe('public-form.controller', () => { jest.fn(), ) - // Assert that the submitterId is hashed when compared + // Assert that the submitterId is upper cased and then hashed when compared expect( checkHasSingleSubmissionValidationFailureSpy.mock.calls[0][1], ).toEqual( - '151c329a583a82e4a768f16ab8c9b7ae621fcfdea574e87925dd56d7f73e367d', + generateHashedSubmitterId( + MOCK_SPCP_SESSION.userName.toUpperCase(), + MOCK_SP_FORM.id, + ), ) // Assert that status is not set, which defaults to intended 200 ok @@ -657,10 +790,9 @@ describe('public-form.controller', () => { MOCK_SP_FORM.getPublicView(), ) // Assert that the response contains the single submission validation failure flag - expect( - (mockRes.json as jest.Mock).mock.calls[0][0] - .hasSingleSubmissionValidationFailure, - ).toEqual(true) + expect((mockRes.json as jest.Mock).mock.calls[0][0].errorCodes).toEqual( + [ErrorCode.respondentSingleSubmissionValidationFailure], + ) // Assert user is logged out expect((mockRes.json as jest.Mock).mock.calls[0][0]).not.toContainKey( 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 a34197495f..f6cd103238 100644 --- a/src/app/modules/form/public-form/public-form.controller.ts +++ b/src/app/modules/form/public-form/public-form.controller.ts @@ -5,6 +5,7 @@ import { err, ok, Result } from 'neverthrow' import { UnreachableCaseError } from 'ts-essentials' import { + ErrorCode, ErrorDto, FormAuthType, FormFieldDto, @@ -54,7 +55,12 @@ import { validateSpcpForm, } from '../../spcp/spcp.util' import { generateHashedSubmitterId } from '../../submission/submission.utils' -import { AuthTypeMismatchError, PrivateFormError } from '../form.errors' +import { + AuthTypeMismatchError, + FormRespondentNotWhitelistedError, + FormRespondentSingleSubmissionValidationError, + PrivateFormError, +} from '../form.errors' import * as FormService from '../form.service' import * as PublicFormService from './public-form.service' @@ -212,7 +218,7 @@ export const handleGetPublicForm: ControllerHandler< // NOTE: If the user does not have any cookie, clearing the cookie still has the same result return res.json({ form: publicForm, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser, }) } @@ -289,7 +295,7 @@ export const handleGetPublicForm: ControllerHandler< }) return res.json({ form: publicForm, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser, }) } @@ -308,7 +314,7 @@ export const handleGetPublicForm: ControllerHandler< }) return res.json({ form: publicForm, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser, }) } @@ -322,11 +328,46 @@ export const handleGetPublicForm: ControllerHandler< } } + // for consistency with whitelist lookup which also uppercases all submitterIds when saving + const submitterId = spcpSession.userName.toUpperCase() + + // validate if respondent is whitelisted + const hasRespondentNotWhitelistedErrorResult = + await FormService.checkHasRespondentNotWhitelistedFailure(form, submitterId) + + if (hasRespondentNotWhitelistedErrorResult.isErr()) { + const error = hasRespondentNotWhitelistedErrorResult.error + logger.error({ + message: 'Error validating if respondent is whitelisted', + meta: logMeta, + error, + }) + return res.sendStatus(HttpStatusCode.InternalServerError) + } + + const hasRespondentNotWhitelistedError = + hasRespondentNotWhitelistedErrorResult.value + if (hasRespondentNotWhitelistedError) { + // created for Datadog logging of error code + new FormRespondentNotWhitelistedError() + + // log user out + spcpSession = undefined + const authCookieName = PublicFormService.getCookieNameByAuthType(authType) + res.clearCookie(authCookieName) + + return res.json({ + form: publicForm, + isIntranetUser, + errorCodes: [ErrorCode.respondentNotWhitelisted], + }) + } + // validate for isSingleSubmission const hasSingleSubmissionValidationFailureResult = await FormService.checkHasSingleSubmissionValidationFailure( publicForm, - generateHashedSubmitterId(spcpSession.userName, form.id), + generateHashedSubmitterId(submitterId, form.id), ) if (hasSingleSubmissionValidationFailureResult.isErr()) { @@ -342,9 +383,11 @@ export const handleGetPublicForm: ControllerHandler< const hasSingleSubmissionValidationFailure = hasSingleSubmissionValidationFailureResult.value - // Do not log user in for the form - // if there is a single submission validation failure if (hasSingleSubmissionValidationFailure) { + // Created for Datadog logging of error code + new FormRespondentSingleSubmissionValidationError() + + // log user out spcpSession = undefined const authCookieName = PublicFormService.getCookieNameByAuthType(authType) res.clearCookie(authCookieName) @@ -352,7 +395,7 @@ export const handleGetPublicForm: ControllerHandler< return res.json({ form: publicForm, // do not need to pre-fill even if MyInfo since user is not logged in isIntranetUser, - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }) } @@ -375,7 +418,7 @@ export const handleGetPublicForm: ControllerHandler< // NOTE: If the user does not have any cookie, clearing the cookie still has the same result return res.json({ form: publicForm, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser, }) } @@ -396,7 +439,7 @@ export const handleGetPublicForm: ControllerHandler< }) return res.json({ form: publicForm, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser, }) } @@ -437,7 +480,7 @@ export const handleGetPublicForm: ControllerHandler< // NOTE: If the user does not have any cookie, clearing the cookie still has the same result return res.json({ form: publicForm, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser, }) } @@ -457,7 +500,7 @@ export const handleGetPublicForm: ControllerHandler< }) return res.json({ form: publicForm, - myInfoError: true, + errorCodes: [ErrorCode.myInfo], isIntranetUser, }) } diff --git a/src/app/modules/frontend/frontend.service.ts b/src/app/modules/frontend/frontend.service.ts index 1342913530..bacb1611c1 100644 --- a/src/app/modules/frontend/frontend.service.ts +++ b/src/app/modules/frontend/frontend.service.ts @@ -2,7 +2,6 @@ import { ClientEnvVars } from '../../../../shared/types/core' import config from '../../config/config' import { captchaConfig } from '../../config/features/captcha.config' import { goGovConfig } from '../../config/features/gogov.config' -import { googleAnalyticsConfig } from '../../config/features/google-analytics.config' import { growthbookConfig } from '../../config/features/growthbook.config' import { paymentConfig } from '../../config/features/payment.config' import { spcpMyInfoConfig } from '../../config/features/spcp-myinfo.config' @@ -21,9 +20,6 @@ export const getClientEnvVars = (): ClientEnvVars => { isSPMaintenance: spcpMyInfoConfig.isSPMaintenance, // Singpass maintenance message isCPMaintenance: spcpMyInfoConfig.isCPMaintenance, // Corppass maintenance message myInfoBannerContent: spcpMyInfoConfig.myInfoBannerContent, // MyInfo maintenance message - // TODO: remove after React rollout #4786 - GATrackingID: googleAnalyticsConfig.GATrackingID, - spcpCookieDomain: spcpMyInfoConfig.spcpCookieDomain, // Cookie domain used for removing spcp cookies stripePublishableKey: paymentConfig.stripePublishableKey, maxPaymentAmountCents: paymentConfig.maxPaymentAmountCents, diff --git a/src/app/modules/myinfo/myinfo.constants.ts b/src/app/modules/myinfo/myinfo.constants.ts index b7981604b9..f7770683fe 100644 --- a/src/app/modules/myinfo/myinfo.constants.ts +++ b/src/app/modules/myinfo/myinfo.constants.ts @@ -42,7 +42,7 @@ export const MYINFO_LOGIN_COOKIE_OPTIONS = { // Important for security - access token cannot be read by client-side JS httpOnly: true, sameSite: 'lax' as const, // Setting to 'strict' prevents Singpass login on Safari, Firefox - secure: !config.isDev, + secure: !config.isDevOrTest, maxAge: spcpMyInfoConfig.spCookieMaxAge, } @@ -52,7 +52,7 @@ export const MYINFO_LOGIN_COOKIE_OPTIONS = { export const MYINFO_AUTH_CODE_COOKIE_OPTIONS = { // Important for security - auth code cannot be read by client-side JS httpOnly: true, - secure: !config.isDev, + secure: !config.isDevOrTest, maxAge: MYINFO_AUTH_CODE_COOKIE_AGE_MS, } diff --git a/src/app/modules/payments/__tests__/stripe.service.spec.ts b/src/app/modules/payments/__tests__/stripe.service.spec.ts index 20ed32b61e..8d9d196e51 100644 --- a/src/app/modules/payments/__tests__/stripe.service.spec.ts +++ b/src/app/modules/payments/__tests__/stripe.service.spec.ts @@ -4,7 +4,12 @@ import { ObjectId } from 'bson' import { keyBy } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, ResultAsync } from 'neverthrow' -import { PaymentChannel, PaymentStatus, SubmissionType } from 'shared/types' +import { + BasicField, + PaymentChannel, + PaymentStatus, + SubmissionType, +} from 'shared/types' import Stripe from 'stripe' import { stripe } from 'src/app/loaders/stripe' @@ -24,7 +29,10 @@ import * as AuthService from '../../auth/auth.service' import * as FeatureFlagService from '../../feature-flags/feature-flags.service' import { PaymentNotFoundError } from '../payments.errors' import * as PaymentsService from '../payments.service' -import { StripeMetadataInvalidError } from '../stripe.errors' +import { + StripeAccountError, + StripeMetadataInvalidError, +} from '../stripe.errors' import * as StripeService from '../stripe.service' import * as StripeUtils from '../stripe.utils' @@ -847,6 +855,106 @@ describe('stripe.service', () => { expect(addPaymentAccountIdSpy).toHaveBeenCalledTimes(1) expect(result.isOk()).toBeTrue() }) + + it('should connect stripe account when form has email fields with pdf summary disabled', async () => { + // Arrange + const mockForm = (await new EncryptedForm({ + admin: MOCK_USER, + title: 'Test Form', + publicKey: 'mockPublicKey', + form_fields: [ + { + _id: new mongoose.Types.ObjectId(), + fieldType: BasicField.Email, + title: 'Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: false, + }, + }, + ], + }).populate('admin')) as IPopulatedEncryptedForm + + const getFeatureFlagSpy = jest + .spyOn(FeatureFlagService, 'getFeatureFlag') + .mockReturnValueOnce(okAsync(true)) + const addPaymentAccountIdSpy = jest.spyOn(mockForm, 'addPaymentAccountId') + const stripeAccountsRetrieveApiSpy = jest + .spyOn(stripe.accounts, 'retrieve') + .mockReturnValueOnce( + Promise.resolve({ + email: 'mockEmail', + } as unknown as Stripe.Response), + ) + const authServiceSpy = jest + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValue(okAsync(true) as any) + + // Act + const result = await StripeService.linkStripeAccountToForm(mockForm, { + accountId: 'accountId', + publishableKey: 'publishableKey', + }) + + // Assert + expect(getFeatureFlagSpy).toHaveBeenCalledTimes(1) + expect(stripeAccountsRetrieveApiSpy).toHaveBeenCalledTimes(1) + expect(authServiceSpy).toHaveBeenCalledTimes(1) + expect(addPaymentAccountIdSpy).toHaveBeenCalledTimes(1) + expect(result.isOk()).toBeTrue() + }) + + it('should not connect stripe account when form has email fields with pdf summary enabled', async () => { + // Arrange + const mockForm = (await new EncryptedForm({ + admin: MOCK_USER, + title: 'Test Form', + publicKey: 'mockPublicKey', + form_fields: [ + { + _id: new mongoose.Types.ObjectId(), + fieldType: BasicField.Email, + title: 'Email Field', + autoReplyOptions: { + hasAutoReply: true, + includeFormSummary: true, + }, + }, + ], + }).populate('admin')) as IPopulatedEncryptedForm + + const getFeatureFlagSpy = jest + .spyOn(FeatureFlagService, 'getFeatureFlag') + .mockReturnValueOnce(okAsync(true)) + const addPaymentAccountIdSpy = jest.spyOn(mockForm, 'addPaymentAccountId') + const stripeAccountsRetrieveApiSpy = jest + .spyOn(stripe.accounts, 'retrieve') + .mockReturnValueOnce( + Promise.resolve({ + email: 'mockEmail', + } as unknown as Stripe.Response), + ) + const authServiceSpy = jest + .spyOn(AuthService, 'validateEmailDomain') + .mockReturnValue(okAsync(true) as any) + + // Act + const result = await StripeService.linkStripeAccountToForm(mockForm, { + accountId: 'accountId', + publishableKey: 'publishableKey', + }) + + // Assert + expect(getFeatureFlagSpy).toHaveBeenCalledTimes(0) + expect(stripeAccountsRetrieveApiSpy).toHaveBeenCalledTimes(0) + expect(authServiceSpy).toHaveBeenCalledTimes(0) + expect(addPaymentAccountIdSpy).not.toHaveBeenCalled() + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf(StripeAccountError) + expect(result._unsafeUnwrapErr().message).toBe( + 'Email fields with pdf summary is not allowed', + ) + }) }) describe('handleStripeEvent', () => { diff --git a/src/app/modules/payments/stripe.service.ts b/src/app/modules/payments/stripe.service.ts index 734d37af01..795e79f8ee 100644 --- a/src/app/modules/payments/stripe.service.ts +++ b/src/app/modules/payments/stripe.service.ts @@ -9,6 +9,7 @@ import isURL from 'validator/lib/isURL' import { featureFlags } from '../../../../shared/constants' import { + EmailFieldBase, PaymentStatus, ReconciliationReportLine, } from '../../../../shared/types' @@ -683,6 +684,18 @@ export const linkStripeAccountToForm = ( formId: form._id, } + const hasEmailFieldWithFormSummary = form.form_fields + .filter((field) => field.fieldType === 'email') + .map((field) => field as EmailFieldBase) + .map((field) => field.autoReplyOptions.includeFormSummary) + .some((x) => x) + + if (hasEmailFieldWithFormSummary) { + return errAsync( + new StripeAccountError('Email fields with pdf summary is not allowed'), + ) + } + return getFeatureFlag(featureFlags.validateStripeEmailDomain, { // If getFeatureFlag throws a DatabaseError, we want to log it, but respond // to the client as if the flag is not found. diff --git a/src/app/modules/sgid/__tests__/sgid.controller.spec.ts b/src/app/modules/sgid/__tests__/sgid.controller.spec.ts index 9473f424fc..d90c271cfd 100644 --- a/src/app/modules/sgid/__tests__/sgid.controller.spec.ts +++ b/src/app/modules/sgid/__tests__/sgid.controller.spec.ts @@ -40,7 +40,7 @@ jest.mock('src/app/modules/form/form.service') const FormService = jest.mocked(RealFormService) jest.mock('src/app/config/config') const MockConfig = jest.mocked(config) -MockConfig.isDev = false +MockConfig.isDevOrTest = false const MOCK_RESPONSE = expressHandler.mockResponse() const MOCK_LOGIN_REQ = expressHandler.mockRequest({ @@ -223,7 +223,7 @@ describe('sgid.controller', () => { maxAge: MOCK_COOKIE_AGE, httpOnly: true, sameSite: 'lax', - secure: !MockConfig.isDev, + secure: !MockConfig.isDevOrTest, ...MOCK_COOKIE_SETTINGS, }, ) diff --git a/src/app/modules/sgid/__tests__/sgid.routes.spec.ts b/src/app/modules/sgid/__tests__/sgid.routes.spec.ts index 94d8ce04eb..1c0a6d6d2b 100644 --- a/src/app/modules/sgid/__tests__/sgid.routes.spec.ts +++ b/src/app/modules/sgid/__tests__/sgid.routes.spec.ts @@ -42,7 +42,7 @@ jest.mock('src/app/modules/form/form.service') const FormService = jest.mocked(RealFormService) jest.mock('src/app/config/config') const MockConfig = jest.mocked(config) -MockConfig.isDev = false +MockConfig.isDevOrTest = false const app = setupApp('/sgid', SgidRouter) diff --git a/src/app/modules/sgid/sgid.controller.ts b/src/app/modules/sgid/sgid.controller.ts index 966671c401..03076a7327 100644 --- a/src/app/modules/sgid/sgid.controller.ts +++ b/src/app/modules/sgid/sgid.controller.ts @@ -109,7 +109,7 @@ export const handleLogin: ControllerHandler< maxAge, httpOnly: true, sameSite: 'lax', // Setting to 'strict' prevents Singpass login on Safari, Firefox - secure: !config.isDev, + secure: !config.isDevOrTest, ...SgidService.getCookieSettings(), }) return res.redirect(target) @@ -141,7 +141,7 @@ export const handleLogin: ControllerHandler< maxAge, httpOnly: true, sameSite: 'lax', // Setting to 'strict' prevents Singpass login on Safari, Firefox - secure: !config.isDev, + secure: !config.isDevOrTest, ...SgidService.getCookieSettings(), }) return res.redirect(target) diff --git a/src/app/modules/spcp/__tests__/spcp.controller.spec.ts b/src/app/modules/spcp/__tests__/spcp.controller.spec.ts index 06871fe3c1..fb60c37d63 100644 --- a/src/app/modules/spcp/__tests__/spcp.controller.spec.ts +++ b/src/app/modules/spcp/__tests__/spcp.controller.spec.ts @@ -50,7 +50,7 @@ jest.mock('src/app/modules/form/form.service') const MockFormService = jest.mocked(FormService) jest.mock('src/app/config/config') const MockConfig = jest.mocked(config) -MockConfig.isDev = false +MockConfig.isDevOrTest = false const MOCK_RESPONSE = expressHandler.mockResponse() @@ -135,7 +135,7 @@ describe('spcp.controller', () => { maxAge: MOCK_COOKIE_AGE, httpOnly: true, sameSite: 'lax', - secure: !MockConfig.isDev, + secure: !MockConfig.isDevOrTest, ...MOCK_COOKIE_SETTINGS, }) expect(MOCK_RESPONSE.redirect).toHaveBeenCalledWith(MOCK_DESTINATION) @@ -433,7 +433,7 @@ describe('spcp.controller', () => { maxAge: MOCK_COOKIE_AGE, httpOnly: true, sameSite: 'lax', - secure: !MockConfig.isDev, + secure: !MockConfig.isDevOrTest, ...MOCK_COOKIE_SETTINGS, }) expect(MOCK_RESPONSE.redirect).toHaveBeenCalledWith(MOCK_DESTINATION) diff --git a/src/app/modules/spcp/spcp.controller.ts b/src/app/modules/spcp/spcp.controller.ts index 4b6f6e3a8d..2347234e5d 100644 --- a/src/app/modules/spcp/spcp.controller.ts +++ b/src/app/modules/spcp/spcp.controller.ts @@ -101,7 +101,7 @@ export const handleSpcpOidcLogin: ( maxAge: cookieDuration, httpOnly: true, sameSite: 'lax', // Setting to 'strict' prevents Singpass login on Safari, Firefox - secure: !config.isDev, + secure: !config.isDevOrTest, ...oidcService.getCookieSettings(), }) return res.redirect(destination) diff --git a/src/app/modules/submission/ParsedResponsesObject.class.ts b/src/app/modules/submission/ParsedResponsesObject.class.ts index db774149d7..657213fe54 100644 --- a/src/app/modules/submission/ParsedResponsesObject.class.ts +++ b/src/app/modules/submission/ParsedResponsesObject.class.ts @@ -28,7 +28,7 @@ import { } from './submission.types' import { getFilteredResponses } from './submission.utils' -type NdiUserInfo = +export type NdiUserInfo = | { authType: | FormAuthType.SP diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts index 8b966ba737..6105bd6541 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts @@ -2,7 +2,7 @@ import expressHandler from '__tests__/unit/backend/helpers/jest-express' import { ObjectId } from 'bson' import { merge } from 'lodash' import { ok, okAsync } from 'neverthrow' -import { FormAuthType } from 'shared/types' +import { ErrorCode, FormAuthType } from 'shared/types' import * as FormService from 'src/app/modules/form/form.service' import { SgidService } from 'src/app/modules/sgid/sgid.service' @@ -275,7 +275,7 @@ describe('email-submission.controller', () => { MockEmailSubmissionService.saveSubmissionMetadata.mock.calls[0][3], ).toEqual( generateHashedSubmitterId( - MOCK_JWT_PAYLOAD_WITH_NRIC.userName, + MOCK_JWT_PAYLOAD_WITH_NRIC.userName.toUpperCase(), mockIsSingleSubmissionEnabledEmailModeForm.id, ), ) @@ -336,7 +336,7 @@ describe('email-submission.controller', () => { MockEmailSubmissionService.saveSubmissionMetadata.mock.calls[0][3], ).toEqual( generateHashedSubmitterId( - MOCK_JWT_PAYLOAD.userName, + MOCK_JWT_PAYLOAD.userName.toUpperCase(), mockIsSingleSubmissionEnabledEmailModeForm.id, ), ) @@ -463,40 +463,62 @@ describe('email-submission.controller', () => { expect(mockRes.json).toHaveBeenCalledWith({ message: 'Your NRIC/FIN/UEN has already been used to respond to this form.', - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }) }) }) - describe('nricMask', () => { - it('should mask nric if form isNricMaskEnabled is true', async () => { + describe('submitter login id collection', () => { + const MOCK_JWT_PAYLOAD = { + userName: 'nric', + } + const MOCK_VALID_SGID_PAYLOAD = { + userName: MOCK_JWT_PAYLOAD.userName, + rememberMe: false, + } + + const MOCK_CP_JWT_PAYLOAD = { + userName: 'uen', + userInfo: 'nric', + rememberMe: false, + } + + beforeEach(() => { + // For SGID auth type + MockSgidService.extractSgidSingpassJwtPayload.mockReturnValueOnce( + ok(MOCK_VALID_SGID_PAYLOAD), + ) + + // For CP auth type + MockOidcService.getOidcService.mockReturnValueOnce({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwt: (_arg1) => ok('jwt'), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwtPayload: (_arg1) => + okAsync(merge(MOCK_CP_JWT_PAYLOAD, MOCK_COOKIE_TIMESTAMP)), + } as OidcServiceType) + }) + + it('should send nric if form isSubmitterIdCollectionEnabled is true for SgId authType', async () => { // Arrange const mockFormId = new ObjectId() - const mockSgidAuthTypeAndNricMaskingEnabledForm = { + const mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm = { _id: mockFormId, title: 'some form', authType: FormAuthType.SGID, - isNricMaskEnabled: true, + isSubmitterIdCollectionEnabled: true, form_fields: [] as FormFieldSchema[], } as IPopulatedForm - const MOCK_JWT_PAYLOAD_WITH_NRIC = { - userName: 'S1234567A', - } - const MOCK_VALID_SGID_PAYLOAD = { - userName: MOCK_JWT_PAYLOAD_WITH_NRIC.userName, - rememberMe: false, - } MockFormService.retrieveFullFormById.mockReturnValueOnce( - okAsync(mockSgidAuthTypeAndNricMaskingEnabledForm), + okAsync(mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm), ) MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce( - ok(mockSgidAuthTypeAndNricMaskingEnabledForm as IPopulatedEmailForm), + ok( + mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm as IPopulatedEmailForm, + ), ) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( - okAsync(mockSgidAuthTypeAndNricMaskingEnabledForm), - ) - MockSgidService.extractSgidSingpassJwtPayload.mockReturnValueOnce( - ok(MOCK_VALID_SGID_PAYLOAD), + okAsync(mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm), ) const MOCK_REQ = expressHandler.mockRequest({ @@ -517,41 +539,79 @@ describe('email-submission.controller', () => { expect( MockSubmissionService.sendEmailConfirmations, ).toHaveBeenCalledTimes(1) - // Assert nric is masked in email payload + // Assert nric and uen is included in email payload expect( MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0] .answer, - ).toEqual('*****567A') + ).toEqual(MOCK_JWT_PAYLOAD.userName) }) - it('should not mask nric if form isNricMaskEnabled is false', async () => { + it('should not send nric if form isSubmitterIdCollectionEnabled is false for SgId authType', async () => { // Arrange const mockFormId = new ObjectId() - const mockSgidAuthTypeAndNricMaskingDisabledForm = { + const mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm = { _id: mockFormId, title: 'some form', authType: FormAuthType.SGID, - isNricMaskEnabled: false, + isSubmitterIdCollectionEnabled: false, form_fields: [] as FormFieldSchema[], } as IPopulatedForm - const MOCK_JWT_PAYLOAD_WITH_NRIC = { - userName: 'S1234567A', - } - const MOCK_VALID_SGID_PAYLOAD = { - userName: MOCK_JWT_PAYLOAD_WITH_NRIC.userName, - rememberMe: false, - } MockFormService.retrieveFullFormById.mockReturnValueOnce( - okAsync(mockSgidAuthTypeAndNricMaskingDisabledForm), + okAsync(mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm), ) MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce( - ok(mockSgidAuthTypeAndNricMaskingDisabledForm as IPopulatedEmailForm), + ok( + mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm as IPopulatedEmailForm, + ), ) MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( - okAsync(mockSgidAuthTypeAndNricMaskingDisabledForm), + okAsync(mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm), ) - MockSgidService.extractSgidSingpassJwtPayload.mockReturnValueOnce( - ok(MOCK_VALID_SGID_PAYLOAD), + + const MOCK_REQ = expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }) + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + // Act + await submitEmailModeForm(MOCK_REQ, mockRes, jest.fn()) + + // Assert email should be sent + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + expect( + MockSubmissionService.sendEmailConfirmations, + ).toHaveBeenCalledTimes(1) + // Assert nric is not contained in email payload, hence empty array since no other fields + expect( + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData, + ).toEqual([]) + }) + + it('should send nric if form isSubmitterIdCollectionEnabled is true for Cp authType', async () => { + // Arrange + const mockFormId = new ObjectId() + const mockCpAuthTypeAndSubmitterIdCollectionEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.CP, + isSubmitterIdCollectionEnabled: true, + form_fields: [] as FormFieldSchema[], + } as IPopulatedForm + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(mockCpAuthTypeAndSubmitterIdCollectionEnabledForm), + ) + MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce( + ok( + mockCpAuthTypeAndSubmitterIdCollectionEnabledForm as IPopulatedEmailForm, + ), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(mockCpAuthTypeAndSubmitterIdCollectionEnabledForm), ) const MOCK_REQ = expressHandler.mockRequest({ @@ -572,11 +632,66 @@ describe('email-submission.controller', () => { expect( MockSubmissionService.sendEmailConfirmations, ).toHaveBeenCalledTimes(1) - // Assert nric is not masked + // Assert nric is included in email payload expect( - MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0] - .answer, - ).toEqual(MOCK_JWT_PAYLOAD_WITH_NRIC.userName) + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData, + ).toHaveLength(2) + expect( + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData.filter( + (r) => r.question === 'CorpPass Validated UEN', + )[0].answer, + ).toEqual(MOCK_CP_JWT_PAYLOAD.userName) + expect( + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData.filter( + (r) => r.question === 'CorpPass Validated UID', + )[0].answer, + ).toEqual(MOCK_CP_JWT_PAYLOAD.userInfo) + }) + + it('should not send nric if form isSubmitterIdCollectionEnabled is false for Cp authType', async () => { + // Arrange + const mockFormId = new ObjectId() + const mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.CP, + isSubmitterIdCollectionEnabled: false, + form_fields: [] as FormFieldSchema[], + } as IPopulatedForm + MockFormService.retrieveFullFormById.mockReturnValueOnce( + okAsync(mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm), + ) + MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce( + ok( + mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm as IPopulatedEmailForm, + ), + ) + MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce( + okAsync(mockSgidAuthTypeAndSubmitterIdCollectionDisabledForm), + ) + + const MOCK_REQ = expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }) + const mockRes = expressHandler.mockResponse({ + clearCookie: jest.fn().mockReturnThis(), + }) + + // Act + await submitEmailModeForm(MOCK_REQ, mockRes, jest.fn()) + + // Assert email should be sent + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + expect( + MockSubmissionService.sendEmailConfirmations, + ).toHaveBeenCalledTimes(1) + // Assert nric is not contained in email payload, hence empty array since no other fields + expect( + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData, + ).toEqual([]) }) }) }) diff --git a/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts b/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts index 5ef00d0901..623e19d17d 100644 --- a/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts +++ b/src/app/modules/submission/email-submission/__tests__/email-submission.util.spec.ts @@ -138,6 +138,56 @@ describe('email-submission.util', () => { ]) }) + it('should include undefined isVisible fields from autoreply data', async () => { + // Arrange + const response = generateNewSingleAnswerResponse(BasicField.ShortText, { + isVisible: undefined, + }) + + // Assert + const emailData = new SubmissionEmailObj( + [response], + new Set(), + FormAuthType.NIL, + ) + + // Assert + expect(emailData.dataCollationData).toEqual([ + generateSingleAnswerJson(response), + ]) + expect(emailData.autoReplyData).toEqual([ + generateSingleAnswerAutoreply(response), + ]) + expect(emailData.formData).toEqual([ + generateSingleAnswerFormData(response), + ]) + }) + + it('should include isVisible true fields from autoreply data', async () => { + // Arrange + const response = generateNewSingleAnswerResponse(BasicField.ShortText, { + isVisible: true, + }) + + // Assert + const emailData = new SubmissionEmailObj( + [response], + new Set(), + FormAuthType.NIL, + ) + + // Assert + expect(emailData.dataCollationData).toEqual([ + generateSingleAnswerJson(response), + ]) + expect(emailData.autoReplyData).toEqual([ + generateSingleAnswerAutoreply(response), + ]) + expect(emailData.formData).toEqual([ + generateSingleAnswerFormData(response), + ]) + }) + it('should generate table answers with [table] prefix in form and JSON data', () => { const response = generateNewTableResponse() diff --git a/src/app/modules/submission/email-submission/email-submission.controller.ts b/src/app/modules/submission/email-submission/email-submission.controller.ts index d109c6a82a..7d2bc1f011 100644 --- a/src/app/modules/submission/email-submission/email-submission.controller.ts +++ b/src/app/modules/submission/email-submission/email-submission.controller.ts @@ -2,18 +2,13 @@ import { StatusCodes } from 'http-status-codes' import { ok, okAsync, ResultAsync } from 'neverthrow' import { - BasicField, + ErrorCode, FormAuthType, SubmissionErrorDto, SubmissionResponseDto, } from '../../../../../shared/types' import { CaptchaTypes } from '../../../../../shared/types/captcha' -import { maskNric } from '../../../../../shared/utils/nric-mask' -import { - IPopulatedEmailForm, - SgidFieldTitle, - SPCPFieldTitle, -} from '../../../../types' +import { IPopulatedEmailForm } from '../../../../types' import { ParsedEmailModeSubmissionBody } from '../../../../types/api' import { createLoggerWithLabel } from '../../../config/logger' import * as CaptchaMiddleware from '../../../services/captcha/captcha.middleware' @@ -41,7 +36,6 @@ import * as EmailSubmissionMiddleware from '../email-submission/email-submission import ParsedResponsesObject from '../ParsedResponsesObject.class' import * as ReceiverMiddleware from '../receiver/receiver.middleware' import * as SubmissionService from '../submission.service' -import { ProcessedSingleAnswerResponse } from '../submission.types' import { extractEmailConfirmationData, generateHashedSubmitterId, @@ -210,11 +204,12 @@ export const submitEmailModeForm: ControllerHandler< .asyncAndThen((jwt) => oidcService.extractJwtPayload(jwt)) .map((jwt) => ({ form, - parsedResponses: parsedResponses.addNdiResponses({ + parsedResponses, + ndiUserInfo: { authType, uinFin: jwt.userName, userInfo: jwt.userInfo, - }), + }, })) .mapErr((error) => { spcpSubmissionFailure = true @@ -233,10 +228,11 @@ export const submitEmailModeForm: ControllerHandler< .asyncAndThen((jwt) => oidcService.extractJwtPayload(jwt)) .map((jwt) => ({ form, - parsedResponses: parsedResponses.addNdiResponses({ + parsedResponses, + ndiUserInfo: { authType, uinFin: jwt.userName, - }), + }, })) .mapErr((error) => { spcpSubmissionFailure = true @@ -264,10 +260,11 @@ export const submitEmailModeForm: ControllerHandler< (hashedFields) => ({ form, hashedFields, - parsedResponses: parsedResponses.addNdiResponses({ + parsedResponses, + ndiUserInfo: { authType, uinFin, - }), + }, }), ), ) @@ -289,10 +286,11 @@ export const submitEmailModeForm: ControllerHandler< .map( ({ userName: uinFin }) => ({ form, - parsedResponses: parsedResponses.addNdiResponses({ + parsedResponses, + ndiUserInfo: { authType, uinFin, - }), + }, }), ) .mapErr((error) => { @@ -311,30 +309,23 @@ export const submitEmailModeForm: ControllerHandler< }) } }) - .andThen(({ form, parsedResponses, hashedFields }) => { + .andThen(({ form, parsedResponses, ndiUserInfo, hashedFields }) => { let submitterId: string | undefined = undefined - if (form.authType !== FormAuthType.NIL) { - const ndiResponse = parsedResponses.ndiResponses.find( - (response) => - response.question === SPCPFieldTitle.SpNric || - response.question === SPCPFieldTitle.CpUen || - response.question === SgidFieldTitle.SgidNric, - ) as ProcessedSingleAnswerResponse - submitterId = ndiResponse?.answer - ? generateHashedSubmitterId(ndiResponse.answer, form.id) - : undefined + if (form.authType !== FormAuthType.NIL && ndiUserInfo?.uinFin) { + submitterId = generateHashedSubmitterId( + ndiUserInfo.uinFin.toUpperCase(), + form.id, + ) } - if (form.isNricMaskEnabled) { - parsedResponses.ndiResponses = parsedResponses.ndiResponses.map( - (response) => { - if (response.fieldType === BasicField.Nric) { - return { ...response, answer: maskNric(response.answer) } - } - return response - }, - ) + if ( + form.authType !== FormAuthType.NIL && + form.isSubmitterIdCollectionEnabled && + ndiUserInfo + ) { + parsedResponses = parsedResponses.addNdiResponses(ndiUserInfo) } + // Create data for response email as well as email confirmation const emailData = new SubmissionEmailObj( parsedResponses.getAllResponses(), @@ -450,7 +441,7 @@ export const submitEmailModeForm: ControllerHandler< return res.status(StatusCodes.BAD_REQUEST).json({ message: 'Your NRIC/FIN/UEN has already been used to respond to this form.', - hasSingleSubmissionValidationFailure: true, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }) } // Send email confirmations 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 d9af34b619..2b7c74947c 100644 --- a/src/app/modules/submission/email-submission/email-submission.types.ts +++ b/src/app/modules/submission/email-submission/email-submission.types.ts @@ -2,7 +2,9 @@ import { ResponseMetadata, SubmissionType } from 'shared/types' import { FieldResponse, IPopulatedEmailForm } from '../../../../types' import { MyInfoKey } from '../../myinfo/myinfo.types' -import ParsedResponsesObject from '../ParsedResponsesObject.class' +import ParsedResponsesObject, { + NdiUserInfo, +} from '../ParsedResponsesObject.class' import { ProcessedResponse } from '../submission.types' // When a response has been formatted for email, all answerArray @@ -24,6 +26,7 @@ export interface SubmissionHash { export interface IPopulatedEmailFormWithResponsesAndHash { form: IPopulatedEmailForm parsedResponses: ParsedResponsesObject + ndiUserInfo?: NdiUserInfo 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 09f5074893..75cb69445a 100644 --- a/src/app/modules/submission/email-submission/email-submission.util.ts +++ b/src/app/modules/submission/email-submission/email-submission.util.ts @@ -542,7 +542,7 @@ const getAutoReplyFormattedResponse = ( const { question, answer, isVisible } = response const answerSplitByNewLine = answer.split('\n') // Auto reply email will contain only visible fields - if (isVisible) { + if (isVisible !== false) { return { question, // No prefixes for autoreply answerTemplate: answerSplitByNewLine, diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts index dc9452b219..e06245e022 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts @@ -4,9 +4,22 @@ import { ObjectId } from 'bson' import { merge } from 'lodash' import mongoose from 'mongoose' import { ok, okAsync } from 'neverthrow' -import { FormAuthType, MyInfoAttribute } from 'shared/types' +import { + FORM_RESPONDENT_NOT_WHITELISTED_ERROR_MESSAGE, + FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE, +} from 'shared/constants/errors' +import { + BasicField, + ErrorCode, + FormAuthType, + MyInfoAttribute, +} from 'shared/types' import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' +import * as FormService from 'src/app/modules/form/form.service' +import { MyInfoService } from 'src/app/modules/myinfo/myinfo.service' +import * as MyInfoUtil from 'src/app/modules/myinfo/myinfo.util' +import { SgidService } from 'src/app/modules/sgid/sgid.service' import * as OidcService from 'src/app/modules/spcp/spcp.oidc.service/index' import { OidcServiceType } from 'src/app/modules/spcp/spcp.oidc.service/spcp.oidc.service.types' import * as EncryptSubmissionService from 'src/app/modules/submission/encrypt-submission/encrypt-submission.service' @@ -19,6 +32,8 @@ import MailService from 'src/app/services/mail/mail.service' import { FormFieldSchema, IPopulatedEncryptedForm } from 'src/types' import { EncryptSubmissionDto, FormCompleteDto } from 'src/types/api' +import { SubmissionEmailObj } from '../../email-submission/email-submission.util' +import { ProcessedFieldResponse } from '../../submission.types' import { generateHashedSubmitterId, getCookieNameByAuthType, @@ -44,6 +59,9 @@ jest.mock('src/app/utils/pipeline-middleware', () => { } }) jest.mock('src/app/modules/spcp/spcp.oidc.service') +jest.mock('src/app/modules/myinfo/myinfo.util') +jest.mock('src/app/modules/myinfo/myinfo.service') +jest.mock('src/app/modules/sgid/sgid.service') jest.mock('src/app/services/mail/mail.service') jest.mock('src/app/modules/verified-content/verified-content.service', () => { const originalModule = jest.requireActual( @@ -54,12 +72,15 @@ jest.mock('src/app/modules/verified-content/verified-content.service', () => { getVerifiedContent: jest.fn(originalModule.getVerifiedContent), encryptVerifiedContent: jest.fn( ({ verifiedContent }: EncryptVerificationContentParams) => - ok((verifiedContent as SpVerifiedContent).uinFin), + ok(JSON.stringify(verifiedContent as SpVerifiedContent)), ), } }) const MockOidcService = jest.mocked(OidcService) +const MockSgidService = jest.mocked(SgidService) +const MockMyInfoUtil = jest.mocked(MyInfoUtil) +const MockMyInfoService = jest.mocked(MyInfoService) const MockMailService = jest.mocked(MailService) const MockVerifiedContentService = jest.mocked(VerifiedContentService) @@ -298,17 +319,17 @@ describe('encrypt-submission.controller', () => { // Act await submitEncryptModeFormForTest(mockReq, mockRes) - // Assert that submitterId is hashed + // Assert that submitterId is uppercased and then hashed expect(saveIfSubmitterIdIsUniqueSpy).toHaveBeenCalledTimes(1) expect(saveIfSubmitterIdIsUniqueSpy.mock.calls[0][1]).toEqual( generateHashedSubmitterId( - MOCK_JWT_PAYLOAD.userName, + MOCK_JWT_PAYLOAD.userName.toUpperCase(), mockSpAuthTypeAndIsSingleSubmissionEnabledForm.id, ), ) expect(saveIfSubmitterIdIsUniqueSpy.mock.calls[0][2].submitterId).toEqual( generateHashedSubmitterId( - MOCK_JWT_PAYLOAD.userName, + MOCK_JWT_PAYLOAD.userName.toUpperCase(), mockSpAuthTypeAndIsSingleSubmissionEnabledForm.id, ), ) @@ -370,23 +391,270 @@ describe('encrypt-submission.controller', () => { // Act await submitEncryptModeFormForTest(mockReq, mockRes) - // Assert that submitterId is hashed + // Assert that submitterId is uppercased then hashed expect(saveIfSubmitterIdIsUniqueSpy).toHaveBeenCalledTimes(1) expect(saveIfSubmitterIdIsUniqueSpy.mock.calls[0][1]).toEqual( generateHashedSubmitterId( - MOCK_JWT_PAYLOAD.userName, - mockFormId.toHexString(), + MOCK_JWT_PAYLOAD.userName.toUpperCase(), + mockCpAuthTypeAndIsSingleSubmissionEnabledForm.id, ), ) expect(saveIfSubmitterIdIsUniqueSpy.mock.calls[0][2].submitterId).toEqual( generateHashedSubmitterId( - MOCK_JWT_PAYLOAD.userName, - mockFormId.toHexString(), + MOCK_JWT_PAYLOAD.userName.toUpperCase(), + mockCpAuthTypeAndIsSingleSubmissionEnabledForm.id, ), ) }) }) + describe('submitterId whitelisting', () => { + const MOCK_JWT_PAYLOAD = { + userName: 'submitterId', + rememberMe: false, + } + const MOCK_COOKIE_TIMESTAMP = { + iat: 1, + exp: 1, + } + beforeEach(() => { + MockOidcService.getOidcService.mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwt: (_arg1) => ok('jwt'), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwtPayload: (_arg1) => + okAsync(merge(MOCK_JWT_PAYLOAD, MOCK_COOKIE_TIMESTAMP)), + } as OidcServiceType) + + MockMailService.sendSubmissionToAdmin.mockResolvedValue(okAsync(true)) + }) + + it('should return 200 ok when successfully submit form and submitterId is whitelisted', async () => { + // Arrange + const checkHasRespondentNotWhitelistedFailureSpy = jest + .spyOn(FormService, 'checkHasRespondentNotWhitelistedFailure') + .mockReturnValue(okAsync(false)) + const checkHasSingleSubmissionValidationFailureSpy = jest + .spyOn(FormService, 'checkHasSingleSubmissionValidationFailure') + .mockReturnValue(okAsync(false)) + const performEncryptPostSubmissionActionsSpy = jest.spyOn( + EncryptSubmissionService, + 'performEncryptPostSubmissionActions', + ) + const mockFormId = new ObjectId() + const mockSpAuthTypeAndIsSingleSubmissionEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SP, + isSingleSubmission: false, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + form_fields: [] as FormFieldSchema[], + emails: ['test@example.com'], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const mockReq = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.SP, + }, + encryptedFormDef: mockSpAuthTypeAndIsSingleSubmissionEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Act + await submitEncryptModeFormForTest(mockReq, mockRes) + + // Assert that only whitelist failure validation is run for whitelist enabled but single submission disabled settings + expect(checkHasRespondentNotWhitelistedFailureSpy).toHaveBeenCalledTimes( + 1, + ) + expect( + checkHasSingleSubmissionValidationFailureSpy, + ).not.toHaveBeenCalled() + + // Assert email notification should be sent + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + + // Assert that status is not called, which defaults to intended 200 OK + expect(mockRes.status).not.toHaveBeenCalled() + // Assert that response does not any error codes + expect( + (mockRes.json as jest.Mock).mock.calls[0][0].errorCodes, + ).not.toBeDefined() + + expect(performEncryptPostSubmissionActionsSpy).toHaveBeenCalledTimes(1) + }) + + // purpose: used to test that whitelisting and single submission work together + it('should return 200 ok when successfully submit form and submitterId is whitelisted and no single submission validation error', async () => { + // Arrange + const checkHasRespondentNotWhitelistedFailureSpy = jest + .spyOn(FormService, 'checkHasRespondentNotWhitelistedFailure') + .mockReturnValue(okAsync(false)) + const saveIfSubmitterIdIsUniqueSpy = jest + .spyOn(EncryptSubmission, 'saveIfSubmitterIdIsUnique') + .mockResolvedValueOnce( + new EncryptSubmission({ + id: 'dummySubmissionId', + } as unknown as EncryptSubmissionContent), + ) + const performEncryptPostSubmissionActionsSpy = jest.spyOn( + EncryptSubmissionService, + 'performEncryptPostSubmissionActions', + ) + const mockFormId = new ObjectId() + const mockSpAuthTypeAndIsSingleSubmissionEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SP, + isSingleSubmission: true, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + form_fields: [] as FormFieldSchema[], + emails: ['test@example.com'], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const mockReq = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.SP, + }, + encryptedFormDef: mockSpAuthTypeAndIsSingleSubmissionEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Act + await submitEncryptModeFormForTest(mockReq, mockRes) + + // Assert + expect(checkHasRespondentNotWhitelistedFailureSpy).toHaveBeenCalledTimes( + 1, + ) + expect(saveIfSubmitterIdIsUniqueSpy).toHaveBeenCalledTimes(1) + + // Assert email notification should be sent + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) + + // Assert that status is not called, which defaults to intended 200 OK + expect(mockRes.status).not.toHaveBeenCalled() + // Assert that response does not any error codes + expect( + (mockRes.json as jest.Mock).mock.calls[0][0].errorCodes, + ).not.toBeDefined() + + // Assert that user is logged out + expect(mockRes.clearCookie).toHaveBeenCalledWith( + getCookieNameByAuthType( + mockSpAuthTypeAndIsSingleSubmissionEnabledForm.authType as FormAuthType.SP, + ), + ) + + expect(performEncryptPostSubmissionActionsSpy).toHaveBeenCalledTimes(1) + }) + + it('should return 403 with submitterId not whitelisted failure flag when submitterId is not whitelisted', async () => { + // Arrange + const checkHasRespondentNotWhitelistedFailureSpy = jest + .spyOn(FormService, 'checkHasRespondentNotWhitelistedFailure') + .mockReturnValue(okAsync(true)) + const checkHasSingleSubmissionValidationFailureSpy = jest + .spyOn(FormService, 'checkHasSingleSubmissionValidationFailure') + .mockReturnValue(okAsync(false)) + const performEncryptPostSubmissionActionsSpy = jest.spyOn( + EncryptSubmissionService, + 'performEncryptPostSubmissionActions', + ) + const mockFormId = new ObjectId() + const mockSpAuthTypeAndIsSingleSubmissionEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SP, + isSingleSubmission: false, + whitelistedSubmitterIds: { + isWhitelistEnabled: true, + }, + form_fields: [] as FormFieldSchema[], + emails: ['test@example.com'], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const mockReq = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.SP, + }, + encryptedFormDef: mockSpAuthTypeAndIsSingleSubmissionEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Act + await submitEncryptModeFormForTest(mockReq, mockRes) + + // Assert that the submitterId related validations are run + expect(checkHasRespondentNotWhitelistedFailureSpy).toHaveBeenCalledTimes( + 1, + ) + expect( + checkHasSingleSubmissionValidationFailureSpy, + ).not.toHaveBeenCalled() + + // Assert email notification not sent since submission not allowed + expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(0) + + expect(mockRes.status).toHaveBeenCalledOnceWith(403) + + expect((mockRes.json as jest.Mock).mock.calls[0][0].message).toEqual( + FORM_RESPONDENT_NOT_WHITELISTED_ERROR_MESSAGE, + ) + + expect(performEncryptPostSubmissionActionsSpy).not.toHaveBeenCalled() + }) + }) + describe('single submission per submitterId', () => { const MOCK_JWT_PAYLOAD = { userName: 'submitterId', @@ -466,8 +734,7 @@ describe('encrypt-submission.controller', () => { expect(mockRes.status).not.toHaveBeenCalled() // Assert that response does not have the single submission validation failure flag expect( - (mockRes.json as jest.Mock).mock.calls[0][0] - .hasSingleSubmissionValidationFailure, + (mockRes.json as jest.Mock).mock.calls[0][0].errorCodes, ).not.toBeDefined() // Assert that user is logged out @@ -520,11 +787,65 @@ describe('encrypt-submission.controller', () => { // Act await submitEncryptModeFormForTest(mockReq, mockRes) + // Assert that response has the single submission validation failure flag + expect(mockRes.json).toHaveBeenCalledWith({ + message: FORM_SINGLE_SUBMISSION_VALIDATION_ERROR_MESSAGE, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], + }) + + // Assert that the submission is not saved + expect(await EncryptSubmission.countDocuments()).toEqual(0) + }) + + it('should return json response with single submission validation failure flag when submissionId is not unique and does not save submission even when isSubmitterIdCollectedEnabled is true', async () => { + jest + .spyOn(EncryptSubmission, 'saveIfSubmitterIdIsUnique') + .mockResolvedValueOnce(null) + + // Arrange + const mockFormId = new ObjectId() + const mockSpAuthTypeAndIsSingleSubmissionEnabledAndIsSubmitterIdCollectionEnabledForm = + { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.SP, + isSingleSubmission: true, + isSubmitterIdCollectionEnabled: true, + form_fields: [] as FormFieldSchema[], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const mockReq = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.SP, + }, + encryptedFormDef: + mockSpAuthTypeAndIsSingleSubmissionEnabledAndIsSubmitterIdCollectionEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Act + await submitEncryptModeFormForTest(mockReq, mockRes) + // Assert that response has the single submission validation failure flag expect(mockRes.json).toHaveBeenCalledWith({ message: - 'Your NRIC/FIN/UEN has already been used to respond to this form.', - hasSingleSubmissionValidationFailure: true, + 'You have already submitted a response using this NRIC/FIN/UEN. If you require further assistance, please contact the agency that gave you the form link.', + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }) // Assert that the submission is not saved @@ -532,19 +853,35 @@ describe('encrypt-submission.controller', () => { }) }) - describe('nricMask', () => { + describe('submitter login ids collection', () => { const MOCK_NRIC = 'S1234567A' - const MOCK_MASKED_NRIC = '*****567A' + const MOCK_UEN = '123456789A' const MOCK_JWT_PAYLOAD = { userName: MOCK_NRIC, rememberMe: false, } + const MOCK_JWT_CP_PAYLOAD = { + userName: MOCK_UEN, + userInfo: MOCK_NRIC, + rememberMe: false, + } + const MOCK_MYINFO_LOGIN_COOKIE_PAYLOAD = { + uinFin: MOCK_NRIC, + } const MOCK_COOKIE_TIMESTAMP = { iat: 1, exp: 1, } beforeEach(() => { + MockSgidService.extractSgidSingpassJwtPayload.mockReturnValue( + ok(MOCK_JWT_PAYLOAD), + ) + MockMyInfoUtil.extractMyInfoLoginJwt.mockReturnValue(ok('jwt')) + MockMyInfoService.verifyLoginJwt.mockReturnValue( + ok(MOCK_MYINFO_LOGIN_COOKIE_PAYLOAD), + ) + MockOidcService.getOidcService.mockReturnValue({ // eslint-disable-next-line @typescript-eslint/no-unused-vars extractJwt: (_arg1) => ok('jwt'), @@ -556,14 +893,14 @@ describe('encrypt-submission.controller', () => { MockMailService.sendSubmissionToAdmin.mockResolvedValue(okAsync(true)) }) - it('should mask nric if form isNricMaskEnabled is true', async () => { + it('should store login nric in verifiedContent if form isSubmitterIdCollectionEnabled is true for SP authType', async () => { // Arrange const mockFormId = new ObjectId() - const mockSpAuthTypeAndNricMaskingEnabledForm = { + const mockSpAuthTypeAndSubmitterIdCollectionEnabledForm = { _id: mockFormId, title: 'some form', authType: FormAuthType.SP, - isNricMaskEnabled: true, + isSubmitterIdCollectionEnabled: true, form_fields: [] as FormFieldSchema[], getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], } as IPopulatedEncryptedForm @@ -584,38 +921,110 @@ describe('encrypt-submission.controller', () => { formDef: { authType: FormAuthType.SP, }, - encryptedFormDef: mockSpAuthTypeAndNricMaskingEnabledForm, + encryptedFormDef: mockSpAuthTypeAndSubmitterIdCollectionEnabledForm, } as unknown as EncryptSubmissionDto, } as unknown as FormCompleteDto, ) as unknown as SubmitEncryptModeFormHandlerRequest const mockRes = expressHandler.mockResponse() + const expectedVerifiedContent = { uinFin: MOCK_NRIC, userInfo: undefined } + + // Act + await submitEncryptModeFormForTest(MOCK_REQ, mockRes) + // Assert + // that verified content is generated since submitter login id is collected + expect( + MockVerifiedContentService.getVerifiedContent, + ).toHaveBeenCalledWith({ + type: mockSpAuthTypeAndSubmitterIdCollectionEnabledForm.authType, + data: expectedVerifiedContent, + }) + + // that the saved submission is contains the correct verified content + const savedSubmission = await EncryptSubmission.findOne() + + expect(savedSubmission).toBeDefined() + expect(savedSubmission).not.toBeNull() + expect(savedSubmission?.verifiedContent).toEqual( + JSON.stringify(expectedVerifiedContent), + ) + }) + + it('should store login nric and uen in verifiedContent if form isSubmitterIdCollectionEnabled is true for CP authType', async () => { + // Arrange + MockOidcService.getOidcService.mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwt: (_arg1) => ok('jwt'), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwtPayload: (_arg1) => + okAsync(merge(MOCK_JWT_CP_PAYLOAD, MOCK_COOKIE_TIMESTAMP)), + } as OidcServiceType) + + const mockFormId = new ObjectId() + const mockCpAuthTypeAndSubmitterIdCollectionEnabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.CP, + isSubmitterIdCollectionEnabled: true, + form_fields: [] as FormFieldSchema[], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const MOCK_REQ = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.CP, + }, + encryptedFormDef: mockCpAuthTypeAndSubmitterIdCollectionEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + const expectedGetVerifiedContentArg = { + uinFin: MOCK_UEN, + userInfo: MOCK_NRIC, + } + const expectedVerifiedContent = { cpUen: MOCK_UEN, cpUid: MOCK_NRIC } // Act await submitEncryptModeFormForTest(MOCK_REQ, mockRes) // Assert - // that verified content is generated using the masked nric + // that verified content is generated since submitter login id is collected expect( MockVerifiedContentService.getVerifiedContent, ).toHaveBeenCalledWith({ - type: mockSpAuthTypeAndNricMaskingEnabledForm.authType, - data: { uinFin: MOCK_MASKED_NRIC, userInfo: undefined }, + type: mockCpAuthTypeAndSubmitterIdCollectionEnabledForm.authType, + data: expectedGetVerifiedContentArg, }) - // that the saved submission is masked + + // that the saved submission is contains the correct verified content const savedSubmission = await EncryptSubmission.findOne() expect(savedSubmission).toBeDefined() - expect(savedSubmission!.verifiedContent).toEqual(MOCK_MASKED_NRIC) + expect(savedSubmission).not.toBeNull() + expect(savedSubmission?.verifiedContent).toEqual( + JSON.stringify(expectedVerifiedContent), + ) }) - it('should not mask nric if form isNricMaskEnabled is false', async () => { + it('should not collect nric if form isSubmitterIdCollectionEnabled is undefined for SP authType', async () => { // Arrange const mockFormId = new ObjectId() const mockSpAuthTypeAndNricMaskingEnabledForm = { _id: mockFormId, title: 'some form', authType: FormAuthType.SP, - isNricMaskEnabled: false, form_fields: [] as FormFieldSchema[], getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], } as IPopulatedEncryptedForm @@ -646,28 +1055,84 @@ describe('encrypt-submission.controller', () => { await submitEncryptModeFormForTest(MOCK_REQ, mockRes) // Assert - // that verified content is generated using the masked nric + // that verified content is not generated expect( MockVerifiedContentService.getVerifiedContent, - ).toHaveBeenCalledWith({ - type: mockSpAuthTypeAndNricMaskingEnabledForm.authType, - data: { uinFin: MOCK_NRIC, userInfo: undefined }, - }) - // that the saved submission is masked + ).not.toHaveBeenCalled() + // that the saved submission is does not contain verified content const savedSubmission = await EncryptSubmission.findOne() expect(savedSubmission).toBeDefined() - expect(savedSubmission!.verifiedContent).toEqual(MOCK_NRIC) + expect(savedSubmission).not.toBeNull() + expect(savedSubmission!.verifiedContent).toBeUndefined() }) - it('should not mask nric in email notification if form isNricMaskEnabled is false', async () => { + it('should not collect nric or uen if form isSubmitterIdCollectionEnabled is false for CP authType', async () => { // Arrange + MockOidcService.getOidcService.mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwt: (_arg1) => ok('jwt'), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + extractJwtPayload: (_arg1) => + okAsync(merge(MOCK_JWT_CP_PAYLOAD, MOCK_COOKIE_TIMESTAMP)), + } as OidcServiceType) + const mockFormId = new ObjectId() - const mockSpAuthTypeAndNricMaskingDisabledForm = { + const mockSpAuthTypeAndNricMaskingEnabledForm = { _id: mockFormId, title: 'some form', - authType: FormAuthType.SP, - isNricMaskEnabled: false, + authType: FormAuthType.CP, + form_fields: [] as FormFieldSchema[], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + isSubmitterIdCollectionEnabled: false, + } as IPopulatedEncryptedForm + + const MOCK_REQ = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: [], + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: { + authType: FormAuthType.CP, + }, + encryptedFormDef: mockSpAuthTypeAndNricMaskingEnabledForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Act + await submitEncryptModeFormForTest(MOCK_REQ, mockRes) + + // Assert + // that verified content is not generated + expect( + MockVerifiedContentService.getVerifiedContent, + ).not.toHaveBeenCalled() + // that the saved submission is does not contain verified content + const savedSubmission = await EncryptSubmission.findOne() + + expect(savedSubmission).toBeDefined() + expect(savedSubmission).not.toBeNull() + expect(savedSubmission!.verifiedContent).toBeUndefined() + }) + + it('should not include nric in email notification and not store nric if form isSubmitterIdCollectionEnabled is false for MyInfo authType', async () => { + // Arrange + const mockFormId = new ObjectId() + const mockMyInfoAuthTypeAndSubmitterIdCollectionDisabledForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.MyInfo, + isSubmitterIdCollectionEnabled: false, form_fields: [] as FormFieldSchema[], emails: ['test@example.com'], getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], @@ -687,9 +1152,10 @@ describe('encrypt-submission.controller', () => { version: 1, }, formDef: { - authType: FormAuthType.SP, + authType: FormAuthType.MyInfo, }, - encryptedFormDef: mockSpAuthTypeAndNricMaskingDisabledForm, + encryptedFormDef: + mockMyInfoAuthTypeAndSubmitterIdCollectionDisabledForm, } as unknown as EncryptSubmissionDto, } as unknown as FormCompleteDto, ) as unknown as SubmitEncryptModeFormHandlerRequest @@ -698,24 +1164,34 @@ describe('encrypt-submission.controller', () => { // Act await submitEncryptModeFormForTest(MOCK_REQ, mockRes) + // not verified content is added + expect( + MockVerifiedContentService.getVerifiedContent, + ).not.toHaveBeenCalled() + // that the saved submission is does not contain verified content + const savedSubmission = await EncryptSubmission.findOne() + + expect(savedSubmission).toBeDefined() + expect(savedSubmission).not.toBeNull() + expect(savedSubmission!.verifiedContent).toBeUndefined() + // Assert - // email notification should be sent with the unmasked nric + // email notification should be sent expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) - // Assert nric is not masked + // Assert nric is not contained - formData empty array since no parsed responses to be included in email expect( - MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0] - .answer, - ).toEqual(MOCK_NRIC) + MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData, + ).toEqual([]) }) - it('should mask nric in email notification if form isNricMaskEnabled is true', async () => { + it('should include nric in email notification and store nric in verifiedContent if form isSubmitterIdCollectionEnabled is true for SgId authType', async () => { // Arrange const mockFormId = new ObjectId() - const mockSpAuthTypeAndNricMaskingEnabledForm = { + const mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm = { _id: mockFormId, title: 'some form', - authType: FormAuthType.SP, - isNricMaskEnabled: true, + authType: FormAuthType.SGID, + isSubmitterIdCollectionEnabled: true, form_fields: [] as FormFieldSchema[], emails: ['test@example.com'], getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], @@ -735,25 +1211,123 @@ describe('encrypt-submission.controller', () => { version: 1, }, formDef: { - authType: FormAuthType.SP, + authType: FormAuthType.SGID, }, - encryptedFormDef: mockSpAuthTypeAndNricMaskingEnabledForm, + encryptedFormDef: + mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm, } as unknown as EncryptSubmissionDto, } as unknown as FormCompleteDto, ) as unknown as SubmitEncryptModeFormHandlerRequest const mockRes = expressHandler.mockResponse() + const expectedGetVerifiedContentArg = { + uinFin: MOCK_NRIC, + userInfo: undefined, + } + const expectedVerifiedContent = { sgidUinFin: MOCK_NRIC } + // Act await submitEncryptModeFormForTest(MOCK_REQ, mockRes) // Assert - // email notification should be sent with the masked nric + + // that verified content is generated since submitter login id is collected + expect( + MockVerifiedContentService.getVerifiedContent, + ).toHaveBeenCalledWith({ + type: mockSgidAuthTypeAndSubmitterIdCollectionEnabledForm.authType, + data: expectedGetVerifiedContentArg, + }) + + // that the saved submission is contains the correct verified content + const savedSubmission = await EncryptSubmission.findOne() + + expect(savedSubmission).toBeDefined() + expect(savedSubmission).not.toBeNull() + expect(savedSubmission?.verifiedContent).toEqual( + JSON.stringify(expectedVerifiedContent), + ) + + // email notification should be sent with nric included expect(MockMailService.sendSubmissionToAdmin).toHaveBeenCalledTimes(1) - // Assert nric is masked expect( MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0] .answer, - ).toEqual(MOCK_MASKED_NRIC) + ).toEqual(MOCK_NRIC) + }) + }) + + describe('emailData', () => { + it('should have the isVisible field set to true for form fields', async () => { + // Arrange + const performEncryptPostSubmissionActionsSpy = jest.spyOn( + EncryptSubmissionService, + 'performEncryptPostSubmissionActions', + ) + const mockFormId = new ObjectId() + const mockEncryptForm = { + _id: mockFormId, + title: 'some form', + authType: FormAuthType.NIL, + isSubmitterIdCollectionEnabled: false, + form_fields: [ + { + _id: new ObjectId(), + fieldType: BasicField.ShortText, + title: 'Long answer', + description: '', + required: false, + disabled: false, + }, + ] as FormFieldSchema[], + emails: ['test@example.com'], + getUniqueMyInfoAttrs: () => [] as MyInfoAttribute[], + } as IPopulatedEncryptedForm + + const mockResponses = [ + { + _id: new ObjectId(), + question: 'Long answer', + answer: 'this is an answer', + fieldType: 'textarea', + isVisible: true, + }, + ] + + const mockReq = merge( + expressHandler.mockRequest({ + params: { formId: 'some id' }, + body: { + responses: mockResponses, + }, + }), + { + formsg: { + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + }, + formDef: {}, + encryptedFormDef: mockEncryptForm, + } as unknown as EncryptSubmissionDto, + } as unknown as FormCompleteDto, + ) as unknown as SubmitEncryptModeFormHandlerRequest + const mockRes = expressHandler.mockResponse() + + // Setup the SubmissionEmailObj + const emailData = new SubmissionEmailObj( + mockResponses as any as ProcessedFieldResponse[], + new Set(), + FormAuthType.NIL, + ) + + // Act + await submitEncryptModeFormForTest(mockReq, mockRes) + + // Assert + expect(performEncryptPostSubmissionActionsSpy.mock.calls[0][2]).toEqual( + emailData, + ) }) }) }) 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 c115d8459b..ec82ea1f22 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -7,6 +7,7 @@ import Stripe from 'stripe' import { DateString, + ErrorCode, ErrorDto, FormAuthType, Payment, @@ -14,8 +15,8 @@ import { PaymentType, StorageModeSubmissionContentDto, } from '../../../../../shared/types' -import { maskNric } from '../../../../../shared/utils/nric-mask' import { + IAttachmentInfo, IEncryptedForm, IEncryptedSubmissionSchema, IPopulatedEncryptedForm, @@ -43,6 +44,11 @@ import { ApplicationError } from '../../core/core.errors' import { ControllerHandler } from '../../core/core.types' import { setFormTags } from '../../datadog/datadog.utils' import { PermissionLevel } from '../../form/admin-form/admin-form.types' +import { + FormRespondentNotWhitelistedError, + FormRespondentSingleSubmissionValidationError, +} from '../../form/form.errors' +import * as FormService from '../../form/form.service' import { MyInfoService } from '../../myinfo/myinfo.service' import { extractMyInfoLoginJwt } from '../../myinfo/myinfo.util' import { SgidService } from '../../sgid/sgid.service' @@ -255,83 +261,120 @@ const submitEncryptModeForm = async ( } } - let submitterId - // Generate submitterId for Singpass auth modes - if (userName && form.authType !== FormAuthType.NIL) { - submitterId = generateHashedSubmitterId(userName, form.id) - } + const submitterId = userName?.toUpperCase() - // Mask if Nric masking is enabled if ( - userName && - form.isNricMaskEnabled && + submitterId && + form.whitelistedSubmitterIds?.isWhitelistEnabled && (form.authType === FormAuthType.SP || form.authType === FormAuthType.CP || form.authType === FormAuthType.SGID || form.authType === FormAuthType.MyInfo || form.authType === FormAuthType.SGID_MyInfo) ) { - userName = maskNric(userName) - } + const hasRespondentNotWhitelistedErrorResult = + await FormService.checkHasRespondentNotWhitelistedFailure( + form, + submitterId, + ) - // Add NDI responses - switch (form.authType) { - case FormAuthType.CP: { - if (!userName || !userInfo) break - parsedResponses.addNdiResponses({ - authType, - uinFin: userName, - userInfo, + if (hasRespondentNotWhitelistedErrorResult.isErr()) { + const error = hasRespondentNotWhitelistedErrorResult.error + logger.error({ + message: 'Error validating if respondent is whitelisted', + meta: logMeta, + error, }) - break + return res.sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) } - case FormAuthType.SP: - case FormAuthType.SGID: - case FormAuthType.MyInfo: - case FormAuthType.SGID_MyInfo: { - if (!userName) break - parsedResponses.addNdiResponses({ - authType: form.authType, - uinFin: userName, + + const hasRespondentNotWhitelistedError = + hasRespondentNotWhitelistedErrorResult.value + + // Note: hasRespondentNotWhitelistedError occur if admin opens form, + // updates whitelist which excludes submitterId, + // then opens form before respondent submits. + if (hasRespondentNotWhitelistedError) { + const formRespondentNotWhitelistedError = + new FormRespondentNotWhitelistedError() + logger.error({ + message: formRespondentNotWhitelistedError.message, + meta: logMeta, + error: formRespondentNotWhitelistedError, + }) + return res.status(StatusCodes.FORBIDDEN).json({ + message: formRespondentNotWhitelistedError.message, }) - break } } + let hashedSubmitterId + // Generate submitterId for Singpass auth modes + if (submitterId && form.authType !== FormAuthType.NIL) { + hashedSubmitterId = generateHashedSubmitterId(submitterId, form.id) + } + // Encrypt Verified SPCP Fields let verified - if ( - form.authType === FormAuthType.SP || - form.authType === FormAuthType.CP || - form.authType === FormAuthType.SGID || - form.authType === FormAuthType.MyInfo || - form.authType === FormAuthType.SGID_MyInfo - ) { - const encryptVerifiedContentResult = - VerifiedContentService.getVerifiedContent({ - type: form.authType, - data: { uinFin: userName, userInfo }, - }).andThen((verifiedContent) => - VerifiedContentService.encryptVerifiedContent({ - verifiedContent, - formPublicKey: form.publicKey, - }), - ) + if (form.isSubmitterIdCollectionEnabled) { + // Add NDI responses to email payload + switch (form.authType) { + case FormAuthType.CP: { + if (!userName || !userInfo) break + parsedResponses.addNdiResponses({ + authType, + uinFin: userName, + userInfo, + }) + break + } + case FormAuthType.SP: + case FormAuthType.SGID: + case FormAuthType.MyInfo: + case FormAuthType.SGID_MyInfo: { + if (!userName) break + parsedResponses.addNdiResponses({ + authType: form.authType, + uinFin: userName, + }) + break + } + } - if (encryptVerifiedContentResult.isErr()) { - const { error } = encryptVerifiedContentResult - logger.error({ - message: 'Unable to encrypt verified content', - meta: logMeta, - error, - }) + // generate verified content which is used to construct submitter login id for form response + if ( + form.authType === FormAuthType.SP || + form.authType === FormAuthType.CP || + form.authType === FormAuthType.SGID || + form.authType === FormAuthType.MyInfo || + form.authType === FormAuthType.SGID_MyInfo + ) { + const encryptVerifiedContentResult = + VerifiedContentService.getVerifiedContent({ + type: form.authType, + data: { uinFin: userName, userInfo }, + }).andThen((verifiedContent) => + VerifiedContentService.encryptVerifiedContent({ + verifiedContent, + formPublicKey: form.publicKey, + }), + ) - return res - .status(StatusCodes.BAD_REQUEST) - .json({ message: 'Invalid data was found. Please submit again.' }) - } else { - // No errors, set local variable to the encrypted string. - verified = encryptVerifiedContentResult.value + if (encryptVerifiedContentResult.isErr()) { + const { error } = encryptVerifiedContentResult + logger.error({ + message: 'Unable to encrypt verified content', + meta: logMeta, + error, + }) + + return res + .status(StatusCodes.BAD_REQUEST) + .json({ message: 'Invalid data was found. Please submit again.' }) + } else { + // No errors, set local variable to the encrypted string. + verified = encryptVerifiedContentResult.value + } } } @@ -359,7 +402,7 @@ const submitEncryptModeForm = async ( const submissionContent: EncryptSubmissionContent = { form: form._id, authType: form.authType, - submitterId, + submitterId: hashedSubmitterId, myInfoFields: form.getUniqueMyInfoAttrs(), encryptedContent: encryptedContent, verifiedContent: verified, @@ -393,6 +436,7 @@ const submitEncryptModeForm = async ( formId, form, responses: req.formsg.filteredResponses, + unencryptedAttachments: req.formsg.unencryptedAttachments, emailFields: parsedResponses.getAllResponses(), responseMetadata, submissionContent, @@ -656,12 +700,14 @@ const _createSubmission = async ({ form, responseMetadata, responses, + unencryptedAttachments, emailFields, }: { req: Parameters[0] res: Parameters[1] responseMetadata: EncryptSubmissionDto['responseMetadata'] responses: ParsedClearFormFieldResponse[] + unencryptedAttachments?: IAttachmentInfo[] emailFields: ProcessedFieldResponse[] formId: string form: IPopulatedEncryptedForm @@ -684,10 +730,16 @@ const _createSubmission = async ({ // handles the case where submission has already been created for given submissionSingpassId if (!submission) { + const formSingleSubmissionError = + new FormRespondentSingleSubmissionValidationError() + logger.error({ + message: formSingleSubmissionError.message, + meta: logMeta, + error: formSingleSubmissionError, + }) return res.status(StatusCodes.BAD_REQUEST).json({ - message: - 'Your NRIC/FIN/UEN has already been used to respond to this form.', - hasSingleSubmissionValidationFailure: true, + message: formSingleSubmissionError.message, + errorCodes: [ErrorCode.respondentSingleSubmissionValidationFailure], }) } } else { @@ -738,7 +790,6 @@ const _createSubmission = async ({ new Set(), // the MyInfo prefixes are already inserted in middleware form.authType, ) - // We don't await for email submission, as the submission gets saved for encrypt // submissions regardless, the email is more of a notification and shouldn't // stop the storage of the data in the db @@ -775,7 +826,12 @@ const _createSubmission = async ({ timestamp: createdTime.getTime(), }) - return await performEncryptPostSubmissionActions(submission, responses) + return await performEncryptPostSubmissionActions( + submission, + responses, + emailData, + unencryptedAttachments, + ) } export const handleStorageSubmission = [ 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 76172d2606..4f61409015 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts @@ -358,6 +358,7 @@ export const validateStorageSubmission = async ( .map((parsedResponses) => { const responses = [] as EncryptFormFieldResponse[] for (const response of parsedResponses.getAllResponses()) { + // `isVisible` is being stripped out here. Why: https://github.com/opengovsg/FormSG/pull/6907 if (response.isVisible) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { isVisible: _, ...rest } = response @@ -463,6 +464,21 @@ export const encryptSubmission = async ( req.body.version, ) + // Autoreplies are sent after the submission has been saved in the DB, + // but attachments are stripped here. To ensure that users receive their + // attachments in the autoreply we keep the attachments in req.formsg + if (req.formsg) { + req.formsg.unencryptedAttachments = req.body.responses + .filter(isAttachmentResponse) + .map((response) => { + return { + filename: response.filename, + content: response.content, + fieldId: response._id, + } + }) + } + const strippedBodyResponses = req.body.responses.map((response) => { if (isAttachmentResponse(response)) { return { diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts index c6024ddea9..014a01b258 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts @@ -8,6 +8,7 @@ import { } from '../../../../../shared/types' import { FieldResponse, + IAttachmentInfo, IEncryptedSubmissionSchema, IPopulatedEncryptedForm, IPopulatedForm, @@ -25,6 +26,7 @@ import { WebhookValidationError, } from '../../webhook/webhook.errors' import { WebhookFactory } from '../../webhook/webhook.factory' +import { SubmissionEmailObj } from '../email-submission/email-submission.util' import { ResponseModeError, SendEmailConfirmationError, @@ -141,6 +143,8 @@ export const createEncryptSubmissionWithoutSave = ({ export const performEncryptPostSubmissionActions = ( submission: IEncryptedSubmissionSchema, responses: FieldResponse[], + emailData?: SubmissionEmailObj, + attachments?: IAttachmentInfo[], ): ResultAsync< true, | FormNotFoundError @@ -171,6 +175,8 @@ export const performEncryptPostSubmissionActions = ( return sendEmailConfirmations({ form, submission, + attachments, + responsesData: emailData?.autoReplyData, recipientData: extractEmailConfirmationData( responses, form.form_fields, diff --git a/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts new file mode 100644 index 0000000000..c7d597a5d3 --- /dev/null +++ b/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts @@ -0,0 +1,336 @@ +import dbHandler from '__tests__/unit/backend/helpers/jest-db' +import expressHandler from '__tests__/unit/backend/helpers/jest-express' +import { ObjectId } from 'bson' +import { merge } from 'lodash' +import mongoose from 'mongoose' +import { ok, okAsync } from 'neverthrow' +import { FormAuthType, FormWorkflowStepDto, WorkflowType } from 'shared/types' + +import { getMultirespondentSubmissionModel } from 'src/app/models/submission.server.model' +import * as FormService from 'src/app/modules/form/form.service' +import MailService from 'src/app/services/mail/mail.service' + +import { + submitMultirespondentFormForTest, + updateMultirespondentSubmissionForTest, +} from '../multirespondent-submission.controller' + +jest.mock('src/app/modules/datadog/datadog.utils') + +const MultiRespondentSubmission = getMultirespondentSubmissionModel(mongoose) + +const MockFormService = jest.mocked(FormService) + +describe('multirespondent-submission.controller', () => { + beforeAll(async () => { + await dbHandler.connect() + }) + + afterEach(async () => { + jest.clearAllMocks() + await dbHandler.clearDatabase() + }) + + afterAll(async () => { + await dbHandler.closeDatabase() + }) + + const mockFormId = new ObjectId().toHexString() + const mockMrfForm = { + _id: mockFormId, + workflow: [], + } + + const mockSubmissionId = new ObjectId().toHexString() + + describe('mrf completion email notification', () => { + beforeAll(() => { + MockFormService.isFormPublic = jest.fn().mockReturnValue(ok(true)) + MockFormService.checkIsIntranetFormAccess = jest + .fn() + .mockReturnValue(false) + MockFormService.checkFormSubmissionLimitAndDeactivateForm = jest + .fn() + .mockReturnValue(okAsync(mockMrfForm)) + }) + + it('sends completion email when single step mrf is completed', async () => { + // Arrange + const sendMrfWorkflowCompletionEmailSpy = jest.spyOn( + MailService, + 'sendMrfWorkflowCompletionEmail', + ) + + const singleStepWorkflow: FormWorkflowStepDto[] = [ + { + _id: new ObjectId().toHexString(), + workflow_type: WorkflowType.Static, + emails: [], + edit: [], + }, + ] + + MockFormService.checkFormSubmissionLimitAndDeactivateForm = jest + .fn() + .mockReturnValue(okAsync(mockMrfForm)) + + const mockReq = expressHandler.mockRequest({ + params: { formId: mockFormId }, + body: {} as any, + }) + const mockSubmitMrfReq = merge(mockReq, { + formsg: { + formDef: { + _id: mockFormId, + authType: FormAuthType.NIL, + getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), + workflow: singleStepWorkflow, + emails: ['email1@example.com'], + }, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + }, + } as any, + }) + const mockRes = expressHandler.mockResponse({}) + + // Act + await submitMultirespondentFormForTest(mockSubmitMrfReq, mockRes) + + // Assert + expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, + ).toContainAllValues(['email1@example.com']) + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails.length, + ).toBe(1) + }) + + it('sends completion email when multi-step mrf is completed and only to specified steps only and also static emails', async () => { + // Arrange + const sendMrfWorkflowCompletionEmailSpy = jest.spyOn( + MailService, + 'sendMrfWorkflowCompletionEmail', + ) + + const expectedEmails = [ + 'expected1@example.com', + 'expected2@example.com', + 'expected3@example.com', + 'expected4@example.com', + ] + + const stepOneId = new ObjectId().toHexString() + const stepTwoId = new ObjectId().toHexString() + const stepThreeId = new ObjectId().toHexString() + const stepFourId = new ObjectId().toHexString() + + const emailFieldId1 = new ObjectId().toHexString() + const emailFieldId2 = new ObjectId().toHexString() + + const submissionResponses = { + [emailFieldId1]: { + fieldType: 'email', + answer: { + value: expectedEmails[0], + }, + }, + [emailFieldId2]: { + fieldType: 'email', + answer: { + value: 'not_expected_1@example.com', + }, + }, + } + + const fourStepWorkflow: FormWorkflowStepDto[] = [ + { + _id: stepOneId, + workflow_type: WorkflowType.Dynamic, + field: emailFieldId1, + edit: [], + }, + { + _id: stepTwoId, + workflow_type: WorkflowType.Static, + emails: ['not_expected_2@example.com'], + edit: [], + }, + { + _id: stepThreeId, + workflow_type: WorkflowType.Dynamic, + field: emailFieldId2, + edit: [], + }, + { + _id: stepFourId, + workflow_type: WorkflowType.Static, + emails: [expectedEmails[1], expectedEmails[2]], + edit: [], + }, + ] + + MockFormService.checkFormSubmissionLimitAndDeactivateForm = jest + .fn() + .mockReturnValue(okAsync(mockMrfForm)) + + MultiRespondentSubmission.findById = jest.fn().mockReturnValue({ + save: () => true, + }) + + const mockReq = expressHandler.mockRequest({ + params: { + formId: mockFormId, + submissionId: mockSubmissionId, + }, + body: {} as any, + }) + const mockSubmitMrfReq = merge(mockReq, { + formsg: { + formDef: { + _id: mockFormId, + authType: FormAuthType.NIL, + getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), + workflow: fourStepWorkflow, + emails: [expectedEmails[3]], + stepsToNotify: [stepOneId, stepFourId], + }, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: submissionResponses, + workflowStep: fourStepWorkflow.length - 1, // last step + }, + } as any, + }) + const mockRes = expressHandler.mockResponse({}) + + // Act + await updateMultirespondentSubmissionForTest(mockSubmitMrfReq, mockRes) + + // Assert + expect(sendMrfWorkflowCompletionEmailSpy).toHaveBeenCalledTimes(1) + // The emails sent to should only be the expected emails exactly + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails, + ).toContainAllValues(expectedEmails) + expect( + sendMrfWorkflowCompletionEmailSpy.mock.calls[0][0].emails.length, + ).toBe(expectedEmails.length) + }) + + it('does not send completion email when step number >0 and mrf not completed', async () => { + // Arrange + const sendMrfWorkflowCompletionEmailSpy = jest.spyOn( + MailService, + 'sendMrfWorkflowCompletionEmail', + ) + + const selectedEmails = [ + 'seelcted1@example.com', + 'seelcted2@example.com', + 'seelcted3@example.com', + 'seelcted4@example.com', + ] + + const stepOneId = new ObjectId().toHexString() + const stepTwoId = new ObjectId().toHexString() + const stepThreeId = new ObjectId().toHexString() + const stepFourId = new ObjectId().toHexString() + + const emailFieldId1 = new ObjectId().toHexString() + const emailFieldId2 = new ObjectId().toHexString() + + const submissionResponses = { + [emailFieldId1]: { + fieldType: 'email', + answer: { + value: selectedEmails[0], + }, + }, + [emailFieldId2]: { + fieldType: 'email', + answer: { + value: 'not_selected_1@example.com', + }, + }, + } + + const fourStepWorkflow: FormWorkflowStepDto[] = [ + { + _id: stepOneId, + workflow_type: WorkflowType.Dynamic, + field: emailFieldId1, + edit: [], + }, + { + _id: stepTwoId, + workflow_type: WorkflowType.Static, + emails: ['not_selected_2@example.com'], + edit: [], + }, + { + _id: stepThreeId, + workflow_type: WorkflowType.Dynamic, + field: emailFieldId2, + edit: [], + }, + { + _id: stepFourId, + workflow_type: WorkflowType.Static, + emails: [selectedEmails[1], selectedEmails[2]], + edit: [], + }, + ] + + MockFormService.checkFormSubmissionLimitAndDeactivateForm = jest + .fn() + .mockReturnValue(okAsync(mockMrfForm)) + + MultiRespondentSubmission.findById = jest.fn().mockReturnValue({ + save: () => true, + }) + + const mockReq = expressHandler.mockRequest({ + params: { + formId: mockFormId, + submissionId: mockSubmissionId, + }, + body: {} as any, + }) + const mockSubmitMrfReq = merge(mockReq, { + formsg: { + formDef: { + _id: mockFormId, + authType: FormAuthType.NIL, + getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), + workflow: fourStepWorkflow, + emails: [selectedEmails[3]], + stepsToNotify: [stepOneId, stepFourId], + }, + encryptedPayload: { + encryptedContent: 'encryptedContent', + version: 1, + submissionPublicKey: 'submissionPublicKey', + encryptedSubmissionSecretKey: 'encryptedSubmissionSecretKey', + responses: submissionResponses, + workflowStep: fourStepWorkflow.length - 2, // not last step + }, + } as any, + }) + const mockRes = expressHandler.mockResponse({}) + + // Act + await updateMultirespondentSubmissionForTest(mockSubmitMrfReq, mockRes) + + // Assert + expect(sendMrfWorkflowCompletionEmailSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts index a77f808666..24eade2097 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts @@ -9,7 +9,6 @@ import { ErrorDto, FieldResponsesV3, FormAuthType, - FormWorkflowDto, MultirespondentSubmissionDto, SubmissionType, } from '../../../../../shared/types' @@ -18,7 +17,8 @@ import { Environment, IPopulatedMultirespondentForm, } from '../../../../../src/types' -import config from '../../../config/config' +// TODO: (MRF-email-notif) Remove isTest import when MRF email notifications is out of beta +import config, { isTest } from '../../../config/config' import { createLoggerWithLabel, CustomLoggerParams, @@ -54,7 +54,10 @@ import { mapRouteError } from '../submission.utils' import { reportSubmissionResponseTime } from '../submissions.statsd-client' import * as MultirespondentSubmissionMiddleware from './multirespondent-submission.middleware' -import { checkFormIsMultirespondent } from './multirespondent-submission.service' +import { + checkFormIsMultirespondent, + sendMrfOutcomeEmails, +} from './multirespondent-submission.service' import { MultirespondentSubmissionContent, SubmitMultirespondentFormHandlerRequest, @@ -189,6 +192,8 @@ const submitMultirespondentForm = async ( }) } +export const submitMultirespondentFormForTest = submitMultirespondentForm + const _createSubmission = async ({ req, res, @@ -259,10 +264,12 @@ const _createSubmission = async ({ // TODO(MRF/FRM-1591): Add post-submission actions handling // return await performEncryptPostSubmissionActions(submission, responses) + const currentStepNumber = submissionContent.workflowStep + try { - await runMultirespondentWorkflow({ - nextWorkflowStep: submissionContent.workflowStep + 1, // we want to send emails to the addresses linked to the next step of the workflow - formWorkflow: form.workflow ?? [], + await sendNextStepEmail({ + nextStepNumber: currentStepNumber + 1, // we want to send emails to the addresses linked to the next step of the workflow + form, formTitle: form.title, responseUrl: `${appUrl}/${getMultirespondentSubmissionEditPath( form._id, @@ -279,26 +286,50 @@ const _createSubmission = async ({ meta: { ...logMeta, ...createReqMeta(req), - currentWorkflowStep: submissionContent.workflowStep, + currentWorkflowStep: currentStepNumber, formId: form._id, submissionId, }, error: err, }) } + + // TODO: (MRF-email-notif) Remove isTest and betaFlag check when MRF email notifications is out of beta + if (isTest || form.admin.betaFlags.mrfEmailNotifications) { + try { + await sendMrfOutcomeEmails({ + currentStepNumber, + form, + responses, + submissionId, + }) + } catch (err) { + logger.error({ + message: 'Send mrf outcome email error', + meta: { + ...logMeta, + ...createReqMeta(req), + currentWorkflowStep: currentStepNumber, + formId: form._id, + submissionId, + }, + error: err, + }) + } + } } -const runMultirespondentWorkflow = ({ - nextWorkflowStep, - formWorkflow, +const sendNextStepEmail = ({ + nextStepNumber, + form, formTitle, responseUrl, formId, submissionId, responses, }: { - nextWorkflowStep: number - formWorkflow: FormWorkflowDto + nextStepNumber: number + form: IPopulatedMultirespondentForm formTitle: string responseUrl: string formId: string @@ -306,18 +337,20 @@ const runMultirespondentWorkflow = ({ responses: FieldResponsesV3 }): ResultAsync => { const logMeta = { - action: 'runMultirespondentWorkflow', + action: 'sendNextStepEmail', formId, submissionId, - nextWorkflowStep, + nextWorkflowStep: nextStepNumber, + } + + const nextStep = form.workflow[nextStepNumber] + if (!nextStep) { + return okAsync(true) } + return ( // Step 1: Retrieve email addresses for current workflow step - retrieveWorkflowStepEmailAddresses( - formWorkflow, - nextWorkflowStep, - responses, - ) + retrieveWorkflowStepEmailAddresses(nextStep, responses) .mapErr((error) => { logger.error({ message: 'Failed to retrieve workflow step email addresses', @@ -326,8 +359,7 @@ const runMultirespondentWorkflow = ({ }) return error }) - - // Step 2: send out workflow email + // Step 2: send out next workflow step email .asyncAndThen((emails) => { if (!emails) return okAsync(true) return MailService.sendMRFWorkflowStepEmail({ @@ -474,9 +506,9 @@ const updateMultirespondentSubmission = async ( }) try { - await runMultirespondentWorkflow({ - nextWorkflowStep: workflowStep + 1, // we want to send emails to the addresses linked to the next step of the workflow - formWorkflow: submission.workflow, + await sendNextStepEmail({ + nextStepNumber: workflowStep + 1, + form, formTitle: form.title, responseUrl: `${appUrl}/${getMultirespondentSubmissionEditPath( form._id, @@ -500,8 +532,35 @@ const updateMultirespondentSubmission = async ( error: err, }) } + + // TODO: (MRF-email-notif) Remove isTest and betaFlag check when MRF email notifications is out of beta + if (isTest || form.admin.betaFlags.mrfEmailNotifications) { + try { + await sendMrfOutcomeEmails({ + currentStepNumber: workflowStep, + form, + responses, + submissionId, + }) + } catch (err) { + logger.error({ + message: 'Send mrf outcome email error', + meta: { + ...logMeta, + ...createReqMeta(req), + currentWorkflowStep: workflowStep, + formId: form._id, + submissionId, + }, + error: err, + }) + } + } } +export const updateMultirespondentSubmissionForTest = + updateMultirespondentSubmission + export const handleMultirespondentSubmission = [ CaptchaMiddleware.validateCaptchaParams, TurnstileMiddleware.validateTurnstileParams, diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts index 1ce6591dee..1201db1ce0 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts @@ -243,8 +243,9 @@ export const scanAndRetrieveAttachments = async ( .map((id) => { const response = req.body.responses[id] if ( - response.fieldType !== BasicField.Attachment || - response.answer.hasBeenScanned + response.fieldType !== BasicField.Attachment + // TODO: FRM-1839 + FRM-1590 Skip scanning if attachment has already been scanned + // || response.answer.hasBeenScanned ) { return null } @@ -293,6 +294,7 @@ export const scanAndRetrieveAttachments = async ( // Step 3: Update responses with new values. for (const idTaggedAttachmentResponse of scanAndRetrieveFilesResult.value) { const { id, ...attachmentResponse } = idTaggedAttachmentResponse + // TODO: FRM-1839 Skip scanning if attachment has already been scanned attachmentResponse.answer.hasBeenScanned = true // Store the md5 hash in the DB as well for comparison later on. attachmentResponse.answer.md5Hash = crypto @@ -424,6 +426,7 @@ export const validateMultirespondentSubmission = async ( ) .andThen(() => { // Step 3: Match non-editable response fields to previous version + const nonEditableFieldIdsWithResponses = Object.keys( req.body.responses, ).filter((fieldId) => !editableFieldIds.includes(fieldId)) @@ -461,6 +464,17 @@ export const validateMultirespondentSubmission = async ( const previousResponses = previousSubmissionDecryptedContent.responses as ParsedClearFormFieldResponsesV3 + const previousNonEditableFieldIdsWithResponses = Object.keys( + previousResponses, + ).filter((fieldId) => !editableFieldIds.includes(fieldId)) + + for (const fieldId of previousNonEditableFieldIdsWithResponses) { + // ensure that respondents cannot alter a non-editable field by omitting the field in the submission by re-inserting the previous fields that are non-editable + if (!req.body.responses[fieldId]) { + req.body.responses[fieldId] = previousResponses[fieldId] + } + } + return Result.combine( nonEditableFieldIdsWithResponses.map((fieldId) => { const incomingResField = req.body.responses[fieldId] diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts index ef1a04e275..b59b179035 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts @@ -1,7 +1,13 @@ +import { flatten } from 'lodash' import mongoose from 'mongoose' import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' -import { FormResponseMode } from '../../../../../shared/types' +import { + FieldResponsesV3, + FormResponseMode, + FormWorkflowDto, + FormWorkflowStepDto, +} from '../../../../../shared/types' import { IMultirespondentSubmissionSchema, IPopulatedForm, @@ -9,14 +15,19 @@ import { } from '../../../../types' import { createLoggerWithLabel } from '../../../config/logger' import { getMultirespondentSubmissionModel } from '../../../models/submission.server.model' +import { MailSendError } from '../../../services/mail/mail.errors' +import MailService from '../../../services/mail/mail.service' import { transformMongoError } from '../../../utils/handle-mongo-error' import { DatabaseError } from '../../core/core.errors' import { isFormMultirespondent } from '../../form/form.utils' import { + InvalidWorkflowTypeError, ResponseModeError, SubmissionNotFoundError, } from '../submission.errors' +import { retrieveWorkflowStepEmailAddresses } from './multirespondent-submission.utils' + const logger = createLoggerWithLabel(module) const MultirespondentSubmission = getMultirespondentSubmissionModel(mongoose) @@ -60,3 +71,112 @@ export const getMultirespondentSubmission = ( } return okAsync(submission) }) + +export const sendMrfOutcomeEmails = ({ + currentStepNumber, + form, + responses, + submissionId, +}: { + currentStepNumber: number + form: IPopulatedMultirespondentForm + responses: FieldResponsesV3 + submissionId: string +}): ResultAsync => { + const logMeta = { + action: 'sendMrfOutcomeEmails', + formId: form._id, + submissionId, + } + const emailsToNotify = form.emails ?? [] + + const validWorkflowStepsToNotify = (form.stepsToNotify ?? []) + .map((stepId) => + form.workflow.find((step) => step._id.toString() === stepId), + ) + .filter( + (workflowStep) => workflowStep !== undefined, + ) as FormWorkflowStepDto[] + + return ( + // Step 1: Fetch email address from all workflow steps that are selected to notify + Result.combine( + validWorkflowStepsToNotify.map((workflowStep) => + retrieveWorkflowStepEmailAddresses(workflowStep, responses), + ), + ) + .mapErr((error) => { + logger.error({ + message: 'Failed to retrieve workflow step email addresses', + meta: logMeta, + error, + }) + return error + }) + .map((workflowStepEmailsToNotifyList) => { + return flatten(workflowStepEmailsToNotifyList) + }) + // Step 2: Combine static emails and workflow step emails that are selected to notify + .map((workflowStepEmailsToNotify) => { + return [...workflowStepEmailsToNotify, ...emailsToNotify] + }) + // Step 3: Send outcome emails based on type + .asyncAndThen((destinationEmails) => { + if (!destinationEmails || destinationEmails.length <= 0) + return okAsync(true) + + return sendMrfCompletionEmailIfWorkflowCompleted({ + currentStepNumber, + formWorkflow: form.workflow, + destinationEmails, + formId: form._id, + formTitle: form.title, + submissionId, + }) + }) + ) +} + +const sendMrfCompletionEmailIfWorkflowCompleted = ({ + currentStepNumber, + formWorkflow, + destinationEmails, + formId, + formTitle, + submissionId, +}: { + currentStepNumber: number + formWorkflow: FormWorkflowDto + destinationEmails: string[] + formId: string + formTitle: string + submissionId: string +}): ResultAsync => { + const logMeta = { + action: 'sendMrfCompletionEmail', + formId, + submissionId, + } + + const lastStepNumber = formWorkflow.length - 1 + const isLastStep = currentStepNumber === lastStepNumber + const isWorkflowCompleted = isLastStep + + if (isWorkflowCompleted) { + return MailService.sendMrfWorkflowCompletionEmail({ + emails: destinationEmails, + formId, + formTitle, + responseId: submissionId, + }).orElse((error) => { + logger.error({ + message: 'Failed to send workflow completion email', + meta: { ...logMeta, destinationEmails }, + error, + }) + return errAsync(error) + }) + } else { + return okAsync(true) + } +} diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts index ff0247d48e..79a17a8c38 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts @@ -4,7 +4,7 @@ import { err, ok, Result } from 'neverthrow' import { BasicField, FieldResponsesV3, - FormWorkflowDto, + FormWorkflowStepDto, MultirespondentSubmissionDto, SubmissionType, WorkflowType, @@ -42,11 +42,9 @@ export const createMultirespondentSubmissionDto = ( } export const retrieveWorkflowStepEmailAddresses = ( - formWorkflow: FormWorkflowDto, - nextWorkflowStep: number, + step: FormWorkflowStepDto, responses: FieldResponsesV3, ): Result => { - const step = formWorkflow[nextWorkflowStep] if (!step) return ok([]) // Not an error, just that the form has gone past its predefined workflow switch (step.workflow_type) { case WorkflowType.Static: { @@ -54,7 +52,7 @@ export const retrieveWorkflowStepEmailAddresses = ( } case WorkflowType.Dynamic: { const field = responses[step.field] - if (!field || field.fieldType !== BasicField.Email) return ok([]) // Also not an error, just misconfigured + if (!field || field.fieldType !== BasicField.Email) return ok([]) // Not an error, misconfigured or respondent has not filled. return ok([field.answer.value]) } default: { diff --git a/src/app/modules/submission/receiver/receiver.types.ts b/src/app/modules/submission/receiver/receiver.types.ts index 75598d80aa..f7740dbb00 100644 --- a/src/app/modules/submission/receiver/receiver.types.ts +++ b/src/app/modules/submission/receiver/receiver.types.ts @@ -17,6 +17,11 @@ export const isBodyVersion2AndBelow = ( return (body.version ?? 0) < 3 } +/** + * Checks if body is for Multirespondent forms which use version >=3. + * @param body to check version for + * @returns true if body is for Multirespondent forms, false otherwise + */ export const isBodyVersion3AndAbove = ( body: ParsedMultipartForm, ): body is ParsedMultipartForm => { diff --git a/src/app/modules/submission/receiver/receiver.utils.ts b/src/app/modules/submission/receiver/receiver.utils.ts index 6162cecb26..24e4708935 100644 --- a/src/app/modules/submission/receiver/receiver.utils.ts +++ b/src/app/modules/submission/receiver/receiver.utils.ts @@ -53,19 +53,6 @@ export const mapRouteError: MapRouteError = (error) => { } } -/** - * Checks whether attachmentMap contains the given response - * @param attachmentMap Map of field IDs to attachments - * @param response The response to check - * @returns true if response is in map, false otherwise - */ -const isAttachmentResponseFromMap = ( - attachmentMap: Record, - response: ParsedClearFormFieldResponse, -): response is ParsedClearAttachmentResponse => { - return !!attachmentMap[response._id] -} - /** * Adds the attachment's content, filename to each response, * based on their fieldId. @@ -98,12 +85,16 @@ export const addAttachmentToResponses = ( if (responses) { // matches responses to attachments using id, adding filename and content to response responses.forEach((response) => { - if (isAttachmentResponseFromMap(attachmentMap, response)) { + if ( + response.fieldType === BasicField.Attachment && + response._id in attachmentMap + ) { const file = attachmentMap[response._id] - response.filename = file.filename - response.content = file.content + const attachmentResponse = response as ParsedClearAttachmentResponse + attachmentResponse.filename = file.filename + attachmentResponse.content = file.content if (!isVirusScannerEnabled) { - response.answer = file.filename + attachmentResponse.answer = file.filename } } }) @@ -113,7 +104,7 @@ export const addAttachmentToResponses = ( if (isBodyVersion3AndAbove(body)) { Object.keys(body.responses).forEach((id) => { const response = body.responses[id] as ParsedClearFormFieldResponseV3 - if (response.fieldType === BasicField.Attachment) { + if (response.fieldType === BasicField.Attachment && id in attachmentMap) { const file = attachmentMap[id] response.answer.filename = file.filename response.answer.content = file.content diff --git a/src/app/modules/webhook/webhook.constants.ts b/src/app/modules/webhook/webhook.constants.ts index 2a193deb5d..202cb46703 100644 --- a/src/app/modules/webhook/webhook.constants.ts +++ b/src/app/modules/webhook/webhook.constants.ts @@ -24,7 +24,7 @@ const minutes = (m: number) => m * 60 * then the second retry is attempted between 15 and 25 seconds after * the submission. */ -export const RETRY_INTERVALS: RetryInterval[] = config.isDev +export const RETRY_INTERVALS: RetryInterval[] = config.isDevOrTest ? [ { base: 10, jitter: 5 }, { base: 20, jitter: 5 }, diff --git a/src/app/modules/webhook/webhook.consumer.ts b/src/app/modules/webhook/webhook.consumer.ts index 9d770ae225..3ff1bd3db1 100644 --- a/src/app/modules/webhook/webhook.consumer.ts +++ b/src/app/modules/webhook/webhook.consumer.ts @@ -41,7 +41,7 @@ export const startWebhookConsumer = ( // creates a new TCP connection for every new request. // In production, pass an SQS instance to avoid the cost // of establishing new connections. - sqs: config.isDev + sqs: config.isDevOrTest ? undefined : new aws.SQS({ region: config.aws.region, diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts index 0bfcfbfab3..0a7fcc8977 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.settings.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express' +import { rateLimitConfig } from '../../../../../config/config' import * as AdminFormController from '../../../../../modules/form/admin-form/admin-form.controller' +import { limitRate } from '../../../../../utils/limit-rate' export const AdminFormsSettingsRouter = Router() @@ -39,6 +41,16 @@ AdminFormsSettingsRouter.route('/:formId([a-fA-F0-9]{24})/settings') */ .get(AdminFormController.handleGetSettings) +AdminFormsSettingsRouter.route('/:formId([a-fA-F0-9]{24})/settings/whitelist') + .get( + limitRate({ max: rateLimitConfig.downloadFormWhitelist }), + AdminFormController.handleGetWhitelistSetting, + ) + .put( + limitRate({ max: rateLimitConfig.uploadFormWhitelist }), + AdminFormController.handleUpdateWhitelistSetting, + ) + AdminFormsSettingsRouter.route('/:formId([a-fA-F0-9]{24})/collaborators') /** * Updates the collaborator list for a given formId diff --git a/src/app/routes/api/v3/forms/public-forms.issue.routes.ts b/src/app/routes/api/v3/forms/public-forms.issue.routes.ts index 749661183c..3fe17d4781 100644 --- a/src/app/routes/api/v3/forms/public-forms.issue.routes.ts +++ b/src/app/routes/api/v3/forms/public-forms.issue.routes.ts @@ -1,6 +1,8 @@ import { Router } from 'express' +import { rateLimitConfig } from '../../../../config/config' import * as IssueController from '../../../../modules/issue/issue.controller' +import { limitRate } from '../../../../utils/limit-rate' export const PublicFormsIssueRouter = Router() @@ -19,5 +21,6 @@ export const PublicFormsIssueRouter = Router() * @returns 500 if database error occurs */ PublicFormsIssueRouter.route('/:formId([a-fA-F0-9]{24})/issue').post( + limitRate({ max: rateLimitConfig.publicFormIssueFeedback }), IssueController.handleSubmitFormIssue, ) diff --git a/src/app/services/mail/__tests__/mail.service.spec.ts b/src/app/services/mail/__tests__/mail.service.spec.ts index b46e470bd4..84016d7efd 100644 --- a/src/app/services/mail/__tests__/mail.service.spec.ts +++ b/src/app/services/mail/__tests__/mail.service.spec.ts @@ -3,6 +3,7 @@ import { cloneDeep } from 'lodash' import moment from 'moment-timezone' import { err, ok, okAsync } from 'neverthrow' import Mail, { Attachment } from 'nodemailer/lib/mailer' +import { FormResponseMode, PaymentChannel } from 'shared/types' import { extractFormLinkView } from 'src/app/modules/form/form.utils' import { @@ -17,7 +18,12 @@ import { SendAutoReplyEmailsArgs, } from 'src/app/services/mail/mail.types' import * as MailUtils from 'src/app/services/mail/mail.utils' -import { BounceType, IPopulatedForm, ISubmissionSchema } from 'src/types' +import { + BounceType, + IPopulatedEncryptedForm, + IPopulatedForm, + ISubmissionSchema, +} from 'src/types' import { HASH_EXPIRE_AFTER_SECONDS, @@ -960,6 +966,110 @@ describe('mail.service', () => { expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) }) + it('should send single autoreply mail with PDF if autoReply.includeFormSummary == true and no active payment field', async () => { + // Arrange + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + customDataParams.autoReplyMailDatas[0].includeFormSummary = true + const formDef = { + ...customDataParams.form, + responseMode: FormResponseMode.Encrypt, + payments_channel: { + channel: PaymentChannel.Stripe, + }, + payments_field: { + enabled: false, + }, + } as IPopulatedEncryptedForm as IPopulatedForm + customDataParams.form = formDef + + const expectedRenderData: AutoreplySummaryRenderData = { + formData: MOCK_AUTOREPLY_PARAMS.responsesData, + formTitle: MOCK_AUTOREPLY_PARAMS.form.title, + formUrl: `${MOCK_APP_URL}/${MOCK_AUTOREPLY_PARAMS.form._id}`, + refNo: MOCK_AUTOREPLY_PARAMS.submission.id, + submissionTime: moment(MOCK_AUTOREPLY_PARAMS.submission.created) + .tz('Asia/Singapore') + .format('ddd, DD MMM YYYY hh:mm:ss A'), + } + const expectedMailBody = ( + await MailUtils.generateAutoreplyHtml({ + submissionId: MOCK_AUTOREPLY_PARAMS.submission.id, + autoReplyBody: DEFAULT_AUTO_REPLY_BODY, + ...expectedRenderData, + }) + )._unsafeUnwrap() + + const expectedArg = { + ...defaultExpectedArg, + html: expectedMailBody, + // Attachments should be concatted with mock pdf response + attachments: [ + ...(MOCK_AUTOREPLY_PARAMS.attachments ?? []), + { + content: MOCK_PDF, + filename: 'response.pdf', + }, + ], + } + const expectedResponse = await Promise.allSettled([ok(true)]) + + // Act + const actualResult = + await mailService.sendAutoReplyEmails(customDataParams) + + // Assert + expect(actualResult).toEqual(expectedResponse) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) + }) + + it('should send single autoreply mail without PDF if autoReply.includeFormSummary == true and has active payment field', async () => { + // Arrange + sendMailSpy.mockResolvedValueOnce('mockedSuccessResponse') + + const customDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) + customDataParams.autoReplyMailDatas[0].includeFormSummary = true + const formDef = { + ...customDataParams.form, + responseMode: FormResponseMode.Encrypt, + payments_channel: { + channel: PaymentChannel.Stripe, + }, + payments_field: { + enabled: true, + }, + } as IPopulatedEncryptedForm as IPopulatedForm + customDataParams.form = formDef + + const expectedMailBody = ( + await MailUtils.generateAutoreplyHtml({ + submissionId: MOCK_AUTOREPLY_PARAMS.submission.id, + autoReplyBody: DEFAULT_AUTO_REPLY_BODY, + }) + )._unsafeUnwrap() + + const expectedArg = { + ...defaultExpectedArg, + html: expectedMailBody, + // Attachments should not be concatted with mock pdf response + attachments: [...(MOCK_AUTOREPLY_PARAMS.attachments ?? [])], + } + const expectedResponse = await Promise.allSettled([ok(true)]) + + // Act + const actualResult = + await mailService.sendAutoReplyEmails(customDataParams) + + // Assert + expect(actualResult).toEqual(expectedResponse) + // Check arguments passed to sendNodeMail + expect(sendMailSpy).toHaveBeenCalledTimes(1) + expect(sendMailSpy).toHaveBeenCalledWith(expectedArg) + }) + it('should return MailSendError when autoReplyData.email param is an invalid email', async () => { // Arrange const invalidDataParams = cloneDeep(MOCK_AUTOREPLY_PARAMS) diff --git a/src/app/services/mail/mail.service.ts b/src/app/services/mail/mail.service.ts index dea9e0979e..bc6d5e9a31 100644 --- a/src/app/services/mail/mail.service.ts +++ b/src/app/services/mail/mail.service.ts @@ -7,6 +7,7 @@ import Mail from 'nodemailer/lib/mailer' import promiseRetry from 'promise-retry' import validator from 'validator' +import { FormResponseMode, PaymentChannel } from '../../../../shared/types' import { centsToDollars } from '../../../../shared/utils/payments' import { getPaymentInvoiceDownloadUrlPath } from '../../../../shared/utils/urls' import { @@ -18,6 +19,7 @@ import { EmailAdminDataField, IFormDocument, IFormHasEmailSchema, + IPopulatedEncryptedForm, IPopulatedForm, IPopulatedUser, ISubmissionSchema, @@ -31,6 +33,7 @@ import { getAdminEmails, } from '../../modules/form/form.utils' import { formatAsPercentage } from '../../utils/formatters' +import MrfWorkflowCompletionEmail from '../../views/templates/MrfWorkflowCompletionEmail' import MrfWorkflowEmail, { WorkflowEmailData, } from '../../views/templates/MrfWorkflowEmail' @@ -275,6 +278,7 @@ export class MailService { form, submission, index, + isPaymentEnabled, }: SendSingleAutoreplyMailArgs): ResultAsync< true, MailSendError | MailGenerationError @@ -293,8 +297,11 @@ export class MailService { const templateData = { submissionId: submission.id, autoReplyBody, - // Only destructure formSummaryRenderData if form summary is included. - ...(autoReplyMailData.includeFormSummary && formSummaryRenderData), + // Only destructure formSummaryRenderData if form summary is included + // and there aren't any active payment fields (defaults to false) + ...(autoReplyMailData.includeFormSummary && + !(isPaymentEnabled ?? false) && + formSummaryRenderData), } return generateAutoreplyHtml(templateData).andThen((mailHtml) => { @@ -598,10 +605,19 @@ export class MailService { // Create a copy of attachments for attaching of autoreply pdf if needed. const attachmentsWithAutoreplyPdf = [...attachments] + const isEncryptForm = form?.responseMode === FormResponseMode.Encrypt + const encryptFormDef = form as IPopulatedEncryptedForm + const isPaymentEnabled = + isEncryptForm && + encryptFormDef.payments_channel.channel !== PaymentChannel.Unconnected && + encryptFormDef.payments_field.enabled === true // Generate autoreply pdf and append into attachments if any of the mail has // to include a form summary. - if (autoReplyMailDatas.some((data) => data.includeFormSummary)) { + if ( + autoReplyMailDatas.some((data) => data.includeFormSummary) && + !isPaymentEnabled + ) { const pdfBufferResult = await generateAutoreplyPdf(renderData) if (pdfBufferResult.isErr()) { return Promise.allSettled([err(pdfBufferResult.error)]) @@ -624,6 +640,7 @@ export class MailService { autoReplyMailData: mailData, formSummaryRenderData: renderData, index, + isPaymentEnabled, }) }), ) @@ -1064,6 +1081,37 @@ export class MailService { return this.#sendNodeMail(mail, { mailId: 'workflowNotification' }) } + + sendMrfWorkflowCompletionEmail = ({ + emails, + formId, + formTitle, + responseId, + }: { + emails: string[] + formId: string + formTitle: string + responseId: string + }) => { + const htmlData = { + formTitle, + responseId, + } + + const html = render(MrfWorkflowCompletionEmail(htmlData)) + + const mail: MailOptions = { + to: emails, + from: this.#senderFromString, + subject: `Completed - ${formTitle} (${responseId})`, + html, + headers: { + [EMAIL_HEADERS.emailType]: EmailType.WorkflowNotification, + }, + } + + return this.#sendNodeMail(mail, { formId, mailId: 'workflowNotification' }) + } } export default new MailService() diff --git a/src/app/services/mail/mail.types.ts b/src/app/services/mail/mail.types.ts index a571b81b9f..acb26f8808 100644 --- a/src/app/services/mail/mail.types.ts +++ b/src/app/services/mail/mail.types.ts @@ -23,10 +23,11 @@ export type SendSingleAutoreplyMailArgs = { attachments: Mail.Attachment[] formSummaryRenderData: AutoreplySummaryRenderData index: number + isPaymentEnabled?: boolean } export type SendAutoReplyEmailsArgs = { - form: Pick + form: IPopulatedForm submission: Pick attachments?: Mail.Attachment[] responsesData: Pick[] diff --git a/src/app/utils/response-v3.ts b/src/app/utils/response-v3.ts index 60344ae83a..6ae8a75c39 100644 --- a/src/app/utils/response-v3.ts +++ b/src/app/utils/response-v3.ts @@ -37,8 +37,9 @@ export const isFieldResponseV3Equal = ( const rMd5 = rAnswer.md5Hash return ( - (!lMd5 || !rMd5 || lMd5 === rMd5) && - l.answer.hasBeenScanned === rAnswer.hasBeenScanned + !lMd5 || !rMd5 || lMd5 === rMd5 + // TODO: FRM-1839 + FRM-1590 Skip scanning if attachment has already been scanned + // && l.answer.hasBeenScanned === rAnswer.hasBeenScanned ) } case BasicField.Section: diff --git a/src/app/views/templates/MrfWorkflowCompletionEmail.tsx b/src/app/views/templates/MrfWorkflowCompletionEmail.tsx new file mode 100644 index 0000000000..bc345e4bce --- /dev/null +++ b/src/app/views/templates/MrfWorkflowCompletionEmail.tsx @@ -0,0 +1,76 @@ +import { + Body, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Row, + Text, +} from '@react-email/components' +import { FORMSG_LOGO_URL } from '../../constants/formsg-logo' + +import { + headingStyle, + innerContainerStyle, + outerContainerStyle, + textStyle, +} from './styles' + +export type WorkflowEmailData = { + formTitle: string + responseId: string +} + +export const MrfWorkflowCompletionEmail = ({ + // Defaults are provided only for testing purposes in react-email-preview. + formTitle = 'Test form title', + responseId = '64303c45828035f732088a41' +}: WorkflowEmailData): JSX.Element => { + return ( + + + + + + + FormSG + + + + + + {formTitle} has been completed by all respondents. + + + + + + Response ID + + + {responseId} + + + + +
+
+
+ + + + For more details, please contact the respondent(s) or form administrator. + + + +
+ + + ) +} + +export default MrfWorkflowCompletionEmail diff --git a/src/types/config.ts b/src/types/config.ts index 9d06d38316..d8a24929ff 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -59,7 +59,10 @@ export type MailConfig = { export type RateLimitConfig = { submissions: number sendAuthOtp: number + publicFormIssueFeedback: number downloadPaymentReceipt: number + downloadFormWhitelist: number + uploadFormWhitelist: number publicApi: number platformApi: number } @@ -82,6 +85,8 @@ export type Config = { cookieSettings: SessionOptions['cookie'] // Consts isDev: boolean + isTest: boolean + isDevOrTest: boolean nodeEnv: Environment useMockTwilio: boolean useMockPostmanSms: boolean @@ -186,7 +191,10 @@ export interface IOptionalVarsSchema { rateLimit: { submissions: number sendAuthOtp: number + publicFormIssueFeedback: number downloadPaymentReceipt: number + downloadFormWhitelist: number + uploadFormWhitelist: number publicApi: number platformApi: number } diff --git a/src/types/form.ts b/src/types/form.ts index 91fce51dbb..8bf4e357bc 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -25,6 +25,8 @@ import { LogicDto, MyInfoAttribute, PublicFormDto, + WhitelistedSubmitterIds, + WhitelistedSubmitterIdsWithReferenceOid, } from '../../shared/types' import { OverrideProps } from '../app/modules/form/admin-form/admin-form.types' @@ -63,7 +65,7 @@ type FormDefaultableKey = | 'hasCaptcha' | 'hasIssueNotification' | 'authType' - | 'isNricMaskEnabled' + | 'isSubmitterIdCollectionEnabled' | 'isSingleSubmission' | 'status' | 'inactiveMessage' @@ -103,7 +105,7 @@ export type PickDuplicateForm = Pick< | 'startPage' | 'endPage' | 'authType' - | 'isNricMaskEnabled' + | 'isSubmitterIdCollectionEnabled' | 'isSingleSubmission' | 'inactiveMessage' | 'submissionLimit' @@ -187,6 +189,12 @@ export interface IFormSchema extends IForm, Document, PublicView { * Retrieve form settings. */ getSettings(): FormSettings + + /** + * Retrieve the full whitelistedSubmitterId property of the form document. + */ + getWhitelistedSubmitterIds(): WhitelistedSubmitterIdsWithReferenceOid + /** * Retrieve form webhook settings. */ @@ -263,7 +271,9 @@ interface IFormBaseDocument { hasCaptcha: NonNullable hasIssueNotification: NonNullable authType: NonNullable - isNricMaskEnabled: NonNullable + isSubmitterIdCollectionEnabled: NonNullable< + T['isSubmitterIdCollectionEnabled'] + > isSingleSubmission: NonNullable status: NonNullable inactiveMessage: NonNullable @@ -293,6 +303,7 @@ export interface IEncryptedForm extends IForm { payments_field: FormPaymentsField business?: FormBusinessField emails?: string[] + whitelistedSubmitterIds?: WhitelistedSubmitterIds } export type IEncryptedFormSchema = IEncryptedForm & IFormSchema @@ -319,8 +330,8 @@ export type IPopulatedEmailForm = IPopulatedForm & IEmailForm export interface IMultirespondentForm extends IForm { publicKey: string - emails?: never workflow: FormWorkflowDto + stepsToNotify: string[] } export type IMultirespondentFormSchema = IMultirespondentForm & IFormSchema diff --git a/src/types/form_whitelisted_submitter_ids.ts b/src/types/form_whitelisted_submitter_ids.ts new file mode 100644 index 0000000000..8cea89409b --- /dev/null +++ b/src/types/form_whitelisted_submitter_ids.ts @@ -0,0 +1,28 @@ +import { Model } from 'mongoose' + +import { IFormSchema } from './form' + +export interface IFormWhitelistedSubmitterIdsSchema { + formId: IFormSchema['_id'] + myPublicKey: string + myPrivateKey: string + nonce: string + cipherTexts: string[] +} + +export interface IFormWhitelistedSubmitterIdsModel + extends Model { + findEncryptionPropertiesById( + whitelistId: string, + ): Promise< + Pick< + IFormWhitelistedSubmitterIdsSchema, + 'myPublicKey' | 'myPrivateKey' | 'nonce' + > + > + + checkIfSubmitterIdIsWhitelisted( + whitelistId: string, + submitterId: string, + ): Promise +} diff --git a/src/types/index.ts b/src/types/index.ts index 9b6e400f76..5fc43de979 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,3 +25,4 @@ export * from './payment' export * from './admin_feedback' export * from './feature_flag' export * from './form_workflow_step' +export * from './form_whitelisted_submitter_ids' diff --git a/src/types/vendor/express.d.ts b/src/types/vendor/express.d.ts index ab8212af94..5f09017bd6 100644 --- a/src/types/vendor/express.d.ts +++ b/src/types/vendor/express.d.ts @@ -1,8 +1,10 @@ +import { GrowthBook } from '@growthbook/growthbook' import { RateLimitInfo } from 'express-rate-limit' import { FormResponseMode } from 'shared/types' import { SgidUser } from '../../app/modules/auth/auth.types' import { EncryptSubmissionDto, MultirespondentSubmissionDto } from '../api' +import { IAttachmentInfo } from '../email_mode_data' import { IPopulatedMultirespondentForm } from '../form' import { IPopulatedEncryptedForm, IPopulatedForm, IUserSchema } from '../types' @@ -10,6 +12,10 @@ declare global { namespace Express { export interface Request { id?: string + /** + * This property is added to all requests for Growthbook feature flagging purposes except on test env. + */ + growthbook?: GrowthBook /** * This property is added to all requests with the `limit`, `current`, * and `remaining` number of requests and, if the store provides it, a `resetTime` Date object. @@ -33,6 +39,7 @@ declare global { featureFlags?: string[] encryptedPayload?: EncryptSubmissionDto encryptedFormDef?: IPopulatedEncryptedForm + unencryptedAttachments?: IAttachmentInfo[] } | { responseMode: FormResponseMode.Multirespondent