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 (
+
+ )
+}
+
+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
-
-
-
-
-
-
-
-
-
+
)
}
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}
+
+
+
+
+
+
+
+
+
+ )
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ {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