Skip to content

Commit

Permalink
feat: multi-respondent forms (#6960)
Browse files Browse the repository at this point in the history
* feat: multi-respondent form prototype with edit response links

* chore: fix build errors

* fix: guard against editing authtype

* feat: MRF encryption scheme

* test: fix test files

* chore: fix build error

* chore: update copy for form creation modal

* chore: update variable name in frontend public form provider

* feat: attachment handling in multi-respondent forms for respondent 1+

* chore: fix build errors

* chore: move some stuff around

* test: fix backend tests

* test: update test suite

* chore: copy changes

* chore: more copy changes, add test mock clearing so that tests pass

* fix: nits

* chore: add linear ticket labels to all TODOs

* feat: gate mrf form creation on frontend and backend

* fix: require publicKey on form creation for multi-respondent forms

* chore(frm-1599): update multiparty create form modal icon

* fix: typing issues

* fix: remove unused import

* fix: update joi validator to array

* fix: column cell not respecting disable required validation

* fix: mislabelled test name

* fix: model ux issue when admin without mrf flag creates new form

* feat(mrf): response link with key (#6967)

* feat: append key in response link

* refactor: extract iskeypairvalid to fe utils

* feat: add prefilling of key

* feat: add auto decryption if key is correct

* refactor: extract key into getmultirespondentsubmissioneditpath utils

---------

Co-authored-by: Ken <ken@open.gov.sg>
  • Loading branch information
justynoh and KenLSM committed Dec 21, 2023
1 parent 1d9dd47 commit 2d68a62
Show file tree
Hide file tree
Showing 154 changed files with 8,289 additions and 4,815 deletions.
57 changes: 19 additions & 38 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"@emotion/styled": "^11.6.0",
"@floating-ui/react-dom-interactions": "^0.9.3",
"@growthbook/growthbook-react": "^0.17.0",
"@opengovsg/formsg-sdk": "^0.11.0",
"@opengovsg/formsg-sdk": "^0.12.0-alpha.1",
"@stablelib/base64": "^1.0.1",
"@stripe/react-stripe-js": "^1.15.0",
"@stripe/stripe-js": "^1.44.1",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/app/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ADMINFORM_USETEMPLATE_ROUTE,
BILLING_ROUTE,
DASHBOARD_ROUTE,
EDIT_SUBMISSION_PAGE_SUBROUTE,
LANDING_PAYMENTS_ROUTE,
LANDING_ROUTE,
LOGIN_CALLBACK_ROUTE,
Expand Down Expand Up @@ -145,6 +146,14 @@ export const AppRouter = (): JSX.Element => {
/>
}
/>
<Route
path={EDIT_SUBMISSION_PAGE_SUBROUTE}
element={
<ParamIdValidator
element={<PublicElement element={<PublicFormPage />} />}
/>
}
/>
</Route>
<Route
path={`${ADMINFORM_ROUTE}/:formId`}
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/assets/icons/MultiParty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const MultiParty = (
props: React.SVGProps<SVGSVGElement>,
): JSX.Element => {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20.5732 10.032L18.9012 11.5921L18.8229 11.6652L18.9012 11.7383L19.715 12.4977L19.7833 12.5614L19.8515 12.4977L23.0984 9.46808L23.1768 9.39498L23.0985 9.32186L19.8515 6.29096L19.7833 6.22727L19.715 6.29094L18.9012 7.05031L18.8229 7.1234L18.9012 7.19651L20.5734 8.75793H15.5221C15.2037 5.65438 12.5816 3.2335 9.39403 3.2335C5.99166 3.2335 3.2335 5.99166 3.2335 9.39403C3.2335 12.7964 5.99166 15.5546 9.39403 15.5546C12.581 15.5546 15.2026 13.1346 15.5219 10.032H20.5732ZM10.0317 19.4264L11.5918 21.0985L11.6649 21.1768L11.738 21.0985L12.4974 20.2846L12.5611 20.2164L12.4974 20.1482L9.46778 16.9012L9.39468 16.8229L9.32156 16.9012L6.29066 20.1482L6.22698 20.2164L6.29065 20.2846L7.05001 21.0985L7.1231 21.1768L7.19622 21.0985L8.75763 19.4263V24.4778C5.65422 24.7964 3.2335 27.4184 3.2335 30.6059C3.2335 34.0082 5.99166 36.7664 9.39402 36.7664C12.7964 36.7664 15.5546 34.0082 15.5546 30.6059C15.5546 27.4188 13.1345 24.7971 10.0317 24.478V19.4264ZM29.9682 15.5219L29.9682 20.5735L28.4081 18.9014L28.335 18.8231L28.2619 18.9014L27.5025 19.7153L27.4388 19.7835L27.5025 19.8517L30.5321 23.0987L30.6052 23.177L30.6783 23.0987L33.7092 19.8517L33.7729 19.7835L33.7093 19.7153L32.9499 18.9014L32.8768 18.8231L32.8037 18.9014L31.2423 20.5736V15.5221C34.3457 15.2035 36.7664 12.5815 36.7664 9.39403C36.7664 5.99166 34.0082 3.2335 30.6059 3.2335C27.2035 3.2335 24.4453 5.99166 24.4453 9.39403C24.4453 12.5811 26.8654 15.2028 29.9682 15.5219ZM13.3809 9.39403C13.3809 11.5959 11.5959 13.3809 9.39403 13.3809C7.19214 13.3809 5.40716 11.5959 5.40716 9.39403C5.40716 7.19214 7.19214 5.40716 9.39403 5.40716C11.5959 5.40716 13.3809 7.19214 13.3809 9.39403ZM9.39402 26.619C11.5959 26.619 13.3809 28.404 13.3809 30.6059C13.3809 32.8078 11.5959 34.5927 9.39402 34.5927C7.19214 34.5927 5.40716 32.8078 5.40716 30.6059C5.40715 28.404 7.19214 26.619 9.39402 26.619ZM30.6059 13.3809C28.404 13.3809 26.619 11.5959 26.619 9.39403C26.619 7.19214 28.404 5.40716 30.6059 5.40716C32.8078 5.40716 34.5927 7.19214 34.5927 9.39403C34.5927 11.5959 32.8078 13.3809 30.6059 13.3809ZM34.3734 30.5266C34.3734 32.6514 32.6509 34.3739 30.5261 34.3739C28.4013 34.3739 26.6788 32.6514 26.6788 30.5266C26.6788 28.4017 28.4013 26.6792 30.5261 26.6792C32.6509 26.6792 34.3734 28.4017 34.3734 30.5266ZM30.5261 36.7668C33.9725 36.7668 36.7664 33.973 36.7664 30.5266C36.7664 27.0802 33.9725 24.2863 30.5261 24.2863C27.0797 24.2863 24.2859 27.0802 24.2859 30.5266C24.2859 33.973 27.0797 36.7668 30.5261 36.7668Z"
fill="#293044"
stroke="#445072"
stroke-width="0.2"
/>
</svg>
)
}
1 change: 1 addition & 0 deletions frontend/src/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ export * from './BxsStar'
export * from './BxStar'
export * from './BxsXCircle'
export * from './BxX'
export * from './MultiParty'
export * from './PhHandsClapping'
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { useCallback, useMemo, useRef } from 'react'
import { RegisterOptions, useForm } from 'react-hook-form'
import { BiUpload } from 'react-icons/bi'
import {
FormControl,
FormErrorMessage,
IconButton,
Input,
Skeleton,
Stack,
} from '@chakra-ui/react'

import { GUIDE_SECRET_KEY_LOSS } from '~constants/links'
import { useIsMobile } from '~hooks/useIsMobile'
import { isKeypairValid, SECRET_KEY_REGEX } from '~utils/secretKeyValidation'

import Button from '../Button'
import FormLabel from '../FormControl/FormLabel'
import Link from '../Link'

const SECRET_KEY_NAME = 'secretKey'

export type SecretKeyVerificationInputProps = {
publicKey: string | null
setSecretKey: (secretKey: string) => void
isLoading: boolean
description: string
isButtonFullWidth: boolean
showGuideLink: boolean
buttonText: string
prefillSecretKey?: string
}

interface SecretKeyFormInputs {
[SECRET_KEY_NAME]: string
}

export const SecretKeyVerificationInput = ({
publicKey,
setSecretKey,
isLoading,
description,
isButtonFullWidth,
showGuideLink,
buttonText,
prefillSecretKey,
}: SecretKeyVerificationInputProps) => {
const isMobile = useIsMobile()

const {
formState: { errors },
setError,
register,
setValue,
handleSubmit,
} = useForm<SecretKeyFormInputs>({
defaultValues: { secretKey: prefillSecretKey },
})

const fileUploadRef = useRef<HTMLInputElement | null>(null)

const secretKeyValidationRules: RegisterOptions = useMemo(() => {
return {
required: "Please enter the form's secret key",
validate: (secretKey: string) => {
// Should not see this error message.
if (!publicKey) return 'Unexpected form mode'

const isValid = isKeypairValid(publicKey, secretKey)
return isValid || 'The secret key provided is invalid'
},
}
}, [publicKey])

const handleVerifyKeypair = handleSubmit(({ secretKey }) => {
return setSecretKey(secretKey.trim())
})

const handleFileSelect = useCallback(
({ target }: React.ChangeEvent<HTMLInputElement>) => {
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().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)
},
[setError, setValue],
)

return (
<form onSubmit={handleVerifyKeypair} noValidate>
{/* Hidden input field to trigger file selector, can be anywhere in the DOM */}
<Input
name="secretKeyFile"
ref={fileUploadRef}
type="file"
accept="text/plain"
onChange={handleFileSelect}
display="none"
/>
<FormControl isRequired isInvalid={!!errors.secretKey} mb="1rem">
<FormLabel description={description}>
Enter or upload Secret Key
</FormLabel>
<Stack direction="row" spacing="0.5rem">
<Skeleton isLoaded={!isLoading} w="100%">
<Input
isDisabled={isLoading}
{...register(SECRET_KEY_NAME, secretKeyValidationRules)}
/>
</Skeleton>
<Skeleton isLoaded={!isLoading}>
<IconButton
isDisabled={isLoading}
variant="outline"
aria-label="Pass secret key from file"
icon={<BiUpload />}
onClick={() => fileUploadRef.current?.click()}
/>
</Skeleton>
</Stack>
<FormErrorMessage>{errors.secretKey?.message}</FormErrorMessage>
</FormControl>
<Stack
spacing={{ base: '1.5rem', md: '2rem' }}
align="center"
direction={{ base: 'column', md: 'row' }}
mt="2rem"
>
<Button
isFullWidth={isMobile || isButtonFullWidth}
isDisabled={isLoading}
type="submit"
>
{buttonText}
</Button>
{showGuideLink && (
<Link variant="standalone" isExternal href={GUIDE_SECRET_KEY_LOSS}>
Can't find your Secret Key?
</Link>
)}
</Stack>
</form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { SecretKeyVerificationInput as default } from './SecretKeyVerificationInput'
2 changes: 2 additions & 0 deletions frontend/src/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export const ACTIVE_ADMINFORM_RESULTS_ROUTE_REGEX = new RegExp(
`${ADMINFORM_ROUTE}/([a-fA-F0-9]{24})/${ADMINFORM_RESULTS_SUBROUTE}(/${RESULTS_FEEDBACK_SUBROUTE}|/${RESULTS_CHARTS_SUBROUTE})?/?`,
'i',
)

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'
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import { useDesignColorTheme } from '../utils/useDesignColorTheme'
import FieldRow from './FieldRow'

interface BuilderFieldsProps {
responseMode: AdminFormDto['responseMode']
fields: AdminFormDto['form_fields']
visibleFieldIds: FieldIdSet
isDraggingOver: boolean
}

export const BuilderFields = ({
responseMode,
fields,
visibleFieldIds,
isDraggingOver,
Expand Down Expand Up @@ -54,6 +56,7 @@ export const BuilderFields = ({
: {}
return (
<FieldRow
responseMode={responseMode}
index={i}
key={f._id}
field={f}
Expand Down
Loading

0 comments on commit 2d68a62

Please sign in to comment.