Skip to content

Commit

Permalink
feat(admin-form): support nric masking (#7388)
Browse files Browse the repository at this point in the history
* feat(admin-form): add frontend nric mask toggle to singpass auth settings

* fix(admin-form): fix spacing and change units from px to rem

* feat(admin-form): create isNricMaskingEnabled field in database when form is created

* feat(admin-form): enable fetching and updating isNricMaskingEnabled field in backend routes

* feat(admin-form): connect frontend component with server api routes

* fix(admin-form): make code terser, remove unused params, fix nullish-coalesce operator usage

* fix(admin-page): fix flickering bug

* feat(duplicate-api): implement duplication of isNricMaskEnabled field

* feat(public-form): prevent nric from reaching frontend if masked

* feat(nric-mask): apply masking when submitting forms

* feat(nric-mask): update frontend template to reflect masking toggle changes

* fix(index.html): checkout to develop branch

* fix: remove console.log

* fix(toggle-component): replace nullish coalesce with ternary operator

* chore(public-form-controller): remove masking when fetching public form at backend

* fix: refactor email-submission-controller to mask at single location

* fix: clean up nric-mask util function and unused imports

* fix: update field with evaluated map value

* feat: add nric masking to the frontend public form provider

* feat: remove tooltip and labelComponentRight from Toggle to match design

* feat: update settings auth page to reflect design master

* feat: add err message

* feat: change px to rem

* feat: disable independently for nric masking and auth toggle

* fix: change MyInfo to Myinfo in description message

* fix: update form model test suite to expect new isNricMaskEnabled field

* feat: add tests for nric mask util and masking for email submission

* feat: add test for isNricMaskDisabled for email submission

* feat: add tests for storage mode submission masking

* fix: fix bug with submission not being able to save authType and defaulting to nil

* feat: add stories for updated settings auth page

* feat: update playwright test helper to support new auth setting flow

* fix: fix review comments and run frontend lint fix

* fix: fix based on chromatic review

* fix: fix review comments

* feat: add testing for getDuplicateParams to ensure that nricmask is correctly duplicated

* refactor: extract skeleton component out to own file

---------

Co-authored-by: Ken <[email protected]>
  • Loading branch information
kevin9foong and KenLSM authored Jun 27, 2024
1 parent c6ba38d commit 79106eb
Show file tree
Hide file tree
Showing 35 changed files with 1,050 additions and 238 deletions.
28 changes: 19 additions & 9 deletions __tests__/e2e/helpers/createForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/features/admin-form/common/AdminViewFormService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
/>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/admin-form/preview/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const PREVIEW_MOCK_UINFIN = 'S1234567A'
export const PREVIEW_MASKED_MOCK_UINFIN = '*****567A'
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,42 @@ export default {
} as Meta

const Template: Story = () => <SettingsAuthPage />
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,
}),
}

Expand All @@ -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: [
Expand All @@ -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' })],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const SettingsAuthPage = (): JSX.Element => {

return (
<>
<CategoryHeader>Enable Singpass authentication</CategoryHeader>
<CategoryHeader>Singpass</CategoryHeader>
<AuthSettingsSection />
</>
)
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/features/admin-form/settings/SettingsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Text textStyle="subhead-1" color="secondary.500" mb="2.5rem" mt="2.5rem">
Authenticate respondents by NRIC/FIN.{' '}
<Link
textStyle="subhead-1"
href={GUIDE_SPCP_ESRVCID}
isExternal
// Needed for link to open since there are nested onClicks
onClickCapture={(e) => e.stopPropagation()}
>
Learn more about Singpass authentication
</Link>
</Text>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<Box my="2.5rem">
<InlineMessage mb="1rem">{explanationText}</InlineMessage>
</Box>
)
}
Loading

0 comments on commit 79106eb

Please sign in to comment.