diff --git a/.ebextensions/env-file-creation.config b/.ebextensions/env-file-creation.config index bf59ee6d6b..2d6b460e14 100644 --- a/.ebextensions/env-file-creation.config +++ b/.ebextensions/env-file-creation.config @@ -44,9 +44,9 @@ files: aws ssm get-parameter --name "${ENV_TYPE}-sentry" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env aws ssm get-parameter --name "${ENV_TYPE}-sms" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env aws ssm get-parameter --name "${ENV_TYPE}-ndi" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env - aws ssm get-parameter --name "${ENV_TYPE}-sgid" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env aws ssm get-parameter --name "${ENV_TYPE}-verified-fields" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env aws ssm get-parameter --name "${ENV_TYPE}-webhook-verified-content" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env + aws ssm get-parameter --name "${ENV_SITE_NAME}-sgid" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env aws ssm get-parameter --name "${ENV_SITE_NAME}-payment" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env aws ssm get-parameter --name "${ENV_SITE_NAME}-cron-payment" --with-decryption --region $AWS_REGION | jq -r '.Parameter.Value' >> $TARGET_DIR/.env diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index de932b94e7..6bdba671ec 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -44,8 +44,9 @@ jobs: - name: Use Node.js uses: actions/setup-node@v3 with: - node-version: 14 + node-version: 18 cache: 'npm' + cache-dependency-path: '**/package-lock.json' - name: Install dependencies run: npm i # 👇 Adds Chromatic as a step in the workflow diff --git a/.github/workflows/deploy-virus-scanner-production.yml b/.github/workflows/deploy-virus-scanner-production.yml index ff79f94157..12b008938f 100644 --- a/.github/workflows/deploy-virus-scanner-production.yml +++ b/.github/workflows/deploy-virus-scanner-production.yml @@ -18,6 +18,6 @@ jobs: uses: ./.github/workflows/aws-deploy-scanner.yml with: environment: 'production' - provisionedConcurrency: 5 + provisionedConcurrency: 10 checkoutBranch: 'release-al2' secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index 83fb56625f..5e3749f87c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,26 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -#### [v6.82.0](https://github.com/opengovsg/FormSG/compare/v6.82.0...v6.82.0) +#### [v6.83.0](https://github.com/opengovsg/FormSG/compare/v6.83.0...v6.83.0) + +- fix(sgid-login): add graceful handling of errors when work email is invalid [`#6819`](https://github.com/opengovsg/FormSG/pull/6819) +- test: fix error message for InvalidFileKeyError [`#6821`](https://github.com/opengovsg/FormSG/pull/6821) +- chore: update serverless package.json [`#6820`](https://github.com/opengovsg/FormSG/pull/6820) +- fix(storybook): build crash, and missing NumberField customVal [`#6808`](https://github.com/opengovsg/FormSG/pull/6808) + +#### [v6.83.0](https://github.com/opengovsg/FormSG/compare/v6.82.0...v6.83.0) + +> 19 October 2023 + +- chore: increase hot lambda to 10 [`#6815`](https://github.com/opengovsg/FormSG/pull/6815) +- feat(sgid-myinfo): add even more sgid myinfo fields [`#6807`](https://github.com/opengovsg/FormSG/pull/6807) +- chore(deps-dev): bump @babel/traverse from 7.22.11 to 7.23.2 in /serverless/virus-scanner [`#6809`](https://github.com/opengovsg/FormSG/pull/6809) +- chore(deps-dev): bump @babel/traverse from 7.20.1 to 7.23.2 in /frontend [`#6810`](https://github.com/opengovsg/FormSG/pull/6810) +- build: merge release 6.82.0 into develop [`#6801`](https://github.com/opengovsg/FormSG/pull/6801) +- feat(sgid-login): general availability and multi-employment [`#6763`](https://github.com/opengovsg/FormSG/pull/6763) +- feat: add more myinfo sgid fields [`#6766`](https://github.com/opengovsg/FormSG/pull/6766) +- build: release v6.82.0 [`#6800`](https://github.com/opengovsg/FormSG/pull/6800) +- chore: bump version to v6.83.0 [`0e9c4bc`](https://github.com/opengovsg/FormSG/commit/0e9c4bc2b154d9d8e77e9e36a2105ef7d85d209b) #### [v6.82.0](https://github.com/opengovsg/FormSG/compare/v6.81.0...v6.82.0) @@ -19,7 +38,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump type-fest from 4.3.1 to 4.4.0 in /shared [`#6782`](https://github.com/opengovsg/FormSG/pull/6782) - chore(ci): backend jest config [`#6772`](https://github.com/opengovsg/FormSG/pull/6772) - build: release v6.81.0 [`#6777`](https://github.com/opengovsg/FormSG/pull/6777) -- chore: bump version to v6.82.0 [`5dab965`](https://github.com/opengovsg/FormSG/commit/5dab965019d626ccd3ff6ef20f4d9f6c386db8c8) +- chore: bump version to v6.82.0 [`9b23483`](https://github.com/opengovsg/FormSG/commit/9b234831c1eb84b6ffca9b13d10f54a029b43202) #### [v6.81.0](https://github.com/opengovsg/FormSG/compare/v6.80.0...v6.81.0) diff --git a/__tests__/e2e/fixtures/auth.ts b/__tests__/e2e/fixtures/auth.ts index 6d9f51921b..61f5c4b6f5 100644 --- a/__tests__/e2e/fixtures/auth.ts +++ b/__tests__/e2e/fixtures/auth.ts @@ -25,7 +25,10 @@ export const test = baseTest.extend({ await page.goto(LOGIN_PAGE) await page.getByRole('textbox', { name: /log in/i }).fill(ADMIN_EMAIL) - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure OTP success message is seen await expect( diff --git a/__tests__/e2e/login.spec.ts b/__tests__/e2e/login.spec.ts index cd2192320b..a70715cd4a 100644 --- a/__tests__/e2e/login.spec.ts +++ b/__tests__/e2e/login.spec.ts @@ -20,7 +20,10 @@ test.describe('login', () => { .getByRole('textbox', { name: /log in/i }) .fill('user@non-white-listed-agency.com') - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure error message is seen await expect( @@ -36,7 +39,10 @@ test.describe('login', () => { await expect(page).toHaveURL(LOGIN_PAGE) await page.getByRole('textbox', { name: /log in/i }).fill(legitUserEmail) - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure OTP success message is seen await expect( @@ -61,7 +67,10 @@ test.describe('login', () => { await expect(page).toHaveURL(LOGIN_PAGE) await page.getByRole('textbox', { name: /log in/i }).fill(legitUserEmail) - await page.getByRole('button', { name: /log in/i }).click() + await page + .getByRole('button', { name: /log in/i }) + .first() + .click() // Ensure OTP success message is seen await expect( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 85bb1a0068..9745ac9ad0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.82.0", + "version": "6.83.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.82.0", + "version": "6.83.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", @@ -145,6 +145,7 @@ "patch-package": "^6.4.7", "prettier": "^2.5.1", "puppeteer": "^13.0.0", + "react-refresh": "^0.14.0", "react-scripts": "^4.0.3", "resize-observer-polyfill": "^1.5.1", "rimraf": "^3.0.2", @@ -184,11 +185,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dependencies": { - "@babel/highlight": "^7.18.6" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" }, "engines": { "node": ">=6.9.0" @@ -243,13 +245,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.1.tgz", - "integrity": "sha512-u1dMdBUmA7Z0rBB97xh8pIhviK7oItYOkjbsCxTWMknyvbQRBwX7/gn4JXurRdirWMFh+ZtYARqkA6ydogVZpg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.20.0", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" }, "engines": { @@ -386,9 +389,9 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", - "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "engines": { "node": ">=6.9.0" @@ -407,25 +410,25 @@ } }, "node_modules/@babel/helper-function-name": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", - "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.18.10", - "@babel/types": "^7.19.0" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" @@ -552,29 +555,29 @@ } }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "dependencies": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", - "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "engines": { "node": ">=6.9.0" } @@ -618,12 +621,12 @@ } }, "node_modules/@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dependencies": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, "engines": { @@ -631,9 +634,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.1.tgz", - "integrity": "sha512-hp0AYxaZJhxULfM1zyp7Wgr+pSUKBcP3M+PHnSzWGdXOzg/kHWIgiUWARvubhUKGOEw3xqY4x+lyZ9ytBVcELw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -2108,33 +2111,33 @@ } }, "node_modules/@babel/template": { - "version": "7.18.10", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", - "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.18.10", - "@babel/types": "^7.18.10" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.1.tgz", - "integrity": "sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.1", - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-function-name": "^7.19.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.20.1", - "@babel/types": "^7.20.0", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -2143,12 +2146,12 @@ } }, "node_modules/@babel/types": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.0.tgz", - "integrity": "sha512-Jlgt3H0TajCW164wkTOTzHkZb075tMQMULzrLUoUeKmO7eFL96GgDxf7/Axhc5CAuKE3KFyVW1p6ysKsi2oXAg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dependencies": { - "@babel/helper-string-parser": "^7.19.4", - "@babel/helper-validator-identifier": "^7.19.1", + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -39212,6 +39215,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.4.1.tgz", @@ -48459,11 +48471,12 @@ } }, "@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "requires": { - "@babel/highlight": "^7.18.6" + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" } }, "@babel/compat-data": { @@ -48504,13 +48517,14 @@ } }, "@babel/generator": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.1.tgz", - "integrity": "sha512-u1dMdBUmA7Z0rBB97xh8pIhviK7oItYOkjbsCxTWMknyvbQRBwX7/gn4JXurRdirWMFh+ZtYARqkA6ydogVZpg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "requires": { - "@babel/types": "^7.20.0", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" }, "dependencies": { @@ -48614,9 +48628,9 @@ } }, "@babel/helper-environment-visitor": { - "version": "7.18.9", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", - "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true }, "@babel/helper-explode-assignable-expression": { @@ -48629,22 +48643,22 @@ } }, "@babel/helper-function-name": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", - "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "requires": { - "@babel/template": "^7.18.10", - "@babel/types": "^7.19.0" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" } }, "@babel/helper-hoist-variables": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", - "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", "dev": true, "requires": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" } }, "@babel/helper-member-expression-to-functions": { @@ -48738,23 +48752,23 @@ } }, "@babel/helper-split-export-declaration": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", - "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", "dev": true, "requires": { - "@babel/types": "^7.18.6" + "@babel/types": "^7.22.5" } }, "@babel/helper-string-parser": { - "version": "7.19.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", - "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==" + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==" }, "@babel/helper-validator-identifier": { - "version": "7.19.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", - "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==" + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" }, "@babel/helper-validator-option": { "version": "7.18.6", @@ -48786,19 +48800,19 @@ } }, "@babel/highlight": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", - "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "requires": { - "@babel/helper-validator-identifier": "^7.18.6", - "chalk": "^2.0.0", + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", "js-tokens": "^4.0.0" } }, "@babel/parser": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.1.tgz", - "integrity": "sha512-hp0AYxaZJhxULfM1zyp7Wgr+pSUKBcP3M+PHnSzWGdXOzg/kHWIgiUWARvubhUKGOEw3xqY4x+lyZ9ytBVcELw==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true }, "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { @@ -49779,41 +49793,41 @@ } }, "@babel/template": { - "version": "7.18.10", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", - "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "requires": { - "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.18.10", - "@babel/types": "^7.18.10" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" } }, "@babel/traverse": { - "version": "7.20.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.1.tgz", - "integrity": "sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.1", - "@babel/helper-environment-visitor": "^7.18.9", - "@babel/helper-function-name": "^7.19.0", - "@babel/helper-hoist-variables": "^7.18.6", - "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.20.1", - "@babel/types": "^7.20.0", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.0.tgz", - "integrity": "sha512-Jlgt3H0TajCW164wkTOTzHkZb075tMQMULzrLUoUeKmO7eFL96GgDxf7/Axhc5CAuKE3KFyVW1p6ysKsi2oXAg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "requires": { - "@babel/helper-string-parser": "^7.19.4", - "@babel/helper-validator-identifier": "^7.19.1", + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, @@ -77924,6 +77938,12 @@ } } }, + "react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true + }, "react-remove-scroll": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 20b3e3e771..57012c320b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.82.0", + "version": "6.83.0", "homepage": ".", "private": true, "dependencies": { @@ -104,8 +104,8 @@ "build:dd-chunk": "webpack --config webpack.dd.config.js", "test": "cross-env SKIP_PREFLIGHT_CHECK=true craco test --silent", "eject": "react-scripts eject", - "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook", + "storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006", + "build-storybook": "NODE_OPTIONS=--openssl-legacy-provider build-storybook", "lint": "eslint ./ --ignore-path .gitignore && prettier . -c", "lint:fix": "eslint ./ --ignore-path .gitignore --fix && prettier . -c --write", "test:a11y": "npm run build-storybook && jest --config .storybook/storyshots/jest.config.js", @@ -185,6 +185,7 @@ "patch-package": "^6.4.7", "prettier": "^2.5.1", "puppeteer": "^13.0.0", + "react-refresh": "^0.14.0", "react-scripts": "^4.0.3", "resize-observer-polyfill": "^1.5.1", "rimraf": "^3.0.2", diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 577636c813..256f665304 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -13,8 +13,8 @@ import { DASHBOARD_ROUTE, LANDING_PAYMENTS_ROUTE, LANDING_ROUTE, + LOGIN_CALLBACK_ROUTE, LOGIN_ROUTE, - OGP_LOGIN_ROUTE, PAYMENT_PAGE_SUBROUTE, PRIVACY_POLICY_ROUTE, PUBLICFORM_ROUTE, @@ -36,7 +36,7 @@ import { ResponsesPage, } from '~features/admin-form/responses' import { SettingsPage } from '~features/admin-form/settings/SettingsPage' -import { SgidLoginPage } from '~features/login' +import { SelectProfilePage } from '~features/login' import { FormPaymentPage } from '~features/public-form/components/FormPaymentPage/FormPaymentPage' import { BillingPage } from '~features/user/billing' @@ -101,8 +101,8 @@ export const AppRouter = (): JSX.Element => { element={} />} /> } />} + path={LOGIN_CALLBACK_ROUTE} + element={} />} /> >((props, ref) => ( + + + + + + + )), +) + +export const SingpassFullLogoSvgr = chakra(MemoSingpassFullLogoSvgr) diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 01eee2bf69..cb1a9a0e9f 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -2,7 +2,7 @@ export const LANDING_ROUTE = '/' export const LANDING_PAYMENTS_ROUTE = '/payments' export const DASHBOARD_ROUTE = '/dashboard' export const LOGIN_ROUTE = '/login' -export const OGP_LOGIN_ROUTE = '/ogp-login' +export const LOGIN_CALLBACK_ROUTE = '/login/select-profile' export const TOU_ROUTE = '/terms' export const PRIVACY_POLICY_ROUTE = '/privacy' diff --git a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx index abc879eab1..60d36bde4c 100644 --- a/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx +++ b/frontend/src/features/admin-form/create/builder-and-design/BuilderAndDesignDrawer/FieldListDrawer/field-panels/MyInfoPanel.tsx @@ -1,8 +1,10 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { Droppable } from 'react-beautiful-dnd' import { Link as ReactLink } from 'react-router-dom' import { Box, Text } from '@chakra-ui/react' +import { useFeatureIsOn, useGrowthBook } from '@growthbook/growthbook-react' +import { featureFlags } from '~shared/constants' import { AdminFormDto, FormAuthType, @@ -35,7 +37,7 @@ import { DraggableMyInfoFieldListOption } from '../FieldListOption' import { FieldSection } from './FieldSection' -const SGID_SUPPORTED: Set = new Set([ +const SGID_SUPPORTED_V1 = [ MyInfoAttribute.Name, MyInfoAttribute.DateOfBirth, MyInfoAttribute.PassportNumber, @@ -44,26 +46,72 @@ const SGID_SUPPORTED: Set = new Set([ // phone number formats. // MyInfo phone numbers support country code, while sgID-MyInfo does not. // MyInfoAttribute.MobileNo, - - // FRM-1189: This is disabled due to slight different formatting. - // We format the Myinfo response by separates lines in addresses with comma - // Whereas sgID separates each line with newline. - // This should be enabled in future work - // MyInfoAttribute.RegisteredAddress, -]) - -/** - * If sgID is used, checks if the corresponding - * MyInfo field is supported by sgID. - */ -const sgIDUnSupported = ( - form: AdminFormDto | undefined, - fieldType: MyInfoAttribute, -): boolean => - form?.authType === FormAuthType.SGID_MyInfo && !SGID_SUPPORTED.has(fieldType) +] +const SGID_SUPPORTED_V2 = [ + ...SGID_SUPPORTED_V1, + MyInfoAttribute.Sex, + MyInfoAttribute.Race, + MyInfoAttribute.Nationality, + MyInfoAttribute.HousingType, + MyInfoAttribute.HdbType, + MyInfoAttribute.RegisteredAddress, + MyInfoAttribute.BirthCountry, + MyInfoAttribute.VehicleNo, + MyInfoAttribute.Employment, + MyInfoAttribute.WorkpassStatus, + MyInfoAttribute.Marital, + MyInfoAttribute.MobileNo, + MyInfoAttribute.WorkpassExpiryDate, + MyInfoAttribute.ResidentialStatus, + MyInfoAttribute.Dialect, + MyInfoAttribute.Occupation, + MyInfoAttribute.CountryOfMarriage, + MyInfoAttribute.MarriageCertNo, + MyInfoAttribute.MarriageDate, + MyInfoAttribute.DivorceDate, +] export const MyInfoFieldPanel = () => { const { data: form, isLoading } = useCreateTabForm() + + const { user } = useUser() + + // FRM-1444: Remove once rollout is 100% and stable + const growthbook = useGrowthBook() + + useEffect(() => { + if (growthbook) { + growthbook.setAttributes({ + // Only update the `adminEmail` and `adminAgency` attributes, keep the rest the same + ...growthbook.getAttributes(), + adminEmail: user?.email, + adminAgency: user?.agency.shortName, + }) + } + }, [growthbook, user]) + + const showSgidMyInfoV2 = useFeatureIsOn(featureFlags.myinfoSgid) + + const sgidSupportedFinal = useMemo(() => { + return showSgidMyInfoV2 ? SGID_SUPPORTED_V2 : SGID_SUPPORTED_V1 + }, [showSgidMyInfoV2]) + + /** + * If sgID is used, checks if the corresponding + * MyInfo field is supported by sgID. + */ + const sgIDUnSupported = useCallback( + (form: AdminFormDto | undefined, fieldType: MyInfoAttribute): boolean => { + const sgidSupported: Set = new Set(sgidSupportedFinal) + + return ( + form?.authType === FormAuthType.SGID_MyInfo && + !sgidSupported.has(fieldType) + ) + }, + [sgidSupportedFinal], + ) + // myInfo should be disabled if // 1. form response mode is not email mode // 2. form auth type is not myInfo @@ -83,9 +131,8 @@ export const MyInfoFieldPanel = () => { (fieldType: MyInfoAttribute): boolean => { return isDisabled || sgIDUnSupported(form, fieldType) }, - [form, isDisabled], + [form, isDisabled, sgIDUnSupported], ) - const { user } = useUser() return ( <> @@ -220,9 +267,6 @@ const MyInfoText = ({ return ( - {authType === FormAuthType.SGID_MyInfo - ? 'Some MyInfo fields are not yet supported in your selected authentication type. ' - : null} {`Only 30 MyInfo fields are allowed in Email mode (${numMyInfoFields}/30). `} Learn more diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index 30f7a1c0e8..82e2c5e131 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -1,7 +1,11 @@ -import { useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +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 { @@ -10,7 +14,9 @@ import { } from '~features/analytics/AnalyticsService' import { LoginForm, LoginFormInputs } from './components/LoginForm' +import { OrDivider } from './components/OrDivider' import { OtpForm, OtpFormInputs } from './components/OtpForm' +import { SgidLoginButton } from './components/SgidLoginButton' import { LoginPageTemplate } from './LoginPageTemplate' export type LoginOtpData = { @@ -22,6 +28,27 @@ export const LoginPage = (): JSX.Element => { 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 'Your sgID login session has expired. Please login again.' + default: + return 'Something went wrong. Please try again later.' + } + }, [statusCode]) + + useEffect(() => { + if (!toastMessage) return + toast({ description: toastMessage }) + }, [toast, toastMessage]) + const handleSendOtp = async ({ email }: LoginFormInputs) => { const trimmedEmail = email.trim() await sendLoginOtp(trimmedEmail).then(({ otpPrefix }) => { @@ -60,7 +87,11 @@ export const LoginPage = (): JSX.Element => { return ( {!email ? ( - + + + + + ) : ( +type ModalErrorMessages = { + hideCloseButton?: boolean + preventBackdropDismissal?: boolean + header: string + body: string | (() => React.ReactElement) + cta: string + onCtaClick: (disclosureProps: ErrorDisclosureProps) => void +} + +const MODAL_ERRORS: Record = { + NO_WORKEMAIL: { + hideCloseButton: true, + preventBackdropDismissal: true, + header: "Singpass login isn't available to you yet", + body: 'It is progressively being made available to agencies. In the meantime, please log in using your email address.', + cta: 'Back to login', + onCtaClick: () => window.location.assign(LOGIN_ROUTE), + }, + INVALID_WORKEMAIL: { + header: "You don't have access to this service", + body: () => ( + + It may be available only to select agencies or authorised individuals. + If you believe you should have access to this service, please{' '} + + contact us + + . + + ), + cta: 'Choose another account', + onCtaClick: (disclosureProps) => disclosureProps.onClose(), + }, +} + +const ErrorDisclosure = ( + props: { + errorMessages: ModalErrorMessages | undefined + } & ErrorDisclosureProps, +) => { + const isMobile = useIsMobile() + if (!props.errorMessages) { + return null + } + const { errorMessages, ...disclosureProps } = props + const { + onCtaClick, + body, + cta, + hideCloseButton, + header, + preventBackdropDismissal, + } = errorMessages + return ( + props.onClose()} + closeOnOverlayClick={!preventBackdropDismissal} + > + + + {!hideCloseButton ? : null} + {header} + + + {typeof body === 'function' ? body() : {body}} + + + + + + + + ) +} +export const SelectProfilePage = (): JSX.Element => { + const profilesResponse = useSgidProfiles() + const [, setIsAuthenticated] = useLocalStorage(LOGGED_IN_KEY) + const { user } = useUser() + const [errorContext, setErrorContext] = useState< + ModalErrorMessages | undefined + >() + + const errorDisclosure = useDisclosure() + const toast = useToast({ isClosable: true, status: 'danger' }) + + // If redirected back here but already authed, redirect to dashboard. + if (user) window.location.replace(DASHBOARD_ROUTE) + // User doesn't have any profiles, should reattempt to login + if (profilesResponse.error) window.location.replace(LOGIN_ROUTE) + + useEffect(() => { + if (profilesResponse.data?.profiles.length === 0) { + errorDisclosure.onOpen() + setErrorContext(MODAL_ERRORS.NO_WORKEMAIL) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [profilesResponse.data?.profiles.length]) + + const handleSetProfile = async (profile: SgidPublicOfficerEmployment) => { + ApiService.post(SGID_PROFILES_ENDPOINT, { + workEmail: profile.work_email, + }) + .then(() => { + window.location.assign(DASHBOARD_ROUTE) + setIsAuthenticated(true) + }) + .catch((err) => { + console.log({ err }) + if (err.code === StatusCodes.UNAUTHORIZED) { + errorDisclosure.onOpen() + setErrorContext(MODAL_ERRORS.INVALID_WORKEMAIL) + return + } + toast({ description: 'Something went wrong. Please try again later.' }) + }) + } + + return ( + + } + > + + Choose an account to continue to FormSG + + + {!profilesResponse.data ? ( + + ) : ( + profilesResponse.data?.profiles.map((profile) => ( + handleSetProfile(profile)} + /> + )) + )} + + + Or, login manually using email and OTP + + + + + ) +} + +const ProfileItem = ({ + profile, + onClick, +}: { + profile: SgidPublicOfficerEmployment + onClick: () => void +}) => { + return ( + + + + {profile.work_email} + + + {[profile.agency_name, profile.department_name].join(', ')} + + + {profile.employment_title} + + + + + + + ) +} diff --git a/frontend/src/features/login/SgidLoginPage.tsx b/frontend/src/features/login/SgidLoginPage.tsx deleted file mode 100644 index d1b4a97f37..0000000000 --- a/frontend/src/features/login/SgidLoginPage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useEffect, useMemo } from 'react' -import { BiLogInCircle } from 'react-icons/bi' -import { useMutation } from 'react-query' -import { useSearchParams } from 'react-router-dom' - -import { DASHBOARD_ROUTE } from '~constants/routes' -import { useToast } from '~hooks/useToast' -import { getSgidAuthUrl } from '~services/AuthService' -import Button from '~components/Button' -import { InlineMessage } from '~components/InlineMessage/InlineMessage' - -import { useUser } from '~features/user/queries' - -import { LoginPageTemplate } from './LoginPageTemplate' - -export const SgidLoginPage = (): JSX.Element => { - const [params] = useSearchParams() - const toast = useToast({ isClosable: true, status: 'danger' }) - - const statusCode = params.get('status') - const toastMessage = useMemo(() => { - switch (statusCode) { - case null: - case '200': - return - case '401': - return 'Your SGID-linked work email does not belong to a whitelisted public service email domain. Please use OTP login instead.' - default: - return 'Something went wrong. Please try again later.' - } - }, [statusCode]) - - useEffect(() => { - if (!toastMessage) return - toast({ description: toastMessage }) - }, [toast, toastMessage]) - - const { user } = useUser() - - // If redirected back here but already authed, redirect to dashboard. - if (user) window.location.replace(DASHBOARD_ROUTE) - - const handleLoginMutation = useMutation(getSgidAuthUrl, { - onSuccess: (data) => { - window.location.assign(data.redirectUrl) - }, - }) - - return ( - - - This is an experimental service currently offered to OGP officers only. - - - - ) -} diff --git a/frontend/src/features/login/components/LoginForm.tsx b/frontend/src/features/login/components/LoginForm.tsx index 3acfb93d50..8ff4d87d02 100644 --- a/frontend/src/features/login/components/LoginForm.tsx +++ b/frontend/src/features/login/components/LoginForm.tsx @@ -1,16 +1,14 @@ import { useCallback } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { FormControl, Stack, useBreakpointValue } from '@chakra-ui/react' +import { FormControl, Stack } from '@chakra-ui/react' import isEmail from 'validator/lib/isEmail' -import { FORM_GUIDE } from '~constants/links' 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' -import Link from '~components/Link' export type LoginFormInputs = { email: string @@ -36,8 +34,6 @@ export const LoginForm = ({ onSubmit }: LoginFormProps): JSX.Element => { }) } - const isMobile = useBreakpointValue({ base: true, xs: true, lg: false }) - return (
{ spacing="1.5rem" align="center" > - - - {t('features.login.components.LoginForm.haveAQuestion')} - ) diff --git a/frontend/src/features/login/components/OrDivider.tsx b/frontend/src/features/login/components/OrDivider.tsx new file mode 100644 index 0000000000..c9c34be1a4 --- /dev/null +++ b/frontend/src/features/login/components/OrDivider.tsx @@ -0,0 +1,11 @@ +import { Divider, HStack, Text } from '@chakra-ui/react' + +export const OrDivider = (): JSX.Element => { + return ( + + + or + + + ) +} diff --git a/frontend/src/features/login/components/SgidLoginButton.tsx b/frontend/src/features/login/components/SgidLoginButton.tsx new file mode 100644 index 0000000000..f19ebe0641 --- /dev/null +++ b/frontend/src/features/login/components/SgidLoginButton.tsx @@ -0,0 +1,43 @@ +import { useForm } from 'react-hook-form' +import { useMutation } from 'react-query' +import { Flex, Link, Text, VStack } from '@chakra-ui/react' + +import { SGID_VALID_ORG_PAGE } from '~shared/constants' + +import { SingpassFullLogoSvgr } from '~assets/svgrs/singpass/SingpassFullLogoSvgr' +import { getSgidAuthUrl } from '~services/AuthService' +import Button from '~components/Button' + +export const SgidLoginButton = (): JSX.Element => { + const { formState } = useForm() + + const handleLoginMutation = useMutation(getSgidAuthUrl, { + onSuccess: (data) => { + window.location.assign(data.redirectUrl) + }, + }) + return ( + + + + For{' '} + + select agencies + + + + ) +} diff --git a/frontend/src/features/login/index.ts b/frontend/src/features/login/index.ts index f22bc829d7..0b15ab6929 100644 --- a/frontend/src/features/login/index.ts +++ b/frontend/src/features/login/index.ts @@ -1,2 +1,2 @@ export { LoginPage as default } from './LoginPage' -export { SgidLoginPage } from './SgidLoginPage' +export { SelectProfilePage } from './SelectProfilePage' diff --git a/frontend/src/features/login/queries.ts b/frontend/src/features/login/queries.ts new file mode 100644 index 0000000000..9dde41e788 --- /dev/null +++ b/frontend/src/features/login/queries.ts @@ -0,0 +1,23 @@ +import { useQuery, UseQueryResult } from 'react-query' + +import { SgidProfilesDto } from '~shared/types/auth' + +import { ApiError } from '~typings/core' + +import { ApiService } from '~services/ApiService' + +export const SGID_PROFILES_ENDPOINT = '/auth/sgid/profiles' + +const sgidProfileKeys = { + base: ['sgidProfiles'] as const, +} + +export const useSgidProfiles = (): UseQueryResult< + SgidProfilesDto, + ApiError +> => { + return useQuery(sgidProfileKeys.base, async () => { + const { data } = await ApiService.get(SGID_PROFILES_ENDPOINT) + return data + }) +} diff --git a/frontend/src/mocks/msw/handlers/public-form.ts b/frontend/src/mocks/msw/handlers/public-form.ts index e71c9e4b1f..cb1f44e0fd 100644 --- a/frontend/src/mocks/msw/handlers/public-form.ts +++ b/frontend/src/mocks/msw/handlers/public-form.ts @@ -168,10 +168,15 @@ export const BASE_FORM = { { ValidationOptions: { _id: '6148614ee2fb650012928dd9', - customVal: null, selectedValidation: null, - customMin: null, - customMax: null, + LengthValidationOptions: { + selectedLengthValidation: null, + customVal: null, + }, + RangeValidationOptions: { + customMin: null, + customMax: null, + }, id: '6148614ee2fb650012928dd9', }, title: 'Number', diff --git a/package-lock.json b/package-lock.json index 4429128abb..4e0e45c8e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.82.0", + "version": "6.83.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.82.0", + "version": "6.83.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", diff --git a/package.json b/package.json index a7d9462c58..a44f0c421b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.82.0", + "version": "6.83.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " diff --git a/serverless/virus-scanner/package-lock.json b/serverless/virus-scanner/package-lock.json index 435dfa20db..f6475eca16 100644 --- a/serverless/virus-scanner/package-lock.json +++ b/serverless/virus-scanner/package-lock.json @@ -925,12 +925,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz", - "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dev": true, "dependencies": { - "@babel/highlight": "^7.22.10", + "@babel/highlight": "^7.22.13", "chalk": "^2.4.2" }, "engines": { @@ -1054,12 +1054,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "dependencies": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -1109,22 +1109,22 @@ "dev": true }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -1216,9 +1216,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true, "engines": { "node": ">=6.9.0" @@ -1248,12 +1248,12 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz", - "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, @@ -1324,9 +1324,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.11.tgz", - "integrity": "sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -1513,33 +1513,33 @@ } }, "node_modules/@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz", - "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.11", - "@babel/types": "^7.22.11", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -1548,13 +1548,13 @@ } }, "node_modules/@babel/types": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz", - "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -2980,9 +2980,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.44.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.2.tgz", - "integrity": "sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg==", + "version": "8.44.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.6.tgz", + "integrity": "sha512-P6bY56TVmX8y9J87jHNgQh43h6VVU+6H7oN7hgvivV81K2XY8qJZ5vqPy/HdUoVIelii2kChYVzQanlswPWVFw==", "dev": true, "peer": true, "dependencies": { @@ -2991,9 +2991,9 @@ } }, "node_modules/@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.6.tgz", + "integrity": "sha512-zfM4ipmxVKWdxtDaJ3MP3pBurDXOCoyjvlpE3u6Qzrmw4BPbfm4/ambIeTk/r/J0iq/+2/xp0Fmt+gFvXJY2PQ==", "dev": true, "peer": true, "dependencies": { @@ -3002,9 +3002,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.3.tgz", + "integrity": "sha512-CS2rOaoQ/eAgAfcTfq6amKG7bsN+EMcgGY4FAFQdvSj2y1ixvOZTUA9mOtCai7E1SYu283XNw7urKK30nP3wkQ==", "dev": true, "peer": true }, @@ -3075,9 +3075,9 @@ "dev": true }, "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", + "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true, "peer": true }, @@ -5553,9 +5553,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", - "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", "dev": true, "peer": true }, @@ -10386,9 +10386,9 @@ } }, "node_modules/terser": { - "version": "5.19.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", - "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.22.0.tgz", + "integrity": "sha512-hHZVLgRA2z4NWcN6aS5rQDc+7Dcy58HOf2zbYwmFcQ+ua3h6eEFf5lIDKTzbWwlazPyOZsFQO8V80/IjVNExEw==", "dev": true, "peer": true, "dependencies": { @@ -11161,9 +11161,9 @@ "dev": true }, "node_modules/webpack": { - "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", "dev": true, "peer": true, "dependencies": { @@ -12338,12 +12338,12 @@ } }, "@babel/code-frame": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz", - "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==", + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", "dev": true, "requires": { - "@babel/highlight": "^7.22.10", + "@babel/highlight": "^7.22.13", "chalk": "^2.4.2" }, "dependencies": { @@ -12443,12 +12443,12 @@ } }, "@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dev": true, "requires": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -12491,19 +12491,19 @@ } }, "@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "dev": true }, "@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dev": true, "requires": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" } }, "@babel/helper-hoist-variables": { @@ -12568,9 +12568,9 @@ "dev": true }, "@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "dev": true }, "@babel/helper-validator-option": { @@ -12591,12 +12591,12 @@ } }, "@babel/highlight": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz", - "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", "js-tokens": "^4.0.0" }, @@ -12654,9 +12654,9 @@ } }, "@babel/parser": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.11.tgz", - "integrity": "sha512-R5zb8eJIBPJriQtbH/htEQy4k7E2dHWlD2Y2VT07JCzwYZHBxV5ZYtM0UhXSNMT74LyxuM+b1jdL7pSesXbC/g==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "dev": true }, "@babel/plugin-syntax-async-generators": { @@ -12786,42 +12786,42 @@ } }, "@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dev": true, "requires": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" } }, "@babel/traverse": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz", - "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", "dev": true, "requires": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.11", - "@babel/types": "^7.22.11", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz", - "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dev": true, "requires": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, @@ -13994,9 +13994,9 @@ } }, "@types/eslint": { - "version": "8.44.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.2.tgz", - "integrity": "sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg==", + "version": "8.44.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.6.tgz", + "integrity": "sha512-P6bY56TVmX8y9J87jHNgQh43h6VVU+6H7oN7hgvivV81K2XY8qJZ5vqPy/HdUoVIelii2kChYVzQanlswPWVFw==", "dev": true, "peer": true, "requires": { @@ -14005,9 +14005,9 @@ } }, "@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.6.tgz", + "integrity": "sha512-zfM4ipmxVKWdxtDaJ3MP3pBurDXOCoyjvlpE3u6Qzrmw4BPbfm4/ambIeTk/r/J0iq/+2/xp0Fmt+gFvXJY2PQ==", "dev": true, "peer": true, "requires": { @@ -14016,9 +14016,9 @@ } }, "@types/estree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.3.tgz", + "integrity": "sha512-CS2rOaoQ/eAgAfcTfq6amKG7bsN+EMcgGY4FAFQdvSj2y1ixvOZTUA9mOtCai7E1SYu283XNw7urKK30nP3wkQ==", "dev": true, "peer": true }, @@ -14089,9 +14089,9 @@ "dev": true }, "@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", + "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", "dev": true, "peer": true }, @@ -16061,9 +16061,9 @@ } }, "es-module-lexer": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", - "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.1.tgz", + "integrity": "sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==", "dev": true, "peer": true }, @@ -19720,9 +19720,9 @@ } }, "terser": { - "version": "5.19.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", - "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.22.0.tgz", + "integrity": "sha512-hHZVLgRA2z4NWcN6aS5rQDc+7Dcy58HOf2zbYwmFcQ+ua3h6eEFf5lIDKTzbWwlazPyOZsFQO8V80/IjVNExEw==", "dev": true, "peer": true, "requires": { @@ -20278,9 +20278,9 @@ "dev": true }, "webpack": { - "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", "dev": true, "peer": true, "requires": { diff --git a/serverless/virus-scanner/package.json b/serverless/virus-scanner/package.json index ae195a3ced..973f4ab9ad 100644 --- a/serverless/virus-scanner/package.json +++ b/serverless/virus-scanner/package.json @@ -27,25 +27,24 @@ "@types/aws-lambda": "^8.10.101", "@types/clamscan": "^2.0.2", "@types/convict": "^6.1.1", + "@types/jest": "^29.5.1", "@types/node": "^18.0.1", "@types/pino": "^7.0.5", "@types/uuid": "^9.0.2", "@types/watch": "^1.0.3", "dotenv-cli": "^6.0.0", - "serverless": "^3.19.0", - "serverless-dotenv-plugin": "^4.0.1", - "typescript": "^4.7.4", "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "jest-extended": "^3.2.4", "jest-localstorage-mock": "^2.4.26", "jest-mock-axios": "^4.7.2", + "serverless": "^3.19.0", + "serverless-dotenv-plugin": "^4.0.1", "ts-jest": "^29.0.5", "ts-loader": "^8.2.0", "ts-node": "^10.9.1", "ts-node-dev": "^2.0.0", "type-fest": "^3.10.0", - "typescript": "^4.9.4", - "@types/jest": "^29.5.1" + "typescript": "^4.9.4" } } diff --git a/shared/constants/feature-flags.ts b/shared/constants/feature-flags.ts index f1c57d7bbc..de9158b6a1 100644 --- a/shared/constants/feature-flags.ts +++ b/shared/constants/feature-flags.ts @@ -8,4 +8,5 @@ export const featureFlags = { 'encryption-boundary-shift-hard-validation' as const, encryptionBoundaryShiftVirusScanner: 'encryption-boundary-shift-virus-scanner' as const, + myinfoSgid: 'myinfo-sgid' as const, } diff --git a/shared/constants/links.ts b/shared/constants/links.ts index 3e08bb7ff5..2bf18b30f8 100644 --- a/shared/constants/links.ts +++ b/shared/constants/links.ts @@ -1,3 +1,6 @@ export const SUPPORT_FORM_LINK = 'https://go.gov.sg/formsg-support' export const PUBLIC_PAYMENTS_GUIDE_LINK = 'https://go.gov.sg/formsg-guide-payments' + +export const SGID_VALID_ORG_PAGE = + 'https://docs.id.gov.sg/faq-users#as-a-government-officer-why-am-i-not-able-to-login-to-my-work-tool-using-sgid' diff --git a/shared/types/auth.ts b/shared/types/auth.ts new file mode 100644 index 0000000000..484e8efe61 --- /dev/null +++ b/shared/types/auth.ts @@ -0,0 +1,12 @@ +export type SgidProfilesDto = { + profiles: SgidPublicOfficerEmploymentList +} + +export type SgidPublicOfficerEmployment = { + agency_name: string + department_name: string + employment_title: string + employment_type: string + work_email: string +} +export type SgidPublicOfficerEmploymentList = Array diff --git a/src/app/modules/auth/auth.types.ts b/src/app/modules/auth/auth.types.ts index ba7e1a50ee..1b41ccff46 100644 --- a/src/app/modules/auth/auth.types.ts +++ b/src/app/modules/auth/auth.types.ts @@ -1,3 +1,10 @@ +import { SgidPublicOfficerEmploymentList } from 'shared/types/auth' + import { IPopulatedUser } from 'src/types' export type SessionUser = IPopulatedUser + +export type SgidUser = { + profiles: SgidPublicOfficerEmploymentList + expiry: number +} diff --git a/src/app/modules/auth/sgid/auth-sgid.controller.ts b/src/app/modules/auth/sgid/auth-sgid.controller.ts index 99033b4703..fe5019de55 100644 --- a/src/app/modules/auth/sgid/auth-sgid.controller.ts +++ b/src/app/modules/auth/sgid/auth-sgid.controller.ts @@ -1,6 +1,8 @@ import { StatusCodes } from 'http-status-codes' import { ErrorDto, GetSgidAuthUrlResponseDto } from 'shared/types' +import { SgidProfilesDto } from 'shared/types/auth' +import { resolveRedirectionUrl } from '../../../../app/utils/urls' import { createLoggerWithLabel } from '../../../config/logger' import { createReqMeta } from '../../../utils/request' import { ControllerHandler } from '../../core/core.types' @@ -37,7 +39,7 @@ export const generateAuthUrl: ControllerHandler< error, }) return res - .sendStatus(StatusCodes.INTERNAL_SERVER_ERROR) + .status(StatusCodes.INTERNAL_SERVER_ERROR) .json({ message: 'Generating SGID authentication url failed. Please try again later.', @@ -46,7 +48,14 @@ export const generateAuthUrl: ControllerHandler< }) } -export const handleLogin: ControllerHandler< +/** + * Handler for GET /api/v3/auth/sgid/login/callback endpoint. + * + * @return 200 with redirect to frontend /login/callback if there are no errors + * @return 400 when code or state is not provided, or state is incorrect + * @return 500 when processing the code verifier cookie fails, or when an unknown error occurs + */ +export const handleLoginCallback: ControllerHandler< unknown, ErrorDto | undefined, unknown, @@ -57,7 +66,7 @@ export const handleLogin: ControllerHandler< res.clearCookie(SGID_CODE_VERIFIER_COOKIE_NAME) const logMeta = { - action: 'handleLogin', + action: 'handleLoginCallback', code, state, ...createReqMeta(req), @@ -65,8 +74,6 @@ export const handleLogin: ControllerHandler< const coreErrorMessage = 'Failed to log in via SGID. Please try again later.' - let status - if (!code || state !== SGID_LOGIN_OAUTH_STATE) { logger.error({ message: @@ -74,59 +81,178 @@ export const handleLogin: ControllerHandler< meta: logMeta, }) - status = StatusCodes.BAD_REQUEST - } else if (!codeVerifier) { + const status = StatusCodes.BAD_REQUEST + res.redirect(resolveRedirectionUrl(`/login?status=${status}`)) + return + } + if (!codeVerifier) { logger.error({ message: 'Error logging in via sgID: code verifier cookie is empty', meta: logMeta, }) - status = StatusCodes.BAD_REQUEST - } else { - await AuthSgidService.retrieveAccessToken(code, codeVerifier) - .andThen(({ accessToken, sub }) => - AuthSgidService.retrieveUserInfo(accessToken, sub), - ) - .andThen((email) => - AuthService.validateEmailDomain(email).andThen((agency) => - UserService.retrieveUser(email, agency._id), - ), - ) - .map((user) => { - if (!req.session) { - logger.error({ - message: 'Error logging in user; req.session is undefined', - meta: logMeta, - }) - - status = StatusCodes.INTERNAL_SERVER_ERROR - return - } - - // Add user info to session. - const { _id } = user.toObject() as SessionUser - req.session.user = { _id } - logger.info({ - message: `Successfully logged in user ${user._id}`, - meta: logMeta, - }) + const status = StatusCodes.BAD_REQUEST + res.redirect(resolveRedirectionUrl(`/login?status=${status}`)) + return + } + if (!req.session) { + logger.error({ + message: 'Error logging in user; req.session is undefined', + meta: logMeta, + }) - // Redirect user to the SGID login page - status = StatusCodes.OK - }) - // Step 3b: Error occured in one of the steps. - .mapErr((error) => { - logger.warn({ - message: 'Error occurred when trying to log in via SGID', - meta: logMeta, - error, - }) + const status = StatusCodes.INTERNAL_SERVER_ERROR + res.redirect(resolveRedirectionUrl(`/login?status=${status}`)) + return + } - const { statusCode } = mapRouteError(error, coreErrorMessage) + await AuthSgidService.retrieveAccessToken(code, codeVerifier) + .andThen(({ accessToken, sub }) => + AuthSgidService.retrieveUserInfo(accessToken, sub), + ) + .map((profiles) => { + // expire profiles after 5 minutes to avoid situations where login-jacking when + // the previous user navigated away without selecting a profile + req.session.sgid = { profiles, expiry: Date.now() + 1000 * 60 * 5 } - status = statusCode + // User needs to select profile that will be used for the login + res.redirect(resolveRedirectionUrl(`/login/select-profile`)) + return + }) + .mapErr((error) => { + logger.warn({ + message: 'Error occurred when trying to log in via SGID', + meta: logMeta, + error, }) + + const { statusCode } = mapRouteError(error, coreErrorMessage) + + res.redirect(resolveRedirectionUrl(`/login?status=${statusCode}`)) + return + }) +} + +/** + * Handler for GET /api/v3/auth/sgid/profiles endpoint. + * + * @return 200 with list of profiles + * @return 400 when session or profile is invalid + * @return 401 when session has expired + */ +export const getProfiles: ControllerHandler< + unknown, + SgidProfilesDto, + unknown +> = async (req, res) => { + const logMeta = { + action: 'getProfiles', + ...createReqMeta(req), + } + if (!req.session) { + logger.error({ + message: 'Error logging in via sgID: session is invalid', + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).send() + } + + if (!req.session.sgid?.profiles) { + logger.error({ + message: 'Error logging in via sgID: profile is invalid', + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).send() + } + + if (req.session.sgid.expiry < Date.now()) { + logger.info({ + message: 'Error logging in via sgID: session has expired', + meta: logMeta, + }) + res.redirect(StatusCodes.UNAUTHORIZED, resolveRedirectionUrl(`/login`)) + return } - return res.redirect(`/ogp-login?status=${status}`) + return res + .status(StatusCodes.OK) + .json({ profiles: req.session.sgid.profiles }) +} + +/** + * Handler for POST /api/v3/auth/sgid/profiles endpoint. + * + * @return 200 when OTP has been been successfully sent + * @return 400 when session, profile, or workEmail is invalid + * @return 401 when email domain is not whitelisted + * @return 500 when unknown errors occurs during email validation, or creating the new account + */ +export const setProfile: ControllerHandler< + unknown, + { message: string } | ErrorDto, + { workEmail: string } +> = async (req, res) => { + const logMeta = { + action: 'setProfile', + ...createReqMeta(req), + } + + const coreErrorMessage = 'Failed to log in via SGID. Please try again later.' + + if (!req.session) { + const message = 'Error logging in via sgID: session is invalid' + logger.error({ + message, + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).json({ message }) + } + + if (!req.session.sgid?.profiles) { + const message = 'Error logging in via sgID: profile is invalid' + logger.error({ + message, + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).json({ message }) + } + + const selectedProfile = req.session.sgid.profiles.find( + (profile) => profile.work_email === req.body.workEmail, + ) + if (!selectedProfile) { + const message = 'Error logging in via sgID: selected profile is invalid' + logger.error({ + message, + meta: logMeta, + }) + return res.status(StatusCodes.BAD_REQUEST).json({ message }) + } + + return AuthService.validateEmailDomain(selectedProfile.work_email) + .andThen((agency) => + UserService.retrieveUser(selectedProfile.work_email, agency._id), + ) + .map((user) => { + // Add user info to session. + const { _id } = user.toObject() as SessionUser + req.session.user = { _id } + logger.info({ + message: `Successfully logged in user ${user._id}`, + meta: logMeta, + }) + return res.status(StatusCodes.OK).json({ message: 'Ok' }) + }) + .mapErr((error) => { + const message = 'Error occurred when trying to log in via SGID' + logger.warn({ + message, + meta: logMeta, + error, + }) + + const { statusCode } = mapRouteError(error, coreErrorMessage) + + return res.status(statusCode).json({ message }) + }) } diff --git a/src/app/modules/auth/sgid/auth-sgid.service.ts b/src/app/modules/auth/sgid/auth-sgid.service.ts index 3322b95e75..1426c1a010 100644 --- a/src/app/modules/auth/sgid/auth-sgid.service.ts +++ b/src/app/modules/auth/sgid/auth-sgid.service.ts @@ -1,6 +1,7 @@ import { generatePkcePair, SgidClient } from '@opengovsg/sgid-client' import fs from 'fs' import { err, ok, Result, ResultAsync } from 'neverthrow' +import { SgidPublicOfficerEmploymentList } from 'shared/types/auth' import { ISgidVarsSchema } from 'src/types' @@ -13,9 +14,9 @@ import { } from '../../sgid/sgid.errors' const logger = createLoggerWithLabel(module) - export const SGID_LOGIN_OAUTH_STATE = 'login' -const SGID_OGP_WORK_EMAIL_SCOPE = 'ogpofficerinfo.work_email' +const SGID_POCDEX_PUBLIC_OFFICER_EMPLOYMENTS_SCOPE = + 'pocdex.public_officer_details' export class AuthSgidServiceClass { private client: SgidClient @@ -56,7 +57,9 @@ export class AuthSgidServiceClass { try { const result = this.client.authorizationUrl({ state: SGID_LOGIN_OAUTH_STATE, - scope: ['openid', SGID_OGP_WORK_EMAIL_SCOPE].join(' '), + scope: ['openid', SGID_POCDEX_PUBLIC_OFFICER_EMPLOYMENTS_SCOPE].join( + ' ', + ), nonce: null, codeChallenge, }) @@ -108,11 +111,14 @@ export class AuthSgidServiceClass { retrieveUserInfo( accessToken: string, sub: string, - ): ResultAsync { + ): ResultAsync { return ResultAsync.fromPromise( - this.client - .userinfo({ accessToken, sub }) - .then(({ data }) => data[SGID_OGP_WORK_EMAIL_SCOPE]), + this.client.userinfo({ accessToken, sub }).then(({ data }) => { + const employments: SgidPublicOfficerEmploymentList = JSON.parse( + data[SGID_POCDEX_PUBLIC_OFFICER_EMPLOYMENTS_SCOPE], + ) + return employments + }), (error) => { logger.error({ message: 'Failed to retrieve user info from sgID', @@ -124,7 +130,15 @@ export class AuthSgidServiceClass { }) return new SgidFetchUserInfoError() }, - ) + ).andThen((employments) => { + // Ensure that all emails are in lowercase + const cleanedProfile = employments.map((employment) => ({ + ...employment, + work_email: employment.work_email.toLowerCase(), + })) + + return ok(cleanedProfile) + }) } } diff --git a/src/app/modules/myinfo/myinfo.adapter.ts b/src/app/modules/myinfo/myinfo.adapter.ts index 18e569f303..27d12b298b 100644 --- a/src/app/modules/myinfo/myinfo.adapter.ts +++ b/src/app/modules/myinfo/myinfo.adapter.ts @@ -14,6 +14,7 @@ import { MyInfoChildVaxxStatus, MyInfoDataTransformer, } from '../../../../shared/types' +import { createLoggerWithLabel } from '../../config/logger' import { formatAddress, @@ -26,6 +27,8 @@ import { } from './myinfo.format' import { isMyInfoChildrenBirthRecords } from './myinfo.util' +const logger = createLoggerWithLabel(module) + /** * Converts an internal MyInfo attribute used in FormSG to a scope * which can be used to retrieve data from MyInfo. @@ -422,6 +425,14 @@ export class MyInfoData // Above cases should be exhaustive for all attributes supported by Form. // Fall back to leaving field editable as data shape is unknown. default: + logger.error({ + message: 'Unknown attribute found in Singpass MyInfo field', + meta: { + action: '_isDataReadOnly', + myInfoValue, + attr, + }, + }) return false } } diff --git a/src/app/modules/myinfo/myinfo.service.ts b/src/app/modules/myinfo/myinfo.service.ts index 3a8f0f8047..61a3217768 100644 --- a/src/app/modules/myinfo/myinfo.service.ts +++ b/src/app/modules/myinfo/myinfo.service.ts @@ -281,6 +281,7 @@ export class MyInfoServiceClass { fieldValue, myInfoAttr, myInfoConstantsList, + myInfoData, ) } } diff --git a/src/app/modules/myinfo/myinfo.util.ts b/src/app/modules/myinfo/myinfo.util.ts index 533d18a26b..c29b22f78f 100644 --- a/src/app/modules/myinfo/myinfo.util.ts +++ b/src/app/modules/myinfo/myinfo.util.ts @@ -44,12 +44,14 @@ import { FormAuthNoEsrvcIdError, FormNotFoundError, } from '../form/form.errors' +import { SGIDMyInfoData } from '../sgid/sgid.adapter' import { SGID_MYINFO_LOGIN_COOKIE_NAME } from '../sgid/sgid.constants' import { ProcessedChildrenResponse, ProcessedFieldResponse, } from '../submission/submission.types' +import { MyInfoData } from './myinfo.adapter' import { MYINFO_LOGIN_COOKIE_NAME } from './myinfo.constants' import { MyInfoCookieStateError, @@ -542,7 +544,7 @@ export const handleMyInfoChildHashResponse = ( */ export const getMyInfoAttributeConstantsList = ( myInfoAttr: string | string[], -) => { +): string[] | undefined => { switch (myInfoAttr) { case MyInfoAttribute.Occupation: return myInfoOccupations @@ -576,15 +578,22 @@ export const logIfFieldValueNotInMyinfoList = ( fieldValue: string, myInfoAttr: string | string[], myInfoList: string[], + myInfoData: MyInfoData | SGIDMyInfoData, ) => { const isFieldValueInMyinfoList = myInfoList.includes(fieldValue) - if (!isFieldValueInMyinfoList) { + const myInfoSource = + myInfoData instanceof MyInfoData ? 'Singpass MyInfo' : 'SGID MyInfo' + // SGID returns NA instead of empty field values, we don't need this to be logged + // as this is expected behaviour + const isNAFromSgid = myInfoAttr === 'SGID MyInfo' && fieldValue === 'NA' + if (!isNAFromSgid || !isFieldValueInMyinfoList) { logger.error({ message: 'Myinfo field value not found in existing Myinfo constants list', meta: { action: 'prefillAndSaveMyInfoFields', myInfoFieldValue: fieldValue, myInfoAttr, + myInfoSource, }, }) } diff --git a/src/app/modules/sgid/sgid.adapter.ts b/src/app/modules/sgid/sgid.adapter.ts index 1885c239f7..ebe3d73710 100644 --- a/src/app/modules/sgid/sgid.adapter.ts +++ b/src/app/modules/sgid/sgid.adapter.ts @@ -2,27 +2,67 @@ import { MyInfoAttribute as InternalAttr, MyInfoDataTransformer, } from '../../../../shared/types' +import { createLoggerWithLabel } from '../../config/logger' import { SGID_MYINFO_NRIC_NUMBER_SCOPE, SGIDScope as ExternalAttr, } from './sgid.constants' +import { formatAddress, formatVehicles } from './sgid.format' import { SGIDScopeToValue } from './sgid.types' +const logger = createLoggerWithLabel(module) + export const internalAttrToScope = (attr: InternalAttr): ExternalAttr => { switch (attr) { case InternalAttr.Name: return ExternalAttr.Name + case InternalAttr.Sex: + return ExternalAttr.Sex case InternalAttr.DateOfBirth: return ExternalAttr.DateOfBirth + case InternalAttr.Race: + return ExternalAttr.Race + case InternalAttr.Nationality: + return ExternalAttr.Nationality + case InternalAttr.HousingType: + return ExternalAttr.HousingType + case InternalAttr.HdbType: + return ExternalAttr.HdbType case InternalAttr.PassportNumber: return ExternalAttr.PassportNumber case InternalAttr.PassportExpiryDate: return ExternalAttr.PassportExpiryDate - case InternalAttr.MobileNo: - return ExternalAttr.MobileNumber case InternalAttr.RegisteredAddress: return ExternalAttr.RegisteredAddress + case InternalAttr.BirthCountry: + return ExternalAttr.BirthCountry + case InternalAttr.VehicleNo: + return ExternalAttr.VehicleNo + case InternalAttr.Employment: + return ExternalAttr.Employment + case InternalAttr.WorkpassStatus: + return ExternalAttr.WorkpassStatus + case InternalAttr.WorkpassExpiryDate: + return ExternalAttr.WorkpassExpiryDate + case InternalAttr.Marital: + return ExternalAttr.MaritalStatus + case InternalAttr.MobileNo: + return ExternalAttr.MobileNoWithCountryCode + case InternalAttr.ResidentialStatus: + return ExternalAttr.ResidentialStatus + case InternalAttr.Dialect: + return ExternalAttr.Dialect + case InternalAttr.Occupation: + return ExternalAttr.Occupation + case InternalAttr.CountryOfMarriage: + return ExternalAttr.CountryOfMarriage + case InternalAttr.MarriageCertNo: + return ExternalAttr.MarriageCertNo + case InternalAttr.MarriageDate: + return ExternalAttr.MarriageDate + case InternalAttr.DivorceDate: + return ExternalAttr.DivorceDate default: // This should be removed once sgID reaches parity with MyInfo. // For now, the returned value will be automatically filtered @@ -44,16 +84,52 @@ const internalAttrToSGIDExternal = ( switch (attr) { case InternalAttr.Name: return ExternalAttr.Name + case InternalAttr.Sex: + return ExternalAttr.Sex case InternalAttr.DateOfBirth: return ExternalAttr.DateOfBirth + case InternalAttr.Race: + return ExternalAttr.Race + case InternalAttr.Nationality: + return ExternalAttr.Nationality + case InternalAttr.HousingType: + return ExternalAttr.HousingType + case InternalAttr.HdbType: + return ExternalAttr.HdbType case InternalAttr.PassportNumber: return ExternalAttr.PassportNumber case InternalAttr.PassportExpiryDate: return ExternalAttr.PassportExpiryDate case InternalAttr.MobileNo: - return ExternalAttr.MobileNumber + return ExternalAttr.MobileNoWithCountryCode case InternalAttr.RegisteredAddress: return ExternalAttr.RegisteredAddress + case InternalAttr.BirthCountry: + return ExternalAttr.BirthCountry + case InternalAttr.ResidentialStatus: + return ExternalAttr.ResidentialStatus + case InternalAttr.VehicleNo: + return ExternalAttr.VehicleNo + case InternalAttr.Employment: + return ExternalAttr.Employment + case InternalAttr.WorkpassStatus: + return ExternalAttr.WorkpassStatus + case InternalAttr.WorkpassExpiryDate: + return ExternalAttr.WorkpassExpiryDate + case InternalAttr.Marital: + return ExternalAttr.MaritalStatus + case InternalAttr.Dialect: + return ExternalAttr.Dialect + case InternalAttr.Occupation: + return ExternalAttr.Occupation + case InternalAttr.CountryOfMarriage: + return ExternalAttr.CountryOfMarriage + case InternalAttr.MarriageCertNo: + return ExternalAttr.MarriageCertNo + case InternalAttr.MarriageDate: + return ExternalAttr.MarriageDate + case InternalAttr.DivorceDate: + return ExternalAttr.DivorceDate default: return undefined } @@ -79,16 +155,37 @@ export class SGIDMyInfoData /** * Placeholder. For now, there are not enough public fields in * sgID's MyInfo catalog to require significant formatting. + * SGID returns 'NA' for field values that do not exist (vs empty string returned by Singpass MyInfo) * @param attr sgID's MyInfo OAuth scope. * @returns the formatted field. */ _formatFieldValue(attr: ExternalAttr): string | undefined { - return this.#payload[attr] + const fieldValue = this.#payload[attr] + switch (attr) { + case ExternalAttr.RegisteredAddress: + return formatAddress(fieldValue) + case ExternalAttr.VehicleNo: + return formatVehicles(fieldValue) + default: + // SGID returns NA if they do not have the value. We want to return an empty string instead, + // so that this can be processed by our frontend in the same way as Singpass MyInfo + return fieldValue === 'NA' ? '' : fieldValue + } } /** - * Refer to the myInfo data catalogue to see which fields should be read-only - * and which fields should be editable by the user. + * SGID only returns verified MyInfo fields, unless the field contains marriage-related information + * (decision by SNDGO & MSF due to overseas unregistered marriages). + * An empty myInfo field will always evaluate + * to false so that the field can be filled by form-filler. + * + * The affected marriage fields are: + * - marital + * - marriagedate + * - divorcedate + * - countryofmarriage + * - marriagecertno + * * @param attr sgID MyInfo OAuth scope. * @param fieldValue FormSG field value. * @returns Whether the data/field should be readonly. @@ -98,15 +195,43 @@ export class SGIDMyInfoData if (!data || !fieldValue) return false switch (attr) { - case ExternalAttr.MobileNumber: + case ExternalAttr.MobileNoWithCountryCode: case ExternalAttr.RegisteredAddress: case ExternalAttr.Name: case ExternalAttr.PassportNumber: case ExternalAttr.DateOfBirth: + case ExternalAttr.ResidentialStatus: case ExternalAttr.PassportExpiryDate: + case ExternalAttr.Sex: + case ExternalAttr.Race: + case ExternalAttr.Nationality: + case ExternalAttr.HousingType: + case ExternalAttr.HdbType: + case ExternalAttr.BirthCountry: + case ExternalAttr.VehicleNo: + case ExternalAttr.Employment: + case ExternalAttr.WorkpassStatus: + case ExternalAttr.WorkpassExpiryDate: + case ExternalAttr.Dialect: + case ExternalAttr.Occupation: return !!data + // Fields required to always be editable according to MyInfo docs + case ExternalAttr.MaritalStatus: + case ExternalAttr.CountryOfMarriage: + case ExternalAttr.MarriageCertNo: + case ExternalAttr.MarriageDate: + case ExternalAttr.DivorceDate: + return false // Fall back to leaving field editable as data shape is unknown. default: + logger.error({ + message: 'Unknown attribute found in SGID MyInfo field', + meta: { + action: '_isDataReadOnly', + fieldValue, + attr, + }, + }) return false } } @@ -127,9 +252,17 @@ export class SGIDMyInfoData } } const fieldValue = this._formatFieldValue(externalAttr) + logger.info({ + message: 'get field value', + meta: { + action: 'getFieldValueForAttr', + fieldValue, + externalAttr, + }, + }) return { fieldValue, - isReadOnly: true, + isReadOnly: this._isDataReadOnly(externalAttr, fieldValue), } } } diff --git a/src/app/modules/sgid/sgid.constants.ts b/src/app/modules/sgid/sgid.constants.ts index 57ccc8855a..4350ccc99a 100644 --- a/src/app/modules/sgid/sgid.constants.ts +++ b/src/app/modules/sgid/sgid.constants.ts @@ -33,7 +33,27 @@ export enum SGIDScope { DateOfBirth = 'myinfo.date_of_birth', PassportNumber = 'myinfo.passport_number', PassportExpiryDate = 'myinfo.passport_expiry_date', - MobileNumber = 'myinfo.mobile_number', Email = 'myinfo.email', RegisteredAddress = 'myinfo.registered_address', + Sex = 'myinfo.sex', + Race = 'myinfo.race', + Nationality = 'myinfo.nationality', + HousingType = 'myinfo.housingtype', + HdbType = 'myinfo.hdbtype', + BirthCountry = 'myinfo.birth_country', + ResidentialStatus = 'myinfo.residentialstatus', + VehicleNo = 'myinfo.vehicles', + Employment = 'myinfo.name_of_employer', + WorkpassStatus = 'myinfo.workpass_status', + WorkpassExpiryDate = 'myinfo.workpass_expiry_date', + MaritalStatus = 'myinfo.marital_status', + // SGID also has another myinfo.mobile_number field that does not contain the country code prefix. + // We use the one that contains prefix, as this matches our mobile number form field. + MobileNoWithCountryCode = 'myinfo.mobile_number_with_country_code', + Dialect = 'myinfo.dialect', + Occupation = 'myinfo.occupation', + CountryOfMarriage = 'myinfo.country_of_marriage', + MarriageCertNo = 'myinfo.marriage_certificate_number', + MarriageDate = 'myinfo.marriage_date', + DivorceDate = 'myinfo.divorce_date', } diff --git a/src/app/modules/sgid/sgid.controller.ts b/src/app/modules/sgid/sgid.controller.ts index 8223649dae..966671c401 100644 --- a/src/app/modules/sgid/sgid.controller.ts +++ b/src/app/modules/sgid/sgid.controller.ts @@ -2,6 +2,7 @@ import { ParamsDictionary } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' import { FormAuthType } from '../../../../shared/types' +import { Environment } from '../../../types' import config from '../../config/config' import { createLoggerWithLabel } from '../../config/logger' import { ControllerHandler } from '../core/core.types' @@ -38,7 +39,15 @@ export const handleLogin: ControllerHandler< } const { formId, rememberMe, decodedQuery } = parsedState.value - const target = decodedQuery ? `/${formId}${decodedQuery}` : `/${formId}` + + // For local dev, we need to specify the frontend app URL as this is different from the backend's app URL + const redirectTargetRaw = + process.env.NODE_ENV === Environment.Dev + ? `${config.app.feAppUrl}/${formId}` + : `/${formId}` + const target = decodedQuery + ? `${redirectTargetRaw}${decodedQuery}` + : `${redirectTargetRaw}` const formResult = await FormService.retrieveFullFormById(formId) if (formResult.isErr()) { diff --git a/src/app/modules/sgid/sgid.format.ts b/src/app/modules/sgid/sgid.format.ts new file mode 100644 index 0000000000..73d93dcc4d --- /dev/null +++ b/src/app/modules/sgid/sgid.format.ts @@ -0,0 +1,43 @@ +import { createLoggerWithLabel } from '../../config/logger' + +const logger = createLoggerWithLabel(module) + +/** + * Formats SGID MyInfo attribute as address. + * SGID MyInfo multi-line address are newline-separated, while MyInfo multi-line addresses are comma-separated + * @param addr The address to format. + * @example '29 ROCHDALE ROAD\n\nSINGAPORE 535842' + * @returns Formatted address is comma separated, same as the output of formatAddress in myinfo.format.ts + */ +export const formatAddress = (addr: string): string => { + const formattedAddress = addr.replace(/(\n)+/g, ', ') + return formattedAddress +} + +/** + * Formats SGID vehicle types. + * SGID vehicles are a stringified array of objects, we want to output this as a string + * @param vehicles The vehicles to format. + * @example '[{"vehicle_number":"CB6171D"},{"vehicle_number":"SJQ7247B"}]' + * @returns Formatted address is comma separated, same as the output of formatAddress in myinfo.format.ts + */ +export const formatVehicles = (vehicles: string): string => { + if (vehicles && vehicles !== '[]') { + try { + const vehiclesObject = JSON.parse(vehicles) + const vehicleNos = vehiclesObject + .map((vehicle: { vehicle_number: string }) => vehicle['vehicle_number']) + .join(', ') + return vehicleNos + } catch (error) { + logger.error({ + message: 'Failed to parse vehicles', + meta: { action: 'formatVehicles', vehicles }, + error, + }) + return '' + } + } else { + return '' + } +} diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts index 8f78e7b779..56002eb66e 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts @@ -1416,7 +1416,7 @@ describe('encrypt-submission.service', () => { ) }) - it('should return errAsync if lambda returns an errored response (e.g. file not found)', async () => { + it('should return errAsync if lambda returns an errored response (e.g. file not found) when a valid file key is used', async () => { // Arrange const failurePayload = { statusCode: 404, @@ -1438,7 +1438,11 @@ describe('encrypt-submission.service', () => { // Assert expect(awsSpy).toHaveBeenCalledOnce() expect(actualResult.isErr()).toEqual(true) - expect(actualResult._unsafeUnwrapErr()).toEqual(new InvalidFileKeyError()) + expect(actualResult._unsafeUnwrapErr()).toEqual( + new InvalidFileKeyError( + 'Invalid file key - file key is not found in the quarantine bucket. The file must be uploaded first.', + ), + ) }) it("should return errAsync if the lambda's errored response is not in the right format", async () => { diff --git a/src/app/routes/api/v3/auth/auth-sgid.routes.ts b/src/app/routes/api/v3/auth/auth-sgid.routes.ts index bbc1cf0a06..87490cf54a 100644 --- a/src/app/routes/api/v3/auth/auth-sgid.routes.ts +++ b/src/app/routes/api/v3/auth/auth-sgid.routes.ts @@ -6,4 +6,38 @@ export const AuthSGIDRouter = Router() AuthSGIDRouter.get('/authurl', AuthSgidController.generateAuthUrl) -AuthSGIDRouter.get('/login', AuthSgidController.handleLogin) +/** + * Receives the selected login details from Sgid + * Sets the returned profiles in req.session.sgid + * @route POST /api/v3/auth/sgid/login/callback + * + * The frontend should query the available profiles through GET /api/v3/auth/sgid/profiles + * + * @return 200 with redirect to frontend /login/callback if there are no errors + * @return 400 when code or state is not provided, or state is incorrect + * @return 500 when processing the code verifier cookie fails, or when an unknown error occurs + */ +AuthSGIDRouter.get('/login/callback', AuthSgidController.handleLoginCallback) + +/** + * Sets the selected user profile + * Uses get request to retrieve available profiles + * @route GET /api/v3/auth/sgid/profiles + * + * @return 200 with list of profiles + * @return 400 when session or profile is invalid + * @return 401 when session has expired + */ +AuthSGIDRouter.get('/profiles', AuthSgidController.getProfiles) + +/** + * Sets the selected user profile + * Uses post request to select the workemail from the request body + * @route POST /api/v3/auth/sgid/profiles + * + * @return 200 when OTP has been been successfully sent + * @return 400 when session, profile, or workEmail is invalid + * @return 401 when email domain is invalid + * @return 500 when unknown errors occurs during email validation, or creating the new account + */ +AuthSGIDRouter.post('/profiles', AuthSgidController.setProfile) diff --git a/src/app/utils/urls.ts b/src/app/utils/urls.ts new file mode 100644 index 0000000000..99a807f414 --- /dev/null +++ b/src/app/utils/urls.ts @@ -0,0 +1,11 @@ +import { Environment } from '../../types' +import config from '../config/config' + +export const resolveRedirectionUrl = (rootUrl: string) => { + // For local dev, we need to specify the frontend app URL as this is different from the backend's app URL + const hostname = + process.env.NODE_ENV === Environment.Dev ? `${config.app.feAppUrl}` : `` + + const resolvedUrl = `${hostname}${rootUrl}` + return resolvedUrl +} diff --git a/src/types/vendor/express.d.ts b/src/types/vendor/express.d.ts index 9b00e06e8e..779ced97d9 100644 --- a/src/types/vendor/express.d.ts +++ b/src/types/vendor/express.d.ts @@ -1,5 +1,6 @@ import { RateLimitInfo } from 'express-rate-limit' +import { SgidUser } from '../../app/modules/auth/auth.types' import { EncryptSubmissionDto } from '../api' import { IPopulatedEncryptedForm, IPopulatedForm, IUserSchema } from '../types' @@ -37,6 +38,7 @@ declare module 'express-session' { user?: { _id: IUserSchema['_id'] } + sgid?: SgidUser } export interface AuthedSessionData extends SessionData {