diff --git a/__tests__/e2e/helpers/createForm.ts b/__tests__/e2e/helpers/createForm.ts
index 1133ea9483..f8f19603c5 100644
--- a/__tests__/e2e/helpers/createForm.ts
+++ b/__tests__/e2e/helpers/createForm.ts
@@ -291,20 +291,30 @@ const addAuthSettings = async (
await page.getByRole('tab', { name: 'Singpass' }).click()
// Ensure that we are on the auth page
- await expect(
- page.getByRole('heading', { name: 'Enable Singpass authentication' }),
- ).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Singpass' })).toBeVisible()
await page
.locator('label', {
- has: page.locator(
- `input[type='radio'][value='${formSettings.authType}']`,
- ),
+ has: page.locator('[aria-label="Enable Singpass authentication"]'),
})
- .first() // Since 'Singpass' will match all radio options, pick the first matching one.
- .click({ position: { x: 1, y: 1 } }) // Clicking the center of the sgid button launches the sgid contact form, put this here until we get rid of the link
+ .click()
- await expectToast(page, /form authentication successfully updated/i)
+ await expectToast(page, /singpass authentication successfully enabled/i)
+
+ // Don't need to click if SGID is desired auth type
+ // since SGID is the default once Singpass is enabled
+ if (formSettings.authType !== FormAuthType.SGID) {
+ await page
+ .locator('label', {
+ has: page.locator(
+ `input[type='radio'][value='${formSettings.authType}']`,
+ ),
+ })
+ .first() // Since 'Singpass' will match all radio options, pick the first matching one.
+ .click({ position: { x: 1, y: 1 } }) // Clicking the center of the sgid button launches the sgid contact form, put this here until we get rid of the link
+
+ await expectToast(page, /singpass authentication successfully updated/i)
+ }
switch (formSettings.authType) {
case FormAuthType.SP:
diff --git a/frontend/src/features/admin-form/common/AdminViewFormService.ts b/frontend/src/features/admin-form/common/AdminViewFormService.ts
index 61d2b11b0c..0550a11635 100644
--- a/frontend/src/features/admin-form/common/AdminViewFormService.ts
+++ b/frontend/src/features/admin-form/common/AdminViewFormService.ts
@@ -24,7 +24,10 @@ import {
filterHiddenInputs,
} from '~features/public-form/utils'
-import { PREVIEW_MOCK_UINFIN } from '../preview/constants'
+import {
+ PREVIEW_MASKED_MOCK_UINFIN,
+ PREVIEW_MOCK_UINFIN,
+} from '../preview/constants'
// endpoint exported for testing
export const ADMIN_FORM_ENDPOINT = '/admin/forms'
@@ -58,7 +61,11 @@ export const previewForm = async (
// Add default mock authenticated state if previewing an authenticatable form
// and if server has not already sent back a mock authenticated state.
if (data.form.authType !== FormAuthType.NIL && !data.spcpSession) {
- data.spcpSession = { userName: PREVIEW_MOCK_UINFIN }
+ data.spcpSession = {
+ userName: data.form.isNricMaskEnabled
+ ? PREVIEW_MASKED_MOCK_UINFIN
+ : PREVIEW_MOCK_UINFIN,
+ }
}
// Inject MyInfo preview values into form fields (if they are MyInfo fields).
@@ -87,7 +94,11 @@ export const viewFormTemplate = async (
// Add default mock authenticated state if previewing an authenticatable form
// and if server has not already sent back a mock authenticated state.
if (data.form.authType !== FormAuthType.NIL && !data.spcpSession) {
- data.spcpSession = { userName: PREVIEW_MOCK_UINFIN }
+ data.spcpSession = {
+ userName: data.form.isNricMaskEnabled
+ ? PREVIEW_MASKED_MOCK_UINFIN
+ : PREVIEW_MOCK_UINFIN,
+ }
}
// Inject MyInfo preview values into form fields (if they are MyInfo fields).
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 87c7dc52e3..78bf0c033f 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,7 +6,10 @@ import { FormAuthType, FormLogoState, FormStartPage } from '~shared/types'
import { useIsMobile } from '~hooks/useIsMobile'
-import { PREVIEW_MOCK_UINFIN } from '~features/admin-form/preview/constants'
+import {
+ PREVIEW_MASKED_MOCK_UINFIN as PREVIEW_MASKED_MOCK_UINFIN,
+ 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 {
@@ -197,7 +200,9 @@ export const StartPageView = () => {
showHeader
loggedInId={
form && form.authType !== FormAuthType.NIL
- ? PREVIEW_MOCK_UINFIN
+ ? form.isNricMaskEnabled
+ ? PREVIEW_MASKED_MOCK_UINFIN
+ : PREVIEW_MOCK_UINFIN
: undefined
}
{...formHeaderProps}
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 899ba8ccb8..a7f4e07875 100644
--- a/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx
+++ b/frontend/src/features/admin-form/create/end-page/EndPageContent.tsx
@@ -13,7 +13,10 @@ import { PaymentsThankYouSvgr } from '~components/FormEndPage/PaymentsThankYouSv
import { ThankYouSvgr } from '~components/FormEndPage/ThankYouSvgr'
import { useAdminForm } from '~features/admin-form/common/queries'
-import { PREVIEW_MOCK_UINFIN } from '~features/admin-form/preview/constants'
+import {
+ PREVIEW_MASKED_MOCK_UINFIN,
+ PREVIEW_MOCK_UINFIN,
+} from '~features/admin-form/preview/constants'
import { useEnv } from '~features/env/queries'
import {
FormBannerLogo,
@@ -90,7 +93,9 @@ export const EndPageContent = (): JSX.Element => {
onLogout={undefined}
loggedInId={
form && form.authType !== FormAuthType.NIL
- ? PREVIEW_MOCK_UINFIN
+ ? form.isNricMaskEnabled
+ ? PREVIEW_MASKED_MOCK_UINFIN
+ : PREVIEW_MOCK_UINFIN
: undefined
}
/>
diff --git a/frontend/src/features/admin-form/preview/constants.ts b/frontend/src/features/admin-form/preview/constants.ts
index cad5f73ab2..e07557d54a 100644
--- a/frontend/src/features/admin-form/preview/constants.ts
+++ b/frontend/src/features/admin-form/preview/constants.ts
@@ -1 +1,2 @@
export const PREVIEW_MOCK_UINFIN = 'S1234567A'
+export const PREVIEW_MASKED_MOCK_UINFIN = '*****567A'
diff --git a/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx b/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx
index ed50e67158..00f193268c 100644
--- a/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx
+++ b/frontend/src/features/admin-form/settings/SettingsAuthPage.stories.tsx
@@ -35,25 +35,42 @@ export default {
} as Meta
const Template: Story = () =>
-export const PrivateEmailForm = Template.bind({})
-PrivateEmailForm.parameters = {
+export const PrivateEmailNilAuthForm = Template.bind({})
+PrivateEmailNilAuthForm.parameters = {
msw: buildMswRoutes({ status: FormStatus.Private }),
}
-export const PrivateStorageForm = Template.bind({})
-PrivateStorageForm.parameters = {
+export const PrivateStorageNilAuthForm = Template.bind({})
+PrivateStorageNilAuthForm.parameters = {
msw: buildMswRoutes({
responseMode: FormResponseMode.Encrypt,
status: FormStatus.Private,
}),
}
-export const PublicEmailSingpassForm = Template.bind({})
-PublicEmailSingpassForm.parameters = {
+export const PublicEmailNilAuthForm = Template.bind({})
+PublicEmailNilAuthForm.parameters = {
msw: buildMswRoutes({
+ responseMode: FormResponseMode.Email,
status: FormStatus.Public,
- authType: FormAuthType.SP,
- esrvcId: 'STORYBOOK-TEST',
+ }),
+}
+
+export const PublicStorageNilAuthForm = Template.bind({})
+PublicStorageNilAuthForm.parameters = {
+ msw: buildMswRoutes({
+ responseMode: FormResponseMode.Encrypt,
+ status: FormStatus.Public,
+ }),
+}
+
+// purpose: tests that isNricMaskEnabled should not affect setting options available
+export const PublicStorageNilAuthFormNricMaskingEnabled = Template.bind({})
+PublicStorageNilAuthFormNricMaskingEnabled.parameters = {
+ msw: buildMswRoutes({
+ responseMode: FormResponseMode.Encrypt,
+ status: FormStatus.Public,
+ isNricMaskEnabled: true,
}),
}
@@ -63,9 +80,31 @@ PrivateStorageCorppassForm.parameters = {
status: FormStatus.Private,
authType: FormAuthType.CP,
responseMode: FormResponseMode.Encrypt,
+ esrvcId: 'STORYBOOK-TEST',
+ }),
+}
+
+export const PublicEmailSingpassForm = Template.bind({})
+PublicEmailSingpassForm.parameters = {
+ msw: buildMswRoutes({
+ status: FormStatus.Public,
+ authType: FormAuthType.SP,
+ esrvcId: 'STORYBOOK-TEST',
}),
}
+export const PrivateEmailMyInfoWithoutMyInfoFieldsForm = Template.bind({})
+PrivateEmailMyInfoWithoutMyInfoFieldsForm.parameters = {
+ msw: [
+ ...buildMswRoutes({
+ status: FormStatus.Private,
+ authType: FormAuthType.MyInfo,
+ esrvcId: 'STORYBOOK-TEST',
+ }),
+ ...createFormBuilderMocks({ form_fields: [] }),
+ ],
+}
+
export const PrivateEmailMyinfoForm = Template.bind({})
PrivateEmailMyinfoForm.parameters = {
msw: [
@@ -78,6 +117,39 @@ PrivateEmailMyinfoForm.parameters = {
],
}
+export const PublicEmailMyInfoForm = Template.bind({})
+PublicEmailMyInfoForm.parameters = {
+ msw: [
+ ...buildMswRoutes({
+ status: FormStatus.Public,
+ authType: FormAuthType.MyInfo,
+ esrvcId: 'STORYBOOK-TEST',
+ }),
+ ...createFormBuilderMocks({ form_fields: MOCK_FORM_FIELDS_WITH_MYINFO }),
+ ],
+}
+
+export const PrivateEmailSingpassFormNricMaskingEnabled = Template.bind({})
+PrivateEmailSingpassFormNricMaskingEnabled.parameters = {
+ msw: buildMswRoutes({
+ status: FormStatus.Private,
+ authType: FormAuthType.SGID,
+ isNricMaskEnabled: true,
+ }),
+}
+export const PrivateEmailMyInfoFormNricMaskingEnabled = Template.bind({})
+PrivateEmailMyInfoFormNricMaskingEnabled.parameters = {
+ msw: [
+ ...buildMswRoutes({
+ status: FormStatus.Private,
+ authType: FormAuthType.MyInfo,
+ esrvcId: 'STORYBOOK-TEST',
+ isNricMaskEnabled: true,
+ }),
+ ...createFormBuilderMocks({ form_fields: MOCK_FORM_FIELDS_WITH_MYINFO }),
+ ],
+}
+
export const Loading = Template.bind({})
Loading.parameters = {
msw: [getAdminFormSettings({ delay: 'infinite' })],
diff --git a/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx b/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx
index 6f6fff49f7..ea52b3f1e4 100644
--- a/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx
+++ b/frontend/src/features/admin-form/settings/SettingsAuthPage.tsx
@@ -18,7 +18,7 @@ export const SettingsAuthPage = (): JSX.Element => {
return (
<>
- Enable Singpass authentication
+ Singpass
>
)
diff --git a/frontend/src/features/admin-form/settings/SettingsService.ts b/frontend/src/features/admin-form/settings/SettingsService.ts
index bb1691c9e8..9778b786f4 100644
--- a/frontend/src/features/admin-form/settings/SettingsService.ts
+++ b/frontend/src/features/admin-form/settings/SettingsService.ts
@@ -91,6 +91,15 @@ export const updateFormAuthType: UpdateFormFn<'authType'> = async (
return updateFormSettings(formId, { authType: newAuthType })
}
+export const updateFormNricMask: UpdateFormFn<'isNricMaskEnabled'> = async (
+ formId,
+ newIsNricMaskEnabled,
+) => {
+ return updateFormSettings(formId, {
+ isNricMaskEnabled: newIsNricMaskEnabled,
+ })
+}
+
export const updateFormEsrvcId: UpdateFormFn<'esrvcId'> = async (
formId,
newEsrvcId,
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDescriptionText.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDescriptionText.tsx
new file mode 100644
index 0000000000..52c46df96a
--- /dev/null
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDescriptionText.tsx
@@ -0,0 +1,21 @@
+import { Text } from '@chakra-ui/react'
+
+import { GUIDE_SPCP_ESRVCID } from '~constants/links'
+import Link from '~components/Link'
+
+export const AuthSettingsDescriptionText = () => {
+ return (
+
+ Authenticate respondents by NRIC/FIN.{' '}
+ e.stopPropagation()}
+ >
+ Learn more about Singpass authentication
+
+
+ )
+}
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx
new file mode 100644
index 0000000000..7ead62f0d0
--- /dev/null
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsDisabledExplanationText.tsx
@@ -0,0 +1,33 @@
+import { Box } from '@chakra-ui/react'
+
+import InlineMessage from '~components/InlineMessage'
+
+interface AuthSettingsDisabledExplanationTextProps {
+ isFormPublic: boolean
+ containsMyInfoFields: boolean
+}
+
+const CONTAINS_MYINFO_FIELDS_DISABLED_EXPLANATION_TEXT =
+ 'To change Singpass authentication settings, close your form to new responses and remove all existing Myinfo fields.'
+const FORM_IS_PUBLIC_DISABLED_EXPLANATION_TEXT =
+ 'To change Singpass authentication settings, close your form to new responses.'
+
+export const AuthSettingsDisabledExplanationText = ({
+ isFormPublic,
+ containsMyInfoFields,
+}: AuthSettingsDisabledExplanationTextProps) => {
+ const explanationText = containsMyInfoFields
+ ? CONTAINS_MYINFO_FIELDS_DISABLED_EXPLANATION_TEXT
+ : isFormPublic
+ ? FORM_IS_PUBLIC_DISABLED_EXPLANATION_TEXT
+ : null
+
+ if (!explanationText) {
+ return null
+ }
+ return (
+
+ {explanationText}
+
+ )
+}
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx
index 9a79d64bcc..9e11242b16 100644
--- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSection.tsx
@@ -1,60 +1,23 @@
-import {
- Fragment,
- KeyboardEventHandler,
- MouseEventHandler,
- useCallback,
- useMemo,
- useState,
-} from 'react'
-import { Box, Flex, Skeleton, Spacer, Text } from '@chakra-ui/react'
+import { useMemo } from 'react'
+import { Box } from '@chakra-ui/react'
import { FormAuthType, FormSettings, FormStatus } from '~shared/types/form'
-import { GUIDE_SPCP_ESRVCID } from '~constants/links'
-import InlineMessage from '~components/InlineMessage'
-import Link from '~components/Link'
-import Radio from '~components/Radio'
-import { Tag } from '~components/Tag'
-
import { useAdminForm } from '~features/admin-form/common/queries'
import { isMyInfo } from '~features/myinfo/utils'
-import { useMutateFormSettings } from '../../mutations'
-
-import { FORM_AUTHTYPES } from './constants'
-import { EsrvcIdBox } from './EsrvcIdBox'
-
-const esrvcidRequired = (authType: FormAuthType) => {
- switch (authType) {
- case FormAuthType.SP:
- case FormAuthType.MyInfo:
- case FormAuthType.CP:
- return true
- default:
- return false
- }
-}
+import { AuthSettingsDescriptionText } from './AuthSettingsDescriptionText'
+import { AuthSettingsDisabledExplanationText } from './AuthSettingsDisabledExplanationText'
+import { AuthSettingsSingpassSection } from './AuthSettingsSingpassSection'
+import { FormSingpassAuthToggle } from './FormSingpassAuthToggle'
interface AuthSettingsSectionProps {
settings: FormSettings
}
-export const AuthSettingsSectionSkeleton = (): JSX.Element => {
- return (
-
- {Object.entries(FORM_AUTHTYPES).map(([authType, textToRender]) => (
-
- {textToRender}
-
- ))}
-
- )
-}
-
export const AuthSettingsSection = ({
settings,
}: AuthSettingsSectionProps): JSX.Element => {
- const { mutateFormAuthType } = useMutateFormSettings()
const { data: form } = useAdminForm()
const containsMyInfoFields = useMemo(
@@ -62,120 +25,26 @@ export const AuthSettingsSection = ({
[form?.form_fields],
)
- const [focusedValue, setFocusedValue] = useState()
-
const isFormPublic = settings.status === FormStatus.Public
- const isDisabled = useCallback(
- (authType: FormAuthType) =>
- isFormPublic || containsMyInfoFields || mutateFormAuthType.isLoading,
- [isFormPublic, containsMyInfoFields, mutateFormAuthType.isLoading],
- )
-
- const isEsrvcIdBoxDisabled = useMemo(
- () => isFormPublic || mutateFormAuthType.isLoading,
- [isFormPublic, mutateFormAuthType.isLoading],
- )
-
- const handleEnterKeyDown: KeyboardEventHandler = useCallback(
- (e) => {
- if (
- (e.key === 'Enter' || e.key === ' ') &&
- focusedValue &&
- !isDisabled(focusedValue) &&
- focusedValue !== settings.authType
- ) {
- return mutateFormAuthType.mutate(focusedValue)
- }
- },
- [focusedValue, isDisabled, mutateFormAuthType, settings.authType],
- )
-
- const handleOptionClick = useCallback(
- (authType: FormAuthType): MouseEventHandler =>
- (e) => {
- if (
- !isDisabled(authType) &&
- e.type === 'click' &&
- // Required so only real clicks get registered.
- // Typical radio behaviour is that the 'click' event is triggered on change.
- // See: https://www.w3.org/TR/2012/WD-html5-20121025/content-models.html#interactive-content
- // https://github.com/facebook/react/issues/7407#issuecomment-237082712
- e.clientX !== 0 &&
- e.clientY !== 0 &&
- authType !== settings.authType
- ) {
- return mutateFormAuthType.mutate(authType)
- }
- },
- [isDisabled, mutateFormAuthType, settings.authType],
- )
-
- const radioOptions: [FormAuthType, string][] = Object.entries(
- FORM_AUTHTYPES,
- ) as [FormAuthType, string][]
-
return (
-
- Authenticate respondents by NRIC.{' '}
- e.stopPropagation()}
- >
- Learn more about Singpass authentication
-
-
- {isFormPublic ? (
-
- To change authentication method, close your form to new responses.
-
- ) : containsMyInfoFields ? (
-
- To change authentication method, remove existing Myinfo fields on your
- form. You can still update your e-service ID.
-
+
+
+
+ {settings.authType !== FormAuthType.NIL ? (
+
) : null}
- setFocusedValue(e)}
- >
- {radioOptions.map(([authType, text]) => (
-
-
-
-
- {text}
- {authType === FormAuthType.SGID ||
- authType === FormAuthType.SGID_MyInfo ? (
- <>
-
-
- Free
-
- >
- ) : null}
-
-
-
- {esrvcidRequired(authType) && authType === settings.authType ? (
-
- ) : null}
-
- ))}
-
)
}
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx
index c09c0b2188..59be85bc0c 100644
--- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionContainer.tsx
@@ -1,9 +1,7 @@
import { useAdminFormSettings } from '../../queries'
-import {
- AuthSettingsSection,
- AuthSettingsSectionSkeleton,
-} from './AuthSettingsSection'
+import { AuthSettingsSection } from './AuthSettingsSection'
+import { AuthSettingsSectionSkeleton } from './AuthSettingsSectionSkeleton'
export const AuthSettingsSectionContainer = (): JSX.Element => {
const { data: settings } = useAdminFormSettings()
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionSkeleton.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionSkeleton.tsx
new file mode 100644
index 0000000000..626b2ed051
--- /dev/null
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSectionSkeleton.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from '@chakra-ui/react'
+
+import Toggle from '~components/Toggle'
+
+import { AuthSettingsDescriptionText } from './AuthSettingsDescriptionText'
+
+export const AuthSettingsSectionSkeleton = (): JSX.Element => {
+ return (
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx
new file mode 100644
index 0000000000..2a1ab54b33
--- /dev/null
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/AuthSettingsSingpassSection.tsx
@@ -0,0 +1,29 @@
+import { Divider } from '@chakra-ui/react'
+
+import { FormSettings } from '~shared/types/form'
+
+import { FormNricMaskToggle } from './FormNricMaskToggle'
+import { SingpassAuthOptionsRadio } from './SingpassAuthOptionsRadio'
+
+export interface AuthSettingsSingpassSectionProps {
+ settings: FormSettings
+ isFormPublic: boolean
+ containsMyInfoFields: boolean
+}
+
+export const AuthSettingsSingpassSection = ({
+ settings,
+ isFormPublic,
+ containsMyInfoFields,
+}: AuthSettingsSingpassSectionProps): JSX.Element => {
+ 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
new file mode 100644
index 0000000000..faf39820a6
--- /dev/null
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormNricMaskToggle.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 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
new file mode 100644
index 0000000000..7fcbdd25bd
--- /dev/null
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/FormSingpassAuthToggle.tsx
@@ -0,0 +1,45 @@
+import { useCallback } from 'react'
+
+import { FormAuthType, FormSettings } from '~shared/types'
+
+import Toggle from '~components/Toggle'
+
+import { useMutateFormSettings } from '../../mutations'
+
+interface FormSingpassAuthToggleProps {
+ settings: FormSettings
+ isDisabled: boolean
+}
+
+const DEFAULT_FORM_AUTH_TYPE = FormAuthType.SGID
+
+export const FormSingpassAuthToggle = ({
+ settings,
+ isDisabled,
+}: FormSingpassAuthToggleProps): JSX.Element => {
+ const isSingpassAuthEnabled =
+ settings && settings?.authType !== FormAuthType.NIL
+
+ const { mutateFormAuthType } = useMutateFormSettings()
+
+ const handleToggleSingpassAuth = useCallback(() => {
+ if (!settings || mutateFormAuthType.isLoading) return
+ const nextAuthType =
+ settings.authType === FormAuthType.NIL
+ ? DEFAULT_FORM_AUTH_TYPE
+ : FormAuthType.NIL
+ return mutateFormAuthType.mutate(nextAuthType)
+ }, [mutateFormAuthType, settings, DEFAULT_FORM_AUTH_TYPE])
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SingpassAuthOptionsRadio.tsx b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SingpassAuthOptionsRadio.tsx
new file mode 100644
index 0000000000..1185d7d76a
--- /dev/null
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/SingpassAuthOptionsRadio.tsx
@@ -0,0 +1,121 @@
+import {
+ Fragment,
+ KeyboardEventHandler,
+ MouseEventHandler,
+ useCallback,
+ useState,
+} from 'react'
+import { Box, Flex, Spacer } from '@chakra-ui/react'
+
+import { FormAuthType, FormSettings, FormStatus } from '~shared/types'
+
+import Radio from '~components/Radio'
+import { Tag } from '~components/Tag'
+
+import { useMutateFormSettings } from '../../mutations'
+
+import { FORM_SINGPASS_AUTHTYPES } from './constants'
+import { EsrvcIdBox } from './EsrvcIdBox'
+
+export interface SingpassAuthOptionsRadioProps {
+ settings: FormSettings
+ isDisabled: boolean
+}
+
+const isEsrvcidRequired = (authType: FormAuthType) => {
+ switch (authType) {
+ case FormAuthType.SP:
+ case FormAuthType.MyInfo:
+ case FormAuthType.CP:
+ return true
+ default:
+ return false
+ }
+}
+
+const radioOptions: [FormAuthType, string][] = Object.entries(
+ FORM_SINGPASS_AUTHTYPES,
+) as [FormAuthType, string][]
+
+export const SingpassAuthOptionsRadio = ({
+ settings,
+ isDisabled,
+}: SingpassAuthOptionsRadioProps): JSX.Element => {
+ const { mutateFormAuthType } = useMutateFormSettings()
+ const [focusedValue, setFocusedValue] = useState()
+
+ const isFormPublic = settings.status === FormStatus.Public
+
+ const isEsrvcIdBoxDisabled = isFormPublic || mutateFormAuthType.isLoading
+
+ const checkIsDisabled = useCallback(() => {
+ return isDisabled || mutateFormAuthType.isLoading
+ }, [isDisabled, mutateFormAuthType.isLoading])
+
+ const handleEnterKeyDown: KeyboardEventHandler = useCallback(
+ (e) => {
+ if (
+ (e.key === 'Enter' || e.key === ' ') &&
+ focusedValue &&
+ !checkIsDisabled() &&
+ focusedValue !== settings.authType
+ ) {
+ return mutateFormAuthType.mutate(focusedValue)
+ }
+ },
+ [focusedValue, checkIsDisabled, mutateFormAuthType, settings.authType],
+ )
+
+ const handleOptionClick = useCallback(
+ (authType: FormAuthType): MouseEventHandler =>
+ (e) => {
+ if (
+ !checkIsDisabled() &&
+ e.type === 'click' &&
+ // Required so only real clicks get registered.
+ // Typical radio behaviour is that the 'click' event is triggered on change.
+ // See: https://www.w3.org/TR/2012/WD-html5-20121025/content-models.html#interactive-content
+ // https://github.com/facebook/react/issues/7407#issuecomment-237082712
+ e.clientX !== 0 &&
+ e.clientY !== 0 &&
+ authType !== settings.authType
+ ) {
+ return mutateFormAuthType.mutate(authType)
+ }
+ },
+ [mutateFormAuthType, settings.authType, checkIsDisabled],
+ )
+
+ return (
+ setFocusedValue(e)}
+ >
+ {radioOptions.map(([authType, text]) => (
+
+
+
+
+ {text}
+ {authType === FormAuthType.SGID ||
+ authType === FormAuthType.SGID_MyInfo ? (
+ <>
+
+
+ Free
+
+ >
+ ) : null}
+
+
+
+ {isEsrvcidRequired(authType) && authType === settings.authType ? (
+
+ ) : null}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts
index 01a6e5837e..1479c7a352 100644
--- a/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts
+++ b/frontend/src/features/admin-form/settings/components/AuthSettingsSection/constants.ts
@@ -1,13 +1,8 @@
import { FormAuthType } from '~shared/types/form'
-export type EmailFormAuthType = FormAuthType
-export type StorageFormAuthType = FormAuthType
+type FormSingpassAuthType = Exclude
-export const FORM_AUTHTYPES: Record<
- StorageFormAuthType | EmailFormAuthType,
- string
-> = {
- [FormAuthType.NIL]: 'None',
+export const FORM_SINGPASS_AUTHTYPES: Record = {
[FormAuthType.SGID]: 'Singpass App-only Login',
[FormAuthType.SGID_MyInfo]: 'Singpass App-only with Myinfo',
[FormAuthType.SP]: 'Singpass',
diff --git a/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx b/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx
index 08dc15be9f..df87c9ef90 100644
--- a/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx
+++ b/frontend/src/features/admin-form/settings/components/FormCaptchaToggle.tsx
@@ -10,7 +10,7 @@ export const FormCaptchaToggle = (): JSX.Element => {
const { data: settings, isLoading: isLoadingSettings } =
useAdminFormSettings()
- const hasCaptcha = useMemo(() => settings && settings?.hasCaptcha, [settings])
+ const hasCaptcha = useMemo(() => settings?.hasCaptcha, [settings])
const { mutateFormCaptcha } = useMutateFormSettings()
diff --git a/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx b/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx
index a4c254bcb5..2ba9e21dd0 100644
--- a/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx
+++ b/frontend/src/features/admin-form/settings/components/FormIssueNotificationToggle.tsx
@@ -10,10 +10,7 @@ export const FormIssueNotificationToggle = (): JSX.Element => {
const { data: settings, isLoading: isLoadingSettings } =
useAdminFormSettings()
- const hasIssueNotification = useMemo(
- () => settings && settings?.hasIssueNotification,
- [settings],
- )
+ const hasIssueNotification = settings?.hasIssueNotification
const { mutateFormIssueNotification } = useMutateFormSettings()
diff --git a/frontend/src/features/admin-form/settings/mutations.ts b/frontend/src/features/admin-form/settings/mutations.ts
index 6ab9b87249..27875a1eb1 100644
--- a/frontend/src/features/admin-form/settings/mutations.ts
+++ b/frontend/src/features/admin-form/settings/mutations.ts
@@ -36,6 +36,7 @@ import {
updateFormInactiveMessage,
updateFormIssueNotification,
updateFormLimit,
+ updateFormNricMask,
updateFormStatus,
updateFormTitle,
updateFormWebhookRetries,
@@ -229,55 +230,96 @@ export const useMutateFormSettings = () => {
},
)
+ const generateFormAuthTypeMutationToastMessageText = (
+ prevAuthType: FormAuthType | undefined,
+ nextAuthType: FormAuthType,
+ ) => {
+ if (prevAuthType === FormAuthType.NIL) {
+ return 'Singpass authentication successfully enabled.'
+ }
+ if (nextAuthType === FormAuthType.NIL) {
+ return 'Singpass authentication successfully disabled.'
+ }
+ return 'Singpass authentication successfully updated.'
+ }
+
const mutateFormAuthType = useMutation<
FormSettings,
ApiError,
FormAuthType,
{ previousSettings?: FormSettings }
- >((nextAuthType: FormAuthType) => updateFormAuthType(formId, nextAuthType), {
- // Optimistic update
- onMutate: async (newData) => {
- // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
- await queryClient.cancelQueries(formSettingsQueryKey)
-
- // Snapshot the previous value
- const previousSettings =
- queryClient.getQueryData(formSettingsQueryKey)
-
- // Optimistically update to the new value
- queryClient.setQueryData(
- formSettingsQueryKey,
- (old) => {
- if (!old) return
- return {
- ...old,
- authType: newData,
- }
- },
- )
-
- // Return a context object with the snapshotted value
- return { previousSettings }
- },
- onSuccess: (newData) => {
- handleSuccess({
- newData,
- toastDescription: 'Form authentication successfully updated.',
- })
+ >(
+ (nextAuthType: FormAuthType) => {
+ return updateFormAuthType(formId, nextAuthType)
},
- onError: (error, _newData, context) => {
- if (context?.previousSettings) {
- queryClient.setQueryData(formSettingsQueryKey, context.previousSettings)
- }
- handleError(error)
+ {
+ // Optimistic update
+ onMutate: async (newData) => {
+ // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
+ await queryClient.cancelQueries(formSettingsQueryKey)
+
+ // Snapshot the previous value
+ const previousSettings =
+ queryClient.getQueryData(formSettingsQueryKey)
+
+ // Optimistically update to the new value
+ queryClient.setQueryData(
+ formSettingsQueryKey,
+ (old) => {
+ if (!old) return
+ return {
+ ...old,
+ authType: newData,
+ }
+ },
+ )
+
+ // Return a context object with the snapshotted value
+ return { previousSettings }
+ },
+ onSuccess: (newData, newAuthType, context) => {
+ const prevAuthType = context?.previousSettings?.authType
+ handleSuccess({
+ newData,
+ toastDescription: generateFormAuthTypeMutationToastMessageText(
+ prevAuthType,
+ newAuthType,
+ ),
+ })
+ },
+ onError: (error, _newData, context) => {
+ if (context?.previousSettings) {
+ queryClient.setQueryData(
+ formSettingsQueryKey,
+ context.previousSettings,
+ )
+ }
+ handleError(error)
+ },
+ onSettled: (_data, error) => {
+ if (error) {
+ // Refetch data if any error occurs
+ queryClient.invalidateQueries(formSettingsQueryKey)
+ }
+ },
},
- onSettled: (_data, error) => {
- if (error) {
- // Refetch data if any error occurs
- queryClient.invalidateQueries(formSettingsQueryKey)
- }
+ )
+
+ const mutateNricMask = useMutation(
+ (nextIsNricMaskEnabled: boolean) =>
+ updateFormNricMask(formId, nextIsNricMaskEnabled),
+ {
+ onSuccess: (newData) => {
+ handleSuccess({
+ newData,
+ toastDescription: newData.isNricMaskEnabled
+ ? 'NRIC masking is now enabled on your form.'
+ : 'NRIC masking is now disabled on your form.',
+ })
+ },
+ onError: handleError,
},
- })
+ )
const mutateFormWebhookUrl = useMutation(
(nextUrl?: string) => updateFormWebhookUrl(formId, nextUrl),
@@ -348,6 +390,7 @@ export const useMutateFormSettings = () => {
mutateFormEmails,
mutateFormTitle,
mutateFormAuthType,
+ mutateNricMask,
mutateFormEsrvcId,
mutateFormBusiness,
mutateGST,
diff --git a/frontend/src/features/public-form/PublicFormProvider.tsx b/frontend/src/features/public-form/PublicFormProvider.tsx
index 0d4604e011..fc7c72cbdd 100644
--- a/frontend/src/features/public-form/PublicFormProvider.tsx
+++ b/frontend/src/features/public-form/PublicFormProvider.tsx
@@ -30,6 +30,7 @@ import {
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'
@@ -142,6 +143,11 @@ 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 { isNotFormId, toast, vfnToastIdRef, expiryInMs, ...commonFormValues } =
useCommonFormProvider(formId)
diff --git a/shared/constants/form.ts b/shared/constants/form.ts
index 86444f4393..fca00f0db6 100644
--- a/shared/constants/form.ts
+++ b/shared/constants/form.ts
@@ -1,6 +1,7 @@
const PUBLIC_FORM_FIELDS = [
'admin',
'authType',
+ 'isNricMaskEnabled',
'endPage',
'esrvcId',
'form_fields',
@@ -29,6 +30,7 @@ export const MULTIRESPONDENT_PUBLIC_FORM_FIELDS = [
const FORM_SETTINGS_FIELDS = [
'responseMode',
'authType',
+ 'isNricMaskEnabled',
'esrvcId',
'hasCaptcha',
'hasIssueNotification',
diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts
index e632f0bd6d..48df079cdd 100644
--- a/shared/types/form/form.ts
+++ b/shared/types/form/form.ts
@@ -145,6 +145,7 @@ export interface FormBase {
hasCaptcha: boolean
hasIssueNotification: boolean
authType: FormAuthType
+ isNricMaskEnabled: boolean
status: FormStatus
diff --git a/shared/utils/__tests__/nric-mask.spec.ts b/shared/utils/__tests__/nric-mask.spec.ts
new file mode 100644
index 0000000000..5954255937
--- /dev/null
+++ b/shared/utils/__tests__/nric-mask.spec.ts
@@ -0,0 +1,10 @@
+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/nric-mask.ts b/shared/utils/nric-mask.ts
new file mode 100644
index 0000000000..9bb3743a73
--- /dev/null
+++ b/shared/utils/nric-mask.ts
@@ -0,0 +1,26 @@
+/**
+ * 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/models/__tests__/form.server.model.spec.ts b/src/app/models/__tests__/form.server.model.spec.ts
index 3992d76cfe..3f737abbd5 100644
--- a/src/app/models/__tests__/form.server.model.spec.ts
+++ b/src/app/models/__tests__/form.server.model.spec.ts
@@ -72,6 +72,7 @@ const MOCK_MULTIRESPONDENT_FORM_PARAMS = {
const FORM_DEFAULTS = {
authType: 'NIL',
+ isNricMaskEnabled: false,
inactiveMessage:
'If you think this is a mistake, please contact the agency that gave you the form link.',
isListed: true,
@@ -2619,6 +2620,52 @@ describe('Form Model', () => {
})
})
+ describe('getDuplicateParams', () => {
+ it('should duplicate all required fields', () => {
+ // Arrange
+ const MOCK_ALL_OVERRIDE_PARAMS = {
+ admin: 'duplicated admin',
+ title: 'duplicated title',
+ responseMode: FormResponseMode.Email,
+ emails: ['duplicated email'],
+ submissionLimit: null,
+ }
+ const MOCK_ALL_FORM_PARAMS = {
+ title: 'Test Form',
+ admin: MOCK_ADMIN_OBJ_ID,
+ authType: FormAuthType.SP,
+ isNricMaskEnabled: true,
+ inactiveMessage: 'inactive_test',
+ responseMode: FormResponseMode.Encrypt,
+ submissionLimit: 1000,
+ }
+ const sourceForm = new Form(MOCK_ALL_FORM_PARAMS)
+
+ // Act
+ const duplicatedForm = sourceForm.getDuplicateParams(
+ MOCK_ALL_OVERRIDE_PARAMS,
+ )
+
+ // Assert
+ expect(duplicatedForm.title).toEqual(MOCK_ALL_OVERRIDE_PARAMS.title)
+ expect(duplicatedForm.admin).toEqual(MOCK_ALL_OVERRIDE_PARAMS.admin)
+ expect(duplicatedForm.responseMode).toEqual(
+ MOCK_ALL_OVERRIDE_PARAMS.responseMode,
+ )
+ expect(duplicatedForm.emails).toEqual(MOCK_ALL_OVERRIDE_PARAMS.emails)
+ expect(duplicatedForm.submissionLimit).toEqual(
+ 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.inactiveMessage).toEqual(
+ MOCK_ALL_FORM_PARAMS.inactiveMessage,
+ )
+ })
+ })
+
describe('insertFormField', () => {
it('should return updated document with inserted form field', async () => {
// Arrange
diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts
index ab48f2630d..3fce97ba6d 100644
--- a/src/app/models/form.server.model.ts
+++ b/src/app/models/form.server.model.ts
@@ -517,6 +517,11 @@ const compileFormModel = (db: Mongoose): IFormModel => {
},
},
+ isNricMaskEnabled: {
+ type: Boolean,
+ default: false,
+ },
+
// This must be before `status` since `status` has setters reliant on
// whether esrvcId is available, and mongoose@v6 now saves objects with keys
// in the order the keys are specifified in the schema instead of the object.
@@ -700,6 +705,7 @@ const compileFormModel = (db: Mongoose): IFormModel => {
'startPage',
'endPage',
'authType',
+ 'isNricMaskEnabled',
'inactiveMessage',
'responseMode',
'submissionLimit',
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 ec5de1fa4c..ce5a88d8dc 100644
--- a/src/app/modules/form/admin-form/admin-form.middlewares.ts
+++ b/src/app/modules/form/admin-form/admin-form.middlewares.ts
@@ -21,6 +21,7 @@ const webhookSettingsValidator = Joi.object({
export const updateSettingsValidator = celebrate({
[Segments.BODY]: Joi.object({
authType: Joi.string().valid(...Object.values(FormAuthType)),
+ isNricMaskEnabled: Joi.boolean(),
emails: Joi.alternatives().try(
Joi.array().items(Joi.string().email()),
Joi.string().email({ multiple: true }),
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
new file mode 100644
index 0000000000..b9ef3b1e08
--- /dev/null
+++ b/src/app/modules/submission/email-submission/__tests__/email-submission.controller.spec.ts
@@ -0,0 +1,173 @@
+import expressHandler from '__tests__/unit/backend/helpers/jest-express'
+import { ObjectId } from 'bson'
+import { ok, okAsync } from 'neverthrow'
+import { FormAuthType } from 'shared/types'
+
+import * as FormService from 'src/app/modules/form/form.service'
+import { SgidService } from 'src/app/modules/sgid/sgid.service'
+import * as EmailSubmissionService from 'src/app/modules/submission/email-submission/email-submission.service'
+import * as SubmissionService from 'src/app/modules/submission/submission.service'
+import MailService from 'src/app/services/mail/mail.service'
+import {
+ FormFieldSchema,
+ IEmailSubmissionSchema,
+ IPopulatedEmailForm,
+ IPopulatedForm,
+} from 'src/types'
+
+import { submitEmailModeForm } from '../email-submission.controller'
+
+jest.mock(
+ 'src/app/modules/submission/email-submission/email-submission.service',
+)
+jest.mock('src/app/modules/form/form.service')
+jest.mock('src/app/modules/submission/submission.service')
+jest.mock('src/app/modules/sgid/sgid.service')
+jest.mock('src/app/modules/submission/submissions.statsd-client')
+jest.mock('src/app/services/mail/mail.service')
+
+const MockEmailSubmissionService = jest.mocked(EmailSubmissionService)
+const MockFormService = jest.mocked(FormService)
+const MockSubmissionService = jest.mocked(SubmissionService)
+const MockSgidService = jest.mocked(SgidService)
+const MockMailService = jest.mocked(MailService)
+
+describe('email-submission.controller', () => {
+ describe('nricMask', () => {
+ beforeEach(() => {
+ const MOCK_SUBMISSION_HASH = {
+ hash: 'some hash',
+ salt: 'some salt',
+ }
+ MockEmailSubmissionService.hashSubmission.mockReturnValueOnce(
+ okAsync(MOCK_SUBMISSION_HASH),
+ )
+ MockEmailSubmissionService.saveSubmissionMetadata.mockReturnValueOnce(
+ okAsync({} as IEmailSubmissionSchema),
+ )
+ MockFormService.isFormPublic.mockReturnValueOnce(ok(true))
+
+ MockMailService.sendSubmissionToAdmin.mockReturnValueOnce(okAsync(true))
+
+ MockSubmissionService.validateAttachments.mockReturnValueOnce(
+ okAsync(true),
+ )
+ MockSubmissionService.sendEmailConfirmations.mockReturnValueOnce(
+ okAsync(true),
+ )
+ })
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should mask nric if form isNricMaskEnabled is true', async () => {
+ // Arrange
+ const mockFormId = new ObjectId()
+ const mockSgidAuthTypeAndNricMaskingEnabledForm = {
+ _id: mockFormId,
+ title: 'some form',
+ authType: FormAuthType.SGID,
+ isNricMaskEnabled: 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),
+ )
+ MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce(
+ ok(mockSgidAuthTypeAndNricMaskingEnabledForm as IPopulatedEmailForm),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(mockSgidAuthTypeAndNricMaskingEnabledForm),
+ )
+ 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 masked in email payload
+ expect(
+ MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0]
+ .answer,
+ ).toEqual('*****567A')
+ })
+
+ it('should not mask nric if form isNricMaskEnabled is false', async () => {
+ // Arrange
+ const mockFormId = new ObjectId()
+ const mockSgidAuthTypeAndNricMaskingDisabledForm = {
+ _id: mockFormId,
+ title: 'some form',
+ authType: FormAuthType.SGID,
+ isNricMaskEnabled: 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),
+ )
+ MockEmailSubmissionService.checkFormIsEmailMode.mockReturnValueOnce(
+ ok(mockSgidAuthTypeAndNricMaskingDisabledForm as IPopulatedEmailForm),
+ )
+ MockFormService.checkFormSubmissionLimitAndDeactivateForm.mockReturnValueOnce(
+ okAsync(mockSgidAuthTypeAndNricMaskingDisabledForm),
+ )
+ 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 masked
+ expect(
+ MockMailService.sendSubmissionToAdmin.mock.calls[0][0].formData[0]
+ .answer,
+ ).toEqual(MOCK_JWT_PAYLOAD_WITH_NRIC.userName)
+ })
+ })
+})
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 2cc6f73dde..fea4a06be8 100644
--- a/src/app/modules/submission/email-submission/email-submission.controller.ts
+++ b/src/app/modules/submission/email-submission/email-submission.controller.ts
@@ -1,11 +1,13 @@
import { ok, okAsync, ResultAsync } from 'neverthrow'
import {
+ BasicField,
FormAuthType,
SubmissionErrorDto,
SubmissionResponseDto,
} from '../../../../../shared/types'
import { CaptchaTypes } from '../../../../../shared/types/captcha'
+import { maskNric } from '../../../../../shared/utils/nric-mask'
import { IPopulatedEmailForm } from '../../../../types'
import { ParsedEmailModeSubmissionBody } from '../../../../types/api'
import { createLoggerWithLabel } from '../../../config/logger'
@@ -46,7 +48,7 @@ import { mapRouteError, SubmissionEmailObj } from './email-submission.util'
const logger = createLoggerWithLabel(module)
-const submitEmailModeForm: ControllerHandler<
+export const submitEmailModeForm: ControllerHandler<
{ formId: string },
SubmissionResponseDto | SubmissionErrorDto,
ParsedEmailModeSubmissionBody,
@@ -302,6 +304,16 @@ const submitEmailModeForm: ControllerHandler<
}
})
.andThen(({ form, parsedResponses, hashedFields }) => {
+ if (form.isNricMaskEnabled) {
+ parsedResponses.ndiResponses = parsedResponses.ndiResponses.map(
+ (response) => {
+ if (response.fieldType === BasicField.Nric) {
+ return { ...response, answer: maskNric(response.answer) }
+ }
+ return response
+ },
+ )
+ }
// Create data for response email as well as email confirmation
const emailData = new SubmissionEmailObj(
parsedResponses.getAllResponses(),
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
new file mode 100644
index 0000000000..35e1916447
--- /dev/null
+++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts
@@ -0,0 +1,193 @@
+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, MyInfoAttribute } from 'shared/types'
+
+import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model'
+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 VerifiedContentService from 'src/app/modules/verified-content/verified-content.service'
+import {
+ EncryptVerificationContentParams,
+ SpVerifiedContent,
+} from 'src/app/modules/verified-content/verified-content.types'
+import MailService from 'src/app/services/mail/mail.service'
+import { FormFieldSchema, IPopulatedEncryptedForm } from 'src/types'
+import { EncryptSubmissionDto, FormCompleteDto } from 'src/types/api'
+
+import { submitEncryptModeFormForTest } from '../encrypt-submission.controller'
+import { SubmitEncryptModeFormHandlerRequest } from '../encrypt-submission.types'
+
+jest.mock('src/app/utils/pipeline-middleware', () => {
+ const MockPipeline = jest.fn().mockImplementation(() => {
+ return {
+ execute: jest.fn(() => {
+ return okAsync(true)
+ }),
+ }
+ })
+
+ return {
+ Pipeline: MockPipeline,
+ }
+})
+jest.mock('src/app/modules/spcp/spcp.oidc.service')
+jest.mock('src/app/services/mail/mail.service')
+jest.mock('src/app/modules/verified-content/verified-content.service', () => {
+ const originalModule = jest.requireActual(
+ 'src/app/modules/verified-content/verified-content.service',
+ )
+ return {
+ ...originalModule,
+ getVerifiedContent: jest.fn(originalModule.getVerifiedContent),
+ encryptVerifiedContent: jest.fn(
+ ({ verifiedContent }: EncryptVerificationContentParams) =>
+ ok((verifiedContent as SpVerifiedContent).uinFin),
+ ),
+ }
+})
+
+const MockOidcService = jest.mocked(OidcService)
+const MockMailService = jest.mocked(MailService)
+const MockVerifiedContentService = jest.mocked(VerifiedContentService)
+
+const EncryptSubmission = getEncryptSubmissionModel(mongoose)
+
+describe('encrypt-submission.controller', () => {
+ describe('nricMask', () => {
+ beforeAll(async () => await dbHandler.connect())
+ afterEach(async () => await dbHandler.clearDatabase())
+ afterAll(async () => await dbHandler.closeDatabase())
+
+ 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))
+ })
+
+ const MOCK_NRIC = 'S1234567A'
+ const MOCK_MASKED_NRIC = '*****567A'
+
+ const MOCK_JWT_PAYLOAD = {
+ userName: MOCK_NRIC,
+ rememberMe: false,
+ }
+ const MOCK_COOKIE_TIMESTAMP = {
+ iat: 1,
+ exp: 1,
+ }
+
+ it('should mask nric if form isNricMaskEnabled is true', async () => {
+ // Arrange
+ const mockFormId = new ObjectId()
+ const mockSpAuthTypeAndNricMaskingEnabledForm = {
+ _id: mockFormId,
+ title: 'some form',
+ authType: FormAuthType.SP,
+ isNricMaskEnabled: 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.SP,
+ },
+ 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 generated using the masked nric
+ expect(
+ MockVerifiedContentService.getVerifiedContent,
+ ).toHaveBeenCalledWith({
+ type: mockSpAuthTypeAndNricMaskingEnabledForm.authType,
+ data: { uinFin: MOCK_MASKED_NRIC, userInfo: undefined },
+ })
+ // that the saved submission is masked
+ const savedSubmission = await EncryptSubmission.findOne()
+
+ expect(savedSubmission).toBeDefined()
+ expect(savedSubmission!.verifiedContent).toEqual(MOCK_MASKED_NRIC)
+ })
+
+ it('should not mask nric if form isNricMaskEnabled is false', 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
+
+ const MOCK_REQ = merge(
+ expressHandler.mockRequest({
+ params: { formId: 'some id' },
+ body: {
+ responses: [],
+ },
+ }),
+ {
+ formsg: {
+ encryptedPayload: {
+ encryptedContent: 'encryptedContent',
+ version: 1,
+ },
+ formDef: {
+ authType: FormAuthType.SP,
+ },
+ 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 generated using the masked nric
+ expect(
+ MockVerifiedContentService.getVerifiedContent,
+ ).toHaveBeenCalledWith({
+ type: mockSpAuthTypeAndNricMaskingEnabledForm.authType,
+ data: { uinFin: MOCK_NRIC, userInfo: undefined },
+ })
+ // that the saved submission is masked
+ const savedSubmission = await EncryptSubmission.findOne()
+
+ expect(savedSubmission).toBeDefined()
+ expect(savedSubmission!.verifiedContent).toEqual(MOCK_NRIC)
+ })
+ })
+})
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 cf18910d54..c19aac09a6 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts
@@ -14,6 +14,7 @@ import {
PaymentType,
StorageModeSubmissionContentDto,
} from '../../../../../shared/types'
+import { maskNric } from '../../../../../shared/utils/nric-mask'
import {
IEncryptedForm,
IEncryptedSubmissionSchema,
@@ -243,6 +244,19 @@ const submitEncryptModeForm = async (
}
}
+ // Mask if Nric masking is enabled
+ if (
+ uinFin &&
+ form.isNricMaskEnabled &&
+ (form.authType === FormAuthType.SP ||
+ form.authType === FormAuthType.CP ||
+ form.authType === FormAuthType.SGID ||
+ form.authType === FormAuthType.MyInfo ||
+ form.authType === FormAuthType.SGID_MyInfo)
+ ) {
+ uinFin = maskNric(uinFin)
+ }
+
// Encrypt Verified SPCP Fields
let verified
if (
@@ -303,7 +317,7 @@ const submitEncryptModeForm = async (
const submissionContent: EncryptSubmissionContent = {
form: form._id,
- auth: form.authType,
+ authType: form.authType,
myInfoFields: form.getUniqueMyInfoAttrs(),
encryptedContent: encryptedContent,
verifiedContent: verified,
@@ -342,6 +356,8 @@ const submitEncryptModeForm = async (
})
}
+export const submitEncryptModeFormForTest = submitEncryptModeForm
+
const _createPaymentSubmission = async ({
req,
res,
@@ -608,7 +624,6 @@ const _createSubmission = async ({
logMeta: CustomLoggerParams['meta']
}) => {
const submission = new EncryptSubmission(submissionContent)
-
try {
await submission.save()
} catch (err) {
diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
index c5ca26424c..0bafb5c0b3 100644
--- a/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
+++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.types.ts
@@ -79,7 +79,7 @@ export type SubmitEncryptModeFormHandlerRequest =
export type EncryptSubmissionContent = {
form: IPopulatedEncryptedForm['_id']
- auth: IPopulatedEncryptedForm['authType']
+ authType: IPopulatedEncryptedForm['authType']
myInfoFields: MyInfoAttribute[]
encryptedContent: string
verifiedContent: string | undefined
diff --git a/src/types/form.ts b/src/types/form.ts
index 1bd445ea2d..218fd04402 100644
--- a/src/types/form.ts
+++ b/src/types/form.ts
@@ -63,6 +63,7 @@ type FormDefaultableKey =
| 'hasCaptcha'
| 'hasIssueNotification'
| 'authType'
+ | 'isNricMaskEnabled'
| 'status'
| 'inactiveMessage'
| 'submissionLimit'
@@ -101,6 +102,7 @@ export type PickDuplicateForm = Pick<
| 'startPage'
| 'endPage'
| 'authType'
+ | 'isNricMaskEnabled'
| 'inactiveMessage'
| 'submissionLimit'
| 'responseMode'
@@ -259,6 +261,7 @@ interface IFormBaseDocument {
hasCaptcha: NonNullable
hasIssueNotification: NonNullable
authType: NonNullable
+ isNricMaskEnabled: NonNullable
status: NonNullable
inactiveMessage: NonNullable
// NOTE: Due to the way creating a form works, creating a form without specifying submissionLimit will throw an error.