diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a380ef9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + workflow_dispatch: + push: + pull_request: + types: [opened, reopened] + schedule: + # * is a special character in YAML so you have to quote this string + - cron: '30 15 * * *' +jobs: + publish: + runs-on: ubuntu-latest + env: + APP_NAME: formsg-intl + steps: + - name: Checkout opengovsg/FormSG into the same local dir + uses: actions/checkout@v4 + with: + repository: opengovsg/FormSG + ref: refs/heads/release-al2 + - name: Checkout this repo + uses: actions/checkout@v4 + with: + path: repo + - name: Move repo contents into local root + run: mv repo/* . + - name: Replace files with intl-specific ones + run: | + cp -rf replacements/* . + rm -rf replacements + + - run: ls -al frontend + + - name: Substitute index.html OG params + run: | + cat frontend/public/index.html | \ + sed 's/__OG_TITLE__/Form/' | \ + sed 's/__OG_DESCRIPTION__/Secure forms from the government/' | \ + sed 's/__OG_IMAGE__/og-img-metatag-publicform.png/' > frontend/public/index2.html && \ + mv frontend/public/index2.html frontend/public/index.html + + - name: Set app version + run: | + echo "APP_VERSION=$(jq -r .version package.json)-$(echo ${GITHUB_REF##*/})-$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASS }} + + - name: Build and push docker image + uses: docker/build-push-action@v5 + with: + push: true + context: . + tags: | + opengovsg/${{ env.APP_NAME }}:latest + opengovsg/${{ env.APP_NAME }}:${{ env.APP_VERSION }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6bba59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c5a201a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,105 @@ +# syntax=docker/dockerfile:1 + +FROM node:hydrogen-alpine3.18 as build + +# node-modules-builder stage installs/compiles the node_modules folder +# Python version must be specified starting in alpine3.12 +RUN apk update && apk upgrade && \ + apk --no-cache add --virtual native-deps \ + g++ gcc libgcc libstdc++ linux-headers autoconf automake make nasm python3 git curl && \ + npm install --quiet node-gyp -g +WORKDIR /build + +COPY package.json package-lock.json ./ +COPY shared/package.json shared/package-lock.json ./shared/ +COPY frontend/package.json frontend/package-lock.json ./frontend/ +COPY frontend/patches ./frontend/patches + +# Allow running of postinstall scripts +# RUN npm config set unsafe-perm true +# --legacy-peer-deps flag +# A breaking change in the peer dependency resolution strategy was introduced in +# npm 7. This resulted in npm throwing an error when installing packages: +# npm ERR! code ERESOLVE +# npm ERR! ERESOLVE unable to resolve dependency tree +# See also: +# * https://stackoverflow.com/questions/66239691/what-does-npm-install-legacy-peer-deps-do-exactly-when-is-it-recommended-wh +# NOTE: This flag is used again later in the build process when calling npm prune. +RUN npm ci --legacy-peer-deps + +COPY . ./ + +# --openssl-legacy-provider flag +# A breaking change in the SSL provider was introduced in node 17. This caused +# webpack 4 to break. This is an interim solution; we should investigate removing +# this flag once angular has been removed and we have upgraded to CRA5 (which uses +# webpack 5). +# See also: +# * https://stackoverflow.com/questions/69692842/error-message-error0308010cdigital-envelope-routinesunsupported +# * https://github.com/webpack/webpack/issues/14532#issuecomment-1304378535 +# These options are only used in the build stage, not the start stage. +ENV NODE_OPTIONS="--max-old-space-size=4096 --openssl-legacy-provider" + +RUN npm run build +RUN cat ./assets/demo-watermark.css >> `ls ./dist/frontend/static/css/*.css` + +# Move mockpass to prod dependency since we need the static certs +RUN npm install -P @opengovsg/mockpass + +RUN npm prune --production --legacy-peer-deps + +# This stage builds the final container +FROM node:hydrogen-alpine3.18 +LABEL maintainer="Demos at OGP" +WORKDIR /opt/formsg + +# Install build from backend-build +COPY --from=build /build/node_modules /opt/formsg/node_modules +COPY --from=build /build/package.json /opt/formsg/package.json +COPY --from=build /build/dist /opt/formsg/dist + +# Grab Singpass RP jwks config from __tests__ +COPY --from=build /build/__tests__/setup/certs /opt/formsg/__tests__/setup/certs + +# Built backend goes back to root working directory +RUN mv /opt/formsg/dist/backend/src /opt/formsg/ +RUN mv /opt/formsg/dist/backend/shared /opt/formsg/ + +# Install chromium from official docs +# https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#running-on-alpine +# Note that each alpine version supports a specific version of chromium +# Note that chromium and puppeteer-core are released together and it is the only version +# that is guaranteed to work. Upgrades must be done in lockstep. +# https://www.npmjs.com/package/puppeteer-core?activeTab=versions for corresponding versions + +RUN apk add --no-cache \ +# Compatible chromium versions can be found here https://pkgs.alpinelinux.org/packages?name=chromium&branch=v3.18&repo=&arch=&maintainer= + chromium=119.0.6045.159-r0 \ + nss \ + freetype \ + freetype-dev \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + tini + +# Tell Puppeteer to skip installing Chrome. We'll be using the installed package. +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser + +# This package is needed to render Chinese characters in autoreply PDFs +RUN apk add font-wqy-zenhei --repository https://dl-cdn.alpinelinux.org/alpine/edge/community + +ENV CHROMIUM_BIN=/usr/bin/chromium-browser + +# Run as non-privileged user +RUN addgroup -S formsguser && adduser -S -g formsguser formsguser +USER formsguser + +ENV NODE_ENV=production +EXPOSE 5000 + +# tini is the init process that will adopt orphaned zombie processes +# e.g. chromium when launched to create a new PDF +ENTRYPOINT [ "tini", "-s", "--" ] +CMD [ "npm", "start" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..caa477c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Open Government Products + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe6a1c7 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# FormSG International Edition + +Builds and publishes a Docker image to +opengovsg/formsg-intl + diff --git a/replacements/frontend/src/components/GovtMasthead/GovtMasthead.tsx b/replacements/frontend/src/components/GovtMasthead/GovtMasthead.tsx new file mode 100644 index 0000000..46a78ee --- /dev/null +++ b/replacements/frontend/src/components/GovtMasthead/GovtMasthead.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { + Box, + chakra, + Collapse, + Flex, + Stack, + Text, + useDisclosure, + VisuallyHidden, +} from '@chakra-ui/react' + +import { BxsBank } from '~assets/icons/BxsBank' +import { BxsLockAlt } from '~assets/icons/BxsLockAlt' +import { useIsMobile } from '~hooks/useIsMobile' + +import { GovtMastheadItem } from './GovtMastheadItem' + +export interface GovtMastheadProps { + defaultIsOpen?: boolean +} + +interface GovtMastheadChildrenProps { + isOpen: boolean + isMobile: boolean + onToggle: () => void + children: React.ReactNode +} + +interface HeaderBarProps extends GovtMastheadChildrenProps { + /** + * ID of the expandable section for accessibility. + */ + ariaControlId: string +} + +const HeaderBar = ({ + isMobile, + children, + onToggle, + isOpen, + ariaControlId, +}: HeaderBarProps): JSX.Element => { + const styleProps = { + bg: 'neutral.200', + py: { base: '0.5rem', md: '0.375rem' }, + px: { base: '1.5rem', md: '1.75rem', lg: '2rem' }, + textStyle: { base: 'legal', md: 'caption-2' }, + display: 'flex', + width: '100%', + } + + // Mobile + if (isMobile) { + return ( + + + {isOpen + ? 'Collapse masthead' + : 'Expand masthead to find out how to identify an official government website'} + + {children} + + ) + } + + // Non-mobile + return {children} +} + +export const GovtMasthead = ({ + defaultIsOpen, +}: GovtMastheadProps): JSX.Element => { + const { onToggle, isOpen } = useDisclosure({ defaultIsOpen }) + const isMobile = useIsMobile() + + const ariaControlId = 'govt-masthead-expandable' + + return ( + + {}} + isMobile={isMobile} + isOpen={isOpen} + ariaControlId={ariaControlId} + > + + A white-labelled build of FormSG + + + + + + + + + + + + + + + ) +} diff --git a/replacements/frontend/src/features/login/components/LoginForm.tsx b/replacements/frontend/src/features/login/components/LoginForm.tsx new file mode 100644 index 0000000..d22be1a --- /dev/null +++ b/replacements/frontend/src/features/login/components/LoginForm.tsx @@ -0,0 +1,75 @@ +import { useCallback } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { FormControl, Stack } from '@chakra-ui/react' +import isEmail from 'validator/lib/isEmail' + +import { INVALID_EMAIL_ERROR } from '~constants/validation' +import Button from '~components/Button' +import FormErrorMessage from '~components/FormControl/FormErrorMessage' +import FormLabel from '~components/FormControl/FormLabel' +import Input from '~components/Input' + +export type LoginFormInputs = { + email: string +} + +interface LoginFormProps { + onSubmit: (inputs: LoginFormInputs) => Promise +} + +export const LoginForm = ({ onSubmit }: LoginFormProps): JSX.Element => { + const { t } = useTranslation() + + const { handleSubmit, register, formState, setError } = + useForm() + + const validateEmail = useCallback((value: string) => { + return isEmail(value.trim()) || INVALID_EMAIL_ERROR + }, []) + + const onSubmitForm = async (inputs: LoginFormInputs) => { + return onSubmit(inputs).catch((e) => { + setError('email', { type: 'server', message: e.message }) + }) + } + + return ( +
+ + + {t( + 'features.login.components.LoginForm.onlyAvailableForPublicOfficers', + )} + + + {formState.errors.email && ( + {formState.errors.email.message} + )} + + + + +
+ ) +} diff --git a/replacements/frontend/src/features/login/components/LoginPage.tsx b/replacements/frontend/src/features/login/components/LoginPage.tsx new file mode 100644 index 0000000..c325cf5 --- /dev/null +++ b/replacements/frontend/src/features/login/components/LoginPage.tsx @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSearchParams } from 'react-router-dom' +import { Stack } from '@chakra-ui/react' +import { StatusCodes } from 'http-status-codes' + +import { LOGGED_IN_KEY } from '~constants/localStorage' +import { useLocalStorage } from '~hooks/useLocalStorage' +import { useToast } from '~hooks/useToast' +import { sendLoginOtp, verifyLoginOtp } from '~services/AuthService' + +import { + trackAdminLogin, + trackAdminLoginFailure, +} from '~features/analytics/AnalyticsService' + +import { LoginForm, LoginFormInputs } from './components/LoginForm' +import { OtpForm, OtpFormInputs } from './components/OtpForm' +import { LoginPageTemplate } from './LoginPageTemplate' +import { useIsIntranetCheck } from './queries' + +export type LoginOtpData = { + email: string +} + +export const LoginPage = (): JSX.Element => { + const { t } = useTranslation() + const { data: isIntranetIp } = useIsIntranetCheck() + + const [, setIsAuthenticated] = useLocalStorage(LOGGED_IN_KEY) + const [email, setEmail] = useState() + const [otpPrefix, setOtpPrefix] = useState('') + + const [params] = useSearchParams() + const toast = useToast({ isClosable: true, status: 'danger' }) + + const statusCode = params.get('status') + const toastMessage = useMemo(() => { + switch (statusCode) { + case null: + case StatusCodes.OK.toString(): + return + case StatusCodes.UNAUTHORIZED.toString(): + return t('features.login.LoginPage.expiredSgIdSession') + default: + return t('features.common.errors.generic') + } + }, [statusCode]) + + useEffect(() => { + if (!toastMessage) return + toast({ description: toastMessage }) + }, [toast, toastMessage]) + + const handleSendOtp = async ({ email }: LoginFormInputs) => { + const trimmedEmail = email.trim() + await sendLoginOtp(trimmedEmail).then(({ otpPrefix }) => { + setOtpPrefix(otpPrefix) + setEmail(trimmedEmail) + }) + } + + const handleVerifyOtp = async ({ otp }: OtpFormInputs) => { + // Should not happen, since OtpForm component is only shown when there is + // already an email state set. + if (!email) { + throw new Error('Something went wrong') + } + try { + await verifyLoginOtp({ otp, email }) + trackAdminLogin() + return setIsAuthenticated(true) + } catch (error) { + if (error instanceof Error) { + trackAdminLoginFailure(error.message) + } + throw error + } + } + + const handleResendOtp = async () => { + // Should not happen, since OtpForm component is only shown when there is + // already an email state set. + if (!email) { + throw new Error('Something went wrong') + } + await sendLoginOtp(email).then(({ otpPrefix }) => setOtpPrefix(otpPrefix)) + } + + return ( + + {!email ? ( + + + + ) : ( + + )} + + ) +} diff --git a/replacements/frontend/src/features/public-form/PublicFormService.ts b/replacements/frontend/src/features/public-form/PublicFormService.ts new file mode 100644 index 0000000..e02b541 --- /dev/null +++ b/replacements/frontend/src/features/public-form/PublicFormService.ts @@ -0,0 +1,483 @@ +import { PresignedPost } from 'aws-sdk/clients/s3' +import axios from 'axios' + +import { + MULTIRESPONDENT_FORM_SUBMISSION_VERSION, + VIRUS_SCANNER_SUBMISSION_VERSION, +} from '~shared/constants' +import { SubmitFormIssueBodyDto, SuccessMessageDto } from '~shared/types' +import { + AttachmentPresignedPostDataMapType, + AttachmentSizeMapType, + FormFieldDto, + PaymentFieldsDto, +} from '~shared/types/field' +import { + ProductItem, + PublicFormAuthLogoutDto, + PublicFormAuthRedirectDto, + SubmitFormFeedbackBodyDto, +} from '~shared/types/form' +import { + FormAuthType, + FormDto, + PublicFormViewDto, +} from '~shared/types/form/form' +import { + MultirespondentSubmissionDto, + ResponseMetadata, + SubmissionResponseDto, +} from '~shared/types/submission' + +import { transformAllIsoStringsToDate } from '~utils/date' +import { + API_BASE_URL, + ApiService, + processFetchResponse, +} from '~services/ApiService' +import { FormFieldValues } from '~templates/Field' + +import { + createClearSubmissionFormData, + createClearSubmissionWithVirusScanningFormData, + createClearSubmissionWithVirusScanningFormDataV3, + getAttachmentsMap, +} from './utils/createSubmission' +import { filterHiddenInputs } from './utils/filterHiddenInputs' + +export const PUBLIC_FORMS_ENDPOINT = '/forms' + +/** + * Gets public view of form, along with any + * identify information obtained from Singpass/Corppass/MyInfo. + * @param formId FormId of form in question + * @returns Public view of form, with additional identify information + */ +export const getPublicFormView = async ( + formId: string, +): Promise => { + return ApiService.get(`${PUBLIC_FORMS_ENDPOINT}/${formId}`) + .then(({ data }) => data) + .then(transformAllIsoStringsToDate) +} + +/** + * Gets the redirect url for public form login + * @param formId form id of form to log in. + * @param isPersistentLogin whether login is persistent; affects cookie lifetime. + * @returns redirect url for public form login + */ +export const getPublicFormAuthRedirectUrl = async ( + formId: string, + isPersistentLogin = false, + encodedQuery?: string, +): Promise => { + return ApiService.get( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/auth/redirect`, + { params: { encodedQuery, isPersistentLogin } }, + ).then(({ data }) => data.redirectURL) +} + +/** + * Logs out of current public form session + * @param authType authType of form to log out. + * @returns Success message + */ +export const logoutPublicForm = async ( + authType: Exclude, +): Promise => { + return ApiService.get( + `${PUBLIC_FORMS_ENDPOINT}/auth/${authType}/logout`, + ).then(({ data }) => data) +} + +/** + * Returns the data of a single submission of a given multirespondent form + * @param arg.formId The id of the form to query + * @param arg.submissionId The id of the submission + * @returns The data of the submission + */ +export const getMultirespondentSubmissionById = async ({ + formId, + submissionId, +}: { + formId: string + submissionId: string +}): Promise => { + return ApiService.get( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/${submissionId}`, + ).then(({ data }) => data) +} + +export type SubmitEmailFormArgs = { + formId: string + captchaResponse?: string | null + captchaType?: string + formFields: FormFieldDto[] + formLogics: FormDto['form_logics'] + formInputs: FormFieldValues + responseMetadata?: ResponseMetadata +} + +export type SubmitStorageFormArgs = SubmitEmailFormArgs & { + publicKey: string + paymentReceiptEmail?: string + paymentProducts?: Array + payments?: PaymentFieldsDto +} + +export type SubmitStorageFormClearArgs = SubmitEmailFormArgs & { + paymentReceiptEmail?: string + paymentProducts?: Array + payments?: PaymentFieldsDto +} + +export type FieldIdToQuarantineKeyType = { + fieldId: string + quarantineBucketKey: string +} + +export type SubmitStorageFormWithVirusScanningArgs = + SubmitStorageFormClearArgs & { + fieldIdToQuarantineKeyMap: FieldIdToQuarantineKeyType[] + } + +export type SubmitMultirespondentFormWithVirusScanningArgs = + SubmitEmailFormArgs & { + // publicKey: string + fieldIdToQuarantineKeyMap: FieldIdToQuarantineKeyType[] + } + +export const submitEmailModeForm = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + responseMetadata, +}: SubmitEmailFormArgs): Promise => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + const formData = createClearSubmissionFormData({ + formFields, + formInputs: filteredInputs, + responseMetadata, + }) + + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/email`, + formData, + { + params: { + captchaResponse: String(captchaResponse), + captchaType: captchaType, + }, + }, + ).then(({ data }) => data) +} + +// TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved +// Submit storage mode form with virus scanning (storage v2.1+) +export const submitStorageModeFormWithFetch = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + paymentReceiptEmail, + responseMetadata, + paymentProducts, + payments, + fieldIdToQuarantineKeyMap, +}: SubmitStorageFormWithVirusScanningArgs) => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + + const formData = createClearSubmissionWithVirusScanningFormData( + { + formFields, + formInputs: filteredInputs, + responseMetadata, + paymentReceiptEmail, + paymentProducts, + payments, + version: VIRUS_SCANNER_SUBMISSION_VERSION, + }, + fieldIdToQuarantineKeyMap, + ) + + // Add captcha response to query string + const queryString = new URLSearchParams({ + captchaResponse: String(captchaResponse), + captchaType, + }).toString() + + const response = await fetch( + `${API_BASE_URL}${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/storage?${queryString}`, + { + method: 'POST', + body: formData, + headers: { + Accept: 'application/json', + }, + }, + ) + + return processFetchResponse(response) +} + +// Submit storage mode form with virus scanning (storage v2.1+) +export const submitStorageModeForm = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + paymentReceiptEmail, + responseMetadata, + paymentProducts, + payments, + fieldIdToQuarantineKeyMap, +}: SubmitStorageFormWithVirusScanningArgs) => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + + const formData = createClearSubmissionWithVirusScanningFormData( + { + formFields, + formInputs: filteredInputs, + responseMetadata, + paymentReceiptEmail, + paymentProducts, + payments, + version: VIRUS_SCANNER_SUBMISSION_VERSION, + }, + fieldIdToQuarantineKeyMap, + ) + + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/storage`, + formData, + { + params: { + captchaResponse: String(captchaResponse), + captchaType: captchaType, + }, + }, + ).then(({ data }) => data) +} + +// TODO (#5826): Fallback mutation using Fetch. Remove once network error is resolved +export const submitEmailModeFormWithFetch = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + responseMetadata, +}: SubmitEmailFormArgs): Promise => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + const formData = createClearSubmissionFormData({ + formFields, + formInputs: filteredInputs, + responseMetadata, + }) + + // Add captcha response to query string + const queryString = new URLSearchParams({ + captchaResponse: String(captchaResponse), + captchaType, + }).toString() + + const response = await fetch( + `${API_BASE_URL}${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/email?${queryString}`, + { + method: 'POST', + body: formData, + headers: { + Accept: 'application/json', + }, + }, + ) + + return processFetchResponse(response) +} + +// Submit storage mode form with virus scanning (storage v2.1+) +export const submitMultirespondentForm = async ({ + formFields, + formLogics, + formInputs, + formId, + captchaResponse = null, + captchaType = '', + responseMetadata, + fieldIdToQuarantineKeyMap, +}: SubmitMultirespondentFormWithVirusScanningArgs) => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + + const formData = createClearSubmissionWithVirusScanningFormDataV3( + { + formFields, + formInputs: filteredInputs, + responseMetadata, + version: MULTIRESPONDENT_FORM_SUBMISSION_VERSION, + }, + fieldIdToQuarantineKeyMap, + ) + + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/multirespondent`, + formData, + { + params: { + captchaResponse: String(captchaResponse), + captchaType: captchaType, + }, + }, + ).then(({ data }) => data) +} + +export const updateMultirespondentSubmission = async ({ + formFields, + formLogics, + formInputs, + formId, + submissionId, + captchaResponse = null, + captchaType = '', + responseMetadata, + fieldIdToQuarantineKeyMap, +}: SubmitMultirespondentFormWithVirusScanningArgs & { + submissionId?: string +}) => { + const filteredInputs = filterHiddenInputs({ + formFields, + formInputs, + formLogics, + }) + + const formData = createClearSubmissionWithVirusScanningFormDataV3( + { + formFields, + formInputs: filteredInputs, + responseMetadata, + version: MULTIRESPONDENT_FORM_SUBMISSION_VERSION, + }, + fieldIdToQuarantineKeyMap, + ) + + return ApiService.put( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/${submissionId}`, + formData, + { + params: { + captchaResponse: String(captchaResponse), + captchaType: captchaType, + }, + }, + ).then(({ data }) => data) +} + +/** + * Post feedback for a given form. + * @param formId the id of the form to post feedback for + * @param submissionId the id of the form submission to post feedback for + * @param feedbackToPost object containing the feedback + * @returns success message + */ +export const submitFormFeedback = async ( + formId: string, + submissionId: string, + feedbackToPost: SubmitFormFeedbackBodyDto, +): Promise => { + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/${submissionId}/feedback`, + feedbackToPost, + ).then(({ data }) => data) +} + +/** + * Post issue for a given form. + * @param formId the id of the form to post feedback for + * @param issueToPost object containing the issue + * @returns success message + */ +export const submitFormIssue = async ( + formId: string, + issueToPost: SubmitFormIssueBodyDto, +): Promise => { + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/issue`, + issueToPost, + ).then(({ data }) => data) +} + +export const getAttachmentSizes = async ({ + formFields, + formInputs, +}: { + formFields: FormFieldDto[] + formInputs: FormFieldValues +}) => { + const attachmentsMap = getAttachmentsMap(formFields, formInputs) + const attachmentSizes: AttachmentSizeMapType[] = [] + for (const id in attachmentsMap) { + // Check if id is a valid ObjectId. mongoose.isValidaObjectId cannot be used as it will throw a Reference Error. + const isValidObjectId = new RegExp(/^[0-9a-fA-F]{24}$/).test(id) + if (!isValidObjectId) throw new Error(`Invalid attachment id: ${id}`) + attachmentSizes.push({ id, size: attachmentsMap[id].size }) + } + return attachmentSizes +} + +/** + * Get presigned post data for attachments. + * @returns presigned post data for attachments. + */ +export const getAttachmentPresignedPostData = async ({ + attachmentSizes, + formId, +}: { + attachmentSizes: AttachmentSizeMapType[] + formId: string +}) => { + return ApiService.post( + `${PUBLIC_FORMS_ENDPOINT}/${formId}/submissions/get-s3-presigned-post-data`, + attachmentSizes, + ).then(({ data }) => data) +} + +export const uploadAttachmentToQuarantine = async ( + { url, fields }: PresignedPost, + file: File, +) => { + const parsedUrl = new URL(url) + return await (parsedUrl.hostname.endsWith('.r2.cloudflarestorage.com') + ? axios.putForm(parsedUrl.toString(), file) + : axios.postForm(url, { + ...fields, + file, + })) +} diff --git a/replacements/frontend/src/i18n/locales/features/login/en-sg.ts b/replacements/frontend/src/i18n/locales/features/login/en-sg.ts new file mode 100644 index 0000000..362f5dc --- /dev/null +++ b/replacements/frontend/src/i18n/locales/features/login/en-sg.ts @@ -0,0 +1,44 @@ +import { Login } from '.' + +export const enSG: Login = { + LoginPage: { + slogan: 'Build secure government forms in minutes', + banner: 'You can now collect payments directly on your form!', + expiredSgIdSession: + 'Your sgID login session has expired. Please login again.', + }, + SelectProfilePage: { + accountSelection: 'Choose an account to continue to FormSG', + manualLogin: 'Or, login manually using email and OTP', + noWorkEmailHeader: "Singpass login isn't available to you yet", + noWorkEmailBody: + 'It is progressively being made available to agencies. In the meantime, please log in using your email address.', + noWorkEmailCta: 'Back to login', + invalidWorkEmailHeader: "You don't have access to this service", + invalidWorkEmailBodyRestriction: + 'It may be available only to select agencies or authorised individuals. If you believe you should have access to this service, please', + invalidWorkEmailBodyContact: 'contact us', + invalidWorkEmailCta: 'Choose another account', + }, + components: { + LoginForm: { + onlyAvailableForPublicOfficers: 'Log in with your email address', + emailEmptyErrorMsg: 'Please enter an email address', + login: 'Log in', + haveAQuestion: 'Have a question?', + }, + OTPForm: { + signin: 'Sign in', + otpRequired: 'OTP is required.', + otpLengthCheck: 'Please enter a 6 digit OTP.', + otpTypeCheck: 'Only numbers are allowed.', + otpFromEmail: 'Enter OTP sent to {email}', + }, + SgidLoginButton: { + forText: 'For', + selectAgenciesText: 'id S9812379B only', + loginText: 'Log in with', + appText: 'app', + }, + }, +} diff --git a/replacements/frontend/src/pages/Landing/Home/LandingPage.tsx b/replacements/frontend/src/pages/Landing/Home/LandingPage.tsx new file mode 100644 index 0000000..6aef875 --- /dev/null +++ b/replacements/frontend/src/pages/Landing/Home/LandingPage.tsx @@ -0,0 +1,432 @@ +import { BiLockAlt, BiMailSend, BiRightArrowAlt } from 'react-icons/bi' +import { Link as ReactLink } from 'react-router-dom' +import { + Accordion, + Box, + Flex, + Icon, + Image, + ListItem, + OrderedList, + SimpleGrid, + Stack, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + VisuallyHidden, + Wrap, +} from '@chakra-ui/react' +import dedent from 'dedent' + +import { AppFooter } from '~/app/AppFooter' +import { AppPublicHeader } from '~/app/AppPublicHeader' +import FormBrandLogo from '~/assets/svgs/brand/brand-mark-colour.svg' + +import { BxlGithub } from '~assets/icons/BxlGithub' +import { BxsHelpCircle } from '~assets/icons/BxsHelpCircle' +import { + CONTACT_US, + FORM_GUIDE, + GUIDE_ATTACHMENT_SIZE_LIMIT, + GUIDE_E2EE, + GUIDE_SECRET_KEY_LOSS, + GUIDE_STORAGE_MODE, + GUIDE_TRANSFER_OWNERSHIP, + OGP_ALL_PRODUCTS, + OGP_FORMSG_REPO, +} from '~constants/links' +import { + LANDING_PAYMENTS_ROUTE, + LOGIN_ROUTE, + TOU_ROUTE, +} from '~constants/routes' +import { useIsMobile } from '~hooks/useIsMobile' +import { useMdComponents } from '~hooks/useMdComponents' +import Button from '~components/Button' +import { FeatureBanner } from '~components/FeatureBanner/FeatureBanner' +import Link from '~components/Link' +import { MarkdownText } from '~components/MarkdownText' +import { Tab } from '~components/Tabs' +import { LottieAnimation } from '~templates/LottieAnimation' + +import { ExternalFormLink } from '../components/ExternalFormLink' +import { FeatureGridItem } from '../components/FeatureGridItem' +import { FeatureLink } from '../components/FeatureLink' +import { FeatureSection } from '../components/FeatureSection' +import { HelpAccordionItem } from '../components/HelpAccordionItem' +import { LandingSection } from '../components/LandingSection' +import { OrderedListIcon } from '../components/OrderedListIcon' +import { SectionBodyText } from '../components/SectionBodyText' +import { SectionTitleText } from '../components/SectionTitleText' +import { StatsItem } from '../components/StatsItem' + +import formsHeroAnimation from './assets/images/animation-hero.json' +import howFormsWorksAnimation from './assets/images/animation-mode.json' +import enterEmailAnimation from './assets/images/animation-typing.json' +import helpCenterImg from './assets/images/help_center.svg' +import featureDndImg from './assets/images/icon_dnd.svg' +import featureEmailImg from './assets/images/icon_email.svg' +import featureIntegrationsImg from './assets/images/icon_integrations.svg' +import featureLogicImg from './assets/images/icon_logic.svg' +import featurePrefillImg from './assets/images/icon_prefill.svg' +import featureWebhooksImg from './assets/images/icon_webhooks.svg' +import meetingCollaborationImg from './assets/images/meeting_collaboration.svg' +import ogpSuiteImg from './assets/images/ogp_suite.svg' +import openSourceImg from './assets/images/open_source.svg' +import restrictedIcaLogo from './assets/images/restricted__ica.png' +import restrictedMfaLogo from './assets/images/restricted__mfa.png' +import restrictedMoeLogo from './assets/images/restricted__moe.png' +import restrictedMohLogo from './assets/images/restricted__moh.png' +import restrictedMomLogo from './assets/images/restricted__mom.png' +import restrictedMsfLogo from './assets/images/restricted__msf.png' +import restrictedNparksLogo from './assets/images/restricted__nparks.png' +import restrictedPaLogo from './assets/images/restricted__pa.png' +import storageModeImg from './assets/images/storage_mode.svg' +import { useLanding } from './queries' + +export const LandingPage = (): JSX.Element => { + const { data } = useLanding() + const isMobile = useIsMobile() + const mdComponents = useMdComponents() + + return ( + <> + + + + + + Build secure government forms in minutes. + + + Instant, customisable forms with zero code or cost, to safely + collect classified and sensitive data. + + + + + + + + + + + + + Our form building and data collection features + + + + + + + + + + + + + + + No onboarding, no fees, no code. + + + Sign in with your government email address, and start building + forms immediately. It’s free, and requires no onboarding or + approvals. + + + + + + + + + + + + + All form responses are either encrypted end-to-end (Storage mode) or + sent directly to your email inbox (Email mode). This means third + parties will not be able to access or view your form data. + + + } + > + Read more about Storage Mode + + + + + Our code is open source, meaning anyone can help improve it and build + on it, including governments of other countries. + + } + > + Fork it on Github + + + + + + Have a question? Most answers can be found in our self-service Help + Center. Common questions include: + + + + + {dedent` + If you have lost your secret key, take these steps immediately: + + 1. If your form is live, duplicate your form, save the new secret key securely and replace the original form's link with the new form's link to continue collecting responses. Deactivate the original form as soon as possible to avoid losing further responses. + + 2. On the computer you used to create the original form, search for 'Form Secret Key'. Secret keys typically downloaded into your Downloads folder as .txt files with 'Form Secret Key' in the filename. + + 3. If you have created multiple forms with similar titles in the past, it is possible that you have confused the different forms' secret keys with each other, as form titles are in the secret keys' filenames. Try all secret keys with similar filenames on your form. + + 4. If you remember sending an email to share your secret key with collaborators, search the Sent folder in your email for the keyword 'secret key' and your form title. + + 5. If you still cannot find your secret key and would like our help to debug this further, contact us on our [help form](${CONTACT_US}). + + Without your secret key, you will not be able to access your existing response data. Additionally, it's not possible for us to recover your lost secret key or response data on your behalf. This is because Form does not retain your secret key or any other way to unlock your encrypted data - the only way to ensure response data is truly private to agencies only. This is an important security benefit, because that means even if our server were to be compromised, an attacker would never be able to unlock your encrypted responses. + `} + + + Source + + + + + {dedent` + The current size limit is 7 MB for email mode forms, and 20 MB for storage mode forms. + + 7 MB for email mode forms is a hard limit because the email service we use has a fixed 10 MB outgoing size, and we buffer 3 MB for email fields and metadata. + + Because the smallest unit you can attach per attachment field is 1 MB, you can have a max of 7 attachments on your form in email mode, and a max of 20 attachments in storage mode. If your user has to submit more than 7 documents in email mode (or more than 20 in storage mode), you may create just one attachment field of 7 or 20 MB in their respective modes, and advise your user to zip documents up and submit as one attachment. + `} + + + Source + + + + + {dedent` + When a respondent submits a response, response data is encrypted in the respondent's browser before being sent to our servers for storage. This means that by the time Form's servers receive responses, they have already been scrambled and are stored in this unreadable form. Your response data remains in this encrypted state until you decrypt your responses with your secret key, transforming them into a readable format. + + The benefit of end-to-end encryption is that response data enters and remains in Form's servers in an encrypted state. This ensures that even if our servers are compromised by an attack, attackers will still not be able to decrypt and view your response data, as they do not possess your secret key. + `} + + + Source + + + + + {dedent` + You can transfer ownership on the top right hand corner of each form by clicking the Add Collaborator button. + + Note that you might not need to transfer ownership of your form. You may simply add your colleague as a collaborator. Collaborators have the same rights as form creators, except they cannot delete the form. + `} + + + Source + + + + + + } + > + Visit our Help Center + + + + + + Storage mode + Email mode + + + + + + View your responses within Form. All data is end-to-end + encrypted, which means third parties, will not be able to access + or view your form data. + + + + + Log in to FormSG via Internet or Intranet + + + + Create a new Storage mode form and store Secret Key safely + + + + Build and share form link with respondents + + + + Upload Secret Key and view your responses + + + + Download your responses as a CSV + + + + + + Receive your responses at your email address. Form sends + responses directly to your email and does not store any response + data. + + + + + Log in to Form + + + + Create a new form and choose Email mode + + + + Build and publish your form + + + + Collect responses at your email address + + + + + + + + + + Start building your form now. + + + + + + + + ) +} diff --git a/replacements/src/app/loaders/express/constants.ts b/replacements/src/app/loaders/express/constants.ts new file mode 100644 index 0000000..d29aa4d --- /dev/null +++ b/replacements/src/app/loaders/express/constants.ts @@ -0,0 +1,72 @@ +import config from '../../config/config' + +export const CSP_CORE_DIRECTIVES = { + imgSrc: [ + "'self'", + 'blob:', + 'data:', + 'https://www.googletagmanager.com/', + 'https://www.google-analytics.com/', + `https://s3-${config.aws.region}.amazonaws.com/agency.form.sg/`, + config.aws.imageBucketUrl, + config.aws.logoBucketUrl, + '*', + 'https://*.google-analytics.com', + 'https://*.googletagmanager.com', + ], + fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com/'], + scriptSrc: [ + "'self'", + 'https://ssl.google-analytics.com/', + 'https://www.google-analytics.com/', + 'https://www.tagmanager.google.com/', + 'https://www.google.com/recaptcha/', + 'https://www.recaptcha.net/recaptcha/', + 'https://www.gstatic.com/recaptcha/releases/', + 'https://challenges.cloudflare.com', + 'https://js.stripe.com/v3', + // GA4 https://developers.google.com/tag-platform/tag-manager/web/csp + // not actively used yet, loading specific files due to CSP bypass issue + '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', + ], + connectSrc: [ + "'self'", + 'https://www.google-analytics.com/', + 'https://ssl.google-analytics.com/', + 'https://*.browser-intake-datadoghq.com', + 'https://sentry.io/api/', + ...new Set([ + new URL(config.aws.attachmentBucketUrl).hostname, + new URL(config.aws.imageBucketUrl).hostname, + new URL(config.aws.logoBucketUrl).hostname, + new URL(config.aws.virusScannerQuarantineS3BucketUrl).hostname, + ]), + 'https://*.google-analytics.com', + 'https://*.analytics.google.com', + 'https://*.googletagmanager.com', + ], + frameSrc: [ + "'self'", + 'https://www.google.com/recaptcha/', + 'https://www.recaptcha.net/recaptcha/', + 'https://challenges.cloudflare.com', + 'https://js.stripe.com/', + ], + styleSrc: [ + "'self'", + 'https://www.google.com/recaptcha/', + 'https://www.recaptcha.net/recaptcha/', + 'https://www.gstatic.com/recaptcha/', + 'https://www.gstatic.cn/', + "'unsafe-inline'", + 'https://www.gstatic.com/charts/', // React Google Charts for FormSG charts + ], + workerSrc: [ + "'self'", + 'blob:', // DataDog RUM session replay - https://docs.datadoghq.com/real_user_monitoring/faq/content_security_policy/ + ], + frameAncestors: ['*'], +}