├── .husky ├── .gitignore ├── commit-msg ├── pre-push ├── pre-commit └── _ │ └── husky.sh ├── .nvmrc ├── frontend ├── docs │ ├── security.md │ ├── testing.md │ ├── state-management.md │ ├── api-data.md │ ├── deployment.md │ ├── error-handling.md │ ├── performance.md │ ├── components-styling.md │ ├── project-structure.md │ └── project-config.md ├── src │ ├── app │ │ ├── index.ts │ │ ├── __tests__ │ │ │ └── App.test.tsx │ │ ├── App.tsx │ │ ├── AppFooter.tsx │ │ ├── PrivateElement.tsx │ │ ├── PublicElement.tsx │ │ └── AppRouter.tsx │ ├── features │ │ ├── health │ │ │ ├── index.ts │ │ │ ├── routes │ │ │ │ ├── index.tsx │ │ │ │ └── HealthPage.tsx │ │ │ └── api │ │ │ │ └── queries.ts │ │ ├── dashboard │ │ │ ├── index.ts │ │ │ └── routes │ │ │ │ ├── index.tsx │ │ │ │ └── DashboardPage.tsx │ │ └── auth │ │ │ ├── constants.ts │ │ │ ├── api │ │ │ ├── logout.ts │ │ │ ├── verifyLoginOtp.ts │ │ │ ├── sendLoginOtp.ts │ │ │ └── fetchUser.ts │ │ │ ├── index.ts │ │ │ ├── routes │ │ │ ├── index.tsx │ │ │ └── LoginPage.tsx │ │ │ └── components │ │ │ ├── ResendOtpButton.tsx │ │ │ ├── OtpForm.tsx │ │ │ └── LoginForm.tsx │ ├── theme │ │ ├── components │ │ │ └── index.ts │ │ └── index.ts │ ├── constants │ │ ├── events.ts │ │ └── routes.ts │ ├── react-app-env.d.ts │ ├── lib │ │ ├── api.tsx │ │ ├── auth.tsx │ │ └── storage.tsx │ ├── hooks │ │ ├── useIsDesktop.ts │ │ └── useInterval.ts │ ├── templates │ │ └── AppGrid.tsx │ ├── setupTests.ts │ ├── index.tsx │ ├── utils │ │ └── lazyImport.ts │ ├── logo.svg │ └── serviceWorker.ts ├── .storybook │ ├── preview-head.html │ ├── manager.js │ ├── introduction │ │ └── Welcome │ │ │ ├── Welcome.stories.tsx │ │ │ └── Welcome.tsx │ ├── preview.js │ ├── main.js │ ├── themes.ts │ └── manager-head.html ├── .env.development ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── __mocks__ │ └── react-markdown.tsx ├── config-overrides.js ├── .gitignore ├── .prettierignore ├── tsconfig.paths.json ├── tsconfig.json ├── README.md ├── package.json └── .snyk ├── shared ├── src │ ├── types │ │ ├── index.ts │ │ ├── health.dto.ts │ │ └── auth.dto.ts │ ├── constants │ │ └── index.ts │ ├── index.ts │ ├── __tests__ │ │ └── index.test.ts │ └── decorators │ │ ├── is-gov-sg-email.ts │ │ └── __tests__ │ │ └── is-gov-sg-email.spec.ts ├── tsconfig.build.json ├── jest.config.ts ├── tsconfig.json └── package.json ├── backend ├── .gitignore ├── nest-cli.json ├── src │ ├── database │ │ ├── entities │ │ │ ├── index.ts │ │ │ ├── session.entity.ts │ │ │ └── user.entity.ts │ │ ├── datasource-seed.ts │ │ ├── datasource.ts │ │ ├── ormconfig.ts │ │ ├── database-config.service.ts │ │ └── migrations │ │ │ └── 1659538032978-initial.ts │ ├── types │ │ ├── session.ts │ │ ├── express.d.ts │ │ └── express-session.d.ts │ ├── config │ │ ├── config.module.ts │ │ ├── config.service.ts │ │ └── config.schema.ts │ ├── tracing │ │ ├── index.ts │ │ ├── trace-id.provider.ts │ │ └── __tests__ │ │ │ └── trace-id.provider.spec.ts │ ├── otp │ │ ├── otp.module.ts │ │ └── otp.service.ts │ ├── mailer │ │ ├── mailer.module.ts │ │ └── mailer.service.ts │ ├── health │ │ ├── health.module.ts │ │ ├── __tests__ │ │ │ └── health.controller.spec.ts │ │ └── health.controller.ts │ ├── auth │ │ ├── auth.module.ts │ │ ├── __tests__ │ │ │ ├── auth.service.spec.ts │ │ │ └── auth.controller.spec.ts │ │ ├── auth.service.ts │ │ └── auth.controller.ts │ ├── main.ts │ ├── api.module.ts │ ├── core │ │ └── providers │ │ │ └── logged-validation.pipe.ts │ ├── middlewares │ │ ├── helmet.middleware.ts │ │ └── session.middleware.ts │ └── app.module.ts ├── tsconfig.build.json ├── jest-dotenv.config.js ├── .env.test ├── .env.development ├── jest.config.ts ├── tsconfig.json ├── scripts │ └── env │ │ ├── putter.mjs │ │ └── loader.mjs ├── README.md └── package.json ├── .dockerignore ├── docs ├── images │ ├── first-run.png │ └── use-this-template.png ├── deploying │ ├── images │ │ ├── fly │ │ │ ├── deployed.png │ │ │ ├── sign-up.png │ │ │ ├── dashboard.png │ │ │ ├── access-tokens.png │ │ │ ├── app-dashboard.png │ │ │ ├── try-for-free.png │ │ │ ├── access-tokens-menu.png │ │ │ ├── create-organization.png │ │ │ ├── create-staging-branch.png │ │ │ └── github-actions-secrets.png │ │ └── digitalocean │ │ │ ├── db-params.png │ │ │ ├── env-vars.png │ │ │ ├── resources.png │ │ │ ├── landing-page.png │ │ │ ├── app-from-source.png │ │ │ ├── attach-database.png │ │ │ └── create-database.png │ ├── README.md │ ├── going-public.md │ ├── for-everyone.md │ ├── for-engineers.md │ └── for-hustlers.md ├── README.md └── tooling.md ├── .vscode └── settings.json ├── Dockerrun.aws.json ├── commitlint.config.js ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── bug_report.md │ └── technical-specification.md ├── mergify.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── build.yml ├── appspec.json ├── .eslintrc.js ├── docker-compose.yml ├── Dockerfile ├── fly.toml ├── Dockerfile.fly ├── .gitignore ├── package.json ├── LICENSE ├── README.md ├── ecs-task-definition.json └── wait-for-it.sh /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.11.1 2 | -------------------------------------------------------------------------------- /frontend/docs/security.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/docs/testing.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/src/types/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/docs/state-management.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /shared/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env* 3 | -------------------------------------------------------------------------------- /frontend/docs/api-data.md: -------------------------------------------------------------------------------- 1 | Work in progress 2 | -------------------------------------------------------------------------------- /frontend/docs/deployment.md: -------------------------------------------------------------------------------- 1 | Work in progress 2 | -------------------------------------------------------------------------------- /frontend/docs/error-handling.md: -------------------------------------------------------------------------------- 1 | Work in progress 2 | -------------------------------------------------------------------------------- /frontend/docs/performance.md: -------------------------------------------------------------------------------- 1 | Work in progress 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/build 2 | **/node_modules 3 | .git 4 | -------------------------------------------------------------------------------- /frontend/docs/components-styling.md: -------------------------------------------------------------------------------- 1 | Work in progress 2 | -------------------------------------------------------------------------------- /shared/src/index.ts: -------------------------------------------------------------------------------- 1 | export const NAME = 'ts-template' 2 | -------------------------------------------------------------------------------- /frontend/src/app/index.ts: -------------------------------------------------------------------------------- 1 | export { App as default } from './App' 2 | -------------------------------------------------------------------------------- /frontend/src/features/health/index.ts: -------------------------------------------------------------------------------- 1 | export * from './routes' 2 | -------------------------------------------------------------------------------- /frontend/src/features/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './routes' 2 | -------------------------------------------------------------------------------- /frontend/src/theme/components/index.ts: -------------------------------------------------------------------------------- 1 | export const components = {} 2 | -------------------------------------------------------------------------------- /frontend/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/constants/events.ts: -------------------------------------------------------------------------------- 1 | export const UNAUTHORIZED_EVENT = 'unauthorized-event' 2 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /docs/images/first-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/images/first-run.png -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | # react-scripts will load this automatically 2 | REACT_APP_ENV='development' 3 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /backend/src/database/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './session.entity' 2 | export * from './user.entity' 3 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /docs/images/use-this-template.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/images/use-this-template.png -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --from origin/develop --to HEAD --verbose 5 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "build", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/deploying/images/fly/deployed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/deployed.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/sign-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/sign-up.png -------------------------------------------------------------------------------- /frontend/src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const routes = { 2 | index: '/', 3 | login: '/login', 4 | health: '/health', 5 | } 6 | -------------------------------------------------------------------------------- /backend/jest-dotenv.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const dotenv = require('dotenv') 3 | 4 | dotenv.config({ path: '.env.test' }) 5 | -------------------------------------------------------------------------------- /docs/deploying/images/fly/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/dashboard.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/access-tokens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/access-tokens.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/app-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/app-dashboard.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/try-for-free.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/try-for-free.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run on-backend -- pre-commit && npm run on-frontend -- pre-commit 5 | -------------------------------------------------------------------------------- /backend/.env.test: -------------------------------------------------------------------------------- 1 | DB_HOST='localhost' 2 | DB_NAME='dev' 3 | DB_PASSWORD='postgres' 4 | DB_PORT='5432' 5 | DB_USERNAME='postgres' 6 | NODE_ENV=test 7 | -------------------------------------------------------------------------------- /backend/src/types/session.ts: -------------------------------------------------------------------------------- 1 | import { Session, SessionData } from 'express-session' 2 | 3 | export type UserSession = Session & Partial 4 | -------------------------------------------------------------------------------- /docs/deploying/images/digitalocean/db-params.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/digitalocean/db-params.png -------------------------------------------------------------------------------- /docs/deploying/images/digitalocean/env-vars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/digitalocean/env-vars.png -------------------------------------------------------------------------------- /docs/deploying/images/digitalocean/resources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/digitalocean/resources.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/access-tokens-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/access-tokens-menu.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/create-organization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/create-organization.png -------------------------------------------------------------------------------- /shared/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "build", "**/*spec.ts", "jest.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/deploying/images/digitalocean/landing-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/digitalocean/landing-page.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/create-staging-branch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/create-staging-branch.png -------------------------------------------------------------------------------- /docs/deploying/images/fly/github-actions-secrets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/fly/github-actions-secrets.png -------------------------------------------------------------------------------- /backend/.env.development: -------------------------------------------------------------------------------- 1 | DB_HOST='localhost' 2 | DB_NAME='dev' 3 | DB_PASSWORD='postgres' 4 | DB_PORT='5432' 5 | DB_USERNAME='postgres' 6 | NODE_ENV=development 7 | -------------------------------------------------------------------------------- /docs/deploying/images/digitalocean/app-from-source.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/digitalocean/app-from-source.png -------------------------------------------------------------------------------- /docs/deploying/images/digitalocean/attach-database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/digitalocean/attach-database.png -------------------------------------------------------------------------------- /docs/deploying/images/digitalocean/create-database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opengovsg/ts-template/HEAD/docs/deploying/images/digitalocean/create-database.png -------------------------------------------------------------------------------- /backend/src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../database/entities' 2 | 3 | declare module 'express' { 4 | export interface Request { 5 | user: User 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/features/auth/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Key to store whether the admin is logged in in localStorage. 3 | */ 4 | export const STORAGE_LOGGED_IN_KEY = 'is-logged-in' 5 | -------------------------------------------------------------------------------- /frontend/__mocks__/react-markdown.tsx: -------------------------------------------------------------------------------- 1 | function ReactMarkdown({ children }: { children: React.ReactNode }) { 2 | return <>{children} 3 | } 4 | 5 | export default ReactMarkdown 6 | -------------------------------------------------------------------------------- /frontend/src/features/auth/api/logout.ts: -------------------------------------------------------------------------------- 1 | import { api } from '~lib/api' 2 | 3 | export const logout = async (): Promise => { 4 | return api.url('/auth/logout').post().json() 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/types/express-session.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../database/entities' 2 | 3 | declare module 'express-session' { 4 | interface SessionData { 5 | user?: User 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons' 2 | 3 | import { StorybookTheme } from './themes' 4 | 5 | addons.setConfig({ 6 | theme: StorybookTheme.manager, 7 | }) 8 | -------------------------------------------------------------------------------- /shared/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { NAME } from '../index' 2 | 3 | describe('dummy test', () => { 4 | test('equality of same', () => { 5 | expect(NAME).toEqual(NAME) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /shared/src/types/health.dto.ts: -------------------------------------------------------------------------------- 1 | export interface HealthDto { 2 | status: string 3 | info?: Record 4 | error?: Record 5 | details: Record 6 | } 7 | -------------------------------------------------------------------------------- /.husky/_/husky.sh: -------------------------------------------------------------------------------- 1 | echo "husky - DEPRECATED 2 | 3 | Please remove the following two lines from $0: 4 | 5 | #!/usr/bin/env sh 6 | . \"\$(dirname -- \"\$0\")/_/husky.sh\" 7 | 8 | They WILL FAIL in v10.0.0 9 | " -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "eslint.validate": ["javascript", "typescript"], 6 | "editor.formatOnSave": true 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare namespace NodeJS { 3 | interface ProcessEnv { 4 | REACT_APP_ENV: 'development' | 'production' | 'staging' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSEBDockerrunVersion": "1", 3 | "Image": { 4 | "Name": "@REPO:@TAG", 5 | "Update": "true" 6 | }, 7 | "Ports": [ 8 | { 9 | "ContainerPort": "8080" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /frontend/src/features/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api/fetchUser' 2 | export * from './api/logout' 3 | export * from './api/sendLoginOtp' 4 | export * from './api/verifyLoginOtp' 5 | export * from './constants' 6 | export * from './routes' 7 | -------------------------------------------------------------------------------- /frontend/src/features/auth/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { PublicElement } from '~/app/PublicElement' 2 | 3 | import { LoginPage } from './LoginPage' 4 | 5 | export const AuthRoutes = () => { 6 | return } /> 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/features/health/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { PrivateElement } from '~/app/PrivateElement' 2 | 3 | import { HealthPage } from './HealthPage' 4 | 5 | export const HealthRoutes = () => { 6 | return } /> 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/features/dashboard/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { PrivateElement } from '~/app/PrivateElement' 2 | 3 | import { DashboardPage } from './DashboardPage' 4 | 5 | export const DashboardRoutes = () => { 6 | return } /> 7 | } 8 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | ignores: [(message) => /^Bumps \[(.+)\]\((.+)\)(.*).$/m.test(message)], 4 | rules: { 5 | 'scope-case': [2, 'always', ['pascal-case', 'lower-case', 'camel-case']], 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { ConfigService } from './config.service' 4 | 5 | @Global() 6 | @Module({ 7 | providers: [ConfigService], 8 | exports: [ConfigService], 9 | }) 10 | export class ConfigModule {} 11 | -------------------------------------------------------------------------------- /frontend/src/features/health/routes/HealthPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react' 2 | 3 | import { useHealth } from '../api/queries' 4 | 5 | export const HealthPage = (): JSX.Element => { 6 | const health = useHealth() 7 | 8 | return {JSON.stringify(health)} 9 | } 10 | -------------------------------------------------------------------------------- /frontend/config-overrides.js: -------------------------------------------------------------------------------- 1 | const { aliasWebpack, aliasJest, configPaths } = require('react-app-alias-ex') 2 | 3 | const aliasMap = configPaths('./tsconfig.paths.json') 4 | 5 | const options = { 6 | alias: aliasMap, 7 | } 8 | module.exports = aliasWebpack(options) 9 | module.exports.jest = aliasJest(options) 10 | -------------------------------------------------------------------------------- /backend/src/tracing/index.ts: -------------------------------------------------------------------------------- 1 | // https://docs.datadoghq.com/tracing/trace_collection/dd_libraries/nodejs/ 2 | 3 | import ddTrace from 'dd-trace' 4 | 5 | ddTrace.init({ logInjection: true }) // initialized in a different file to avoid hoisting. 6 | 7 | export { TraceIdProvider } from './trace-id.provider' 8 | export default ddTrace 9 | -------------------------------------------------------------------------------- /frontend/src/features/auth/api/verifyLoginOtp.ts: -------------------------------------------------------------------------------- 1 | import { api } from '~lib/api' 2 | import { 3 | VerifyOtpRequestDto, 4 | VerifyOtpResponseDto, 5 | } from '~shared/types/auth.dto' 6 | 7 | export const verifyLoginOtp = async (params: VerifyOtpRequestDto) => { 8 | return api.url('/auth/verify').post(params).json() 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/otp/otp.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { ConfigModule } from '../config/config.module' 4 | import { OtpService } from './otp.service' 5 | 6 | @Global() 7 | @Module({ 8 | imports: [ConfigModule], 9 | providers: [OtpService], 10 | exports: [OtpService], 11 | }) 12 | export class OtpModule {} 13 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # For syntax visit https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-on-github/about-code-owners#example-of-a-codeowners-file 2 | 3 | # Set one primary team as default owner for the whole repository 4 | 5 | * @opengovsg/team-name 6 | 7 | # List overrides for parts of the repo below (if necessary) 8 | -------------------------------------------------------------------------------- /backend/src/database/datasource-seed.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { DataSource, DataSourceOptions } from 'typeorm' 3 | 4 | import { base } from './ormconfig' 5 | 6 | const config: DataSourceOptions = { 7 | ...base, 8 | migrations: [join(__dirname, 'seeds', '*{.js,.ts}')], 9 | } 10 | 11 | export const appDataSource = new DataSource(config) 12 | -------------------------------------------------------------------------------- /backend/src/mailer/mailer.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { ConfigModule } from '../config/config.module' 4 | import { MailerService } from './mailer.service' 5 | 6 | @Global() 7 | @Module({ 8 | imports: [ConfigModule], 9 | providers: [MailerService], 10 | exports: [MailerService], 11 | }) 12 | export class MailerModule {} 13 | -------------------------------------------------------------------------------- /frontend/.storybook/introduction/Welcome/Welcome.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/react' 2 | 3 | import { Welcome as Component } from './Welcome' 4 | 5 | export default { 6 | title: 'Introduction/Welcome', 7 | component: Component, 8 | parameters: { 9 | layout: 'fullscreen', 10 | }, 11 | } as Meta 12 | 13 | export const Welcome = () => 14 | -------------------------------------------------------------------------------- /frontend/src/app/__tests__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | 3 | import { App } from '../App' 4 | 5 | test('renders page', async () => { 6 | render() 7 | 8 | await waitFor(() => 9 | expect( 10 | screen.getAllByText('Scaffold a starter project in minutes').length, 11 | ).toEqual(2), 12 | ) 13 | }) 14 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "ts-template", 3 | "name": "ts-template", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TerminusModule } from '@nestjs/terminus' 3 | import { TypeOrmModule } from '@nestjs/typeorm' 4 | 5 | import { HealthController } from './health.controller' 6 | 7 | @Module({ 8 | imports: [TerminusModule, TypeOrmModule], 9 | controllers: [HealthController], 10 | }) 11 | export class HealthModule {} 12 | -------------------------------------------------------------------------------- /frontend/src/features/auth/api/sendLoginOtp.ts: -------------------------------------------------------------------------------- 1 | import { api } from '~lib/api' 2 | import type { 3 | SendLoginOtpRequestDto, 4 | SendLoginOtpResponseDto, 5 | } from '~shared/types/auth.dto' 6 | 7 | export const sendLoginOtp = async (params: SendLoginOtpRequestDto) => { 8 | params.email = params.email.toLowerCase() 9 | return api.url('/auth').post(params).json() 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/features/health/api/queries.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | 3 | import { api } from '~lib/api' 4 | import { HealthDto } from '~shared/types/health.dto' 5 | 6 | export function useHealth() { 7 | const { data } = useQuery( 8 | ['health'], 9 | () => api.url(`/health`).get().json(), 10 | { 11 | suspense: true, 12 | }, 13 | ) 14 | return { data } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/features/auth/api/fetchUser.ts: -------------------------------------------------------------------------------- 1 | import { api } from '~lib/api' 2 | import type { WhoAmIResponseDto } from '~shared/types/auth.dto' 3 | 4 | /** 5 | * Fetches the user from the server using the current session cookie. 6 | * 7 | * @returns the logged in user if session is valid, will throw 401 error if not. 8 | */ 9 | export const fetchUser = async () => { 10 | return api.url('/auth/whoami').get().json() 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/lib/api.tsx: -------------------------------------------------------------------------------- 1 | import wretch from 'wretch' 2 | 3 | import { UNAUTHORIZED_EVENT } from '~constants/events' 4 | 5 | /** 6 | * Default API client pointing to backend. 7 | * Automatically catches 403 errors and invalidates authentication state. 8 | */ 9 | export const api = wretch('/api/v1') 10 | .catcher(403, (err) => { 11 | window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)) 12 | throw err 13 | }) 14 | .errorType('json') 15 | -------------------------------------------------------------------------------- /frontend/src/hooks/useIsDesktop.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery, UseMediaQueryOptions, useTheme } from '@chakra-ui/react' 2 | import { get } from '@chakra-ui/utils' 3 | 4 | export const useIsDesktop = (opts?: UseMediaQueryOptions): boolean => { 5 | const theme = useTheme() 6 | const lgBreakpoint = String(get(theme, 'breakpoints.lg', '64em')) 7 | const [isAtLeastLg] = useMediaQuery(`(min-width: ${lgBreakpoint})`, opts) 8 | 9 | return isAtLeastLg 10 | } 11 | -------------------------------------------------------------------------------- /shared/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | roots: ['/src'], 7 | testPathIgnorePatterns: ['/build/', '/node_modules/'], 8 | collectCoverageFrom: ['/src/**/*.{ts,js}'], 9 | coveragePathIgnorePatterns: ['/build', '/node_modules'], 10 | } 11 | 12 | export default config 13 | -------------------------------------------------------------------------------- /appspec.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "Resources": [ 4 | { 5 | "TargetService": { 6 | "Type": "AWS::ECS::Service", 7 | "Properties": { 8 | "TaskDefinition": "arn:aws:ecs:ap-southeast-1::task-definition/application-server:1", 9 | "LoadBalancerInfo": { 10 | "ContainerName": "app-server", 11 | "ContainerPort": 8080 12 | } 13 | } 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | storybook-static 26 | build-storybook.log 27 | -------------------------------------------------------------------------------- /backend/src/database/datasource.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { DataSource, DataSourceOptions } from 'typeorm' 3 | 4 | import { base } from './ormconfig' 5 | 6 | export const config: DataSourceOptions = { 7 | ...base, 8 | migrations: [join(__dirname, 'migrations', '*{.js,.ts}')], 9 | } 10 | 11 | // For CLI migrations. 12 | // TypeORM Module instantiates its own datasource which should be injected as necessary. 13 | export const appDataSource = new DataSource(config) 14 | -------------------------------------------------------------------------------- /frontend/src/templates/AppGrid.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, GridProps } from '@chakra-ui/react' 2 | 3 | /** 4 | * Component that controls the various grid areas according to the app's 5 | * responsive breakpoints. 6 | */ 7 | export const AppGrid = (props: GridProps) => ( 8 | 14 | ) 15 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | storybook-static 26 | build-storybook.log 27 | 28 | .storybook/preview-head.html 29 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation for ts-template 2 | 3 | This contains documentation for ts-template. It may also be used to 4 | hold documentation for the application, including design documents, 5 | though the established practice is to have those in Notion. 6 | 7 | ## In this section 8 | 9 | - [Deploying](./deploying/): Learn more about the processes needed to 10 | launch your product on a platform for your audience to use 11 | - [Tooling](./tooling.md): The components that are used to build and run 12 | this application 13 | -------------------------------------------------------------------------------- /frontend/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { StorybookTheme } from './themes' 2 | import { ChakraProvider } from '@chakra-ui/react' 3 | 4 | export const parameters = { 5 | actions: { argTypesRegex: '^on[A-Z].*' }, 6 | docs: { 7 | theme: StorybookTheme.docs, 8 | inlineStories: true, 9 | }, 10 | controls: { 11 | matchers: { 12 | color: /(background|color)$/i, 13 | date: /Date$/, 14 | }, 15 | }, 16 | } 17 | 18 | export const decorators = [ 19 | (storyFn) => {storyFn()}, 20 | ] 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['opengovsg/javascript'], 3 | ignorePatterns: ['coverage', 'build', 'node_modules', 'jest.config.ts'], 4 | root: true, 5 | overrides: [ 6 | { 7 | files: ['*.ts'], 8 | extends: ['opengovsg'], 9 | }, 10 | { 11 | files: ['frontend/**/*.ts', '*.tsx'], 12 | extends: ['opengovsg', 'opengovsg/react'], 13 | }, 14 | { 15 | files: ['frontend/**/*.js', '*.jsx'], 16 | extends: ['opengovsg/javascript', 'opengovsg/react'], 17 | }, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/database/entities/session.entity.ts: -------------------------------------------------------------------------------- 1 | import { ISession } from 'connect-typeorm' 2 | import { Column, DeleteDateColumn, Entity, Index, PrimaryColumn } from 'typeorm' 3 | 4 | @Entity({ name: 'sessions' }) 5 | export class Session implements ISession { 6 | @PrimaryColumn('varchar', { length: 255 }) 7 | id!: string 8 | 9 | @Index('sessions_expiredAt_idx') 10 | @Column('bigint') 11 | expiredAt = Date.now() 12 | 13 | @Column('text', { default: '' }) 14 | json!: string 15 | 16 | @DeleteDateColumn() 17 | destroyedAt?: Date 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | 7 | // Handle TypeError: env.window.matchMedia is not a function 8 | window.matchMedia = 9 | window.matchMedia || 10 | function () { 11 | return { 12 | matches: false, 13 | addListener: () => null, 14 | removeListener: () => null, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | setupFiles: ['/jest-dotenv.config.js'], 7 | testPathIgnorePatterns: ['/build/', '/node_modules/'], 8 | collectCoverageFrom: ['/src/**/*.{ts,js}'], 9 | coveragePathIgnorePatterns: ['/build', '/node_modules'], 10 | moduleNameMapper: { 11 | '~shared/(.*)': '/../shared/src/$1', 12 | }, 13 | } 14 | 15 | export default config 16 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | import App from './app' 5 | import * as serviceWorker from './serviceWorker' 6 | 7 | const root = createRoot(document.getElementById('root') as HTMLElement) 8 | root.render( 9 | 10 | 11 | , 12 | ) 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister() 18 | -------------------------------------------------------------------------------- /frontend/src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react' 2 | // Importing from main so @chakra-cli can work properly without complaining about ESM. 3 | import { theme as baseTheme } from '@opengovsg/design-system-react/build/main/theme/theme' 4 | 5 | import { components } from './components' 6 | 7 | /** 8 | * Design system themes can be found at 9 | * https://github.com/opengovsg/design-system/tree/main/token-gen/themes. 10 | * README for importing themes can be found at 11 | * https://github.com/opengovsg/design-system/tree/main/token-gen. 12 | */ 13 | export const theme = extendTheme(baseTheme, { 14 | components, 15 | }) 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "~shared/*": ["../shared/src/*"], 6 | "~contexts/*": ["./src/contexts/*"], 7 | "~constants/*": ["./src/constants/*"], 8 | "~components/*": ["./src/components/*"], 9 | "~templates/*": ["./src/templates/*"], 10 | "~lib/*": ["./src/lib/*"], 11 | "~features/*": ["./src/features/*"], 12 | "~hooks/*": ["./src/hooks/*"], 13 | "~utils/*": ["./src/utils/*"], 14 | "~pages/*": ["./src/pages/*"], 15 | "~services/*": ["./src/services/*"], 16 | "~/*": ["./src/*"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /backend/src/tracing/trace-id.provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { randomUUID } from 'crypto' 3 | import { IncomingMessage } from 'http' 4 | 5 | @Injectable() 6 | export class TraceIdProvider { 7 | getTraceId(req: IncomingMessage): string { 8 | const extractHeader = (headerName: string) => { 9 | const header = req.headers[headerName.toLowerCase()] 10 | return ([] as string[]).concat(header ?? '').join('') 11 | } 12 | return ( 13 | extractHeader('x-datadog-trace-id') || 14 | extractHeader('x-amzn-trace-id') || 15 | extractHeader('x-request-id') || 16 | randomUUID() 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres:alpine 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | - POSTGRES_DB=dev 10 | ports: 11 | - '5432:5432' 12 | volumes: 13 | - pg-data:/var/lib/postgresql/data 14 | healthcheck: 15 | test: ['CMD-SHELL', 'pg_isready -U postgres'] 16 | interval: 5s 17 | timeout: 5s 18 | retries: 5 19 | maildev: 20 | image: maildev/maildev 21 | ports: 22 | - '1080:80' 23 | - '1025:25' 24 | command: bin/maildev --web 80 --smtp 25 --log-mail-contents 25 | volumes: 26 | pg-data: 27 | -------------------------------------------------------------------------------- /frontend/src/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export const useInterval = ( 4 | callback: () => void, 5 | delay: number | null, 6 | ): void => { 7 | const savedCallback = useRef(callback) 8 | 9 | // Remember the latest callback if it changes. 10 | useEffect(() => { 11 | savedCallback.current = callback 12 | }, [callback]) 13 | 14 | // Set up the interval. 15 | useEffect(() => { 16 | // Don't schedule if no delay is specified. 17 | if (delay === null) { 18 | return 19 | } 20 | 21 | const id = setInterval(() => savedCallback.current(), delay) 22 | 23 | return () => clearInterval(id) 24 | }, [delay]) 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /frontend/src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import 'inter-ui/inter.css' 2 | import '@fontsource/ibm-plex-mono' 3 | 4 | import { ThemeProvider } from '@opengovsg/design-system-react' 5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 6 | 7 | import { theme } from '~/theme' 8 | import { AuthProvider } from '~lib/auth' 9 | 10 | import { AppRouter } from './AppRouter' 11 | 12 | export const queryClient = new QueryClient() 13 | 14 | export const App = (): JSX.Element => ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | -------------------------------------------------------------------------------- /shared/src/types/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsGovSgEmail } from '../decorators/is-gov-sg-email' 2 | export class GenerateOtpDto { 3 | @IsGovSgEmail({ 4 | message: 'This does not appear to be a gov.sg email address', 5 | }) 6 | email: string 7 | } 8 | 9 | export interface VerifyOtpDto { 10 | token: string 11 | email: string 12 | } 13 | 14 | export type SendLoginOtpRequestDto = GenerateOtpDto 15 | 16 | export interface SendLoginOtpResponseDto { 17 | message: string 18 | } 19 | 20 | export type VerifyOtpRequestDto = VerifyOtpDto 21 | 22 | export interface VerifyOtpResponseDto { 23 | message: string 24 | } 25 | 26 | export interface WhoAmIResponseDto { 27 | id: number 28 | email: string 29 | } 30 | -------------------------------------------------------------------------------- /frontend/.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | features: { 3 | emotionAlias: false, 4 | storyStoreV7: true, 5 | previewMdx2: true, 6 | }, 7 | stories: [ 8 | './introduction/Welcome/Welcome.stories.tsx', 9 | '../src/**/*.stories.@(js|jsx|ts|tsx|mdx)', 10 | ], 11 | addons: [ 12 | '@storybook/addon-links', 13 | '@storybook/addon-essentials', 14 | '@storybook/addon-interactions', 15 | '@storybook/preset-create-react-app', 16 | ], 17 | framework: '@storybook/react', 18 | core: { 19 | builder: '@storybook/builder-webpack5', 20 | disableTelemetry: true, 21 | }, 22 | refs: { 23 | '@chakra-ui/react': { 24 | disable: true, 25 | }, 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app/AppFooter.tsx: -------------------------------------------------------------------------------- 1 | import { ResponsiveValue } from '@chakra-ui/react' 2 | import { RestrictedFooter } from '@opengovsg/design-system-react' 3 | 4 | // TODO: Extend from RestrictedFooterProps instead when they are exported by the package in the future. 5 | interface AppFooterProps { 6 | variant?: ResponsiveValue<'full' | 'compact'> 7 | colorMode?: 'light' | 'dark' 8 | } 9 | 10 | export const AppFooter = (props: AppFooterProps): JSX.Element => { 11 | return ( 12 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { TypeOrmModule } from '@nestjs/typeorm' 3 | 4 | import { ConfigModule } from '../config/config.module' 5 | import { Session, User } from '../database/entities' 6 | import { MailerModule } from '../mailer/mailer.module' 7 | import { OtpModule } from '../otp/otp.module' 8 | import { AuthController } from './auth.controller' 9 | import { AuthService } from './auth.service' 10 | 11 | @Module({ 12 | imports: [ 13 | ConfigModule, 14 | OtpModule, 15 | MailerModule, 16 | TypeOrmModule.forFeature([User, Session]), 17 | ], 18 | controllers: [AuthController], 19 | providers: [AuthService], 20 | }) 21 | export class AuthModule {} 22 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | // Initialize Datadog tracer 2 | import 'tracing' 3 | 4 | import { NestFactory } from '@nestjs/core' 5 | import { NestExpressApplication } from '@nestjs/platform-express' 6 | import { ConfigService } from 'config/config.service' 7 | import { Logger } from 'nestjs-pino' 8 | 9 | import { AppModule } from './app.module' 10 | 11 | async function bootstrap() { 12 | const app = await NestFactory.create(AppModule, { 13 | bufferLogs: true, 14 | }) 15 | app.useLogger(app.get(Logger)) 16 | 17 | const config = app.get(ConfigService) 18 | if (!config.isDevEnv) { 19 | app.set('trust proxy', 1) 20 | } 21 | 22 | await app.listen(config.get('port')) 23 | } 24 | 25 | void bootstrap() 26 | -------------------------------------------------------------------------------- /shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "composite": true, 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "strict": true, 10 | "strictPropertyInitialization": false, 11 | "allowSyntheticDefaultImports": true, 12 | "target": "es2017", 13 | "sourceMap": true, 14 | "outDir": "./build", 15 | "rootDir": "./src", 16 | "incremental": true, 17 | "skipLibCheck": true, 18 | "esModuleInterop": true, 19 | "noImplicitAny": false, 20 | "strictBindCallApply": false, 21 | "forceConsistentCasingInFileNames": true 22 | }, 23 | "include": ["src/**/*"] 24 | } 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS node-modules-builder 2 | LABEL maintainer="Open Government Products" 3 | 4 | ARG ENV=production 5 | WORKDIR /usr/src/app 6 | 7 | COPY . ./ 8 | RUN npm ci 9 | RUN ENV=$ENV REACT_APP_ENV=$ENV npm run build \ 10 | && npm prune --production 11 | 12 | FROM node:lts-alpine 13 | WORKDIR /usr/src/app 14 | 15 | COPY --from=node-modules-builder /usr/src/app/backend ./backend 16 | COPY --from=node-modules-builder /usr/src/app/frontend/build ./frontend/build 17 | COPY --from=node-modules-builder /usr/src/app/shared ./shared 18 | COPY --from=node-modules-builder /usr/src/app/node_modules ./node_modules 19 | COPY --from=node-modules-builder /usr/src/app/package.json ./ 20 | 21 | EXPOSE 8080 22 | CMD ["npm", "run", "start"] 23 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | ts-template App 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /frontend/.storybook/introduction/Welcome/Welcome.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Heading, Stack, Text } from '@chakra-ui/react' 2 | 3 | export const Welcome = (): JSX.Element => { 4 | return ( 5 | 6 | 7 | 8 | 14 | Welcome To Storybook 🚢 15 | 16 | 17 | Storybook helps us build and showcase UI components in isolation. 18 | 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/config/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import convict, { Config, Path } from 'convict' 3 | 4 | import { ConfigSchema, schema } from './config.schema' 5 | 6 | @Injectable() 7 | export class ConfigService { 8 | config: Config 9 | 10 | constructor() { 11 | this.config = convict(schema) 12 | this.config.validate() 13 | } 14 | 15 | // We want to implicitly use the return type of convict get method. 16 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 17 | get>(key: K) { 18 | return this.config.get(key) 19 | } 20 | 21 | get isDevEnv(): boolean { 22 | return this.config.get('environment') === 'development' 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/decorators/is-gov-sg-email.ts: -------------------------------------------------------------------------------- 1 | import { isEmail, registerDecorator, ValidationOptions } from 'class-validator' 2 | 3 | export const isGovSgEmail = (value: unknown) => { 4 | return ( 5 | typeof value === 'string' && 6 | isEmail(value) && 7 | value.toString().endsWith('.gov.sg') 8 | ) 9 | } 10 | 11 | export const IsGovSgEmail = (options?: ValidationOptions) => { 12 | // eslint-disable-next-line @typescript-eslint/ban-types 13 | return (object: Object, propertyName: string) => { 14 | registerDecorator({ 15 | name: 'isGovSgEmail', 16 | target: object.constructor, 17 | propertyName, 18 | options, 19 | validator: { 20 | validate: (value: unknown) => isGovSgEmail(value), 21 | }, 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/health/__tests__/health.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { TerminusModule } from '@nestjs/terminus' 2 | import { Test, TestingModule } from '@nestjs/testing' 3 | 4 | import { ConfigModule } from '../../config/config.module' 5 | import { HealthController } from '../health.controller' 6 | 7 | describe('HealthController', () => { 8 | let controller: HealthController 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | imports: [ConfigModule, TerminusModule], 13 | controllers: [HealthController], 14 | }).compile() 15 | 16 | controller = module.get(HealthController) 17 | }) 18 | 19 | it('should be defined', () => { 20 | expect(controller).toBeDefined() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /.github/mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: Approve and merge non-major version dependabot upgrades 3 | conditions: 4 | - author~=^dependabot(|-preview)\[bot\]$ 5 | - check-success~=Lint 6 | - check-success~=Test 7 | - check-success~=Build Docker 8 | - title~=^build\(deps[^)]*\). bump [^\s]+ from ([\d]+)\..+ to \1\. 9 | actions: 10 | review: 11 | type: APPROVE 12 | merge: 13 | method: squash 14 | 15 | - name: Approve and merge Snyk.io upgrades 16 | conditions: 17 | - author=snyk-bot 18 | - check-success~=Lint 19 | - check-success~=Test 20 | - check-success~=Build Docker 21 | - title~=^\[Snyk\] 22 | actions: 23 | review: 24 | type: APPROVE 25 | merge: 26 | method: squash 27 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.paths.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "experimentalDecorators": true, 20 | "strictPropertyInitialization": false 21 | }, 22 | "include": ["src/**/*", ".storybook/**/*", "__mocks__/**/*"], 23 | "exclude": ["**/jest.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/utils/lazyImport.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react' 2 | 3 | /** 4 | * Creates named imports for `React.lazy` 5 | * 6 | * @example 7 | * const { Home } = lazyImport(() => import('./Home'), 'Home') 8 | * @param factory 9 | * a function that, when invoked, will import the desired package 10 | * @param name 11 | * the name of the import 12 | * @returns the desired import, with lazy semantics 13 | * @see https://github.com/facebook/react/issues/14603#issuecomment-726551598 14 | */ 15 | export function lazyImport< 16 | T extends React.ComponentType, 17 | I extends { [K2 in K]: T }, 18 | K extends keyof I, 19 | >(factory: () => Promise, name: K): I { 20 | return Object.create({ 21 | [name]: lazy(() => factory().then((module) => ({ default: module[name] }))), 22 | }) as I 23 | } 24 | -------------------------------------------------------------------------------- /frontend/src/app/PrivateElement.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, NavigateProps, useLocation } from 'react-router-dom' 2 | 3 | import { routes } from '~constants/routes' 4 | import { useAuth } from '~lib/auth' 5 | 6 | interface PrivateElementProps { 7 | /** 8 | * Route to redirect to when user is not authenticated. Defaults to 9 | * `LOGIN_ROUTE` if not provided. 10 | */ 11 | redirectTo?: NavigateProps['to'] 12 | element: React.ReactElement 13 | } 14 | 15 | export const PrivateElement = ({ 16 | element, 17 | redirectTo = routes.login, 18 | }: PrivateElementProps): React.ReactElement => { 19 | const location = useLocation() 20 | 21 | const { isAuthenticated } = useAuth() 22 | 23 | if (isAuthenticated) { 24 | return element 25 | } 26 | 27 | return 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/database/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | Index, 7 | PrimaryGeneratedColumn, 8 | UpdateDateColumn, 9 | } from 'typeorm' 10 | 11 | import { IsGovSgEmail } from '~shared/decorators/is-gov-sg-email' 12 | 13 | @Entity({ name: 'users' }) 14 | export class User { 15 | @PrimaryGeneratedColumn() 16 | id: number 17 | 18 | @Column('varchar', { length: 255 }) 19 | @Index('user_email_idx', { 20 | unique: true, 21 | where: '"deletedAt" IS NULL', 22 | }) 23 | @IsGovSgEmail() 24 | email: string 25 | 26 | @CreateDateColumn({ type: 'timestamptz' }) 27 | createdAt: Date 28 | 29 | @UpdateDateColumn({ type: 'timestamptz' }) 30 | updatedAt: Date 31 | 32 | @DeleteDateColumn({ type: 'timestamptz' }) 33 | deletedAt: Date | null 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/api.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { RouterModule } from '@nestjs/core' 3 | import { TerminusModule } from '@nestjs/terminus' 4 | 5 | import { AuthModule } from './auth/auth.module' 6 | import { HealthModule } from './health/health.module' 7 | import { MailerModule } from './mailer/mailer.module' 8 | import { OtpModule } from './otp/otp.module' 9 | 10 | const apiModules = [ 11 | AuthModule, 12 | TerminusModule, 13 | HealthModule, 14 | OtpModule, 15 | MailerModule, 16 | ] 17 | 18 | @Module({ 19 | imports: [ 20 | ...apiModules, 21 | RouterModule.register([ 22 | { 23 | path: 'api', 24 | children: [ 25 | { 26 | path: 'v1', 27 | children: apiModules, 28 | }, 29 | ], 30 | }, 31 | ]), 32 | ], 33 | }) 34 | export class ApiModule {} 35 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "exclude": [ 6 | "**/jest.config.ts" 7 | ], 8 | "compilerOptions": { 9 | "strict": true, 10 | "strictPropertyInitialization": false, 11 | "esModuleInterop": true, 12 | "module": "commonjs", 13 | "declaration": true, 14 | "removeComments": true, 15 | "emitDecoratorMetadata": true, 16 | "target": "es6", 17 | "sourceMap": true, 18 | "outDir": "./build", 19 | "baseUrl": "./src", 20 | "incremental": true, 21 | "experimentalDecorators": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "paths": { 25 | "~shared/*": [ 26 | "../../shared/build/*" 27 | ] 28 | } 29 | }, 30 | "ts-node": { 31 | "require": [ 32 | "tsconfig-paths/register" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file created by Open Government Products (https://open.gov.sg) 2 | 3 | app = "" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [env] 9 | 10 | [experimental] 11 | allowed_public_ports = [] 12 | auto_rollback = true 13 | 14 | [[services]] 15 | http_checks = [] 16 | internal_port = 8080 17 | processes = ["app"] 18 | protocol = "tcp" 19 | script_checks = [] 20 | [services.concurrency] 21 | hard_limit = 25 22 | soft_limit = 20 23 | type = "connections" 24 | 25 | [[services.ports]] 26 | force_https = true 27 | handlers = ["http"] 28 | port = 80 29 | 30 | [[services.ports]] 31 | handlers = ["tls", "http"] 32 | port = 443 33 | 34 | [[services.tcp_checks]] 35 | grace_period = "1s" 36 | interval = "15s" 37 | restart_limit = 0 38 | timeout = "2s" 39 | -------------------------------------------------------------------------------- /Dockerfile.fly: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS node-modules-builder 2 | LABEL maintainer="Open Government Products" 3 | 4 | ARG ENV=production 5 | WORKDIR /usr/src/app 6 | 7 | COPY . ./ 8 | RUN npm --ignore-scripts ci 9 | RUN npm --prefix backend --ignore-scripts ci 10 | RUN npm --prefix frontend --ignore-scripts ci 11 | RUN npm --prefix shared --ignore-scripts ci 12 | RUN ENV=$ENV REACT_APP_ENV=$ENV npm run build \ 13 | && npm prune --production 14 | 15 | FROM node:lts-alpine 16 | WORKDIR /usr/src/app 17 | 18 | COPY --from=node-modules-builder /usr/src/app/backend ./backend 19 | COPY --from=node-modules-builder /usr/src/app/frontend/build ./frontend/build 20 | COPY --from=node-modules-builder /usr/src/app/shared ./shared 21 | COPY --from=node-modules-builder /usr/src/app/node_modules ./node_modules 22 | COPY --from=node-modules-builder /usr/src/app/package.json ./ 23 | 24 | EXPOSE 8080 25 | CMD ["npm", "run", "start"] 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "npm" # See documentation for possible values 13 | directory: "/frontend" # Location of package manifests 14 | schedule: 15 | interval: "daily" 16 | - package-ecosystem: "npm" # See documentation for possible values 17 | directory: "/backend" # Location of package manifests 18 | schedule: 19 | interval: "daily" 20 | -------------------------------------------------------------------------------- /frontend/.storybook/themes.ts: -------------------------------------------------------------------------------- 1 | import { create } from '@storybook/theming' 2 | 3 | import PackageInfo from '../package.json' 4 | 5 | export const StorybookTheme = { 6 | manager: create({ 7 | base: 'light', 8 | brandTitle: `ts-template v${PackageInfo.version}`, 9 | // UI 10 | appBg: '#f6f7fc', // primary.100, 11 | appBorderColor: '#DADCE3', 12 | appBorderRadius: 0, 13 | // Typography 14 | fontBase: '"Inter", san-serif', 15 | // Text colours 16 | textColor: '#445072', // secondary.500, 17 | textInverseColor: '#445072', // secondary.500, 18 | colorPrimary: '#4A61C0', // primary.500, 19 | colorSecondary: '#4A61C0', // primary.500, 20 | 21 | // Toolbar default and active colors 22 | barTextColor: '#445072', // secondary.500,, 23 | barSelectedColor: '#4A61C0', // primary.500, 24 | }), 25 | docs: create({ 26 | base: 'light', 27 | fontBase: '"Inter", san-serif', 28 | }), 29 | } 30 | -------------------------------------------------------------------------------- /docs/deploying/README.md: -------------------------------------------------------------------------------- 1 | # Deploying Your Application 2 | 3 | This section of the documentation contains guides to deploy your 4 | application to a production environment, ready to receive users. 5 | 6 | - [For Everyone](./for-everyone.md): use DigitalOcean. Read this 7 | if you do not have an engineering background, have no engineers 8 | on your team, and have some time and access to a credit card. 9 | 10 | - [For Engineers](./for-engineers.md): use AWS Elastic Container 11 | Service. Read this if you are an engineer (or if you are bold!). 12 | 13 | - [For Hustlers](./for-hustlers.md): use fly.io. Read this if you want to 14 | launch without any engineering, time or financial resources. 15 | 16 | ## Post-deployment 17 | 18 | - [Going Public](./going-public.md): deploying to a cloud platform is 19 | just the beginning. Read this to know of the other things one has to 20 | do so that a product can be made available to the public. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /shared/src/decorators/__tests__/is-gov-sg-email.spec.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'class-validator' 2 | 3 | import { IsGovSgEmail } from '../is-gov-sg-email' 4 | 5 | describe('IsGovSgEmail', () => { 6 | class TestClass { 7 | @IsGovSgEmail() 8 | email: unknown 9 | } 10 | 11 | it('rejects non-strings', async () => { 12 | const test = new TestClass() 13 | test.email = 2 14 | const result = await validate(test) 15 | expect(result.length).toBe(1) 16 | }) 17 | 18 | it('rejects bad emails', async () => { 19 | const test = new TestClass() 20 | test.email = 'bad@gmail.com,victim@open.gov.sg' 21 | const result = await validate(test) 22 | expect(result.length).toBe(1) 23 | }) 24 | 25 | it('rejects non-gov.sg emails', async () => { 26 | const test = new TestClass() 27 | test.email = 'bad@gmail.com' 28 | const result = await validate(test) 29 | expect(result.length).toBe(1) 30 | }) 31 | 32 | it('accepts gov.sg emails', async () => { 33 | const test = new TestClass() 34 | test.email = 'team@open.gov.sg' 35 | const result = await validate(test) 36 | expect(result).toStrictEqual([]) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /frontend/src/features/auth/components/ResendOtpButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@chakra-ui/react' 2 | import { useMutation } from '@tanstack/react-query' 3 | import { useState } from 'react' 4 | 5 | import { useInterval } from '../../../hooks/useInterval' 6 | 7 | export interface ResendOtpButtonProps { 8 | onResendOtp: () => Promise 9 | } 10 | 11 | export const ResendOtpButton = ({ 12 | onResendOtp, 13 | }: ResendOtpButtonProps): JSX.Element => { 14 | // The counter 15 | const [timer, setTimer] = useState(0) 16 | 17 | const resendOtpMutation = useMutation(onResendOtp, { 18 | // On success, restart the timer before this can be called again. 19 | onSuccess: () => setTimer(60), 20 | }) 21 | 22 | useInterval( 23 | () => setTimer(timer - 1), 24 | // Stop interval if timer hits 0. 25 | timer <= 0 ? null : 1000, 26 | ) 27 | 28 | return ( 29 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/otp/otp.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { totp as totpFactory } from 'otplib' 3 | 4 | import { ConfigService } from '../config/config.service' 5 | 6 | const NUM_MINUTES_IN_AN_HOUR = 60 7 | 8 | @Injectable() 9 | export class OtpService { 10 | constructor(private config: ConfigService) {} 11 | 12 | private totp = totpFactory.clone({ 13 | step: this.config.get('otp.expiry'), 14 | window: [ 15 | this.config.get('otp.numValidPastWindows'), 16 | this.config.get('otp.numValidFutureWindows'), 17 | ], 18 | }) 19 | 20 | private concatSecretWithEmail(email: string): string { 21 | return this.config.get('otp.secret') + email.toLowerCase() 22 | } 23 | 24 | generateOtp(email: string): { token: string; timeLeft: number } { 25 | const token = this.totp.generate(this.concatSecretWithEmail(email)) 26 | const timeLeft = this.totp.options.step 27 | ? Math.floor(this.totp.options.step / NUM_MINUTES_IN_AN_HOUR) // Round down to minutes 28 | : NaN 29 | return { token, timeLeft } 30 | } 31 | 32 | verifyOtp(email: string, token: string): boolean { 33 | return this.totp.verify({ 34 | secret: this.concatSecretWithEmail(email), 35 | token, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/app/PublicElement.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from '@chakra-ui/react' 2 | import { RestrictedGovtMasthead } from '@opengovsg/design-system-react' 3 | import { Navigate, useLocation } from 'react-router-dom' 4 | 5 | import { routes } from '~constants/routes' 6 | import { useAuth } from '~lib/auth' 7 | 8 | interface PublicElementProps { 9 | /** 10 | * If `strict` is true, only non-authed users can access the route. 11 | * i.e. signin page, where authed users accessing that page should be 12 | * redirected out. 13 | * If `strict` is false, then both authed and non-authed users can access 14 | * the route. 15 | * Defaults to `false`. 16 | */ 17 | strict?: boolean 18 | element: React.ReactElement 19 | } 20 | 21 | export const PublicElement = ({ 22 | element, 23 | strict, 24 | }: PublicElementProps): React.ReactElement => { 25 | const location = useLocation() 26 | const state = location.state as { from: Location } | undefined 27 | 28 | const { isAuthenticated } = useAuth() 29 | 30 | if (isAuthenticated && strict) { 31 | return 32 | } 33 | 34 | return ( 35 | 36 | 37 | {element} 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common' 2 | import { 3 | HealthCheck, 4 | HealthCheckService, 5 | MemoryHealthIndicator, 6 | TypeOrmHealthIndicator, 7 | } from '@nestjs/terminus' 8 | 9 | import { HealthDto } from '~shared/types/health.dto' 10 | 11 | import { ConfigService } from '../config/config.service' 12 | 13 | @Controller('health') 14 | export class HealthController { 15 | constructor( 16 | private health: HealthCheckService, 17 | private config: ConfigService, 18 | // Refer to https://github.com/nestjs/terminus/blob/master/sample/ for 19 | // examples of how to add other services/databases to healthcheck. 20 | private db: TypeOrmHealthIndicator, 21 | private memory: MemoryHealthIndicator, 22 | ) {} 23 | 24 | @Get() 25 | @HealthCheck() 26 | async check(): Promise { 27 | return this.health.check([ 28 | async () => this.db.pingCheck('database'), 29 | async () => 30 | this.memory.checkHeap( 31 | 'memory_heap', 32 | this.config.get('health.heapSizeThreshold'), 33 | ), 34 | async () => 35 | this.memory.checkRSS( 36 | 'memory_rss', 37 | this.config.get('health.rssThreshold'), 38 | ), 39 | ]) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/core/providers/logged-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | 3 | import { BadRequestException, Injectable, ValidationPipe } from '@nestjs/common' 4 | import { ValidationError } from 'class-validator' 5 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino' 6 | 7 | @Injectable() 8 | export class LoggedValidationPipe extends ValidationPipe { 9 | constructor( 10 | @InjectPinoLogger(ValidationPipe.name) 11 | private readonly logger: PinoLogger, 12 | ) { 13 | super({ 14 | whitelist: true, 15 | transform: true, 16 | exceptionFactory: (errors: ValidationError[]) => { 17 | errors = this.flattenErrors(errors).filter( 18 | (errors) => !!errors.constraints, 19 | ) 20 | this.logger.info(JSON.stringify(errors)) 21 | const allErrors = errors 22 | .flatMap((e) => Object.values(e.constraints ?? {})) 23 | .join('\n') 24 | return new BadRequestException(allErrors) 25 | }, 26 | }) 27 | } 28 | 29 | private flattenErrors( 30 | errors: ValidationError[], 31 | ): Omit[] { 32 | const result = errors.flatMap(({ children, ...error }) => { 33 | return [error].concat(this.flattenErrors(children || [])) 34 | }) 35 | return result 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Context 2 | 3 | _Why does this PR exist? What problem are you trying to solve? What issue does this close?_ 4 | 5 | Closes [insert issue #] 6 | 7 | ## Approach 8 | 9 | _How did you solve the problem?_ 10 | 11 | **Features**: 12 | 13 | - Details ... 14 | 15 | **Improvements**: 16 | 17 | - Details ... 18 | 19 | **Bug Fixes**: 20 | 21 | - Details ... 22 | 23 | ## Before & After Screenshots 24 | 25 | **BEFORE**: 26 | [insert screenshot here] 27 | 28 | **AFTER**: 29 | [insert screenshot here] 30 | 31 | ## Tests 32 | 33 | _What tests should be run to confirm functionality?_ 34 | 35 | ## Deploy Notes 36 | 37 | _Notes regarding deployment of the contained body of work. These should note any 38 | new dependencies, new scripts, etc._ 39 | 40 | **New environment variables**: 41 | 42 | - `env var` : env var details 43 | 44 | **New scripts**: 45 | 46 | - `script` : script details 47 | 48 | **New dependencies**: 49 | 50 | - `dependency` : dependency details 51 | 52 | **New dev dependencies**: 53 | 54 | - `dependency` : dependency details 55 | 56 | ## Risks 57 | 58 | _Describes potential risks with this PR. Consider carefully what could possibly happen in a worse case scenario. If possible, try to define risks as 2 items: **Impact** and **Likelihood** and use your best judgment._ 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | lcov.info 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | 64 | # Typescript build 65 | build 66 | backend/build 67 | frontend/build 68 | 69 | # JetBrains IDEs' settings 70 | .idea 71 | 72 | **/*.tsbuildinfo 73 | -------------------------------------------------------------------------------- /frontend/src/app/AppRouter.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react' 2 | import { PropsWithChildren, Suspense } from 'react' 3 | import { createBrowserRouter, RouterProvider } from 'react-router-dom' 4 | 5 | import { routes } from '~constants/routes' 6 | import { lazyImport } from '~utils/lazyImport' 7 | 8 | const { AuthRoutes } = lazyImport(() => import('~features/auth'), 'AuthRoutes') 9 | const { DashboardRoutes } = lazyImport( 10 | () => import('~features/dashboard'), 11 | 'DashboardRoutes', 12 | ) 13 | const { HealthRoutes } = lazyImport( 14 | () => import('~features/health'), 15 | 'HealthRoutes', 16 | ) 17 | 18 | const router = createBrowserRouter([ 19 | { 20 | path: routes.index, 21 | element: , 22 | }, 23 | { 24 | path: routes.login, 25 | element: , 26 | }, 27 | { 28 | path: routes.health, 29 | element: , 30 | }, 31 | { 32 | path: '*', 33 | element:
404
, 34 | }, 35 | ]) 36 | 37 | const WithSuspense = ({ children }: PropsWithChildren) => ( 38 | }> 39 | {children} 40 | 41 | ) 42 | 43 | export const AppRouter = (): JSX.Element => { 44 | return ( 45 | 46 | 47 | 48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/database/ormconfig.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import convict from 'convict' 4 | import { join } from 'path' 5 | import { DataSource, DataSourceOptions } from 'typeorm' 6 | 7 | import { schema } from '../config/config.schema' 8 | 9 | const config = convict(schema) 10 | 11 | export const base = { 12 | type: 'postgres', 13 | host: config.get('database.host'), 14 | port: config.get('database.port'), 15 | username: config.get('database.username'), 16 | password: config.get('database.password'), 17 | database: config.get('database.name'), 18 | logging: config.get('database.logging'), 19 | // https://docs.nestjs.com/techniques/database#auto-load-entities 20 | // TODO: remove migrations config and migrate schema separately 21 | migrationsRun: true, 22 | migrations: [join(__dirname, 'migrations', '*{.js,.ts}')], 23 | // js for runtime, ts for typeorm cli 24 | entities: [join(__dirname, 'entities', '*.entity{.js,.ts}')], 25 | ...(config.get('database.ca') 26 | ? { ssl: { ca: config.get('database.ca') } } 27 | : {}), 28 | // ref: https://github.com/typeorm/typeorm/issues/3388 to set pool size 29 | extra: { 30 | min: config.get('database.minPool'), 31 | max: config.get('database.maxPool'), 32 | }, 33 | } as DataSourceOptions 34 | 35 | const dataSource = new DataSource(base) 36 | export default dataSource 37 | -------------------------------------------------------------------------------- /shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "pre-commit": "lint-staged", 7 | "lint": "eslint .", 8 | "lint:fix": "eslint --fix .", 9 | "build": "tsc --build --clean && tsc --build tsconfig.build.json --force", 10 | "test": "jest", 11 | "test:watch": "jest --watch --runInBand", 12 | "test:cov": "jest --coverage --runInBand", 13 | "dev": "tsc-watch" 14 | }, 15 | "devDependencies": { 16 | "@golevelup/ts-jest": "^0.3.3", 17 | "@pulumi/eslint-plugin": "^0.2.0", 18 | "@types/jest": "^28.1.6", 19 | "@typescript-eslint/eslint-plugin": "^5.62.0", 20 | "eslint": "^8.46.0", 21 | "eslint-config-opengovsg": "^2.0.6", 22 | "eslint-config-prettier": "^8.10.0", 23 | "eslint-import-resolver-typescript": "^3.6.0", 24 | "eslint-plugin-import": "^2.28.0", 25 | "eslint-plugin-prettier": "^4.2.1", 26 | "eslint-plugin-react": "^7.33.1", 27 | "eslint-plugin-react-hooks": "^4.6.0", 28 | "eslint-plugin-simple-import-sort": "^10.0.0", 29 | "jest": "^28.1.3", 30 | "lint-staged": "^13.0.3", 31 | "prettier": "^2.8.8", 32 | "ts-jest": "^28.0.7", 33 | "tsc-watch": "^5.0.3", 34 | "typescript": "^5.4.2" 35 | }, 36 | "lint-staged": { 37 | "**/*.(js|jsx|ts|tsx)": [ 38 | "eslint --fix" 39 | ] 40 | }, 41 | "dependencies": { 42 | "class-validator": "^0.14.0", 43 | "ts-node": "^10.9.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/middlewares/helmet.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common' 2 | import { ConfigService } from 'config/config.service' 3 | import { NextFunction, Request, RequestHandler, Response } from 'express' 4 | import helmet from 'helmet' 5 | 6 | @Injectable() 7 | export class HelmetMiddleware implements NestMiddleware { 8 | private middleware: RequestHandler 9 | 10 | constructor(private config: ConfigService) { 11 | this.middleware = helmet({ 12 | contentSecurityPolicy: { 13 | directives: { 14 | defaultSrc: ["'self'"], 15 | baseUri: ["'self'"], 16 | blockAllMixedContent: [], 17 | connectSrc: ["'self'"], 18 | workerSrc: [], 19 | // for google fonts 20 | fontSrc: ["'self'", 'https://fonts.gstatic.com'], 21 | frameSrc: [], 22 | frameAncestors: ["'none'"], 23 | imgSrc: ["'self'", 'data:'], 24 | objectSrc: ["'none'"], 25 | // for google fonts 26 | styleSrc: [ 27 | "'self'", 28 | "'unsafe-inline'", 29 | 'https://fonts.googleapis.com', 30 | ], 31 | scriptSrcAttr: ["'none'"], 32 | scriptSrc: ["'self'"], 33 | upgradeInsecureRequests: config.isDevEnv ? null : [], 34 | }, 35 | }, 36 | }) 37 | } 38 | 39 | use(req: Request, res: Response, next: NextFunction): void { 40 | this.middleware(req, res, next) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/database/database-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm' 3 | import { Client } from 'pg' 4 | 5 | import { ConfigService } from '../config/config.service' 6 | import { base } from './ormconfig' 7 | 8 | @Injectable() 9 | export class DatabaseConfigService implements TypeOrmOptionsFactory { 10 | constructor(private readonly config: ConfigService) {} 11 | 12 | private async createDatabase() { 13 | const client = new Client({ 14 | host: this.config.get('database.host'), 15 | port: this.config.get('database.port'), 16 | user: this.config.get('database.username'), 17 | password: this.config.get('database.password'), 18 | database: this.config.get('database.name'), 19 | ...(this.config.get('database.ca') 20 | ? { ssl: { ca: this.config.get('database.ca') } } 21 | : {}), 22 | }) 23 | await client.connect() 24 | 25 | const res = await client.query( 26 | 'SELECT 1 FROM pg_database WHERE datname = $1;', 27 | [this.config.get('database.name')], 28 | ) 29 | if (res.rowCount === 0) { 30 | await client.query(`CREATE DATABASE ${this.config.get('database.name')}`) 31 | } 32 | await client.end() 33 | } 34 | 35 | async createTypeOrmOptions(): Promise { 36 | // Create database, remove in production 37 | await this.createDatabase() 38 | 39 | return base 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/auth/__tests__/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { getRepositoryToken } from '@nestjs/typeorm' 3 | import { getLoggerToken } from 'nestjs-pino' 4 | 5 | import { ConfigService } from '../../config/config.service' 6 | import { Session, User } from '../../database/entities' 7 | import { MailerService } from '../../mailer/mailer.service' 8 | import { OtpService } from '../../otp/otp.service' 9 | import { AuthService } from '../auth.service' 10 | 11 | describe('AuthService', () => { 12 | let service: AuthService 13 | const mockModel = {} 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | providers: [ 18 | AuthService, 19 | ConfigService, 20 | OtpService, 21 | MailerService, 22 | { 23 | provide: getRepositoryToken(User), 24 | useValue: mockModel, 25 | }, 26 | { 27 | provide: getRepositoryToken(Session), 28 | useValue: mockModel, 29 | }, 30 | { 31 | provide: getLoggerToken(AuthService.name), 32 | useValue: console, 33 | }, 34 | { 35 | provide: getLoggerToken(MailerService.name), 36 | useValue: console, 37 | }, 38 | ], 39 | }).compile() 40 | 41 | service = module.get(AuthService) 42 | }) 43 | 44 | it('should be defined', () => { 45 | expect(service).toBeDefined() 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /backend/src/database/migrations/1659538032978-initial.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm' 2 | 3 | export class initial1659538032978 implements MigrationInterface { 4 | name = 'initial1659538032978' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TABLE "sessions" ("id" character varying(255) NOT NULL, "expiredAt" bigint NOT NULL, "json" text NOT NULL DEFAULT '', "destroyedAt" timestamp, CONSTRAINT "PK_3238ef96f18b355b671619111bc" PRIMARY KEY ("id"))`, 9 | ) 10 | await queryRunner.query( 11 | `CREATE INDEX "sessions_expiredAt_idx" ON "sessions" ("expiredAt") `, 12 | ) 13 | await queryRunner.query( 14 | `CREATE TABLE "users" ("id" SERIAL NOT NULL, "email" character varying(255) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`, 15 | ) 16 | await queryRunner.query( 17 | `CREATE UNIQUE INDEX "user_email_idx" ON "users" ("email") WHERE "deletedAt" IS NULL`, 18 | ) 19 | } 20 | 21 | public async down(queryRunner: QueryRunner): Promise { 22 | await queryRunner.query(`DROP INDEX "public"."user_email_idx"`) 23 | await queryRunner.query(`DROP TABLE "users"`) 24 | await queryRunner.query(`DROP INDEX "public"."sessions_expiredAt_idx"`) 25 | await queryRunner.query(`DROP TABLE "sessions"`) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/deploying/going-public.md: -------------------------------------------------------------------------------- 1 | # Going Public 2 | 3 | The following guide lists the follow-up work that has to be done 4 | before an application can be considered ready for public release 5 | 6 | ## Sign up for Accounts 7 | 8 | Register for the following services: 9 | 10 | | Name | Purpose | 11 | | --------------------- | --------------------------------- | 12 | | Cloudflare | CDN, DNS | 13 | | GovTech, Vodien et al | Domain name registrar | 14 | | Datadog | Logging and monitoring | 15 | | BetterUptime | Uptime monitoring | 16 | | Google Groups | Contact email for the application | 17 | | Zendesk | Product Operations | 18 | 19 | 20 | ## Monitoring 21 | 22 | - Ensure that the application outputs logs that are 23 | [ndjson-formatted](https://ndjson.org). This makes it very 24 | convenient for Datadog (and other tools) to parse and index 25 | your logs. 26 | ts-template uses pino, which natively emits such logs. 27 | 28 | - Generate an API key for your application from Datadog 29 | 30 | - Inject the following env vars into your application: 31 | - `DD_API_KEY` 32 | - `DD_SOURCE` (typically nodejs or similar) 33 | - `DD_SERVICE` (the name of your application) 34 | - `DD_TAGS` (typically `env:staging` or `env:production`) 35 | 36 | - Generate a client key for Datadog RUM 37 | 38 | - Set up BetterUptime 39 | 40 | ## Anything else 41 | 42 | TODO 43 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # OGP React Starter Kit 2 | 3 | Frontend repository for Open Government Product's Starter Kit template, bootstrapped with `Create React App` (with modifications). 4 | 5 | > 📝: The goal of this template is not to support or transform existing apps, but hopefully serve as a collection of resources and best practices. The repository will (hopefully) provide a good jumping off point and be a solid foundation for starting new projects. 6 | 7 | ## Table Of Contents 8 | 9 | - [Project Configuration](docs/project-config.md) 10 | - [Project Structure](docs/project-structure.md) 11 | - [Components and Styling](docs/components-styling.md) 12 | - [API and Data](docs/api-data.md) 13 | - [State Management](docs/state-management.md) 14 | - [Testing](docs/testing.md) 15 | - [Error Handling](docs/error-handling.md) 16 | - [Security](docs/security.md) 17 | - [Performance](docs/performance.md) 18 | - [Deployment](docs/deployment.md) 19 | 20 | --- 21 | 22 | ## Contributing 23 | 24 | Contributions are always welcome! If you have any ideas, suggestions, fixes, feel free to contribute. You can do that by going through the following steps: 25 | 26 | 1. Clone this repo 27 | 2. Create a branch: `git checkout -b your-feature` 28 | 3. Make some changes 29 | 4. Test your changes 30 | 5. Push your branch and open a Pull Request 31 | 32 | ## License 33 | 34 | [Follows the root repository's license](../LICENSE) 35 | 36 | --- 37 | 38 | Parts of the READMEs are referenced and tweaked from [Bulletproof React](https://github.com/alan2207/bulletproof-react). 39 | -------------------------------------------------------------------------------- /frontend/src/features/dashboard/routes/DashboardPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, ButtonGroup, Flex, HStack, Text, VStack } from '@chakra-ui/react' 2 | import { Button } from '@opengovsg/design-system-react' 3 | import { Link } from 'react-router-dom' 4 | 5 | import { routes } from '~constants/routes' 6 | import { useAuth } from '~lib/auth' 7 | 8 | const Navbar = (): JSX.Element => { 9 | const { logout } = useAuth() 10 | 11 | return ( 12 | 21 | 22 | Starter Kit 23 | 24 | 25 | 26 | {/* eslint-disable-next-line @typescript-eslint/no-misused-promises */} 27 | 28 | 29 | 30 | ) 31 | } 32 | 33 | export const DashboardPage = (): JSX.Element => { 34 | const { user } = useAuth() 35 | 36 | return ( 37 | 38 | 39 | 40 | Welcome {user?.email}. 41 | 42 | YOU ARE NOW AUTHENTICATED. Replace this page with the root page of 43 | your application. 44 | 45 | 46 | 49 | 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/middlewares/session.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common' 2 | import { InjectDataSource } from '@nestjs/typeorm' 3 | import { TypeormStore } from 'connect-typeorm' 4 | import { NextFunction, Request, RequestHandler, Response } from 'express' 5 | import session from 'express-session' 6 | import { DataSource } from 'typeorm' 7 | 8 | import { ConfigService } from '../config/config.service' 9 | import { Session } from '../database/entities' 10 | 11 | @Injectable() 12 | export class SessionMiddleware implements NestMiddleware { 13 | private middleware: RequestHandler 14 | 15 | constructor( 16 | private config: ConfigService, 17 | @InjectDataSource() 18 | private readonly dataSource: DataSource, 19 | ) { 20 | const sessionRepository = dataSource.getRepository(Session) 21 | 22 | this.middleware = session({ 23 | resave: false, // can set to false since touch is implemented by our store 24 | saveUninitialized: false, // do not save new sessions that have not been modified 25 | secret: this.config.get('session.secret'), 26 | name: this.config.get('session.name'), 27 | cookie: { 28 | httpOnly: true, 29 | sameSite: 'strict', 30 | maxAge: this.config.get('session.cookie.maxAge'), 31 | secure: !config.isDevEnv, // disable in local dev env 32 | }, 33 | store: new TypeormStore({ 34 | // for every new session, remove this many expired ones. Defaults to 0 35 | cleanupLimit: 2, 36 | }).connect(sessionRepository), 37 | }) 38 | } 39 | 40 | use(req: Request, res: Response, next: NextFunction): void { 41 | this.middleware(req, res, next) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /backend/src/auth/__tests__/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { getRepositoryToken } from '@nestjs/typeorm' 3 | import { getLoggerToken } from 'nestjs-pino' 4 | 5 | import { ConfigModule } from '../../config/config.module' 6 | import { Session, User } from '../../database/entities' 7 | import { MailerService } from '../../mailer/mailer.service' 8 | import { OtpModule } from '../../otp/otp.module' 9 | import { AuthController } from '../auth.controller' 10 | import { AuthService } from '../auth.service' 11 | 12 | describe('AuthController', () => { 13 | let controller: AuthController 14 | const mockModel = {} 15 | 16 | beforeEach(async () => { 17 | const module: TestingModule = await Test.createTestingModule({ 18 | imports: [ConfigModule, OtpModule], 19 | controllers: [AuthController], 20 | providers: [ 21 | AuthService, 22 | MailerService, 23 | { 24 | provide: getRepositoryToken(User), 25 | useValue: mockModel, 26 | }, 27 | { 28 | provide: getRepositoryToken(Session), 29 | useValue: mockModel, 30 | }, 31 | { 32 | provide: getLoggerToken(AuthController.name), 33 | useValue: console, 34 | }, 35 | { 36 | provide: getLoggerToken(AuthService.name), 37 | useValue: console, 38 | }, 39 | { 40 | provide: getLoggerToken(MailerService.name), 41 | useValue: console, 42 | }, 43 | ], 44 | }).compile() 45 | 46 | controller = module.get(AuthController) 47 | }) 48 | 49 | it('should be defined', () => { 50 | expect(controller).toBeDefined() 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /backend/src/mailer/mailer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { PostmanNodemailerTransport } from '@opengovsg/postmangovsg-client' 3 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino' 4 | import { 5 | createTransport, 6 | SendMailOptions, 7 | SentMessageInfo, 8 | Transporter, 9 | } from 'nodemailer' 10 | 11 | import { ConfigService } from '../config/config.service' 12 | 13 | @Injectable() 14 | export class MailerService { 15 | constructor( 16 | private config: ConfigService, 17 | @InjectPinoLogger(MailerService.name) 18 | private readonly logger: PinoLogger, 19 | ) {} 20 | 21 | private chooseTransporter(): Pick { 22 | if (this.config.get('postmangovsgApiKey')) { 23 | return createTransport( 24 | new PostmanNodemailerTransport(this.config.get('postmangovsgApiKey')), 25 | ) 26 | } else if (this.config.isDevEnv) { 27 | return createTransport({ 28 | ...this.config.get('mailer'), 29 | secure: !this.config.isDevEnv, 30 | ignoreTLS: this.config.isDevEnv, 31 | }) 32 | } else { 33 | // FIXME: Once mail services are available, remove this block: 34 | return { 35 | sendMail: (mailOptions: SendMailOptions) => { 36 | this.logger.warn( 37 | `REMOVE ME ONCE POSTMAN OR MAIL IS IN PLACE Logging mail: ${ 38 | mailOptions.html?.toString() ?? '' 39 | }`, 40 | ) 41 | return Promise.resolve() 42 | }, 43 | } 44 | } 45 | } 46 | 47 | private mailer: Pick = this.chooseTransporter() 48 | 49 | sendMail = async (mailOptions: SendMailOptions): Promise => { 50 | this.logger.info('Sending mail') 51 | return this.mailer.sendMail(mailOptions) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@opengovsg/ts-template", 3 | "version": "1.0.0", 4 | "description": "A template for most TypeScript projects in OGP", 5 | "author": "Open Government Products, GovTech Singapore (https://open.gov.sg)", 6 | "license": "MIT", 7 | "scripts": { 8 | "all": "concurrently -c green,blue,yellow", 9 | "on-backend": "npm --prefix backend run", 10 | "on-frontend": "npm --prefix frontend run", 11 | "on-shared": "npm --prefix shared run", 12 | "postinstall": "npm --prefix backend install && npm --prefix frontend install && npm --prefix shared install", 13 | "dev": "npm run all \"docker-compose up\" \"./wait-for-it.sh localhost:5432 -t 0 -- npm run dev:app\"", 14 | "dev:docker": "docker-compose up --build", 15 | "dev:app": "npm run all -- --kill-others \"npm:on-* dev\"", 16 | "lint": "npm run all \"npm:on-* lint\"", 17 | "lint:fix": "npm run all \"npm:on-* lint:fix\"", 18 | "test": "npm run all \"npm:on-* test\"", 19 | "build": "npm run on-shared build && npm run all \"npm:on-*end build\"", 20 | "coverage": "npm run on-backend -- test:cov && cat backend/coverage/lcov.info > lcov.info", 21 | "cz": "git-cz", 22 | "start": "npm --prefix backend start", 23 | "prepare": "husky" 24 | }, 25 | "devDependencies": { 26 | "@commitlint/cli": "^19.8.0", 27 | "@commitlint/config-conventional": "^19.8.0", 28 | "commitizen": "^4.3.1", 29 | "concurrently": "^8.2.2", 30 | "cz-conventional-changelog": "^3.3.0", 31 | "env-cmd": "^10.1.0", 32 | "eslint": "^8.57.1", 33 | "eslint-config-opengovsg": "^2.0.6", 34 | "husky": "^9.1.7" 35 | }, 36 | "lint-staged": { 37 | "**/*.(js|jsx|ts|tsx)": [ 38 | "eslint --fix" 39 | ] 40 | }, 41 | "config": { 42 | "commitizen": { 43 | "path": "./node_modules/cz-conventional-changelog" 44 | } 45 | }, 46 | "dependencies": { 47 | "class-transformer": "^0.5.1", 48 | "class-validator": "^0.14.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/tracing/__tests__/trace-id.provider.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing' 2 | import { randomUUID } from 'crypto' 3 | import { IncomingMessage } from 'http' 4 | 5 | import { TraceIdProvider } from '../trace-id.provider' 6 | 7 | describe('TraceIdProvider', () => { 8 | let traceIdProvider: TraceIdProvider 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [TraceIdProvider], 13 | }).compile() 14 | 15 | traceIdProvider = module.get(TraceIdProvider) 16 | }) 17 | 18 | it('generates its own id if no headers available', () => { 19 | const req = { 20 | headers: {}, 21 | } as unknown as IncomingMessage 22 | const id = traceIdProvider.getTraceId(req) 23 | expect(id).toBeTruthy() 24 | }) 25 | 26 | it('uses request id if available', () => { 27 | const requestId = randomUUID() 28 | const req = { 29 | headers: { 30 | 'x-request-id': requestId, 31 | }, 32 | } as unknown as IncomingMessage 33 | const id = traceIdProvider.getTraceId(req) 34 | expect(id).toBe(requestId) 35 | }) 36 | 37 | it('uses AWS X-Ray id if available', () => { 38 | const requestId = randomUUID() 39 | const req = { 40 | headers: { 41 | 'x-amzn-trace-id': requestId, 42 | 'x-request-id': 'x-request-id', 43 | }, 44 | } as unknown as IncomingMessage 45 | const id = traceIdProvider.getTraceId(req) 46 | expect(id).toBe(requestId) 47 | }) 48 | 49 | it('uses Datadog trace id if available', () => { 50 | const requestId = randomUUID() 51 | const req = { 52 | headers: { 53 | 'x-datadog-trace-id': requestId, 54 | 'x-amzn-trace-id': 'x-amzn-trace-id', 55 | 'x-request-id': 'x-request-id', 56 | }, 57 | } as unknown as IncomingMessage 58 | const id = traceIdProvider.getTraceId(req) 59 | expect(id).toBe(requestId) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | pull_request: 5 | types: [opened, reopened] 6 | 7 | jobs: 8 | build: 9 | name: Build, Test, Lint 10 | uses: ./.github/workflows/build.yml 11 | 12 | deploy-staging: 13 | name: Deploy to Staging 14 | needs: [build] 15 | if: github.ref_name == 'staging' 16 | uses: opengovsg/deploy/.github/workflows/deploy.yml@latest 17 | secrets: 18 | aws-account-id: ${{ secrets.AWS_ACCOUNT_ID_STAGING }} 19 | aws-role-arn: ${{ secrets.AWS_ROLE_ARN_STAGING }} 20 | aws-region: ${{ secrets.AWS_REGION }} 21 | aws-ecr-repo: ${{ secrets.ECR_REPO }} 22 | fly-org-name: ${{ secrets.ORG_NAME_STAGING }} 23 | fly-app-name: ${{ secrets.APP_NAME_STAGING }} 24 | fly-api-token: ${{ secrets.FLY_API_TOKEN }} 25 | with: 26 | env: "stg" 27 | image-tag: ghactions-${{ github.ref_name }}-${{ github.sha }} 28 | ecs-cluster-name: "cluster-application-server" 29 | ecs-service-name: "application-server" 30 | ecs-container-name: "app-server" 31 | codedeploy-application: "AppECS-cluster-application-server" 32 | codedeploy-deployment-group: "DgpECS-cluster-application-server" 33 | 34 | deploy-production: 35 | name: Deploy to Production 36 | needs: [build] 37 | if: github.ref_name == 'production' 38 | uses: opengovsg/deploy/.github/workflows/deploy.yml@latest 39 | secrets: 40 | aws-account-id: ${{ secrets.AWS_ACCOUNT_ID_PROD }} 41 | aws-role-arn: ${{ secrets.AWS_ROLE_ARN_PROD }} 42 | aws-region: ${{ secrets.AWS_REGION }} 43 | aws-ecr-repo: ${{ secrets.ECR_REPO }} 44 | fly-org-name: ${{ secrets.ORG_NAME_PROD }} 45 | fly-app-name: ${{ secrets.APP_NAME_PROD }} 46 | fly-api-token: ${{ secrets.FLY_API_TOKEN }} 47 | with: 48 | env: "prod" 49 | image-tag: ghactions-${{ github.ref_name }}-${{ github.sha }} 50 | ecs-cluster-name: "cluster-application-server" 51 | ecs-service-name: "application-server" 52 | ecs-container-name: "app-server" 53 | codedeploy-application: "AppECS-cluster-application-server" 54 | codedeploy-deployment-group: "DgpECS-cluster-application-server" 55 | -------------------------------------------------------------------------------- /frontend/.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 98 | -------------------------------------------------------------------------------- /backend/scripts/env/putter.mjs: -------------------------------------------------------------------------------- 1 | import { GetParametersByPathCommand, SSMClient } from '@aws-sdk/client-ssm' 2 | import fs from 'fs' 3 | import { exit } from 'process' 4 | import dotenv from 'dotenv' 5 | 6 | /** 7 | * This is a helper for local file runs or jest, as specified in package.json 8 | * It emulates the putting of parameters into SSM which Lambda will do. 9 | * This is not meant to be used in a deployment and is .mjs so we can use top-level await 10 | */ 11 | async function putAllParameters() { 12 | console.log(`Retrieving parameters for ENV=${process.env.ENV}`) 13 | 14 | if (process.env.ENV === 'development') { 15 | console.log('In develop mode! Not putting into SSM param store.') 16 | exit(0) 17 | } 18 | 19 | const client = new SSMClient({ region: 'ap-southeast-1' }) 20 | const prefix = `/application/${process.env.ENV}/` 21 | const params = {} 22 | 23 | let nextToken 24 | 25 | do { 26 | // Handle pagination (max 10 params per call) 27 | const res = await client.send( 28 | new GetParametersByPathCommand({ 29 | Path: prefix, 30 | Recursive: true, 31 | WithDecryption: true, 32 | ...(nextToken ? { NextToken: nextToken } : {}), 33 | }), 34 | ) 35 | 36 | for (const parameter of res.Parameters ?? []) { 37 | const paramName = parameter.Name.slice(prefix.length) 38 | const isStringList = parameter.Type === 'StringList' 39 | params[paramName] = isStringList 40 | ? `[${parameter.Value.split(',').map((x) => `"${x}"`)}]` 41 | : parameter.Value 42 | } 43 | 44 | nextToken = res.NextToken 45 | } while (nextToken) 46 | 47 | const currentEnv = await fs.promises.readFile(`.env.${process.env.ENV}`) 48 | const config = dotenv.parse(currentEnv) 49 | 50 | console.log('The following parameters differ from SSM. Run the generated AWS CLI commands to update them (editing the --type field as necessary):\n') 51 | 52 | for (const [k, v] of Object.entries(config)) { 53 | if (Object.keys(params).includes(k) && params[k] !== v) { 54 | // different values, set override flag 55 | console.log( 56 | `aws ssm put-parameter --overwrite --name /application/${process.env.ENV}/${k} --value ${v} --type String`, 57 | ) 58 | } else if (!Object.keys(params).includes(k)) { 59 | console.log( 60 | `aws ssm put-parameter --name /application/${process.env.ENV}/${k} --value ${v} --type String`, 61 | ) 62 | } 63 | } 64 | } 65 | 66 | await putAllParameters() 67 | -------------------------------------------------------------------------------- /docs/deploying/for-everyone.md: -------------------------------------------------------------------------------- 1 | # Deploying Your Application - A Guide For Everyone 2 | 3 | Learn how to prepare your application to take it from your development 4 | environment to a simple deployment environment 5 | 6 | ## Infrastructure 7 | 8 | We use DigitalOcean to allow for straightforward management of 9 | infrastructure. This is especially helpful for teams with few 10 | engineers, so that they can focus on building better product. 11 | 12 | You will need a credit card to sign up for DigitalOcean. 13 | 14 | ### DigitalOcean Set-up 15 | 16 | On initial login to DigitalOcean, the user will be presented with the 17 | following screen: 18 | 19 | ![DigitalOcean - Landing Page](images/digitalocean/landing-page.png) 20 | 21 | Create a new database cluster, selecting the Singapore datacentre 22 | and PostgreSQL as the engine: 23 | 24 | ![DigitalOcean - Create Database](images/digitalocean/create-database.png) 25 | 26 | In a new tab, create a new App on DigitalOcean by clicking Create App, 27 | followed by Manage Access. Ensure that the DigitalOcean GitHub App 28 | (integration) is installed on both your personal GitHub account as 29 | well as the GitHub organisation containing the repo to deploy[^1]. 30 | 31 | ![DigitalOcean - App From Source](images/digitalocean/app-from-source.png) 32 | 33 | Click Next. Ensure Resources consist of a Web Service read from the 34 | Dockerfile, with 2GB RAM. Add a database as a resource. 35 | 36 | ![DigitalOcean - Resources](images/digitalocean/resources.png) 37 | 38 | Under Configure Your Database, add your Previously Created DigitalOcean 39 | Database. 40 | 41 | ![DigitalOcean - Attach Database](images/digitalocean/attach-database.png) 42 | 43 | Edit the application's environment variables. 44 | 45 | Go back to the database tab, and copy each connection parameter to the 46 | corresponding environment variable as shown in the screenshots below. 47 | Also add `NODE_ENV`, and set that to `production`. 48 | 49 | (If you wish to use email OTPs for your application, sign up for an email 50 | service like SendGrid, then input the mail connection parameters as 51 | the relevant environment variables documented [here](../../backend/src/config/config.schema.ts). 52 | 53 | ![DigitalOcean - Database Params](images/digitalocean/db-params.png) 54 | 55 | ![DigitalOcean - Environment Variables](images/digitalocean/env-vars.png) 56 | 57 | Click ahead to Review, then Create Resources 58 | 59 | ## References 60 | [^1]: https://www.digitalocean.com/community/questions/how-to-properly-link-github-repositories-in-app-platform 61 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/scripts/env/loader.mjs: -------------------------------------------------------------------------------- 1 | import { GetParametersByPathCommand, SSMClient } from '@aws-sdk/client-ssm' 2 | import fs from 'fs' 3 | import { exit } from 'process' 4 | 5 | /** 6 | * This is a helper for local file runs or jest, as specified in package.json 7 | * It emulates the loading of SSM which Lambda will do. 8 | * This is not meant to be used in a deployment and is .mjs so we can use top-level await 9 | */ 10 | async function loadAllParameters() { 11 | console.log(`Retrieving parameters for ENV=${process.env.ENV}`) 12 | 13 | if (process.env.ENV === 'development') { 14 | console.log('In develop mode! Not fetching from SSM param store.') 15 | console.log( 16 | 'Please reference .env.example to populate .env.development file for development environment', 17 | ) 18 | exit(0) 19 | } 20 | const client = new SSMClient({ region: 'ap-southeast-1' }) 21 | const prefix = `/application/${process.env.ENV}/` 22 | const params = {} 23 | 24 | let nextToken 25 | 26 | do { 27 | // Handle pagination (max 10 params per call) 28 | const res = await client.send( 29 | new GetParametersByPathCommand({ 30 | Path: prefix, 31 | Recursive: true, 32 | WithDecryption: true, 33 | ...(nextToken ? { NextToken: nextToken } : {}), 34 | }), 35 | ) 36 | 37 | for (const parameter of res.Parameters ?? []) { 38 | const paramName = parameter.Name.slice(prefix.length) 39 | const isStringList = parameter.Type === 'StringList' 40 | params[paramName] = isStringList 41 | ? `[${parameter.Value.split(',').map((x) => `"${x}"`)}]` 42 | : parameter.Value 43 | 44 | console.log(`${paramName}: ${parameter.Type}`) 45 | } 46 | 47 | nextToken = res.NextToken 48 | } while (nextToken) 49 | 50 | // format strings, JSON strings, and StringList appropriately 51 | const envString = Object.entries(params) 52 | .sort() 53 | .map(([k, v]) => { 54 | const strippedValue = v.replace(/\s/g, '') 55 | const looksLikeJson = strippedValue.includes('{') 56 | return looksLikeJson ? `${k}=${strippedValue}` : `${k}='${strippedValue}'` 57 | }) 58 | .join('\n') 59 | .concat(params['NODE_ENV'] ? '' : `\nNODE_ENV=${process.env.ENV}`) 60 | 61 | if (Object.entries(params).length > 0) { 62 | console.log(`Writing to file .env.${process.env.ENV}`) 63 | await fs.promises.writeFile(`.env.${process.env.ENV}`, envString) 64 | } else { 65 | console.log( 66 | `No env vars found, not writing to file .env.${process.env.ENV}`, 67 | ) 68 | } 69 | } 70 | 71 | await loadAllParameters() 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Except for any logos, trade marks, service marks, names, insignias, emblems, 2 | the Singapore state crest and Singapore national coat of arms, and/or any 3 | other graphic, image or sound which is used to identify any Singapore public 4 | sector agency or is associated with any Singapore public sector agency, or 5 | which belongs to a third party such as Shutterstock, Inc., and/or any asset 6 | or code identified by the Government Technology Agency (“GovTech”) as not 7 | licensed to you (such identification may come in the form of a filename 8 | labelled with “restricted” or similar phrasing), the contents of this 9 | repository are provided under the MIT License SUBJECT FURTHER TO THE FOLLOWING: 10 | 11 | (1) The MIT License and the terms herein (collectively, the “Terms”) shall be 12 | governed by the laws of Singapore. Any dispute arising out of or in connection 13 | with the Terms, including any question regarding its existence, validity or 14 | termination, shall be referred to and finally resolved by arbitration 15 | administered by the Singapore International Arbitration Centre (“SIAC”) in 16 | accordance with the Arbitration Rules of the Singapore International 17 | Arbitration Centre (“SIAC Rules”) for the time being in force, which rules are 18 | deemed to be incorporated by reference in this clause. The seat of arbitration 19 | shall be Singapore. The Tribunal shall consist of one (1) arbitrator. The 20 | language of the arbitration shall be English. 21 | 22 | MIT License 23 | 24 | Copyright (c) 2021 Government Technology Agency 25 | 26 | Permission is hereby granted, free of charge, to any person obtaining a copy 27 | of this software and associated documentation files (the "Software"), to deal 28 | in the Software without restriction, including without limitation the rights 29 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 30 | copies of the Software, and to permit persons to whom the Software is 31 | furnished to do so, subject to the following conditions: 32 | 33 | The above copyright notice and this permission notice shall be included in all 34 | copies or substantial portions of the Software. 35 | 36 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 37 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 38 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 39 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 40 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 41 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 42 | SOFTWARE. 43 | -------------------------------------------------------------------------------- /frontend/src/features/auth/components/OtpForm.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, Stack } from '@chakra-ui/react' 2 | import { zodResolver } from '@hookform/resolvers/zod' 3 | import { 4 | Button, 5 | FormErrorMessage, 6 | FormLabel, 7 | Input, 8 | } from '@opengovsg/design-system-react' 9 | import { useForm } from 'react-hook-form' 10 | import { z } from 'zod' 11 | 12 | import { useIsDesktop } from '~hooks/useIsDesktop' 13 | 14 | import { ResendOtpButton } from './ResendOtpButton' 15 | 16 | const schema = z.object({ 17 | token: z 18 | .string() 19 | .trim() 20 | .min(1, 'OTP is required.') 21 | .regex(/^[0-9\b]+$/, { message: 'Only numbers are allowed.' }) 22 | .length(6, 'Please enter a 6 digit OTP.'), 23 | }) 24 | 25 | export type OtpFormInputs = { 26 | token: string 27 | } 28 | 29 | interface OtpFormProps { 30 | email: string 31 | onSubmit: (inputs: OtpFormInputs) => Promise 32 | onResendOtp: () => Promise 33 | } 34 | 35 | export const OtpForm = ({ 36 | email, 37 | onSubmit, 38 | onResendOtp, 39 | }: OtpFormProps): JSX.Element => { 40 | const { handleSubmit, register, formState, setError } = 41 | useForm({ 42 | resolver: zodResolver(schema), 43 | }) 44 | 45 | const isDesktop = useIsDesktop() 46 | 47 | const onSubmitForm = async (inputs: OtpFormInputs) => { 48 | return onSubmit(inputs).catch((e: { json: { message: string } }) => { 49 | setError('token', { type: 'server', message: e.json.message }) 50 | }) 51 | } 52 | 53 | return ( 54 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 55 |
56 | 57 | 58 | {`Enter OTP sent to ${email.toLowerCase()}`} 59 | 60 | 68 | {formState.errors.token?.message} 69 | 70 | 75 | 82 | 83 | 84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common' 2 | import { InjectRepository } from '@nestjs/typeorm' 3 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino' 4 | import { FindOneOptions, Repository } from 'typeorm' 5 | 6 | import { GenerateOtpDto, VerifyOtpDto } from '~shared/types/auth.dto' 7 | 8 | import { ConfigService } from '../config/config.service' 9 | import { Session, User } from '../database/entities' 10 | import { MailerService } from '../mailer/mailer.service' 11 | import { OtpService } from '../otp/otp.service' 12 | 13 | @Injectable() 14 | export class AuthService { 15 | constructor( 16 | @InjectRepository(User) 17 | private readonly usersRepository: Repository, 18 | @InjectRepository(Session) 19 | private readonly sessionRepository: Repository, 20 | private otpService: OtpService, 21 | private mailerService: MailerService, 22 | private config: ConfigService, 23 | @InjectPinoLogger(AuthService.name) 24 | private readonly logger: PinoLogger, 25 | ) {} 26 | 27 | async generateOtp(generateOtpDto: GenerateOtpDto): Promise { 28 | const { email } = generateOtpDto 29 | const { token, timeLeft } = this.otpService.generateOtp(email) 30 | 31 | const html = `Your OTP is ${token}. It will expire in ${timeLeft} minutes. 32 | Please use this to login to your account. 33 |

If your OTP does not work, please request for a new one.

` 34 | 35 | // TODO: Replace the `from` and `subject` fields with content specific to your application 36 | const mail = { 37 | to: email, 38 | from: `${this.config.get('otp.sender_name')} <${this.config.get( 39 | 'otp.email', 40 | )}>`, 41 | subject: `One-Time Password (OTP) for ${this.config.get( 42 | 'otp.sender_name', 43 | )}`, 44 | html, 45 | } 46 | 47 | this.logger.info(`Sending mail to ${email}`) 48 | await this.mailerService.sendMail(mail) 49 | } 50 | 51 | async verifyOtp(verifyOtpDto: VerifyOtpDto): Promise { 52 | const { token } = verifyOtpDto 53 | let { email } = verifyOtpDto 54 | email = email.toLowerCase() 55 | const isVerified = this.otpService.verifyOtp(email, token) 56 | return isVerified 57 | ? await this.findOrCreate( 58 | { where: { email } }, 59 | { 60 | email, 61 | }, 62 | ) 63 | : undefined 64 | } 65 | 66 | async findOrCreate( 67 | query: FindOneOptions>, 68 | create: Partial>, 69 | ): Promise { 70 | const user = await this.usersRepository.findOne(query) 71 | return user ?? (await this.usersRepository.save(create)) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/lib/auth.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from '@tanstack/react-query' 2 | import { createContext, ReactNode, useCallback, useContext } from 'react' 3 | 4 | import { 5 | fetchUser, 6 | logout as logoutApi, 7 | sendLoginOtp, 8 | STORAGE_LOGGED_IN_KEY, 9 | verifyLoginOtp as verifyLoginOtpApi, 10 | } from '~features/auth' 11 | import { VerifyOtpRequestDto, WhoAmIResponseDto } from '~shared/types/auth.dto' 12 | 13 | import { useLocalStorage } from './storage' 14 | 15 | type AuthContextProps = { 16 | isAuthenticated?: boolean 17 | user?: WhoAmIResponseDto 18 | isLoading: boolean 19 | sendLoginOtp: typeof sendLoginOtp 20 | verifyLoginOtp: (params: VerifyOtpRequestDto) => Promise 21 | logout: typeof logoutApi 22 | } 23 | 24 | const AuthContext = createContext(undefined) 25 | 26 | /** 27 | * Provider component that wraps your app and makes auth object available to any 28 | * child component that calls `useAuth()`. 29 | */ 30 | export const AuthProvider = ({ children }: { children: ReactNode }) => { 31 | const auth = useProvideAuth() 32 | 33 | return {children} 34 | } 35 | 36 | /** 37 | * Hook for components nested in ProvideAuth component to get the current auth object. 38 | */ 39 | export const useAuth = (): AuthContextProps => { 40 | const context = useContext(AuthContext) 41 | if (!context) { 42 | throw new Error(`useAuth must be used within a AuthProvider component`) 43 | } 44 | return context 45 | } 46 | 47 | // Provider hook that creates auth object and handles state 48 | const useProvideAuth = () => { 49 | const [isLoggedIn, setIsLoggedIn] = useLocalStorage( 50 | STORAGE_LOGGED_IN_KEY, 51 | ) 52 | const queryClient = useQueryClient() 53 | 54 | const { data: user, isLoading } = useQuery( 55 | ['currentUser'], 56 | () => fetchUser(), 57 | // 10 minutes staletime, do not need to retrieve so often. 58 | { staleTime: 600000, enabled: !!isLoggedIn }, 59 | ) 60 | 61 | const verifyLoginOtp = useCallback( 62 | async (params: VerifyOtpRequestDto) => { 63 | await verifyLoginOtpApi(params) 64 | setIsLoggedIn(true) 65 | }, 66 | [setIsLoggedIn], 67 | ) 68 | 69 | const logout = useCallback(async () => { 70 | await logoutApi() 71 | if (isLoggedIn) { 72 | // Clear logged in state. 73 | setIsLoggedIn(undefined) 74 | } 75 | queryClient.clear() 76 | }, [isLoggedIn, queryClient, setIsLoggedIn]) 77 | 78 | // Return the user object and auth methods 79 | return { 80 | isAuthenticated: isLoggedIn, 81 | user: isLoggedIn ? user : undefined, 82 | isLoading, 83 | sendLoginOtp, 84 | verifyLoginOtp, 85 | logout, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/features/auth/components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, Link, Stack } from '@chakra-ui/react' 2 | import { zodResolver } from '@hookform/resolvers/zod' 3 | import { 4 | Button, 5 | FormErrorMessage, 6 | FormLabel, 7 | Input, 8 | } from '@opengovsg/design-system-react' 9 | import { useForm } from 'react-hook-form' 10 | import { z } from 'zod' 11 | 12 | import { useIsDesktop } from '~hooks/useIsDesktop' 13 | import { isGovSgEmail } from '~shared/decorators/is-gov-sg-email' 14 | 15 | const schema = z.object({ 16 | email: z 17 | .string() 18 | .trim() 19 | .min(1, 'Please enter an email address.') 20 | .email({ message: 'Please enter a valid email address.' }) 21 | .refine(isGovSgEmail, { 22 | message: 'Please sign in with a gov.sg email address.', 23 | }), 24 | }) 25 | 26 | export type LoginFormInputs = { 27 | email: string 28 | } 29 | interface LoginFormProps { 30 | onSubmit: (inputs: LoginFormInputs) => Promise 31 | } 32 | 33 | export const LoginForm = ({ onSubmit }: LoginFormProps): JSX.Element => { 34 | const onSubmitForm = async (inputs: LoginFormInputs) => { 35 | return onSubmit(inputs).catch((e: { json: { message: string } }) => { 36 | setError('email', { type: 'server', message: e.json.message }) 37 | }) 38 | } 39 | 40 | const { handleSubmit, register, formState, setError } = 41 | useForm({ 42 | resolver: zodResolver(schema), 43 | }) 44 | 45 | const isDesktop = useIsDesktop() 46 | 47 | return ( 48 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 49 |
50 | 56 | 61 | Email 62 | 63 | 69 | {formState.errors.email?.message} 70 | 71 | 76 | 83 | Have a question? 84 | 85 |
86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpStatus, 6 | Post, 7 | Req, 8 | Res, 9 | Session, 10 | } from '@nestjs/common' 11 | import { Request, Response } from 'express' 12 | import { InjectPinoLogger, PinoLogger } from 'nestjs-pino' 13 | 14 | import { GenerateOtpDto, VerifyOtpDto } from '~shared/types/auth.dto' 15 | 16 | import { ConfigService } from '../config/config.service' 17 | import { UserSession } from '../types/session' 18 | import { AuthService } from './auth.service' 19 | 20 | @Controller('auth') 21 | export class AuthController { 22 | constructor( 23 | private readonly config: ConfigService, 24 | private readonly authService: AuthService, 25 | @InjectPinoLogger(AuthController.name) 26 | private readonly logger: PinoLogger, 27 | ) {} 28 | 29 | @Post() 30 | async generateOtp( 31 | @Res() res: Response, 32 | @Body() generateOtpDto: GenerateOtpDto, 33 | ): Promise { 34 | try { 35 | await this.authService.generateOtp(generateOtpDto) 36 | res.status(HttpStatus.OK).json({ message: 'OTP sent' }) 37 | } catch (error) { 38 | this.logger.error(error) 39 | res 40 | .status(HttpStatus.INTERNAL_SERVER_ERROR) 41 | .json({ message: (error as Record).message }) 42 | } 43 | } 44 | 45 | @Post('verify') 46 | async verifyOtp( 47 | @Req() req: Request, 48 | @Res() res: Response, 49 | @Body() verifyOtpDto: VerifyOtpDto, 50 | ): Promise { 51 | try { 52 | const user = await this.authService.verifyOtp(verifyOtpDto) 53 | if (user) { 54 | req.session.user = user 55 | this.logger.info( 56 | `Successfully verified OTP for user ${verifyOtpDto.email}`, 57 | ) 58 | res.status(HttpStatus.OK).json({ message: 'OTP verified' }) 59 | } else { 60 | this.logger.warn(`Incorrect OTP given for ${verifyOtpDto.email}`) 61 | res 62 | .status(HttpStatus.UNAUTHORIZED) 63 | .json({ message: 'Incorrect OTP given' }) 64 | } 65 | } catch (error) { 66 | this.logger.error(error) 67 | res 68 | .status(HttpStatus.INTERNAL_SERVER_ERROR) 69 | .json({ message: (error as Record).message }) 70 | } 71 | } 72 | 73 | @Post('logout') 74 | logout( 75 | @Session() session: UserSession, 76 | @Res({ passthrough: true }) res: Response, 77 | ): void { 78 | res.clearCookie(this.config.get('session.name')) 79 | session.destroy(() => 80 | res.status(HttpStatus.OK).json({ message: 'Logged out' }), 81 | ) 82 | } 83 | 84 | @Get('whoami') 85 | whoami(@Req() req: Request, @Res() res: Response): void { 86 | const user = req.session.user 87 | res 88 | .status(HttpStatus.OK) 89 | .json(user ? { id: user.id, email: user.email } : null) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /docs/tooling.md: -------------------------------------------------------------------------------- 1 | # Tooling 2 | 3 | This document describes the tools that are in place in this project 4 | to facilitate good practice during the development process 5 | 6 | ## Keeping Things Organised 7 | 8 | ## Folder Structure 9 | Two separate TypeScript projects, `frontend/` and `backend/`, 10 | for frontend and backend respectively. 11 | 12 | Structure within frontend/backend folder taken from \[1\]. Notably, 13 | we distinguish between `lib/` and `src/` directories, the latter for 14 | files that we have to process (eg, transpile) into `build/` or `dist/`. 15 | 16 | ## Linting 17 | Done with ESLint, using the following rule configs: 18 | 19 | - `eslint:recommended` 20 | - `plugin:prettier/recommended` 21 | 22 | Prettier is further configured using the rules in `.prettierrc.js`. 23 | 24 | VSCode users may have to add the following to their ESLint extension 25 | settings for linting to work in both `frontend/` and `backend/`: 26 | 27 | ```json 28 | "eslint.workingDirectories": [ 29 | "backend", 30 | "frontend" 31 | ], 32 | ``` 33 | 34 | ### Additional rules 35 | Developers are free to add more ESLint rules that bring their project 36 | in-line with norms specific to their language or framework of choice, 37 | eg. typescript or React. 38 | 39 | ## Conventional Commits 40 | Commit messages follow [conventional commits](https://conventionalcommits.org/). 41 | This is enforced by commitlint, when pushing to remote branch. 42 | 43 | ### Commitizen 44 | [Commitizen](https://github.com/commitizen/cz-cli) has been installed as a 45 | convenience for writing conventional commit messages, via `npm run cz`. 46 | This may be removed to minimise project dependencies. 47 | 48 | ## Commit Hooks 49 | Husky is used in tandem with: 50 | 51 | - lint-staged to ensure files are linted on commit 52 | - commitlint to ensure commits adhere to convention on push 53 | 54 | The pre-push hook will interfere on initial push since commitlint 55 | uses the remote branch as the lower bound in the commit range to inspect, 56 | and there would be no remote branch. Bypass this the first time with 57 | `git push --no-verify`. 58 | 59 | ## Continuous Integration 60 | GitHub Actions is commonly used in OGP. Config at `.github/workflows` 61 | have been provided for convenience, which will run the following: 62 | 63 | - `ci.yml` 64 | - unit tests 65 | - linting 66 | - deploying to AWS Elastic Beanstalk, if on specific branches 67 | 68 | - `codeql.yml` - static analysis 69 | 70 | Builds will fail if any of these tasks fail. 71 | 72 | ## Miscellany 73 | 74 | ### Dependabot 75 | `.github/dependabot.yml` is in place so that npm dependencies will be 76 | regularly updated. Mergify has been configured to automatically 77 | merge non-major releases, configured via `.github/mergify.yml` 78 | 79 | ## References 80 | 81 | \[1\]: https://gist.github.com/tracker1/59f2c13044315f88bee9 82 | -------------------------------------------------------------------------------- /frontend/src/lib/storage.tsx: -------------------------------------------------------------------------------- 1 | // Retrieved from https://usehooks-typescript.com/react-hook/use-local-storage 2 | import { useCallback, useEffect, useState } from 'react' 3 | 4 | /** 5 | * Event name to be used when emitting event to indicate that localStorage has 6 | * been modified. 7 | */ 8 | export const LOCAL_STORAGE_EVENT = 'app-local-storage' 9 | 10 | export const useLocalStorage = ( 11 | key: string, 12 | initialValue?: T, 13 | ): readonly [T | undefined, (value?: T) => void] => { 14 | // Get from local storage then 15 | // parse stored json or return initialValue 16 | const readValue = useCallback(() => { 17 | // Prevent build error "window is undefined" but keep keep working 18 | if (typeof window === 'undefined') { 19 | return initialValue 20 | } 21 | try { 22 | const item = window.localStorage.getItem(key) 23 | return item ? (JSON.parse(item) as T | undefined) : initialValue 24 | } catch (error) { 25 | return initialValue 26 | } 27 | }, [initialValue, key]) 28 | // State to store our value 29 | // Pass initial state function to useState so logic is only executed once 30 | const [storedValue, setStoredValue] = useState(readValue) 31 | // Return a wrapped version of useState's setter function that ... 32 | // ... persists the new value to localStorage. 33 | const setValue = (value?: T) => { 34 | try { 35 | // Allow value to be a function so we have the same API as useState 36 | const newValue = 37 | value instanceof Function 38 | ? (value(storedValue) as T | undefined) 39 | : value 40 | 41 | if (!value) { 42 | window.localStorage.removeItem(key) 43 | } else { 44 | // Save to local storage 45 | window.localStorage.setItem(key, JSON.stringify(newValue)) 46 | // Save state 47 | } 48 | setStoredValue(newValue) 49 | // We dispatch a custom event so every useLocalStorage hook are notified 50 | window.dispatchEvent(new Event(LOCAL_STORAGE_EVENT)) 51 | // eslint-disable-next-line no-empty 52 | } catch { 53 | // TODO (#2640) Pass in some sort of logger here. 54 | } 55 | } 56 | useEffect(() => { 57 | setStoredValue(readValue()) 58 | }, [readValue]) 59 | useEffect(() => { 60 | const handleStorageChange = () => { 61 | setStoredValue(readValue()) 62 | } 63 | // this only works for other documents, not the current one 64 | window.addEventListener('storage', handleStorageChange) 65 | // this is a custom event, triggered in writeValueToLocalStorage 66 | window.addEventListener(LOCAL_STORAGE_EVENT, handleStorageChange) 67 | return () => { 68 | window.removeEventListener('storage', handleStorageChange) 69 | window.removeEventListener(LOCAL_STORAGE_EVENT, handleStorageChange) 70 | } 71 | }, [readValue]) 72 | return [storedValue, setValue] as const 73 | } 74 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | ## DB Quick Guide 2 | 3 | Migrations are stored in `./src/database/migrations`. 4 | 5 | Seed data is stored in `./src/database/seeds`. 6 | 7 | ### Quick Start 8 | 9 | Common commands: 10 | 11 | - `npm run migration:show` - show status of current migrations database (Readonly action) 12 | - `npm run migration:run` - sync your database to the existing schema with the 13 | migrations in `./src/database/migrations`. 14 | - `npm run migration:revert` - revert a migration 15 | - `npm run seed` - populate your data on a fresh database 16 | 17 | Commands can be prefixed with `ENV={environment} ...`, eg `ENV=uat npm run migration:status` to target a particular 18 | environment. 19 | 20 | ### How To Create DB Migrations 21 | 22 | Prerequisite: make sure that your database is synced with the current schema ( 23 | in `./src/database/entities`) with `npm run migration:run`. 24 | 25 | 1. Edit the entities in `entities` according to your new requirements. 26 | 2. Run `npm run migration:gen `, which will generate a migration file of the diff of the edited schema 27 | and the existing database schema 28 | into `./src/database/migrations/-`. 29 | 3. Run `npm run migration:run` to sync your database to the new schema. 30 | 31 | ## `.env` file loading 32 | 33 | `dotenv` is used to load env variables 34 | according to the environment name. This is to reduce the need for 35 | editing one common `.env` file when working across environments. 36 | 37 | ### How to use 38 | 39 | You should have a single `.env` file for each environment. 40 | 41 | ``` 42 | ./ 43 | ├── .env.development 44 | ├── .env.staging 45 | ├── .env.uat 46 | └── .env.prod (in the future) 47 | ``` 48 | 49 | Prefix your bash commands with `ENV={ENV}` to load the correct file. Eg. `ENV=staging npm run ...` will load values 50 | from `.env.staging` and `ENV=uat npm run ... ` will load `.env.uat`. 51 | 52 | Similarly for migrations, prefix your desired environment `ENV=uat npm run migration:run`. 53 | 54 | By default, `ENV=development` is selected when `ENV` is not specified in the bash command. 55 | 56 | ### Loading precedence 57 | 58 | Env files are loaded in order of: 59 | 60 | - `.env` # avoid using this as it is loaded for all environments 61 | - `.env.local` # avoid using this as it is loaded for all environments 62 | - `.env.` 63 | - `.env..local` 64 | where the variables in the last file overrides all else. 65 | 66 | We should avoid using `.env` and `.env.local` as they are loaded for all environments but can be overwritten 67 | by other files, which can cause confusion and ENV variable pollution. 68 | 69 | You can have your own environment specific manual overrides by editing `.env..local`. 70 | 71 | ### Pulling from AWS 72 | 73 | Use `ENV=${env} npm run env:sync`, to pull 74 | latest env vars from AWS into `.env.${env}` file. 75 | 76 | Eg. `ENV=uat npm run env:sync` to generate `.env.uat`. 77 | 78 | Note that you will need to configure your AWS credentials within the CLI. 79 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | workflow_call: 4 | 5 | jobs: 6 | lint: 7 | name: Lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Use Node.js 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version-file: ".nvmrc" 15 | - name: Cache Node.js modules 16 | uses: actions/cache@v3 17 | with: 18 | # npm cache files are stored in `~/.npm` on Linux/macOS 19 | path: ~/.npm 20 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 21 | restore-keys: | 22 | ${{ runner.OS }}-node- 23 | ${{ runner.OS }}- 24 | - run: npm ci 25 | - run: npx lockfile-lint --type npm --path frontend/package-lock.json --validate-https --allowed-hosts npm 26 | - run: npx lockfile-lint --type npm --path backend/package-lock.json --validate-https --allowed-hosts npm 27 | - name: Ensure that codebase builds, build shared code for dependents 28 | run: npm run build 29 | - run: npm run lint 30 | 31 | test: 32 | name: Test 33 | runs-on: ubuntu-latest 34 | services: 35 | postgres: 36 | image: postgres:12 37 | env: 38 | POSTGRES_DB: ts_template_test 39 | POSTGRES_USER: test 40 | POSTGRES_PASSWORD: test 41 | # Set health checks to wait until postgres has started 42 | options: >- 43 | --health-cmd pg_isready 44 | --health-interval 10s 45 | --health-timeout 5s 46 | --health-retries 5 47 | ports: 48 | - 5432:5432 49 | steps: 50 | - uses: actions/checkout@v3 51 | - name: Use Node.js 52 | uses: actions/setup-node@v3 53 | with: 54 | node-version-file: ".nvmrc" 55 | - name: Cache Node.js modules 56 | uses: actions/cache@v3 57 | with: 58 | # npm cache files are stored in `~/.npm` on Linux/macOS 59 | path: ~/.npm 60 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 61 | restore-keys: | 62 | ${{ runner.OS }}-node- 63 | ${{ runner.OS }}- 64 | - run: npm ci 65 | - name: Compile shared code for dependents 66 | run: npm run --prefix shared build 67 | - name: Run tests and generate coverage 68 | run: npm run coverage 69 | env: 70 | DB_NAME: ts_template_test 71 | DB_HOST: localhost 72 | DB_PORT: 5432 73 | DB_USERNAME: test 74 | DB_PASSWORD: test 75 | - name: Coveralls 76 | uses: coverallsapp/github-action@master 77 | if: github.repository == 'opengovsg/ts-template' 78 | with: 79 | github-token: ${{ secrets.GITHUB_TOKEN }} 80 | path-to-lcov: ./lcov.info 81 | 82 | build-docker: 83 | name: Build Docker image 84 | runs-on: ubuntu-latest 85 | if: github.repository == 'opengovsg/ts-template' 86 | steps: 87 | - uses: actions/checkout@v3 88 | - run: docker build -f Dockerfile . 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript Project Files Template for OGP 2 | 3 | A template used by [Open Government Products](https://open.gov.sg) 4 | to build new products 5 | 6 | ## Quickstart 7 | 8 | ``` 9 | # Clone this repo if you're trying this out, or start a new repo with 10 | # ts-template as a template and clone that if starting a new product 11 | 12 | git clone git@github.com:opengovsg/ts-template 13 | cd ts-template 14 | npm install 15 | npm run dev 16 | ``` 17 | 18 | ## Getting Started 19 | 20 | This guide should equip you with the basics you’ll need to build and 21 | launch a simple product, using this as a starting point, even if you 22 | don’t have a background in software engineering. 23 | 24 | ### Preparation - installing software 25 | 26 | You will need the following: 27 | 28 | - A [GitHub account](https://github.com/signup) 29 | - [Docker Desktop](https://www.docker.com/), which is used to run some 30 | supporting applications during your development process 31 | - If you are using an M1 or M2 Mac, ensure you download 32 | Docker Desktop for Apple Silicon 33 | - An application to edit files, like Microsoft's [Visual Studio Code](https://code.visualstudio.com/) 34 | - A client to interact with GitHub, like its [official desktop client](https://desktop.github.com/) 35 | - Some familiarity with using the [Terminal in Mac OS X](https://www.youtube.com/watch?v=aKRYQsKR46I) 36 | - [Volta](https://volta.sh/), by entering into the Terminal the 37 | commands listed on their homepage 38 | - Open a new Terminal, and type the following to install: 39 | - Node.js - `volta install node` 40 | - npm - `volta install npm` 41 | 42 | ### Getting and Preparing the Code in Your Computer 43 | 44 | - Create a [new repository](https://github.com/new), using 45 | opengovsg/ts-template as a template 46 | - From this page, click `Use this template` 47 | ![Use this template](docs/images/use-this-template.png) 48 | - Choose a name for your new repository 49 | - Use [GitHub Desktop](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/adding-and-cloning-repositories/cloning-and-forking-repositories-from-github-desktop) 50 | (or your preferred tool) to clone your newly-created repository into 51 | your computer 52 | - Remember the directory that you cloned to. 53 | You will need this for the next step. 54 | - If you are using a Mac, open the directory in Finder, 55 | then press command-option-c to copy the directory path to memory 56 | - Open a new terminal, and type `cd ` 57 | - Run the command `npm install` 58 | - This pulls in third-party packages for the codebase, which it 59 | depends on to run (ie, dependencies) 60 | - In the same terminal, run `npm run dev` to verify that the application 61 | has been prepared correctly. Your browser should display the following: 62 | ![First Run](docs/images/first-run.png) 63 | 64 | ## Further Reading 65 | 66 | Take time to understand the codebase and what you have to work with 67 | to further develop your product. 68 | 69 | - An index to all the documentation can be found [here](./docs/). 70 | - README for the frontend application can be found [here](./frontend/) 71 | - In a hurry to launch your product? Go straight to [deploying](./docs/deploying/) 72 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassProvider, 3 | MiddlewareConsumer, 4 | Module, 5 | NestModule, 6 | } from '@nestjs/common' 7 | import { APP_PIPE } from '@nestjs/core' 8 | import { ServeStaticModule } from '@nestjs/serve-static' 9 | import { TypeOrmModule } from '@nestjs/typeorm' 10 | import { ApiModule } from 'api.module' 11 | import { ConfigModule } from 'config/config.module' 12 | import { ConfigService } from 'config/config.service' 13 | import { LoggedValidationPipe } from 'core/providers/logged-validation.pipe' 14 | import { DatabaseConfigService } from 'database/database-config.service' 15 | import { Response } from 'express' 16 | import { HelmetMiddleware } from 'middlewares/helmet.middleware' 17 | import { SessionMiddleware } from 'middlewares/session.middleware' 18 | import { LoggerModule, PinoLogger } from 'nestjs-pino' 19 | import { join } from 'path' 20 | import { TraceIdProvider } from 'tracing/trace-id.provider' 21 | 22 | const FRONTEND_PATH = join(__dirname, '..', '..', 'frontend', 'build') 23 | 24 | @Module({ 25 | imports: [ 26 | ApiModule, 27 | ConfigModule, 28 | LoggerModule.forRootAsync({ 29 | providers: [TraceIdProvider], 30 | inject: [TraceIdProvider], 31 | useFactory: (traceProvider: TraceIdProvider) => ({ 32 | pinoHttp: { 33 | redact: ['req.headers.authorization', 'req.headers.cookie'], // This prevents authorization header values and req cookies from being logged 34 | genReqId: traceProvider.getTraceId.bind(undefined), 35 | customProps: (req) => { 36 | const context = { 37 | trace_id: req.headers['x-datadog-trace-id'], 38 | xray_id: req.headers['x-amzn-trace-id'], 39 | } 40 | return { context, scope: 'NestApplication' } 41 | }, 42 | customSuccessMessage: (req, res) => { 43 | return `${req.method ?? ''} ${req.url ?? ''} ${res.statusCode}` 44 | }, 45 | customErrorMessage: (req, res, err) => { 46 | return `${req.method ?? ''} ${req.url ?? ''} ${res.statusCode}: (${ 47 | err.name 48 | }) ${err.message}` 49 | }, 50 | }, 51 | renameContext: 'scope', 52 | }), 53 | }), 54 | TypeOrmModule.forRootAsync({ 55 | imports: [ConfigModule], 56 | inject: [ConfigService], 57 | useClass: DatabaseConfigService, 58 | }), 59 | ServeStaticModule.forRoot({ 60 | rootPath: FRONTEND_PATH, 61 | // '/api*' works for @nestjs/serve-static versions < 3.x 62 | // '/api(.*)' works for @nestjs/serve-static versions >= 3.x 63 | // Reference: https://github.com/nestjs/serve-static/issues/1177 64 | exclude: ['/api(.*)'], // Return 404 for non-existent API routes 65 | serveStaticOptions: { 66 | maxAge: 2 * 60 * 60 * 1000, // 2 hours, same as cloudflare 67 | setHeaders: function (res: Response, path: string) { 68 | // set maxAge to 0 for root index.html 69 | if (path === join(FRONTEND_PATH, 'index.html')) { 70 | res.setHeader('Cache-control', 'public, max-age=0') 71 | } 72 | }, 73 | }, 74 | }), 75 | ], 76 | providers: [ 77 | { 78 | provide: APP_PIPE, 79 | useClass: LoggedValidationPipe, 80 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 81 | // @ts-ignore 82 | inject: [PinoLogger], 83 | } as unknown as ClassProvider, 84 | ], 85 | }) 86 | export class AppModule implements NestModule { 87 | configure(consumer: MiddlewareConsumer): void { 88 | consumer.apply(HelmetMiddleware, SessionMiddleware).forRoutes('*') 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /frontend/docs/project-structure.md: -------------------------------------------------------------------------------- 1 | # 🗄️ Project Structure 2 | 3 | > 📝: Some folders may be missing from the project structure as there the template does not include all the features. The structure is meant to be a guide and not a strict rule. 4 | 5 | Most of the code lives in the `src` folder and looks like this: 6 | 7 | ```sh 8 | ├── src/ 9 | │ ├── assets/ # assets folder can contain all the static files such as images, fonts, etc. 10 | │ ├── components/ # shared components used across the entire application 11 | │ ├── config/ # all the global configuration, env variables etc. get exported from here and used in the app 12 | │ ├── constants/ # shared constants used across the entire application 13 | │ ├── features/ # feature based modules 14 | │ ├── hooks/ # shared hooks used across the entire application 15 | │ ├── lib/ # re-exporting different libraries preconfigured for the application 16 | │ ├── providers/ # all of the application providers 17 | │ ├── routes/ # routes configuration 18 | │ ├── stores/ # global state stores 19 | │ ├── test/ # test utilities and mock server 20 | │ ├── types/ # base types used across the application 21 | │ ├── utils/ # shared utility functions 22 | ``` 23 | 24 | In order to scale the application in the easiest and most maintainable way, keep most of the code inside the `features` folder, which should contain different feature-based modules. Every `feature` folder should contain domain specific code for a given feature. This will allow you to keep functionalities scoped to a feature and not mix its declarations with shared modules. This is much easier to maintain than a flat folder structure with many files. 25 | 26 | A feature could have the following structure: 27 | 28 | ```sh 29 | ├── src/features/awesome-feature/ 30 | │ ├── api/ # exported API request declarations and api hooks related to a specific feature 31 | │ ├── assets/ # assets folder can contain all the static files for a specific feature 32 | │ ├── components/ # components scoped to a specific feature 33 | │ ├── hooks/ # hooks scoped to a specific feature 34 | │ ├── routes/ # route components for a specific feature pages 35 | │ ├── stores/ # state stores for a specific feature 36 | │ ├── types/ # typescript types for TS specific feature domain 37 | │ ├── utils/ # utility functions for a specific feature 38 | │ ├── constants.ts # constants scoped to a specific feature 39 | │ ├── index.ts # entry point for the feature, it should serve as the public API of the given feature and exports everything that should be used outside the feature 40 | ``` 41 | 42 | Everything from a feature should be exported from the `index.ts` file which behaves as the public API of the feature. 43 | 44 | You should import stuff from other features only by using: 45 | 46 | `import { AwesomeComponent } from "~features/awesome-feature"` 47 | 48 | and not 49 | 50 | `import { AwesomeComponent } from "~features/awesome-feature/components/AwesomeComponent` 51 | 52 | > 📝: If you want to be extra-strict, this can also be configured in the ESLint configuration to disallow the later import by the following rule: 53 | 54 | ```js 55 | { 56 | rules: { 57 | 'no-restricted-imports': [ 58 | 'error', 59 | { 60 | patterns: ['~features/*/*'], 61 | }, 62 | ], 63 | } 64 | // ...rest of the configuration 65 | } 66 | ``` 67 | 68 | This was inspired by how [NX](https://nx.dev/) handles libraries that are isolated but available to be used by the other modules. Think of a feature as a library or a module that is self-contained but can expose different parts to other features via its entry point. 69 | -------------------------------------------------------------------------------- /ecs-task-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerDefinitions": [ 3 | { 4 | "name": "app-server", 5 | "cpu": 0, 6 | "portMappings": [ 7 | { 8 | "containerPort": 8080, 9 | "hostPort": 8080, 10 | "protocol": "tcp" 11 | } 12 | ], 13 | "essential": true, 14 | "environment": [ 15 | { 16 | "name": "TZ", 17 | "value": "Asia/Singapore" 18 | }, 19 | { 20 | "name": "NODE_ENV", 21 | "value": "production" 22 | }, 23 | { 24 | "name": "DD_SOURCE", 25 | "value": "nodejs" 26 | } 27 | ], 28 | "mountPoints": [], 29 | "volumesFrom": [], 30 | "secrets": [ 31 | { 32 | "name": "DB_HOST", 33 | "valueFrom": "/app/backend/DB_HOST" 34 | }, 35 | { 36 | "name": "DB_NAME", 37 | "valueFrom": "/app/backend/DB_NAME" 38 | }, 39 | { 40 | "name": "DB_PASSWORD", 41 | "valueFrom": "/app/backend/DB_PASSWORD" 42 | }, 43 | { 44 | "name": "DB_USERNAME", 45 | "valueFrom": "/app/backend/DB_USERNAME" 46 | }, 47 | { 48 | "name": "SESSION_SECRET", 49 | "valueFrom": "/app/backend/SESSION_SECRET" 50 | }, 51 | { 52 | "name": "DD_API_KEY", 53 | "valueFrom": "/app/backend/DD_API_KEY" 54 | }, 55 | { 56 | "name": "DD_SERVICE", 57 | "valueFrom": "/app/backend/DD_SERVICE" 58 | }, 59 | { 60 | "name": "DD_TAGS", 61 | "valueFrom": "/app/backend/DD_TAGS" 62 | } 63 | ], 64 | "logConfiguration": { 65 | "logDriver": "awslogs", 66 | "options": { 67 | "awslogs-group": "/ecs/application-server", 68 | "awslogs-region": "ap-southeast-1", 69 | "awslogs-stream-prefix": "ecs" 70 | } 71 | } 72 | }, 73 | { 74 | "name": "dd-agent", 75 | "image": "public.ecr.aws/datadog/agent:latest", 76 | "cpu": 0, 77 | "portMappings": [ 78 | { 79 | "containerPort": 8126, 80 | "hostPort": 8126, 81 | "protocol": "tcp" 82 | } 83 | ], 84 | "essential": false, 85 | "environment": [ 86 | { 87 | "name": "TZ", 88 | "value": "Asia/Singapore" 89 | }, 90 | { 91 | "name": "DD_APM_NON_LOCAL_TRAFFIC", 92 | "value": "true" 93 | }, 94 | { 95 | "name": "ECS_FARGATE", 96 | "value": "true" 97 | }, 98 | { 99 | "name": "DD_APM_ENABLED", 100 | "value": "true" 101 | }, 102 | { 103 | "name": "DD_SITE", 104 | "value": "datadoghq.com" 105 | } 106 | ], 107 | "mountPoints": [], 108 | "volumesFrom": [], 109 | "secrets": [ 110 | { 111 | "name": "DD_API_KEY", 112 | "valueFrom": "/app/backend/DD_API_KEY" 113 | }, 114 | { 115 | "name": "DD_SERVICE", 116 | "valueFrom": "/app/backend/DD_SERVICE" 117 | }, 118 | { 119 | "name": "DD_TAGS", 120 | "valueFrom": "/app/backend/DD_TAGS_AGENT" 121 | } 122 | ], 123 | "logConfiguration": { 124 | "logDriver": "awslogs", 125 | "options": { 126 | "awslogs-group": "/ecs/dd-agent", 127 | "awslogs-region": "ap-southeast-1", 128 | "awslogs-stream-prefix": "ecs" 129 | } 130 | } 131 | } 132 | ], 133 | "family": "application-server", 134 | "networkMode": "awsvpc", 135 | "volumes": [], 136 | "placementConstraints": [], 137 | "runtimePlatform": { 138 | "operatingSystemFamily": "LINUX" 139 | }, 140 | "requiresCompatibilities": ["FARGATE"], 141 | "taskRoleArn": "arn:aws:iam:::role/application-server-role", 142 | "executionRoleArn": "arn:aws:iam:::role/application-server-role", 143 | "cpu": "512", 144 | "memory": "1024" 145 | } 146 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "lint": "eslint .", 7 | "lint:fix": "eslint --fix .", 8 | "pre-commit": "lint-staged", 9 | "prebuild": "rimraf build", 10 | "build": "nest build", 11 | "preload": "echo ENV=${ENV:-'development'} && dotenv -c ${ENV:-'development'} --", 12 | "dev": "npm run preload -- nest start --watch | npm run pino-pretty", 13 | "debug": "npm run preload -- nest start --debug --watch | npm run pino-pretty", 14 | "start": "node -r tsconfig-paths/register build/main | npm run pino-datadog", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage && sed -i.bak 's/SF:src/SF:backend\\/src/g' coverage/lcov.info", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json", 20 | "env:load": "ENV=${ENV:-'development'} node scripts/env/loader.mjs", 21 | "env:put": "ENV=${ENV:-'development'} node scripts/env/putter.mjs", 22 | "pino-pretty": "pino-pretty --hideObject -i pid,hostname -t SYS:standard -o '\u001b[33m[{req.id}]\u001b[0m \u001b[32m[{scope}]\u001b[0m {msg}'", 23 | "pino-datadog": "if [ -n \"${DD_API_KEY}\" ]; then pino-datadog; else cat; fi", 24 | "migration:gen": "npm run preload -- typeorm-ts-node-esm migration:generate -d src/database/datasource.ts", 25 | "migration:show": "npm run preload -- typeorm-ts-node-esm migration:show -d src/database/datasource.ts", 26 | "migration:run": "npm run preload -- typeorm-ts-node-esm migration:run -d src/database/datasource.ts", 27 | "migration:revert": "npm run preload -- typeorm-ts-node-esm migration:revert -d src/database/datasource.ts", 28 | "seed:gen": "npm run preload -- typeorm-ts-node-esm migration:generate -d src/database/datasource-seed.ts", 29 | "seed:show": "npm run preload -- typeorm-ts-node-esm migration:show -d src/database/datasource-seed.ts", 30 | "seed:run": "npm run preload -- typeorm-ts-node-esm migration:run -d src/database/datasource-seed.ts", 31 | "seed:revert": "npm run preload -- typeorm-ts-node-esm migration:revert -d src/database/datasource-seed.ts" 32 | }, 33 | "dependencies": { 34 | "@nestjs/common": "^9.4.0", 35 | "@nestjs/core": "^9.4.0", 36 | "@nestjs/platform-express": "^9.4.3", 37 | "@nestjs/serve-static": "^4.0.1", 38 | "@nestjs/terminus": "^10.2.3", 39 | "@nestjs/typeorm": "^9.0.1", 40 | "@opengovsg/postmangovsg-client": "^0.0.9", 41 | "connect-typeorm": "^2.0.0", 42 | "convict": "^6.2.4", 43 | "dd-trace": "^5.6.0", 44 | "dotenv": "^16.4.5", 45 | "express-session": "^1.17.3", 46 | "helmet": "^7.1.0", 47 | "nestjs-pino": "^4.0.0", 48 | "nodemailer": "^6.9.12", 49 | "otplib": "^12.0.1", 50 | "pg": "^8.10.0", 51 | "pino-datadog": "^2.0.2", 52 | "pino-http": "^9.0.0", 53 | "reflect-metadata": "^0.1.14", 54 | "rxjs": "^7.8.1", 55 | "typeorm": "^0.3.20" 56 | }, 57 | "devDependencies": { 58 | "@aws-sdk/client-ssm": "^3.532.0", 59 | "@nestjs/cli": "^10.3.2", 60 | "@nestjs/schematics": "^9.1.0", 61 | "@nestjs/testing": "^9.4.1", 62 | "@pulumi/eslint-plugin": "^0.2.0", 63 | "@types/convict": "^6.1.6", 64 | "@types/express": "^4.17.17", 65 | "@types/express-session": "^1.17.7", 66 | "@types/jest": "^28.1.8", 67 | "@types/node": "*", 68 | "@types/nodemailer": "^6.4.14", 69 | "@types/pg": "^8.6.6", 70 | "@types/supertest": "^6.0.2", 71 | "@typescript-eslint/eslint-plugin": "^5.62.0", 72 | "dotenv-cli": "^7.4.1", 73 | "eslint": "^8.57.0", 74 | "eslint-config-opengovsg": "^2.0.6", 75 | "eslint-config-prettier": "^8.10.0", 76 | "eslint-import-resolver-typescript": "^3.6.1", 77 | "eslint-plugin-import": "^2.29.1", 78 | "eslint-plugin-prettier": "^4.2.1", 79 | "eslint-plugin-react": "^7.33.1", 80 | "eslint-plugin-react-hooks": "^4.6.0", 81 | "eslint-plugin-simple-import-sort": "^10.0.0", 82 | "jest": "^28.1.3", 83 | "lint-staged": "^13.2.2", 84 | "pino-pretty": "^10.3.1", 85 | "prettier": "^2.8.8", 86 | "rimraf": "^5.0.5", 87 | "supertest": "^6.3.4", 88 | "ts-jest": "^28.0.8", 89 | "ts-loader": "^9.5.1", 90 | "ts-node": "^10.9.2", 91 | "tsconfig-paths": "^4.2.0", 92 | "typescript": "^5.4.2" 93 | }, 94 | "lint-staged": { 95 | "**/*.(js|jsx|ts|tsx)": [ 96 | "eslint --fix" 97 | ] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "proxy": "http://localhost:8080/", 6 | "dependencies": { 7 | "@chakra-ui/react": "^2.8.2", 8 | "@emotion/react": "^11.11.4", 9 | "@emotion/styled": "^11.10.6", 10 | "@fontsource/ibm-plex-mono": "^5.0.12", 11 | "@hookform/resolvers": "^3.3.4", 12 | "@opengovsg/design-system-react": "^1.18.0", 13 | "@tanstack/react-query": "^4.28.0", 14 | "ci-info": "^4.0.0", 15 | "class-validator": "^0.14.0", 16 | "framer-motion": "^11.0.12", 17 | "inter-ui": "^4.0.2", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "react-hook-form": "^7.51.0", 21 | "react-router-dom": "^6.22.3", 22 | "wretch": "^2.5.1", 23 | "zod": "^3.23.8" 24 | }, 25 | "devDependencies": { 26 | "@chakra-ui/cli": "^2.4.1", 27 | "@pulumi/eslint-plugin": "^0.2.0", 28 | "@snyk/protect": "latest", 29 | "@storybook/addon-actions": "^6.5.16", 30 | "@storybook/addon-essentials": "^7.6.17", 31 | "@storybook/addon-interactions": "^6.5.16", 32 | "@storybook/addon-links": "^6.5.16", 33 | "@storybook/builder-webpack5": "^6.5.16", 34 | "@storybook/manager-webpack5": "^6.5.16", 35 | "@storybook/mdx2-csf": "^1.1.0", 36 | "@storybook/node-logger": "^7.6.17", 37 | "@storybook/preset-create-react-app": "^4.1.2", 38 | "@storybook/react": "^6.5.16", 39 | "@storybook/testing-library": "^0.2.2", 40 | "@testing-library/jest-dom": "^6.4.2", 41 | "@testing-library/react": "^14.0.0", 42 | "@testing-library/user-event": "^14.5.2", 43 | "@types/jest": "^29.5.12", 44 | "@types/node": "*", 45 | "@types/react": "^18.0.32", 46 | "@types/react-dom": "^18.2.21", 47 | "@types/react-router-dom": "^5.3.3", 48 | "@typescript-eslint/eslint-plugin": "^5.62.0", 49 | "babel-plugin-named-exports-order": "^0.0.2", 50 | "cross-env": "^7.0.3", 51 | "eslint": "^8.57.0", 52 | "eslint-config-opengovsg": "^2.0.6", 53 | "eslint-config-prettier": "^8.10.0", 54 | "eslint-import-resolver-typescript": "^3.6.1", 55 | "eslint-plugin-import": "^2.29.1", 56 | "eslint-plugin-prettier": "^4.2.1", 57 | "eslint-plugin-react": "^7.33.1", 58 | "eslint-plugin-react-hooks": "^4.6.0", 59 | "eslint-plugin-simple-import-sort": "^10.0.0", 60 | "lint-staged": "^13.2.0", 61 | "prettier": "^2.8.8", 62 | "prop-types": "^15.8.1", 63 | "react-app-alias-ex": "^2.1.0", 64 | "react-app-rewired": "^2.2.1", 65 | "react-scripts": "^5.0.1", 66 | "typescript": "^4.9.5", 67 | "webpack": "^5.94.0" 68 | }, 69 | "overrides": { 70 | "react-refresh": "0.14.0" 71 | }, 72 | "scriptComments": { 73 | "build": [ 74 | "The flags are required for the app to build successfully.", 75 | "`CI=false` ensures that warnings in the app linter are not treated as errors (preventing a build)", 76 | "`INLINE_RUNTIME_CHUNK=false` prevents inline scripts from appearing in the build output. This is to prevent blank pages due to possible strict CSP rules on the backend" 77 | ] 78 | }, 79 | "scripts": { 80 | "gen:theme-typings": "chakra-cli tokens src/theme/index.ts", 81 | "postinstall": "npm run gen:theme-typings", 82 | "dev": "npm start", 83 | "start": "DISABLE_ESLINT_PLUGIN=true react-app-rewired start", 84 | "build": "cross-env CI=false DISABLE_ESLINT_PLUGIN=true INLINE_RUNTIME_CHUNK=false react-app-rewired build", 85 | "test": "react-app-rewired test --passWithNoTests", 86 | "eject": "react-scripts eject", 87 | "storybook": "start-storybook -p 6006 -s public", 88 | "build-storybook": "build-storybook -s public", 89 | "lint": "eslint .", 90 | "lint:fix": "eslint --fix .", 91 | "pre-commit": "lint-staged", 92 | "snyk-protect": "snyk-protect", 93 | "prepare": "npm run snyk-protect" 94 | }, 95 | "lint-staged": { 96 | "**/*.(js|jsx|ts|tsx)": [ 97 | "eslint --cache --fix" 98 | ] 99 | }, 100 | "browserslist": { 101 | "production": [ 102 | ">0.2%", 103 | "not dead", 104 | "not op_mini all" 105 | ], 106 | "development": [ 107 | "last 1 chrome version", 108 | "last 1 firefox version", 109 | "last 1 safari version" 110 | ] 111 | }, 112 | "snyk": true, 113 | "jest": { 114 | "transformIgnorePatterns": [ 115 | "[/\\\\]node_modules[/\\\\](?!(react-markdown|)).+\\.(js|jsx|mjs|cjs|ts|tsx)$" 116 | ], 117 | "moduleNameMapper": { 118 | "\\.(css|jpg|png)$": "identity-obj-proxy", 119 | "@fontsource/ibm-plex-mono": "identity-obj-proxy", 120 | "react-markdown": "/__mocks__/react-markdown.tsx" 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /docs/deploying/for-engineers.md: -------------------------------------------------------------------------------- 1 | # Deploying Your Application - An Engineer's Guide 2 | 3 | Learn how to prepare your application to take it from your development 4 | environment into a deployment ready to receive users. 5 | 6 | ## Infrastructure 7 | 8 | ### AWS Account Set-up 9 | 10 | #### Managed 11 | 12 | - Create new CloudCity accounts for staging and production 13 | 14 | #### Manual 15 | 16 | - Register for AWS VPN and AWS SSO 17 | - Create root AWS 18 | - Create two AWS accounts, for staging and production 19 | - If desired, set up AWS Control Tower 20 | 21 | ### Setting up compute and networking 22 | 23 | - Use terraform to deploy CRUD application infrastructure, with 24 | template provided in a separate repository. This includes: 25 | - VPCs 26 | - Subnets 27 | - Security Groups 28 | - ECS (preferred) or Elastic Beanstalk (legacy) 29 | - RDS 30 | 31 | - Stash the following secrets for GitHub Actions: 32 | - AWS access keys 33 | - Elastic Container Registry details 34 | - Environment Names 35 | 36 | - Stash secrets in AWS Systems Manager Parameter Store. 37 | The actual secrets to store are defined in ecs-task-definition.json 38 | 39 | - Use the following branch names for deploying: 40 | - `staging` - deploys to staging 41 | - `master` or `release` - deploys to production 42 | 43 | - Wire the deploy GitHub Action to be invoked on push to 44 | the designated branch names 45 | 46 | - Obtain Amazon Certificate Manager (ACM) SSL certificates for 47 | the domain associated with the application 48 | - Configure DNS entries in Cloudflare to facilitate this, 49 | following instructions from AWS 50 | 51 | - Create DNS entries on Cloudflare that point the root domain 52 | and www subdomain to the load balancer 53 | 54 | - Create DNS entries on Cloudflare so that Google Groups 55 | can be used for mail addresses with the domain name associated 56 | with the application 57 | 58 | ### Setting up GitHub Actions 59 | 60 | The GitHub Actions CI workflow (`ci.yml`) uses GitHub OIDC to authenticate with AWS. 61 | 62 | This has several benefits over using AWS access keys: 63 | 64 | - The credentials are short-lived, and can be revoked at any time 65 | - Fine grained access control for credentials 66 | 67 | Read more on OIDC [here](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect). 68 | 69 | The simplest way to set this up is using CloudFormation. In the root AWS account, create a CloudFormation stack: 70 | 71 | ```yaml 72 | Parameters: 73 | GitHubOrg: 74 | Type: String 75 | RepositoryName: 76 | Type: String 77 | OIDCProviderArn: 78 | Description: Arn for the GitHub OIDC Provider. 79 | Default: "" 80 | Type: String 81 | ​ 82 | Conditions: 83 | CreateOIDCProvider: !Equals 84 | - !Ref OIDCProviderArn 85 | - "" 86 | ​ 87 | Resources: 88 | Role: 89 | Type: AWS::IAM::Role 90 | Properties: 91 | AssumeRolePolicyDocument: 92 | Statement: 93 | - Effect: Allow 94 | Action: sts:AssumeRoleWithWebIdentity 95 | Principal: 96 | Federated: !If 97 | - CreateOIDCProvider 98 | - !Ref GithubOidc 99 | - !Ref OIDCProviderArn 100 | Condition: 101 | StringLike: 102 | token.actions.githubusercontent.com:aud: sts.amazonaws.com 103 | token.actions.githubusercontent.com:sub: !Sub repo:${GitHubOrg}/${RepositoryName}:* 104 | Policies: # TODO - Attach any other policies you need to deploy your app (ECR, EB) 105 | - PolicyName: DeployToSomeS3Bucket 106 | PolicyDocument: 107 | Version: "2012-10-17" 108 | Statement: 109 | - Effect: Allow 110 | Action: 111 | - s3:DeleteObject 112 | - s3:GetBucketLocation 113 | - s3:GetObject 114 | - s3:ListBucket 115 | - s3:PutObject 116 | - s3:ListObjectsV2 117 | Resource: arn:aws:s3:::/* 118 | ​ 119 | GithubOidc: 120 | Type: AWS::IAM::OIDCProvider 121 | Condition: CreateOIDCProvider 122 | Properties: 123 | Url: https://token.actions.githubusercontent.com 124 | ClientIdList: 125 | - sts.amazonaws.com 126 | ThumbprintList: 127 | - 6938fd4d98bab03faadb97b34396831e3780aea1 128 | - 1c58a3a8518e8759bf075b76b750d4f2df264fcd 129 | ​ 130 | Outputs: 131 | Role: 132 | Value: !GetAtt Role.Arn 133 | ``` 134 | 135 | The stack creates a role that is assumed by the GitHub Action. The stack provided allows the role to sync with an S3 bucket. You must add any policies required to deploy your application to that role. 136 | 137 | The stack will prompt for a few inputs: 138 | 139 | - `GitHubOrg`: This should be either `datagovsg` or `opengovsg` 140 | - `RepositoryName`: The repository the GitHub Action will run on 141 | - `OIDCProviderARN`: 142 | - If a GitHub OIDC provider already exists in the account, use the ARN of the provider 143 | - Else leave this field blank, and the stack will create a new provider 144 | 145 | After creating the resources, navigate to the GitHub OIDC Provider created, and copy the ARN. 146 | 147 | In the GitHub Action's env vars, set: 148 | 149 | - `AWS_ROLE_ARN`: the copied ARN 150 | - `AWS_REGION`: the region of the AWS account 151 | 152 | That's it! 153 | 154 | > Terraform version coming soon... 155 | -------------------------------------------------------------------------------- /frontend/src/features/auth/routes/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, GridItem, GridProps, Text } from '@chakra-ui/react' 2 | import { FC, PropsWithChildren, useState } from 'react' 3 | 4 | import { AppFooter } from '~/app/AppFooter' 5 | import { useIsDesktop } from '~hooks/useIsDesktop' 6 | import { useAuth } from '~lib/auth' 7 | import { AppGrid } from '~templates/AppGrid' 8 | 9 | import { LoginForm, LoginFormInputs } from '../components/LoginForm' 10 | import { LoginImageSvgr } from '../components/LoginImageSvgr' 11 | import { OtpForm, OtpFormInputs } from '../components/OtpForm' 12 | 13 | export type LoginOtpData = { 14 | email: string 15 | } 16 | 17 | // Component for the split blue/white background. 18 | const BackgroundBox: FC = ({ children }) => ( 19 | 29 | {children} 30 | 31 | ) 32 | 33 | // Component that controls the various grid areas according to responsive breakpoints. 34 | const BaseGridLayout = (props: GridProps) => ( 35 | 36 | ) 37 | 38 | // Grid area styling for the login form. 39 | const LoginGridArea: FC = ({ children }) => ( 40 | 47 | {children} 48 | 49 | ) 50 | 51 | // Grid area styling for the footer. 52 | const FooterGridArea: FC = ({ children }) => ( 53 | 59 | {children} 60 | 61 | ) 62 | 63 | // Grid area styling for the left sidebar that only displays on tablet and desktop breakpoints. 64 | const NonMobileSidebarGridArea: FC = ({ children }) => ( 65 | 75 | {children} 76 | 77 | ) 78 | 79 | export const LoginPage = (): JSX.Element => { 80 | const { sendLoginOtp, verifyLoginOtp } = useAuth() 81 | const isDesktop = useIsDesktop() 82 | 83 | const [email, setEmail] = useState() 84 | 85 | const handleSendOtp = async ({ email }: LoginFormInputs) => { 86 | const trimmedEmail = email.trim() 87 | await sendLoginOtp({ email: trimmedEmail }) 88 | return setEmail(trimmedEmail) 89 | } 90 | 91 | const handleVerifyOtp = async ({ token }: OtpFormInputs) => { 92 | // Should not happen, since OtpForm component is only shown when there is 93 | // already an email state set. 94 | if (!email) { 95 | throw new Error('Something went wrong') 96 | } 97 | return verifyLoginOtp({ token, email }) 98 | } 99 | 100 | const handleResendOtp = async () => { 101 | // Should not happen, since OtpForm component is only shown when there is 102 | // already an email state set. 103 | if (!email) { 104 | throw new Error('Something went wrong') 105 | } 106 | await sendLoginOtp({ email }) 107 | } 108 | 109 | return ( 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 123 | Scaffold a starter project in minutes 124 | 125 | 126 | 127 | Starter Kit 128 | 129 | 136 | Scaffold a starter project in minutes 137 | 138 | 139 | 140 | {!email ? ( 141 | 142 | ) : ( 143 | 148 | )} 149 | 150 | 151 | 152 | 155 | 156 | 160 | 161 | 162 | 163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /frontend/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | // This optional code is used to register a service worker. 3 | // register() is not called by default. 4 | 5 | // This lets the app load faster on subsequent visits in production, and gives 6 | // it offline capabilities. However, it also means that developers (and users) 7 | // will only see deployed updates on subsequent visits to a page, after all the 8 | // existing tabs open on the page have been closed, since previously cached 9 | // resources are updated in the background. 10 | 11 | // To learn more about the benefits of this model and instructions on how to 12 | // opt-in, read https://bit.ly/CRA-PWA 13 | 14 | const isLocalhost = Boolean( 15 | window.location.hostname === 'localhost' || 16 | // [::1] is the IPv6 localhost address. 17 | window.location.hostname === '[::1]' || 18 | // 127.0.0.0/8 are considered localhost for IPv4. 19 | window.location.hostname.match( 20 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, 21 | ), 22 | ) 23 | 24 | type Config = { 25 | onSuccess?: (_registration: ServiceWorkerRegistration) => void 26 | onUpdate?: (_registration: ServiceWorkerRegistration) => void 27 | } 28 | 29 | export function register(config?: Config): void { 30 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 31 | // The URL constructor is available in all browsers that support SW. 32 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) 33 | if (publicUrl.origin !== window.location.origin) { 34 | // Our service worker won't work if PUBLIC_URL is on a different origin 35 | // from what our page is served on. This might happen if a CDN is used to 36 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 37 | return 38 | } 39 | 40 | window.addEventListener('load', () => { 41 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` 42 | 43 | if (isLocalhost) { 44 | // This is running on localhost. Let's check if a service worker still exists or not. 45 | checkValidServiceWorker(swUrl, config) 46 | 47 | // Add some additional logging to localhost, pointing developers to the 48 | // service worker/PWA documentation. 49 | void navigator.serviceWorker.ready.then(() => { 50 | console.log( 51 | 'This web app is being served cache-first by a service ' + 52 | 'worker. To learn more, visit https://bit.ly/CRA-PWA', 53 | ) 54 | }) 55 | } else { 56 | // Is not localhost. Just register service worker 57 | registerValidSW(swUrl, config) 58 | } 59 | }) 60 | } 61 | } 62 | 63 | function registerValidSW(swUrl: string, config?: Config) { 64 | navigator.serviceWorker 65 | .register(swUrl) 66 | .then((registration) => { 67 | registration.onupdatefound = () => { 68 | const installingWorker = registration.installing 69 | if (installingWorker == null) { 70 | return 71 | } 72 | installingWorker.onstatechange = () => { 73 | if (installingWorker.state === 'installed') { 74 | if (navigator.serviceWorker.controller) { 75 | // At this point, the updated precached content has been fetched, 76 | // but the previous service worker will still serve the older 77 | // content until all client tabs are closed. 78 | console.log( 79 | 'New content is available and will be used when all ' + 80 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.', 81 | ) 82 | 83 | // Execute callback 84 | if (config && config.onUpdate) { 85 | config.onUpdate(registration) 86 | } 87 | } else { 88 | // At this point, everything has been precached. 89 | // It's the perfect time to display a 90 | // "Content is cached for offline use." message. 91 | console.log('Content is cached for offline use.') 92 | 93 | // Execute callback 94 | if (config && config.onSuccess) { 95 | config.onSuccess(registration) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | }) 102 | .catch((error) => { 103 | console.error('Error during service worker registration:', [error]) 104 | }) 105 | } 106 | 107 | function checkValidServiceWorker(swUrl: string, config?: Config) { 108 | // Check if the service worker can be found. If it can't reload the page. 109 | fetch(swUrl, { 110 | headers: { 'Service-Worker': 'script' }, 111 | }) 112 | .then((response) => { 113 | // Ensure service worker exists, and that we really are getting a JS file. 114 | const contentType = response.headers.get('content-type') 115 | if ( 116 | response.status === 404 || 117 | (contentType != null && contentType.indexOf('javascript') === -1) 118 | ) { 119 | // No service worker found. Probably a different app. Reload the page. 120 | return navigator.serviceWorker.ready.then((registration) => { 121 | return registration.unregister().then(() => { 122 | window.location.reload() 123 | }) 124 | }) 125 | } else { 126 | // Service worker found. Proceed as normal. 127 | registerValidSW(swUrl, config) 128 | } 129 | }) 130 | .catch(() => { 131 | console.log( 132 | 'No internet connection found. App is running in offline mode.', 133 | ) 134 | }) 135 | } 136 | 137 | export function unregister(): void { 138 | if ('serviceWorker' in navigator) { 139 | navigator.serviceWorker.ready 140 | .then((registration) => { 141 | return registration.unregister() 142 | }) 143 | .catch((error: Error) => { 144 | console.error(error.message) 145 | }) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi -------------------------------------------------------------------------------- /frontend/docs/project-config.md: -------------------------------------------------------------------------------- 1 | # ⚙️ Project Configuration 2 | 3 | The template has been bootstrapped using `Create React App`. To eject the app and customize the configuration, follow the [official documentation](https://create-react-app.dev/docs/available-scripts/#npm-run-eject). 4 | 5 | The following tools are configured and used in this template: 6 | 7 | #### ESLint 8 | 9 | ESLint is a linting tool for JavaScript. 10 | Specific configuration has been defined in [`.eslintrc.js`](../.eslintrc.js). The linter enforces consistency in the codebase and reduces bugs. It also helps to avoid common mistakes and enforce best practices. 11 | 12 | [ESLint Configuration](../.eslintrc.js) 13 | 14 | > 💡: If you are using VSCode, you can install the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for instant feedback on your code using the ESLint rules set in the configuration file. 15 | 16 | #### Prettier 17 | 18 | Prettier is a code formatting tool. Prettier takes care of your code formatting, ESLint takes care of your code style and quality. 19 | 20 | [Prettier Configuration](../../.prettierrc.js) 21 | 22 | > 💡: If you are using VSCode, you can install the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for instant feedback on your code using the Prettier rules set in the configuration file. 23 | 24 | > You can also enable the "format on save" feature in your IDE to automatically format the code based on the configuration. It will also give you good feedback when something is wrong with the code. If the auto-format doesn't work, something is wrong with the code. 25 | 26 | #### TypeScript 27 | 28 | ESLint is great for catching some of the bugs related to the language, but since JavaScript is a dynamic language ESLint cannot check data that run through the applications, which can lead to bugs, especially on larger projects. 29 | 30 | That is why TypeScript should be used. It is very useful during large refactors because it reports any issues you might miss otherwise. 31 | 32 | When refactoring, change the type declaration first, then fix all the TypeScript errors throughout the project and you are done. One thing you should keep in mind is that TypeScript does not protect your application from failing during runtime, it only does type checking during build time, but it increases development confidence drastically anyways. Here is a [great resource on using TypeScript with React](https://react-typescript-cheatsheet.netlify.app/). 33 | 34 | #### Husky 35 | 36 | Husky is a tool for executing git hooks. Use Husky to run your code validations before every commit, thus making sure the code is in the best shape possible at any point of time and no faulty commits get into the repo. It can run linting, code formatting and type checking, etc. before it allows pushing the code. You can check how to configure it [here](https://typicode.github.io/husky/#/?id=usage). 37 | 38 | This repository has a [pre-commit hook](../../.husky/pre-commit) that runs the linter and prettier before every commit. If there are any errors, the commit will fail and you will have to fix them before you can commit. 39 | 40 | [Husky configuration](../../.husky) 41 | 42 | #### Absolute imports 43 | 44 | Absolute imports should always be configured and used because it makes it easier to move files around and avoid messy import paths such as `../../../Component`. Wherever you move the file, all the imports will remain intact. 45 | 46 | This repository has absolute imports configured as follows: 47 | 48 | ```json 49 | { 50 | "compilerOptions": { 51 | "baseUrl": "./", 52 | "paths": { 53 | "~shared/*": ["../shared/src/*"], 54 | "~contexts/*": ["./src/contexts/*"], 55 | "~constants/*": ["./src/constants/*"], 56 | "~components/*": ["./src/components/*"], 57 | "~templates/*": ["./src/templates/*"], 58 | "~features/*": ["./src/features/*"], 59 | "~hooks/*": ["./src/hooks/*"], 60 | "~utils/*": ["./src/utils/*"], 61 | "~pages/*": ["./src/pages/*"], 62 | "~services/*": ["./src/services/*"], 63 | "~/*": ["./src/*"] 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | > 💡: There are also ESLint rules that rely on these paths. If you change the paths in the TypeScript config, make sure they are also changed in the [ESLint configuration](../.eslintrc.js#L25-L56). 70 | 71 | In this project, the tsconfig file `tsconfig.paths.json` is used to configure the paths and merge it with the base configuration, because CRA overrides the `paths` key in the `tsconfig.json` file. 72 | 73 | The `~` symbol is used as the base path for all the imports. Whenever possible, use the named paths such as `~components` instead of `~/component` as [ESLint has been set up](../.eslintrc.js#L25-L56) to automatically sort imports based on the path's perceived importance. 74 | 75 | [Paths Configuration](../tsconfig.paths.json) 76 | 77 | #### React App Rewired 78 | 79 | [react-app-rewired](https://www.npmjs.com/package/react-app-rewired) is a tool that allows you to customize the configuration of the `Create React App` without having to eject the app. 80 | It is used in this application to configure the absolute imports and the TypeScript paths. 81 | 82 | [React App Rewired Configuration](../config-overrides.js) 83 | 84 | #### Storybook 85 | 86 | [Storybook](https://storybook.js.org/) is a tool for developing UI components in isolation. It is a great tool for developing and testing components in isolation without having to worry about the application's state and context. It also helps to document the components and their usage. 87 | 88 | Contrary to popular belief where Storybook is used solely for components, you can also use it to render entire pages and features. APIs can also be mocked in Storybook using the [`msw-storybook-addon`](https://www.npmjs.com/package/msw-storybook-addon) package, amongst other addons which can be found [here](https://storybook.js.org/addons). 89 | 90 | This repository has Storybook configured and you can run it using the following command: 91 | 92 | ```bash 93 | npm run storybook 94 | ``` 95 | 96 | You can then access the Storybook UI at http://localhost:6006. There are some stories already written for the components in the application. You can add more stories for the components you create. 97 | -------------------------------------------------------------------------------- /frontend/.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.21.5 3 | ignore: {} 4 | # patches apply the minimum changes required to fix a vulnerability 5 | patch: 6 | SNYK-JS-LODASH-567746: 7 | - react-scripts > @typescript-eslint/eslint-plugin > @typescript-eslint/experimental-utils > @typescript-eslint/typescript-estree > lodash: 8 | patched: '2021-06-10T04:17:38.739Z' 9 | - snyk > snyk-nodejs-lockfile-parser > @yarnpkg/core > lodash: 10 | patched: '2021-06-10T06:35:48.675Z' 11 | - snyk > snyk-docker-plugin > snyk-nodejs-lockfile-parser > @yarnpkg/core > lodash: 12 | patched: '2021-06-10T06:35:48.675Z' 13 | - '@testing-library/jest-dom > lodash': 14 | patched: '2021-07-26T06:49:58.553Z' 15 | - node-sass > lodash: 16 | patched: '2021-07-26T06:49:58.553Z' 17 | - node-sass > sass-graph > lodash: 18 | patched: '2021-07-26T06:49:58.553Z' 19 | - react-scripts > eslint-plugin-flowtype > lodash: 20 | patched: '2021-07-26T06:49:58.553Z' 21 | - react-scripts > html-webpack-plugin > lodash: 22 | patched: '2021-07-26T06:49:58.553Z' 23 | - react-scripts > webpack-manifest-plugin > lodash: 24 | patched: '2021-07-26T06:49:58.553Z' 25 | - node-sass > gaze > globule > lodash: 26 | patched: '2021-07-26T06:49:58.553Z' 27 | - react-scripts > optimize-css-assets-webpack-plugin > last-call-webpack-plugin > lodash: 28 | patched: '2021-07-26T06:49:58.553Z' 29 | - react-scripts > webpack-dev-server > http-proxy-middleware > lodash: 30 | patched: '2021-07-26T06:49:58.553Z' 31 | - snyk > snyk-nodejs-lockfile-parser > @yarnpkg/core > lodash: 32 | patched: '2021-07-26T06:49:58.553Z' 33 | - react-scripts > eslint-plugin-testing-library > @typescript-eslint/experimental-utils > @typescript-eslint/typescript-estree > lodash: 34 | patched: '2021-07-26T06:49:58.553Z' 35 | - react-scripts > webpack-dev-server > portfinder > async > lodash: 36 | patched: '2021-07-26T06:49:58.553Z' 37 | - snyk > snyk-docker-plugin > snyk-nodejs-lockfile-parser > @yarnpkg/core > lodash: 38 | patched: '2021-07-26T06:49:58.553Z' 39 | - react-scripts > jest > @jest/core > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 40 | patched: '2021-07-26T06:49:58.553Z' 41 | - react-scripts > jest > jest-cli > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 42 | patched: '2021-07-26T06:49:58.553Z' 43 | - react-scripts > jest-circus > jest-runner > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 44 | patched: '2021-07-26T06:49:58.553Z' 45 | - react-scripts > jest-circus > jest-runtime > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 46 | patched: '2021-07-26T06:49:58.553Z' 47 | - react-scripts > jest > @jest/core > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 48 | patched: '2021-07-26T06:49:58.553Z' 49 | - react-scripts > jest > jest-cli > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 50 | patched: '2021-07-26T06:49:58.553Z' 51 | - react-scripts > jest-circus > jest-runner > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 52 | patched: '2021-07-26T06:49:58.553Z' 53 | - react-scripts > jest-circus > jest-runtime > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 54 | patched: '2021-07-26T06:49:58.553Z' 55 | - react-scripts > jest > jest-cli > @jest/core > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 56 | patched: '2021-07-26T06:49:58.553Z' 57 | - react-scripts > jest > @jest/core > jest-runner > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 58 | patched: '2021-07-26T06:49:58.553Z' 59 | - react-scripts > jest-circus > jest-runner > jest-runtime > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 60 | patched: '2021-07-26T06:49:58.553Z' 61 | - react-scripts > jest > @jest/core > jest-runtime > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 62 | patched: '2021-07-26T06:49:58.553Z' 63 | - react-scripts > jest > jest-cli > @jest/core > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 64 | patched: '2021-07-26T06:49:58.553Z' 65 | - react-scripts > jest > @jest/core > jest-runner > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 66 | patched: '2021-07-26T06:49:58.553Z' 67 | - react-scripts > jest-circus > jest-runner > jest-runtime > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 68 | patched: '2021-07-26T06:49:58.553Z' 69 | - react-scripts > jest > @jest/core > jest-runtime > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 70 | patched: '2021-07-26T06:49:58.553Z' 71 | - react-scripts > jest > jest-cli > @jest/core > jest-runner > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 72 | patched: '2021-07-26T06:49:58.553Z' 73 | - react-scripts > jest > @jest/core > jest-runner > jest-runtime > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 74 | patched: '2021-07-26T06:49:58.553Z' 75 | - react-scripts > jest > jest-cli > @jest/core > jest-runtime > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 76 | patched: '2021-07-26T06:49:58.553Z' 77 | - react-scripts > jest > jest-cli > @jest/core > jest-runner > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 78 | patched: '2021-07-26T06:49:58.553Z' 79 | - react-scripts > jest > @jest/core > jest-runner > jest-runtime > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 80 | patched: '2021-07-26T06:49:58.553Z' 81 | - react-scripts > jest > jest-cli > @jest/core > jest-runtime > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 82 | patched: '2021-07-26T06:49:58.553Z' 83 | - react-scripts > jest > jest-cli > @jest/core > jest-runner > jest-runtime > jest-config > jest-environment-jsdom > jsdom > whatwg-url > lodash: 84 | patched: '2021-07-26T06:49:58.553Z' 85 | - react-scripts > jest > jest-cli > @jest/core > jest-runner > jest-runtime > jest-config > jest-environment-jsdom > jsdom > data-urls > whatwg-url > lodash: 86 | patched: '2021-07-26T06:49:58.553Z' 87 | -------------------------------------------------------------------------------- /backend/src/config/config.schema.ts: -------------------------------------------------------------------------------- 1 | import { addFormats, Schema } from 'convict' 2 | 3 | export interface ConfigSchema { 4 | port: number 5 | environment: 'development' | 'staging' | 'production' | 'test' 6 | awsRegion: string 7 | database: { 8 | host: string 9 | username: string 10 | password: string 11 | port: number 12 | name: string 13 | logging: boolean 14 | minPool: number 15 | maxPool: number 16 | ca: string 17 | } 18 | session: { 19 | secret: string 20 | name: string 21 | cookie: { 22 | maxAge: number 23 | } 24 | } 25 | otp: { 26 | expiry: number 27 | secret: string 28 | numValidPastWindows: number 29 | numValidFutureWindows: number 30 | sender_name: string 31 | email: string 32 | } 33 | postmangovsgApiKey: string 34 | mailer: { 35 | auth: { 36 | type: 'login' 37 | user: string 38 | pass: string 39 | } 40 | host: string 41 | port: number 42 | } 43 | health: { heapSizeThreshold: number; rssThreshold: number } 44 | } 45 | 46 | addFormats({ 47 | 'required-string': { 48 | validate: (val?: string): void => { 49 | if (val == undefined || val === '') { 50 | throw new Error('Required value cannot be empty') 51 | } 52 | }, 53 | }, 54 | }) 55 | 56 | export const schema: Schema = { 57 | port: { 58 | doc: 'The port that the service listens on', 59 | env: 'PORT', 60 | format: 'int', 61 | default: 8080, 62 | }, 63 | environment: { 64 | doc: 'The environment that Node.js is running in', 65 | env: 'NODE_ENV', 66 | format: ['development', 'staging', 'production', 'test'], 67 | default: 'development', 68 | }, 69 | awsRegion: { 70 | doc: 'The AWS region for SES. Optional, logs mail to console if absent', 71 | env: 'AWS_REGION', 72 | format: '*', 73 | default: '', 74 | }, 75 | database: { 76 | username: { 77 | env: 'DB_USERNAME', 78 | sensitive: true, 79 | default: '', 80 | format: 'required-string', 81 | }, 82 | password: { 83 | env: 'DB_PASSWORD', 84 | sensitive: true, 85 | default: '', 86 | format: 'required-string', 87 | }, 88 | host: { 89 | env: 'DB_HOST', 90 | default: 'localhost', 91 | format: 'required-string', 92 | }, 93 | port: { 94 | env: 'DB_PORT', 95 | default: 5432, 96 | format: Number, 97 | }, 98 | name: { 99 | env: 'DB_NAME', 100 | default: '', 101 | format: 'required-string', 102 | }, 103 | logging: { 104 | env: 'DB_LOGGING', 105 | default: false, 106 | }, 107 | minPool: { 108 | env: 'DB_MIN_POOL_SIZE', 109 | default: 10, 110 | }, 111 | maxPool: { 112 | env: 'DB_MAX_POOL_SIZE', 113 | default: 100, 114 | }, 115 | ca: { 116 | env: 'CA_CERT', 117 | default: '', 118 | format: String, 119 | }, 120 | }, 121 | session: { 122 | name: { 123 | doc: 'Name of session ID cookie to set in response', 124 | env: 'SESSION_NAME', 125 | default: 'ts-template.sid', 126 | format: String, 127 | }, 128 | secret: { 129 | doc: 'A secret string used to generate sessions for users', 130 | env: 'SESSION_SECRET', 131 | default: 'toomanysecrets', 132 | format: String, 133 | }, 134 | cookie: { 135 | maxAge: { 136 | doc: 'The maximum age for a cookie, expressed in ms', 137 | env: 'COOKIE_MAX_AGE', 138 | format: 'int', 139 | default: 24 * 60 * 60 * 1000, // 24 hours 140 | }, 141 | }, 142 | }, 143 | otp: { 144 | expiry: { 145 | doc: 'The number of seconds that an OTP is valid for a user', 146 | env: 'OTP_EXPIRY', 147 | format: 'int', 148 | default: 300, 149 | }, 150 | secret: { 151 | doc: 'A secret string used to generate TOTPs for users', 152 | env: 'OTP_SECRET', 153 | format: '*', 154 | default: 'toomanysecrets', 155 | }, 156 | numValidPastWindows: { 157 | doc: 'The number of past windows for which tokens should be considered valid, where a window is the duration that an OTP is valid for, e.g. OTP expiry time.', 158 | env: 'OTP_NUM_VALID_PAST_WINDOWS', 159 | format: 'int', 160 | default: 1, 161 | }, 162 | numValidFutureWindows: { 163 | doc: 'The number of future windows for which tokens should be considered valid, where a window is the duration that an OTP is valid for, e.g. OTP expiry time.', 164 | env: 'OTP_NUM_VALID_FUTURE_WINDOWS', 165 | format: 'int', 166 | default: 0, 167 | }, 168 | sender_name: { 169 | doc: 'Name of email sender', 170 | env: 'OTP_SENDER_NAME', 171 | format: String, 172 | default: 'Starter Kit', 173 | }, 174 | email: { 175 | doc: 'Email to send OTP emails from. If POSTMANGOVSG_API_KEY is set, ensure that this email is set to `donotreply@mail.postman.gov.sg`', 176 | env: 'OTP_EMAIL', 177 | format: String, 178 | default: 'donotreply@mail.postman.gov.sg', 179 | }, 180 | }, 181 | postmangovsgApiKey: { 182 | doc: 'The API key used to send emails via Postman', 183 | env: 'POSTMANGOVSG_API_KEY', 184 | format: String, 185 | default: '', 186 | }, 187 | mailer: { 188 | doc: 189 | 'Mailer configuration for SMTP mail services. ' + 190 | 'If POSTMANGOVSG_API_KEY is present, this configuration is ignored and ' + 191 | 'the mailer will use Postman instead.', 192 | auth: { 193 | type: { 194 | doc: 'The type of authentication used. Currently, only "login" is supported', 195 | format: ['login'], 196 | default: 'login', 197 | }, 198 | user: { 199 | doc: 'The user to present to the SMTP service', 200 | env: 'MAILER_USER', 201 | format: String, 202 | default: 'mailer-user', 203 | }, 204 | pass: { 205 | doc: 'The password to present to the SMTP service', 206 | env: 'MAILER_PASSWORD', 207 | format: String, 208 | default: 'mailer-password', 209 | }, 210 | }, 211 | host: { 212 | doc: 'The server hosting the SMTP service', 213 | env: 'MAILER_HOST', 214 | format: String, 215 | default: 'localhost', 216 | }, 217 | port: { 218 | doc: 'The port for the SMTP service', 219 | env: 'MAILER_PORT', 220 | format: 'port', 221 | default: 1025, 222 | }, 223 | }, 224 | health: { 225 | heapSizeThreshold: { 226 | doc: 'Heap size threshold before healthcheck fails (in bytes).', 227 | env: 'HEAP_SIZE_THRESHOLD', 228 | format: 'int', 229 | // TODO: Set to a more reasonable value depending on the instance size used. 230 | default: 200 * 1024 * 1024, // 200MB 231 | }, 232 | rssThreshold: { 233 | doc: 'Resident set size threshold before healthcheck fails (in bytes).', 234 | env: 'RSS_THRESHOLD', 235 | format: 'int', 236 | // TODO: Set to a more reasonable value depending on the instance size used. 237 | default: 3000 * 1024 * 1024, // 3000MB 238 | }, 239 | }, 240 | } 241 | -------------------------------------------------------------------------------- /docs/deploying/for-hustlers.md: -------------------------------------------------------------------------------- 1 | # Deploying Your Application - A Guide For Hustlers 2 | 3 | Learn how to prepare your application to take it from your development 4 | environment to a simple deployment environment, without any 5 | financial resources 6 | 7 | ## Prerequisites 8 | 9 | Some familiarity with using the [Terminal in Mac OS X](https://www.youtube.com/watch?v=aKRYQsKR46I) 10 | is assumed. This is the best way to your product get up and running 11 | on Fly.io, and is covered by their official documentation. 12 | 13 | ## Infrastructure 14 | 15 | We use fly.io to allow for straightforward management of 16 | infrastructure. This is especially helpful for individuals without 17 | any backing from engineers or finance. 18 | 19 | ### Signing up for Fly.io 20 | 21 | Visit [Fly.io](https://fly.io), and follow instructions to 22 | [Get Started](https://fly.io/docs/hands-on/). We will be following 23 | just the steps up till we sign up for an account. 24 | 25 | One of the steps in the instructions will get you to sign up for 26 | an account: 27 | 28 | ``` 29 | $ flyctl auth signup 30 | Opening https://fly.io/app/auth/cli/f2d7.....3465c ... 31 | 32 | Waiting for session... 33 | ``` 34 | 35 | This will take you to their [sign-up page](https://fly.io/app/sign-up). 36 | Create a new sign up using an email address (preferably a group email 37 | representing the product team). 38 | 39 | ![Fly.io - Sign up](./images/fly/sign-up.png) 40 | 41 | Save the new account credentials in 1Password or your preferred 42 | password manager. Verify the email address as needed. 43 | 44 | When prompted to go with a payment plan, locate the link to try 45 | Fly.io for free. In the screenshot below, the link is below the 46 | input for a credit card. 47 | 48 | ![Fly.io - Try for Free](./images/fly/try-for-free.png) 49 | 50 | Your Terminal should now look like this: 51 | 52 | ``` 53 | $ flyctl auth signup 54 | Opening https://fly.io/app/auth/cli/f2d7.....3465c ... 55 | 56 | Waiting for session... Done 57 | successfully logged in as 58 | $ 59 | ``` 60 | 61 | ### Preparing the environment 62 | 63 | Go to the Fly.io [dashboard](https://fly.io/dashboard), and create two 64 | organizations, one for your staging environment, and one for your 65 | production one. Use the naming convention `appname-environmentname` 66 | 67 | ![Fly.io - Dashboard](./images/fly/dashboard.png) 68 | 69 | ![Fly.io - Create Organization](./images/fly/create-organization.png) 70 | 71 | Go to the Terminal, and run the command `flyctl deploy --org `. 72 | Taking care to choose a name that is unique to the product as well as 73 | the environment it is in, answer the prompts as shown in the example 74 | below: 75 | 76 | ``` 77 | $ fly launch --org ts-template-staging 78 | An existing fly.toml file was found for app 79 | ? Would you like to copy its configuration to the new app? Yes 80 | Creating app in /opengovsg/ts-template 81 | Scanning source code 82 | Detected a Dockerfile app 83 | ? App Name (leave blank to use an auto-generated name): ts-template-staging 84 | ? Select region: sin (Singapore) 85 | Created app ts-template-staging in organization ts-template-staging 86 | Wrote config file fly.toml 87 | ? Would you like to set up a Postgresql database now? Yes 88 | For pricing information visit: https://fly.io/docs/about/pricing/#postgresql-clusters 89 | ? Select configuration: Development - Single node, 1x shared CPU, 256MB RAM, 1GB disk 90 | Creating postgres cluster ts-template-staging-db in organization ts-template-staging 91 | Postgres cluster ts-template-staging-db created 92 | Username: postgres 93 | Password: 94 | Hostname: ts-template-staging-db.internal 95 | Proxy Port: 5432 96 | PG Port: 5433 97 | Save your credentials in a secure place -- you won't be able to see them again! 98 | 99 | Monitoring Deployment 100 | 101 | 1 desired, 1 placed, 1 healthy, 0 unhealthy [health checks: 3 total, 3 passing] 102 | --> v0 deployed successfully 103 | 104 | Connect to postgres 105 | Any app within the ts-template-staging organization can connect to postgres using the above credentials and the hostname "ts-template-staging-db.internal." 106 | For example: postgres://postgres:@ts-template-staging-db.internal:5432 107 | 108 | Now that you've set up postgres, here's what you need to understand: https://fly.io/docs/reference/postgres-whats-next/ 109 | WARN The running flyctl agent (v0.0.395) is older than the current flyctl (v0.0.398). 110 | WARN The out-of-date agent will be shut down along with existing wireguard connections. The new agent will start automatically as needed. 111 | 112 | Postgres cluster ts-template-staging-db is now attached to ts-template-staging 113 | The following secret was added to ts-template-staging: 114 | DATABASE_URL=postgres://ts_template_staging:@top2.nearest.of.ts-template-staging-db.internal:5432/ts_template_staging 115 | Postgres cluster ts-template-staging-db is now attached to ts-template-staging 116 | ? Would you like to deploy now? No 117 | Your app is ready. Deploy with `flyctl deploy` 118 | ``` 119 | 120 | There are two sets of database connection parameters given. One is to 121 | be used for general administration of the database, and one is to be 122 | used by the application. Save both into 1Password or your preferred 123 | password manager. 124 | 125 | Ask an engineer if you need help identifying and saving the connection 126 | parameters. 127 | 128 | Add the parameters using [flyctl secrets](https://fly.io/docs/reference/secrets/) 129 | as follows: 130 | 131 | ``` 132 | $ flyctl secrets import --app ts-template-staging 133 | DB_NAME=ts_template_staging 134 | DB_USERNAME=ts_template_staging 135 | DB_HOST=top2.nearest.of.ts-template-staging-db.internal 136 | DB_PASSWORD= 137 | NODE_ENV=staging 138 | 139 | 140 | Secrets are staged for the first deployment 141 | ``` 142 | 143 | (If you wish to use email OTPs for your application, sign up for an email 144 | service like SendGrid, then input the mail connection parameters as 145 | the relevant environment variables documented [here](../../backend/src/config/config.schema.ts). 146 | 147 | 148 | ### Deploying with GitHub Actions 149 | 150 | From the dashboard, go to [Account -> Access Tokens](https://fly.io/user/personal_access_tokens) 151 | 152 | ![Fly.io - Access Tokens Menu](./images/fly/access-tokens-menu.png) 153 | 154 | ![Fly.io - Access Tokens](./images/fly/access-tokens.png) 155 | 156 | Create a new token named `github-actions`. This token will only be shown 157 | once to you; save this in 1Password or your preferred password manager. 158 | 159 | From your GitHub repository page, under Settings -> Secrets -> Actions, 160 | add the following secrets: 161 | 162 | - `APP_NAME_STAGING` 163 | - `APP_NAME_PROD` 164 | - `FLY_API_TOKEN` 165 | 166 | ![Fly.io - GitHub Actions Secrets](./images/fly/github-actions-secrets.png) 167 | 168 | Create a new branch `staging` from the branch containing the code to 169 | be deployed: 170 | 171 | ![Fly.io - Create Staging Branch](./images/fly/create-staging-branch.png) 172 | ### Deploying Locally 173 | If you are impatient and want to deploy straight from your local machine, 174 | run the following command: 175 | 176 | ``` 177 | flyctl deploy --app --dockerfile Dockerfile.fly 178 | ``` 179 | ### Ready for Users 180 | 181 | Your product would then be found on the URL found on the app dashboard: 182 | 183 | ![Fly.io - App Dashboard](./images/fly/app-dashboard.png) 184 | 185 | ![Fly.io - Deployed](./images/fly/deployed.png) 186 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/technical-specification.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Technical specification 3 | about: Describe an engineering solution for delivery 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # Technical Proposal 11 | 12 | ## Introduction 13 | 14 | ### Overview, Problem Description, Summary, or Abstract 15 | 16 | Summary of the problem (from the perspective of the user), the context, suggested solution, and the stakeholders. 17 | 18 | ### Glossary or Terminology 19 | 20 | New terms you come across as you research your design or terms you may suspect your readers/stakeholders not to know. 21 | 22 | ### Context or Background 23 | 24 | Reasons why the problem is worth solving 25 | Origin of the problem 26 | How the problem affects users and company goals 27 | Past efforts made to solve the solution and why they were not effective 28 | How the product relates to team goals, OKRs 29 | How the solution fits into the overall product roadmap and strategy 30 | How the solution fits into the technical strategy 31 | 32 | ### Goals or Product and Technical Requirements 33 | 34 | Product requirements in the form of user stories 35 | Technical requirements 36 | 37 | ### Non-Goals or Out of Scope 38 | 39 | Product and technical requirements that will be disregarded 40 | 41 | ### Future Goals 42 | 43 | Product and technical requirements slated for a future time 44 | 45 | ### Assumptions 46 | 47 | Conditions and resources that need to be present and accessible for the solution to work as described. 48 | 49 | ## Solutions 50 | 51 | ### Current or Existing Solution / Design 52 | 53 | Current solution description 54 | Pros and cons of the current solution 55 | 56 | ### Suggested or Proposed Solution / Design 57 | 58 | External components that the solution will interact with and that it will alter 59 | Dependencies of the current solution 60 | Pros and cons of the proposed solution 61 | Data Model / Schema Changes 62 | Schema definitions 63 | New data models 64 | Modified data models 65 | Data validation methods 66 | Business Logic 67 | API changes 68 | Pseudocode 69 | Flowcharts 70 | Error states 71 | Failure scenarios 72 | Conditions that lead to errors and failures 73 | Limitations 74 | Presentation Layer 75 | User requirements 76 | UX changes 77 | UI changes 78 | Wireframes with descriptions 79 | Links to UI/UX designer’s work 80 | Mobile concerns 81 | Web concerns 82 | UI states 83 | Error handling 84 | Other questions to answer 85 | How will the solution scale? 86 | What are the limitations of the solution? 87 | How will it recover in the event of a failure? 88 | How will it cope with future requirements? 89 | 90 | ### Test Plan 91 | 92 | Explanations of how the tests will make sure user requirements are met 93 | Unit tests 94 | Integrations tests 95 | QA 96 | 97 | ### Monitoring and Alerting Plan 98 | 99 | Logging plan and tools 100 | Monitoring plan and tools 101 | Metrics to be used to measure health 102 | How to ensure observability 103 | Alerting plan and tools 104 | 105 | ### Release / Roll-out and Deployment Plan 106 | 107 | Deployment architecture 108 | Deployment environments 109 | Phased roll-out plan e.g. using feature flags 110 | Plan outlining how to communicate changes to the users, for example, with release notes 111 | 112 | ### Rollback Plan 113 | 114 | Detailed and specific liabilities 115 | Plan to reduce liabilities 116 | Plan describing how to prevent other components, services, and systems from being affected 117 | 118 | ### Alternate Solutions / Designs 119 | 120 | Short summary statement for each alternative solution 121 | Pros and cons for each alternative 122 | Reasons why each solution couldn’t work 123 | Ways in which alternatives were inferior to the proposed solution 124 | Migration plan to next best alternative in case the proposed solution falls through 125 | 126 | ## Further Considerations 127 | 128 | ### Impact on other teams 129 | 130 | How will this increase the work of other people? 131 | 132 | ### Third-party services and platforms considerations 133 | 134 | Is it really worth it compared to building the service in-house? 135 | What are some of the security and privacy concerns associated with the services/platforms? 136 | How much will it cost? 137 | How will it scale? 138 | What possible future issues are anticipated? 139 | 140 | ### Cost analysis 141 | 142 | What is the cost to run the solution per day? 143 | What does it cost to roll it out? 144 | 145 | ### Security considerations 146 | 147 | What are the potential threats? 148 | How will they be mitigated? 149 | How will the solution affect the security of other components, services, and systems? 150 | 151 | ### Privacy considerations 152 | 153 | Does the solution follow local laws and legal policies on data privacy? 154 | How does the solution protect users’ data privacy? 155 | What are some of the tradeoffs between personalization and privacy in the solution? 156 | 157 | ### Accessibility considerations 158 | 159 | How accessible is the solution? 160 | What tools will you use to evaluate its accessibility? 161 | 162 | ### Operational considerations 163 | 164 | Does this solution cause adverse after-effects? 165 | How will data be recovered in case of failure? 166 | How will the solution recover in case of a failure? 167 | How will operational costs be kept low while delivering increased value to the users? 168 | 169 | ### Risks 170 | 171 | What risks are being undertaken with this solution? 172 | Are there risks that once taken can’t be walked back? 173 | What is the cost-benefit analysis of taking these risks? 174 | 175 | ### Support considerations 176 | 177 | How will the support team get across information to users about common issues they may face while interacting with the changes? 178 | How will we ensure that the users are satisfied with the solution and can interact with it with minimal support? 179 | Who is responsible for the maintenance of the solution? 180 | How will knowledge transfer be accomplished if the project owner is unavailable? 181 | 182 | ## Success Evaluation 183 | 184 | ### Impact 185 | 186 | Security impact 187 | Performance impact 188 | Cost impact 189 | Impact on other components and services 190 | 191 | ### Metrics 192 | 193 | List of metrics to capture 194 | Tools to capture and measure metrics 195 | 196 | ## Work 197 | 198 | ### Work estimates and timelines 199 | 200 | List of specific, measurable, and time-bound tasks 201 | Resources needed to finish each task 202 | Time estimates for how long each task needs to be completed 203 | 204 | ### Prioritization 205 | 206 | Categorization of tasks by urgency and impact 207 | 208 | ### Milestones 209 | 210 | Dated checkpoints when significant chunks of work will have been completed 211 | Metrics to indicate the passing of the milestone 212 | 213 | ### Future work 214 | 215 | List of tasks that will be completed in the future 216 | 217 | ## Deliberation 218 | 219 | ### Discussion 220 | 221 | Elements of the solution that members of the team do not agree on and need to be debated further to reach a consensus. 222 | 223 | ### Open Questions 224 | 225 | Questions about things you do not know the answers to or are unsure that you pose to the team and stakeholders for their input. These may include aspects of the problem you don’t know how to resolve yet. 226 | 227 | ## End Matter 228 | 229 | ### Related Work 230 | 231 | Any work external to the proposed solution that is similar to it in some way and is worked on by different teams. It’s important to know this to enable knowledge sharing between such teams when faced with related problems. 232 | 233 | ### References 234 | 235 | Links to documents and resources that you used when coming up with your design and wish to credit. 236 | 237 | ### Acknowledgments 238 | 239 | Credit people who have contributed to the design that you wish to recognize. 240 | --------------------------------------------------------------------------------