├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode └── settings.json ├── README.md ├── backend ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode │ └── launch.json ├── cdk.json ├── infra │ ├── amplify-react-stack.ts │ ├── app.ts │ ├── constants.ts │ └── constructs │ │ ├── apigateway │ │ ├── apigateway-construct.ts │ │ └── index.ts │ │ ├── cognito │ │ ├── identity-pool-construct.ts │ │ ├── index.ts │ │ ├── user-pool-client-construct.ts │ │ └── user-pool-construct.ts │ │ ├── endpoint │ │ ├── endpoint-construct.ts │ │ └── index.ts │ │ └── s3 │ │ ├── index.ts │ │ └── s3-construct.ts ├── jest.config.js ├── jest.setup.ts ├── package-lock.json ├── package.json ├── src │ ├── cognito-triggers │ │ ├── custom-messages │ │ │ ├── custom-message-entry.ts │ │ │ ├── custom-message.ts │ │ │ └── index.ts │ │ └── post-confirmation │ │ │ ├── admin-add-user-to-group.ts │ │ │ ├── index.ts │ │ │ └── post-confirmation-entry.ts │ ├── get-presigned-url-s3 │ │ ├── create-presigned-post.ts │ │ ├── get-cognito-identity-id.ts │ │ ├── get-presigned-url-s3-entry.ts │ │ └── index.ts │ ├── global-types │ │ └── environment.d.ts │ ├── package-lock.json │ ├── package.json │ └── types.ts ├── tsconfig.eslint.json └── tsconfig.json ├── jest.config.js ├── jest.setup.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicons │ ├── android-chrome-192x192.png │ ├── android-chrome-256x256.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── images │ ├── 404 │ │ └── page-not-found.svg │ ├── auth │ │ ├── complete-password-reset │ │ │ └── man-shield.svg │ │ ├── login │ │ │ └── man-door.svg │ │ ├── register │ │ │ └── woman-signing-up.svg │ │ ├── request-password-reset │ │ │ └── text-field.svg │ │ └── resend-registration-link │ │ │ └── woman-confirming.svg │ ├── contacts │ │ ├── contacts.svg │ │ └── mail-icon.svg │ └── global │ │ ├── avatar.webp │ │ └── og.webp ├── robots.txt └── sitemap.xml ├── shared ├── index.ts └── utils.ts ├── src ├── cdk-exports-dev.json ├── components │ ├── alert │ │ ├── alert.tsx │ │ └── index.ts │ ├── avatar │ │ ├── avatar.tsx │ │ └── index.ts │ ├── error-boundary │ │ ├── error-boundary.tsx │ │ └── index.ts │ ├── footer │ │ ├── footer.tsx │ │ └── index.ts │ ├── forms │ │ ├── button.tsx │ │ ├── index.ts │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── radio-button.tsx │ │ ├── select-menu.tsx │ │ └── textarea.tsx │ ├── heading │ │ ├── heading.tsx │ │ └── index.ts │ ├── icons │ │ ├── icons.tsx │ │ └── index.ts │ ├── image │ │ ├── image.tsx │ │ └── index.ts │ ├── layout │ │ ├── center.tsx │ │ ├── container.tsx │ │ ├── flex.tsx │ │ ├── grid.tsx │ │ └── index.ts │ ├── loading-spinner │ │ ├── index.ts │ │ └── loading-spinner.tsx │ ├── modal │ │ ├── index.ts │ │ └── modal.tsx │ ├── navbar │ │ ├── active-link.tsx │ │ ├── index.ts │ │ ├── mobile-menu.tsx │ │ ├── navbar.tsx │ │ ├── use-click-outside.ts │ │ ├── use-logout.tsx │ │ └── user-dropdown.tsx │ ├── next-link │ │ ├── index.ts │ │ └── next-link.tsx │ ├── notification │ │ ├── index.ts │ │ └── notification.tsx │ └── tag │ │ ├── index.tsx │ │ └── tag.tsx ├── constants.ts ├── context │ ├── auth │ │ ├── auth-context.tsx │ │ ├── auth-reducer.ts │ │ └── index.ts │ └── notification │ │ ├── index.ts │ │ ├── notification-context.tsx │ │ └── notification-reducer.ts ├── hooks │ ├── use-async.ts │ └── use-previous.ts ├── next-seo-config.ts ├── pages │ ├── 404.page.tsx │ ├── 404.test.tsx │ ├── _app.page.tsx │ ├── _document.page.tsx │ ├── auth │ │ ├── complete-email-change │ │ │ ├── complete-email-change.tsx │ │ │ └── index.page.ts │ │ ├── complete-password-reset │ │ │ ├── complete-password-reset.tsx │ │ │ ├── index.page.ts │ │ │ └── use-complete-password-reset.ts │ │ ├── complete-registration │ │ │ ├── complete-registration.tsx │ │ │ ├── index.page.ts │ │ │ └── use-complete-registration.ts │ │ ├── login │ │ │ ├── index.page.ts │ │ │ ├── login.tsx │ │ │ └── use-login.ts │ │ ├── register │ │ │ ├── index.page.ts │ │ │ ├── register.tsx │ │ │ └── use-register.ts │ │ ├── request-password-reset │ │ │ ├── index.page.ts │ │ │ ├── request-password-reset.tsx │ │ │ └── use-request-password-reset.ts │ │ └── resend-registration-link │ │ │ ├── index.page.ts │ │ │ ├── resend-registration-link.tsx │ │ │ └── use-resend-registration-link.ts │ ├── home │ │ ├── home.tsx │ │ └── index.ts │ ├── index.page.ts │ └── settings │ │ ├── index.page.ts │ │ ├── personal-section │ │ ├── index.ts │ │ ├── personal-section-form.tsx │ │ └── use-update-personal-information.ts │ │ ├── profile-section │ │ ├── index.ts │ │ ├── profile-section-form.tsx │ │ ├── upload-file.ts │ │ ├── use-file-change.ts │ │ └── use-update-profile.ts │ │ ├── security-section │ │ ├── change-email │ │ │ ├── change-email-form.tsx │ │ │ ├── change-email-modal.tsx │ │ │ ├── index.ts │ │ │ └── use-change-email.ts │ │ ├── change-password │ │ │ ├── change-password-form.tsx │ │ │ ├── change-password-modal.tsx │ │ │ ├── index.ts │ │ │ └── use-change-password.ts │ │ ├── index.ts │ │ └── security-table.tsx │ │ └── settings.tsx ├── styles │ └── tailwind.css ├── test-utils │ ├── __mocks__ │ │ ├── image-mock.ts │ │ └── style-mock.ts │ ├── index.tsx │ └── setup-tests.ts └── utils │ ├── axios-instances.ts │ ├── log-user-in.ts │ ├── refresh-jwt.ts │ └── route-hocs.tsx ├── tailwind.config.js ├── tsconfig.eslint.json └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | ignorePatterns: [ 4 | 'node_modules/', 5 | '**/node_modules/*', 6 | '.next/', 7 | 'out/', 8 | 'public/', 9 | '.prettierrc.js', 10 | '.eslintrc.js', 11 | 'tailwind.config.js', 12 | 'aws-exports.js', 13 | 'backend/', 14 | ], 15 | env: { 16 | browser: true, 17 | commonjs: true, 18 | es6: true, 19 | node: true, 20 | 'jest/globals': true, 21 | jest: true, 22 | }, 23 | parserOptions: {ecmaVersion: 8}, 24 | extends: ['eslint:recommended'], 25 | overrides: [ 26 | // This configuration will apply only to TypeScript files 27 | { 28 | files: ['**/*.ts', '**/*.tsx'], 29 | parser: '@typescript-eslint/parser', 30 | parserOptions: { 31 | project: './tsconfig.eslint.json', 32 | tsconfigRootDir: './', 33 | }, 34 | extends: [ 35 | 'eslint:recommended', 36 | 'airbnb', 37 | 'airbnb/hooks', 38 | 'plugin:prettier/recommended', 39 | 'plugin:react/recommended', 40 | 'plugin:react-hooks/recommended', 41 | 'plugin:@typescript-eslint/recommended', 42 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 43 | 'plugin:testing-library/react', 44 | 'plugin:jest-dom/recommended', 45 | 'plugin:import/errors', 46 | 'plugin:import/warnings', 47 | 'plugin:import/typescript', 48 | 'plugin:jsx-a11y/recommended', 49 | 'plugin:jest/recommended', 50 | 'plugin:jest/style', 51 | ], 52 | rules: { 53 | 'react/prop-types': 'off', 54 | 'react/react-in-jsx-scope': 'off', 55 | 'react/require-default-props': 'off', 56 | 'react/jsx-filename-extension': [0], 57 | 'no-shadow': 'off', 58 | '@typescript-eslint/no-shadow': ['error'], 59 | 'react/jsx-props-no-spreading': 'off', 60 | '@typescript-eslint/explicit-module-boundary-types': 'off', 61 | '@typescript-eslint/no-floating-promises': 'off', 62 | '@typescript-eslint/no-useless-constructor': 'error', 63 | 'jsx-a11y/anchor-is-valid': 'off', 64 | 'jsx-a11y/interactive-supports-focus': 'off', 65 | 'jsx-a11y/click-events-have-key-events': 'off', 66 | 'no-use-before-define': [ 67 | 'error', 68 | {functions: false, classes: true, variables: true}, 69 | ], 70 | 'no-console': 'off', 71 | 'no-unused-vars': 'off', 72 | 'import/no-extraneous-dependencies': [ 73 | 'error', 74 | { 75 | devDependencies: true, 76 | optionalDependencies: false, 77 | peerDependencies: false, 78 | }, 79 | ], 80 | 'import/prefer-default-export': 'off', 81 | 'prettier/prettier': ['error'], 82 | 'import/extensions': 'off', 83 | }, 84 | plugins: [ 85 | 'import', 86 | '@typescript-eslint', 87 | 'jest', 88 | 'prettier', 89 | 'react', 90 | 'react-hooks', 91 | 'testing-library', 92 | 'jest-dom', 93 | ], 94 | globals: { 95 | window: true, 96 | document: true, 97 | localStorage: true, 98 | FormData: true, 99 | FileReader: true, 100 | Blob: true, 101 | navigator: true, 102 | fetch: false, 103 | browser: true, 104 | jest: true, 105 | Atomics: 'readonly', 106 | SharedArrayBuffer: 'readonly', 107 | }, 108 | settings: { 109 | react: { 110 | version: 'detect', 111 | }, 112 | 'import/extensions': ['.js', '.jsx', '.ts', '.tsx'], 113 | 'import/parsers': { 114 | '@typescript-eslint/parser': ['.ts', '.tsx'], 115 | }, 116 | 'import/resolver': { 117 | typescript: {}, 118 | node: { 119 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 120 | moduleDirectory: ['node_modules', 'src/'], 121 | }, 122 | }, 123 | }, 124 | }, 125 | ], 126 | }; 127 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | .env* 33 | 34 | # vercel 35 | .vercel 36 | /out 37 | 38 | #amplify 39 | amplify/\#current-cloud-backend 40 | amplify/.config/local-* 41 | amplify/logs 42 | amplify/mock-data 43 | amplify/backend/amplify-meta.json 44 | amplify/backend/awscloudformation 45 | amplify/backend/.temp 46 | build/ 47 | dist/ 48 | node_modules/ 49 | aws-exports.js 50 | awsconfiguration.json 51 | amplifyconfiguration.json 52 | amplify-build-config.json 53 | amplify-gradle-config.json 54 | amplifytools.xcconfig 55 | .secret-* 56 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | coverage 4 | public -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | arrowParens: 'avoid', 4 | tabWidth: 2, 5 | useTabs: false, 6 | singleQuote: true, 7 | trailingComma: 'all', 8 | bracketSpacing: false, 9 | jsxBracketSameLine: false, 10 | jsxSingleQuote: false, 11 | quoteProps: 'as-needed', 12 | proseWrap: 'always', 13 | endOfLine: 'lf', 14 | insertPragma: false, 15 | }; 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "amplify/.config": true, 4 | "amplify/**/*-parameters.json": true, 5 | "amplify/**/amplify.state": true, 6 | "amplify/**/transform.conf.json": true, 7 | "amplify/#current-cloud-backend": true, 8 | "amplify/backend/amplify-meta.json": true, 9 | "amplify/backend/awscloudformation": true 10 | }, 11 | "eslint.workingDirectories": [ 12 | "./", 13 | "./backend" 14 | ] 15 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Complete Amplify + React authentication 2 | 3 | A repository for an article on 4 | [bobbyhadz.com](https://bobbyhadz.com/blog/aws-amplify-react-auth) that shows 5 | how to implement amplify authentication in a react.js application and manage the 6 | infrastructure using CDK. 7 | 8 | ## How to Use 9 | 10 | 1. Clone the repository 11 | 12 | 2. Install the dependencies 13 | 14 | ```bash 15 | npm run setup 16 | ``` 17 | 18 | 3. Create the CDK stack 19 | 20 | ```bash 21 | npm run cdk-create-stack 22 | ``` 23 | 24 | 4. Open the AWS Console and the stack should be created in your default region 25 | 26 | 5. Start the react application and open `http://localhost:3000`. Note: it's 27 | important that you run the react application on `http://localhost:3000`, 28 | because that's the url we've set up CORS for. 29 | 30 | ```bash 31 | npm run dev 32 | ``` 33 | 34 | 6. Cleanup 35 | 36 | ```bash 37 | npm run cdk-destroy 38 | ``` 39 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | ignorePatterns: [ 4 | 'node_modules/*', 5 | '**/node_modules/', 6 | 'cdk.out/', 7 | '.prettierrc.js', 8 | '.eslintrc.js', 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | project: './tsconfig.eslint.json', 13 | tsconfigRootDir: './', 14 | }, 15 | plugins: ['import', '@typescript-eslint', 'jest', 'prettier'], 16 | extends: [ 17 | 'eslint:recommended', 18 | 'airbnb-base', 19 | 'prettier', 20 | 'prettier/@typescript-eslint', 21 | 'plugin:prettier/recommended', 22 | 'plugin:@typescript-eslint/recommended', 23 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 24 | ], 25 | rules: { 26 | 'no-use-before-define': [ 27 | 'error', 28 | {functions: false, classes: true, variables: true}, 29 | ], 30 | 'import/no-extraneous-dependencies': [ 31 | 'error', 32 | { 33 | devDependencies: true, 34 | optionalDependencies: false, 35 | peerDependencies: false, 36 | }, 37 | ], 38 | // for lambda layer imports starting with /opt/nodejs/ directory 39 | 'import/no-unresolved': ['warn', {ignore: ['/opt/*']}], 40 | 'import/prefer-default-export': 'off', 41 | 'lines-between-class-members': [ 42 | 'error', 43 | 'always', 44 | {exceptAfterSingleLine: true}, 45 | ], 46 | 'no-shadow': 'off', 47 | '@typescript-eslint/no-shadow': ['error'], 48 | 'no-new': 'off', 49 | 'no-console': 'off', 50 | 'no-unused-vars': 'off', 51 | '@typescript-eslint/no-unused-vars': 'error', 52 | 'no-useless-constructor': 'off', 53 | '@typescript-eslint/no-useless-constructor': 'error', 54 | 'jest/no-disabled-tests': 'warn', 55 | 'jest/no-focused-tests': 'error', 56 | 'jest/no-identical-title': 'error', 57 | 'jest/prefer-to-have-length': 'warn', 58 | 'jest/valid-expect': 'error', 59 | 'prettier/prettier': ['error'], 60 | 'import/extensions': [ 61 | 'error', 62 | 'ignorePackages', 63 | { 64 | js: 'never', 65 | jsx: 'never', 66 | ts: 'never', 67 | tsx: 'never', 68 | }, 69 | ], 70 | }, 71 | env: { 72 | 'jest/globals': true, 73 | }, 74 | globals: { 75 | page: true, 76 | browser: true, 77 | context: true, 78 | jestPuppeteer: true, 79 | document: true, 80 | localStorage: true, 81 | }, 82 | settings: { 83 | 'import/core-modules': ['aws-sdk'], 84 | 'import/parsers': { 85 | '@typescript-eslint/parser': ['.ts', '.tsx'], 86 | }, 87 | 'import/resolver': { 88 | typescript: {}, 89 | node: { 90 | paths: ['src'], 91 | moduleDirectory: ['node_modules', 'src/'], 92 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], 93 | }, 94 | }, 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # Serverless directories 72 | .serverless/ 73 | 74 | # DynamoDB Local files 75 | .dynamodb/ 76 | 77 | 78 | infra/*.js 79 | *.d.ts 80 | !environment.d.ts 81 | 82 | # CDK asset staging directory 83 | .cdk.staging 84 | cdk.out 85 | -------------------------------------------------------------------------------- /backend/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | cdk.out 4 | template.yaml -------------------------------------------------------------------------------- /backend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | arrowParens: 'avoid', 4 | tabWidth: 2, 5 | useTabs: false, 6 | singleQuote: true, 7 | trailingComma: 'all', 8 | bracketSpacing: false, 9 | proseWrap: 'always', 10 | }; 11 | -------------------------------------------------------------------------------- /backend/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "CDK Debugger", 8 | "skipFiles": ["/**"], 9 | "runtimeArgs": ["-r", "./node_modules/ts-node/register/transpile-only"], 10 | // Entry point of your stack - replace /infra/app.ts with the entry of your CDK app 11 | "args": ["${workspaceFolder}/infra/app.ts"] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /backend/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts infra/app.ts", 3 | "context": {} 4 | } 5 | -------------------------------------------------------------------------------- /backend/infra/amplify-react-stack.ts: -------------------------------------------------------------------------------- 1 | import * as apiGateway from 'aws-cdk-lib/aws-apigatewayv2'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import {DEPLOY_REGION} from './constants'; 4 | import {HttpApiConstruct} from './constructs/apigateway'; 5 | import { 6 | IdentityPoolConstruct, 7 | UserPoolClientConstruct, 8 | UserPoolConstruct, 9 | } from './constructs/cognito'; 10 | import {EndpointConstruct} from './constructs/endpoint'; 11 | import {UploadsBucketConstruct} from './constructs/s3/s3-construct'; 12 | 13 | export class AmplifyReactStack extends cdk.Stack { 14 | constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { 15 | super(scope, id, props); 16 | 17 | const {userPool} = new UserPoolConstruct(this, 'userpool'); 18 | const {userPoolClient} = new UserPoolClientConstruct( 19 | this, 20 | 'userpoolclient', 21 | {userPool}, 22 | ); 23 | const {identityPool} = new IdentityPoolConstruct(this, 'identitypool', { 24 | userPool, 25 | userPoolClient, 26 | }); 27 | 28 | const {httpApi, httpApiCognitoAuthorizer} = new HttpApiConstruct( 29 | this, 30 | 'http-api', 31 | { 32 | userPool, 33 | userPoolClient, 34 | }, 35 | ); 36 | 37 | const {s3Bucket} = new UploadsBucketConstruct(this, 's3-bucket'); 38 | 39 | const defaultLambdaEnvVars = { 40 | USER_POOL_ID: userPool.userPoolId, 41 | IDENTITY_POOL_ID: identityPool.ref, 42 | ACCOUNT_ID: cdk.Aws.ACCOUNT_ID, 43 | }; 44 | 45 | const getPresignedUrlEndpoint = new EndpointConstruct( 46 | this, 47 | 'get-presigned-url-s3', 48 | { 49 | httpApi, 50 | authorizer: httpApiCognitoAuthorizer, 51 | methods: [apiGateway.HttpMethod.GET], 52 | routePath: '/get-presigned-url-s3', 53 | assetPath: 'get-presigned-url-s3/index.ts', 54 | environment: { 55 | ...defaultLambdaEnvVars, 56 | BUCKET_NAME: s3Bucket.bucketName, 57 | REGION: DEPLOY_REGION as string, 58 | }, 59 | }, 60 | ); 61 | 62 | s3Bucket.grantPut(getPresignedUrlEndpoint.lambda); 63 | s3Bucket.grantPutAcl(getPresignedUrlEndpoint.lambda); 64 | 65 | new cdk.CfnOutput(this, 'userPoolId', { 66 | value: userPool.userPoolId, 67 | }); 68 | new cdk.CfnOutput(this, 'userPoolClientId', { 69 | value: userPoolClient.userPoolClientId, 70 | }); 71 | new cdk.CfnOutput(this, 'identityPoolId', { 72 | value: identityPool.ref, 73 | }); 74 | new cdk.CfnOutput(this, 'region', { 75 | value: cdk.Stack.of(this).region, 76 | }); 77 | new cdk.CfnOutput(this, 'apiUrl', { 78 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 79 | value: httpApi.url!, 80 | }); 81 | new cdk.CfnOutput(this, 'bucketName', { 82 | value: s3Bucket.bucketName, 83 | }); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /backend/infra/app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as cdk from 'aws-cdk-lib'; 3 | import 'source-map-support/register'; 4 | import {AmplifyReactStack} from './amplify-react-stack'; 5 | import {DEPLOY_REGION, STACK_PREFIX} from './constants'; 6 | 7 | const app = new cdk.App(); 8 | 9 | // DEV Stack 10 | new AmplifyReactStack(app, `${STACK_PREFIX}-dev`, { 11 | stackName: `${STACK_PREFIX}-dev`, 12 | env: { 13 | region: DEPLOY_REGION, 14 | }, 15 | tags: {env: 'dev'}, 16 | }); 17 | -------------------------------------------------------------------------------- /backend/infra/constants.ts: -------------------------------------------------------------------------------- 1 | export const STACK_PREFIX = 'amplify-react-auth'; 2 | export const DEPLOY_ENVIRONMENT = process.env.DEPLOY_ENVIRONMENT || 'dev'; 3 | export const DEPLOY_REGION = process.env.CDK_DEFAULT_REGION; 4 | export const FRONTEND_BASE_URL = 'http://localhost:3000'; 5 | -------------------------------------------------------------------------------- /backend/infra/constructs/apigateway/apigateway-construct.ts: -------------------------------------------------------------------------------- 1 | import * as apiGateway from 'aws-cdk-lib/aws-apigatewayv2'; 2 | import * as apiGatewayAuthorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; 3 | import * as cognito from 'aws-cdk-lib/aws-cognito'; 4 | import * as cdk from 'aws-cdk-lib'; 5 | import {Construct} from 'constructs'; 6 | import { 7 | DEPLOY_ENVIRONMENT, 8 | FRONTEND_BASE_URL, 9 | STACK_PREFIX, 10 | } from '../../constants'; 11 | 12 | type HttpApiConstructProps = { 13 | userPool: cognito.UserPool; 14 | userPoolClient: cognito.UserPoolClient; 15 | }; 16 | 17 | export class HttpApiConstruct extends Construct { 18 | public readonly httpApi: apiGateway.HttpApi; 19 | 20 | public readonly httpApiCognitoAuthorizer: apiGatewayAuthorizers.HttpUserPoolAuthorizer; 21 | 22 | constructor(scope: Construct, id: string, props: HttpApiConstructProps) { 23 | super(scope, id); 24 | 25 | this.httpApi = new apiGateway.HttpApi(this, 'api', { 26 | description: `___${DEPLOY_ENVIRONMENT}___ Api for ${STACK_PREFIX}`, 27 | apiName: `${STACK_PREFIX}-api-${DEPLOY_ENVIRONMENT}`, 28 | corsPreflight: { 29 | allowHeaders: [ 30 | 'Content-Type', 31 | 'X-Amz-Date', 32 | 'Authorization', 33 | 'X-Api-Key', 34 | ], 35 | allowMethods: [ 36 | apiGateway.CorsHttpMethod.OPTIONS, 37 | apiGateway.CorsHttpMethod.GET, 38 | apiGateway.CorsHttpMethod.POST, 39 | apiGateway.CorsHttpMethod.PUT, 40 | apiGateway.CorsHttpMethod.PATCH, 41 | apiGateway.CorsHttpMethod.DELETE, 42 | ], 43 | allowCredentials: true, 44 | allowOrigins: [FRONTEND_BASE_URL], 45 | }, 46 | }); 47 | 48 | const {userPool, userPoolClient} = props; 49 | 50 | this.httpApiCognitoAuthorizer = 51 | new apiGatewayAuthorizers.HttpUserPoolAuthorizer( 52 | 'api-cognito-authorizer', 53 | userPool, 54 | { 55 | userPoolClients: [userPoolClient], 56 | identitySource: ['$request.header.Authorization'], 57 | }, 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /backend/infra/constructs/apigateway/index.ts: -------------------------------------------------------------------------------- 1 | export * from './apigateway-construct'; 2 | -------------------------------------------------------------------------------- /backend/infra/constructs/cognito/identity-pool-construct.ts: -------------------------------------------------------------------------------- 1 | import * as cognito from 'aws-cdk-lib/aws-cognito'; 2 | import * as iam from 'aws-cdk-lib/aws-iam'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import {DEPLOY_ENVIRONMENT, STACK_PREFIX} from '../../constants'; 5 | import {Construct} from 'constructs'; 6 | 7 | type IdentityPoolConstructProps = { 8 | userPool: cognito.UserPool; 9 | userPoolClient: cognito.UserPoolClient; 10 | }; 11 | 12 | export class IdentityPoolConstruct extends Construct { 13 | public readonly identityPool: cognito.CfnIdentityPool; 14 | 15 | constructor(scope: Construct, id: string, props: IdentityPoolConstructProps) { 16 | super(scope, id); 17 | 18 | const {userPool, userPoolClient} = props; 19 | 20 | this.identityPool = new cognito.CfnIdentityPool(this, 'identitypool', { 21 | allowUnauthenticatedIdentities: true, 22 | identityPoolName: `${STACK_PREFIX}-${DEPLOY_ENVIRONMENT}`, 23 | cognitoIdentityProviders: [ 24 | { 25 | clientId: userPoolClient.userPoolClientId, 26 | providerName: userPool.userPoolProviderName, 27 | }, 28 | ], 29 | }); 30 | 31 | const isUserCognitoGroupRole = new iam.Role(this, 'users-group-role', { 32 | description: 'Default role for authenticated users', 33 | assumedBy: new iam.FederatedPrincipal( 34 | 'cognito-identity.amazonaws.com', 35 | { 36 | StringEquals: { 37 | 'cognito-identity.amazonaws.com:aud': this.identityPool.ref, 38 | }, 39 | 'ForAnyValue:StringLike': { 40 | 'cognito-identity.amazonaws.com:amr': 'authenticated', 41 | }, 42 | }, 43 | 'sts:AssumeRoleWithWebIdentity', 44 | ), 45 | managedPolicies: [ 46 | iam.ManagedPolicy.fromAwsManagedPolicyName( 47 | 'service-role/AWSLambdaBasicExecutionRole', 48 | ), 49 | ], 50 | }); 51 | 52 | const isAnonymousCognitoGroupRole = new iam.Role( 53 | this, 54 | 'anonymous-group-role', 55 | { 56 | description: 'Default role for anonymous users', 57 | assumedBy: new iam.FederatedPrincipal( 58 | 'cognito-identity.amazonaws.com', 59 | { 60 | StringEquals: { 61 | 'cognito-identity.amazonaws.com:aud': this.identityPool.ref, 62 | }, 63 | 'ForAnyValue:StringLike': { 64 | 'cognito-identity.amazonaws.com:amr': 'authenticated', 65 | }, 66 | }, 67 | 'sts:AssumeRoleWithWebIdentity', 68 | ), 69 | managedPolicies: [ 70 | iam.ManagedPolicy.fromAwsManagedPolicyName( 71 | 'service-role/AWSLambdaBasicExecutionRole', 72 | ), 73 | ], 74 | }, 75 | ); 76 | 77 | const isAdminCognitoGroupRole = new iam.Role(this, 'admins-group-role', { 78 | description: 'Default role for administrator users', 79 | assumedBy: new iam.FederatedPrincipal( 80 | 'cognito-identity.amazonaws.com', 81 | { 82 | StringEquals: { 83 | 'cognito-identity.amazonaws.com:aud': this.identityPool.ref, 84 | }, 85 | 'ForAnyValue:StringLike': { 86 | 'cognito-identity.amazonaws.com:amr': 'authenticated', 87 | }, 88 | }, 89 | 'sts:AssumeRoleWithWebIdentity', 90 | ), 91 | managedPolicies: [ 92 | iam.ManagedPolicy.fromAwsManagedPolicyName( 93 | 'service-role/AWSLambdaBasicExecutionRole', 94 | ), 95 | ], 96 | }); 97 | 98 | new cognito.CfnUserPoolGroup(this, 'users-group', { 99 | groupName: 'Users', 100 | userPoolId: userPool.userPoolId, 101 | description: 'The default group for authenticated users', 102 | precedence: 3, // the role of the group with the lowest precedence - 0 takes effect and is returned by cognito:preferred_role 103 | roleArn: isUserCognitoGroupRole.roleArn, 104 | }); 105 | 106 | new cognito.CfnUserPoolGroup(this, 'admins-group', { 107 | groupName: 'Admins', 108 | userPoolId: userPool.userPoolId, 109 | description: 'The group for admin users with special privileges', 110 | precedence: 2, // the role of the group with the lowest precedence - 0 takes effect and is returned by cognito:preferred_role 111 | roleArn: isAdminCognitoGroupRole.roleArn, 112 | }); 113 | 114 | new cognito.CfnIdentityPoolRoleAttachment( 115 | this, 116 | 'identity-pool-role-attachment', 117 | { 118 | identityPoolId: this.identityPool.ref, 119 | roles: { 120 | authenticated: isUserCognitoGroupRole.roleArn, 121 | unauthenticated: isAnonymousCognitoGroupRole.roleArn, 122 | }, 123 | roleMappings: { 124 | mapping: { 125 | type: 'Token', 126 | ambiguousRoleResolution: 'AuthenticatedRole', 127 | identityProvider: `cognito-idp.${ 128 | cdk.Stack.of(this).region 129 | }.amazonaws.com/${userPool.userPoolId}:${ 130 | userPoolClient.userPoolClientId 131 | }`, 132 | }, 133 | }, 134 | }, 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /backend/infra/constructs/cognito/index.ts: -------------------------------------------------------------------------------- 1 | export * from './identity-pool-construct'; 2 | export * from './user-pool-client-construct'; 3 | export * from './user-pool-construct'; 4 | -------------------------------------------------------------------------------- /backend/infra/constructs/cognito/user-pool-client-construct.ts: -------------------------------------------------------------------------------- 1 | import * as cognito from 'aws-cdk-lib/aws-cognito'; 2 | import * as cdk from 'aws-cdk-lib'; 3 | import {Construct} from 'constructs'; 4 | 5 | type UserPoolClientConstructProps = { 6 | userPool: cognito.UserPool; 7 | }; 8 | 9 | export class UserPoolClientConstruct extends Construct { 10 | public readonly userPoolClient: cognito.UserPoolClient; 11 | 12 | constructor( 13 | scope: Construct, 14 | id: string, 15 | props: UserPoolClientConstructProps, 16 | ) { 17 | super(scope, id); 18 | 19 | const clientReadAttributes = new cognito.ClientAttributes() 20 | .withStandardAttributes({ 21 | givenName: true, 22 | familyName: true, 23 | email: true, 24 | emailVerified: true, 25 | address: true, 26 | birthdate: true, 27 | gender: true, 28 | locale: true, 29 | middleName: true, 30 | fullname: true, 31 | nickname: true, 32 | phoneNumber: true, 33 | phoneNumberVerified: true, 34 | profilePicture: true, 35 | preferredUsername: true, 36 | profilePage: true, 37 | timezone: true, 38 | lastUpdateTime: true, 39 | website: true, 40 | }) 41 | .withCustomAttributes(...['bio', 'country', 'city', 'isAdmin']); 42 | 43 | const clientWriteAttributes = new cognito.ClientAttributes() 44 | .withStandardAttributes({ 45 | givenName: true, 46 | familyName: true, 47 | email: true, 48 | emailVerified: false, 49 | address: true, 50 | birthdate: true, 51 | gender: true, 52 | locale: true, 53 | middleName: true, 54 | fullname: true, 55 | nickname: true, 56 | phoneNumber: true, 57 | profilePicture: true, 58 | preferredUsername: true, 59 | profilePage: true, 60 | timezone: true, 61 | lastUpdateTime: true, 62 | website: true, 63 | }) 64 | .withCustomAttributes(...['bio', 'country', 'city']); 65 | 66 | this.userPoolClient = new cognito.UserPoolClient(this, 'userpool-client', { 67 | userPool: props.userPool, 68 | authFlows: { 69 | adminUserPassword: true, 70 | custom: true, 71 | userSrp: true, 72 | }, 73 | supportedIdentityProviders: [ 74 | cognito.UserPoolClientIdentityProvider.COGNITO, 75 | ], 76 | preventUserExistenceErrors: true, 77 | readAttributes: clientReadAttributes, 78 | writeAttributes: clientWriteAttributes, 79 | }); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /backend/infra/constructs/cognito/user-pool-construct.ts: -------------------------------------------------------------------------------- 1 | import * as cognito from 'aws-cdk-lib/aws-cognito'; 2 | import * as iam from 'aws-cdk-lib/aws-iam'; 3 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 4 | import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs'; 5 | import * as cdk from 'aws-cdk-lib'; 6 | import {Construct} from 'constructs'; 7 | import * as path from 'path'; 8 | import { 9 | DEPLOY_ENVIRONMENT, 10 | FRONTEND_BASE_URL, 11 | STACK_PREFIX, 12 | } from '../../constants'; 13 | 14 | export class UserPoolConstruct extends Construct { 15 | public readonly userPool: cognito.UserPool; 16 | 17 | constructor(scope: Construct, id: string) { 18 | super(scope, id); 19 | 20 | const postAccountConfirmationTrigger = new NodejsFunction( 21 | this, 22 | 'post-confirmation', 23 | { 24 | runtime: lambda.Runtime.NODEJS_18_X, 25 | memorySize: 1024, 26 | timeout: cdk.Duration.seconds(6), 27 | handler: 'main', 28 | entry: path.join( 29 | __dirname, 30 | '/../../../src/cognito-triggers/post-confirmation/index.ts', 31 | ), 32 | bundling: {externalModules: ['aws-sdk']}, 33 | }, 34 | ); 35 | 36 | const customMessagesTrigger = new NodejsFunction(this, 'custom-messages', { 37 | runtime: lambda.Runtime.NODEJS_18_X, 38 | memorySize: 1024, 39 | timeout: cdk.Duration.seconds(6), 40 | handler: 'main', 41 | entry: path.join( 42 | __dirname, 43 | '/../../../src/cognito-triggers/custom-messages/index.ts', 44 | ), 45 | environment: { 46 | FRONTEND_BASE_URL, 47 | }, 48 | bundling: {externalModules: ['aws-sdk']}, 49 | }); 50 | 51 | this.userPool = new cognito.UserPool(this, 'userpool', { 52 | userPoolName: `${STACK_PREFIX}-${DEPLOY_ENVIRONMENT}`, 53 | selfSignUpEnabled: true, 54 | signInAliases: { 55 | email: true, 56 | }, 57 | autoVerify: { 58 | email: true, 59 | }, 60 | standardAttributes: { 61 | givenName: { 62 | required: true, 63 | mutable: true, 64 | }, 65 | familyName: { 66 | required: true, 67 | mutable: true, 68 | }, 69 | }, 70 | customAttributes: { 71 | bio: new cognito.StringAttribute({mutable: true}), 72 | country: new cognito.StringAttribute({mutable: true}), 73 | city: new cognito.StringAttribute({mutable: true}), 74 | isAdmin: new cognito.StringAttribute({mutable: true}), 75 | }, 76 | passwordPolicy: { 77 | minLength: 6, 78 | requireLowercase: true, 79 | requireDigits: false, 80 | requireUppercase: false, 81 | requireSymbols: false, 82 | }, 83 | accountRecovery: cognito.AccountRecovery.EMAIL_ONLY, 84 | lambdaTriggers: { 85 | postConfirmation: postAccountConfirmationTrigger, 86 | customMessage: customMessagesTrigger, 87 | }, 88 | }); 89 | 90 | const adminAddUserToGroupPolicyStatement = new iam.PolicyStatement({ 91 | actions: ['cognito-idp:AdminAddUserToGroup'], 92 | resources: [this.userPool.userPoolArn], 93 | }); 94 | 95 | postAccountConfirmationTrigger.role?.attachInlinePolicy( 96 | new iam.Policy(this, 'post-confirm-trigger-policy', { 97 | statements: [adminAddUserToGroupPolicyStatement], 98 | }), 99 | ); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /backend/infra/constructs/endpoint/endpoint-construct.ts: -------------------------------------------------------------------------------- 1 | import * as apiGateway from 'aws-cdk-lib/aws-apigatewayv2'; 2 | import * as apiGatewayAuthorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; 3 | import * as apiGatewayIntegrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; 4 | import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; 5 | import * as lambda from 'aws-cdk-lib/aws-lambda'; 6 | import {NodejsFunction} from 'aws-cdk-lib/aws-lambda-nodejs'; 7 | import * as cdk from 'aws-cdk-lib'; 8 | import path from 'path'; 9 | import {Construct} from 'constructs'; 10 | 11 | type EndpointConstructProps = { 12 | httpApi: apiGateway.HttpApi; 13 | authorizer: apiGatewayAuthorizers.HttpUserPoolAuthorizer; 14 | routePath: string; 15 | methods: apiGateway.HttpMethod[]; 16 | assetPath: string; 17 | environment?: lambda.FunctionOptions['environment']; 18 | layers?: lambda.LayerVersion[]; 19 | // specify layers or packages to not be included in lambda code as externals 20 | externalModules?: string[]; 21 | dynamo?: { 22 | table: dynamodb.Table; 23 | permissions: string[]; 24 | }; 25 | }; 26 | export class EndpointConstruct extends Construct { 27 | public readonly endpoint: apiGateway.HttpRoute[]; 28 | 29 | public readonly lambda: NodejsFunction; 30 | 31 | constructor(scope: Construct, id: string, props: EndpointConstructProps) { 32 | super(scope, id); 33 | const { 34 | httpApi, 35 | dynamo, 36 | authorizer, 37 | routePath, 38 | assetPath, 39 | methods, 40 | environment, 41 | layers, 42 | externalModules, 43 | } = props; 44 | 45 | this.lambda = new NodejsFunction(this, id, { 46 | runtime: lambda.Runtime.NODEJS_18_X, 47 | memorySize: 1024, 48 | timeout: cdk.Duration.seconds(5), 49 | handler: 'main', 50 | entry: path.join(__dirname, `/../../../src/${assetPath}`), 51 | environment: environment && environment, 52 | layers: layers && layers, 53 | bundling: { 54 | minify: false, 55 | // modules already available in a layer should not be bundled 56 | externalModules: externalModules 57 | ? ['aws-sdk', ...externalModules] 58 | : ['aws-sdk'], 59 | }, 60 | }); 61 | 62 | if (dynamo?.table) { 63 | dynamo.table.grant(this.lambda, ...dynamo.permissions); 64 | } 65 | 66 | this.endpoint = httpApi.addRoutes({ 67 | path: routePath, 68 | methods, 69 | integration: new apiGatewayIntegrations.HttpLambdaIntegration( 70 | `${id}-integration`, 71 | this.lambda, 72 | ), 73 | authorizer, 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /backend/infra/constructs/endpoint/index.ts: -------------------------------------------------------------------------------- 1 | export * from './endpoint-construct'; 2 | -------------------------------------------------------------------------------- /backend/infra/constructs/s3/index.ts: -------------------------------------------------------------------------------- 1 | export * from './s3-construct'; 2 | -------------------------------------------------------------------------------- /backend/infra/constructs/s3/s3-construct.ts: -------------------------------------------------------------------------------- 1 | import * as s3 from 'aws-cdk-lib/aws-s3'; 2 | import {FRONTEND_BASE_URL} from '../../constants'; 3 | import {Construct} from 'constructs'; 4 | 5 | export class UploadsBucketConstruct extends Construct { 6 | public readonly s3Bucket: s3.Bucket; 7 | 8 | constructor(scope: Construct, id: string) { 9 | super(scope, id); 10 | 11 | this.s3Bucket = new s3.Bucket(this, id, { 12 | cors: [ 13 | { 14 | allowedMethods: [ 15 | s3.HttpMethods.GET, 16 | s3.HttpMethods.POST, 17 | s3.HttpMethods.PUT, 18 | ], 19 | allowedOrigins: [FRONTEND_BASE_URL], 20 | allowedHeaders: ['*'], 21 | }, 22 | ], 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // A list of paths to directories that Jest should use to search for files in 3 | roots: ['/src', '/infra'], 4 | 5 | // The glob patterns Jest uses to detect test files 6 | testMatch: ['**/*.test.ts'], 7 | 8 | // tell Jest to use ts-jest for ts/tsx files 9 | transform: { 10 | '^.+\\.tsx?$': 'ts-jest', 11 | }, 12 | 13 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 14 | transformIgnorePatterns: ['/node_modules/'], 15 | 16 | // Automatically clear mock calls and instances between every test 17 | clearMocks: true, 18 | 19 | // An array of glob patterns indicating a set of files for which coverage information should be collected 20 | collectCoverageFrom: ['src/**/*.{ts,tsx,js,jsx,mjs}'], 21 | 22 | // The directory where Jest should output its coverage files 23 | coverageDirectory: 'coverage', 24 | 25 | // An array of regexp pattern strings used to skip coverage collection 26 | coveragePathIgnorePatterns: ['\\\\node_modules\\\\'], 27 | 28 | // An array of file extensions your modules use 29 | moduleFileExtensions: ['ts', 'js', 'json'], 30 | 31 | // A list of paths to modules that run some code to configure or set up the testing framework before each test file in the suite is executed. 32 | setupFilesAfterEnv: ['./jest.setup.js'], 33 | 34 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 35 | testPathIgnorePatterns: [ 36 | '\\\\node_modules\\\\', 37 | '/src/__tests__/utils/', 38 | ], 39 | 40 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 41 | testURL: 'http://localhost', 42 | 43 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 44 | // If the value is modern, @sinnonjs/fake-timers will be used as implementation instead of Jest's own legacy implementation. 45 | timers: 'modern', 46 | 47 | // Indicates whether each individual test should be reported during the run 48 | verbose: false, 49 | }; 50 | -------------------------------------------------------------------------------- /backend/jest.setup.ts: -------------------------------------------------------------------------------- 1 | jest.setTimeout(30000); 2 | 3 | export {}; 4 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "setup": "npm install && npm install --prefix src/", 4 | "cdk-bootstrap": "npx aws-cdk bootstrap", 5 | "cdk-synth": "npx aws-cdk synth amplify-react-auth-dev", 6 | "cdk-deploy": "npx aws-cdk deploy amplify-react-auth-dev --outputs-file ../src/cdk-exports-dev.json", 7 | "cdk-create-stack": "npm run cdk-bootstrap && npm run cdk-deploy", 8 | "cdk-destroy": "npx aws-cdk destroy amplify-react-auth-dev", 9 | "test": "jest", 10 | "watch": "tsc -w", 11 | "build": "tsc", 12 | "type-check": "tsc --project tsconfig.json --pretty --noEmit", 13 | "lint": "eslint . --ext js,jsx,ts,tsx --fix" 14 | }, 15 | "dependencies": { 16 | "aws-cdk-lib": "^2.124.0", 17 | "constructs": "^10.3.0", 18 | "source-map-support": "^0.5.21", 19 | "yup": "^0.32.9" 20 | }, 21 | "devDependencies": { 22 | "@types/aws-lambda": "^8.10.132", 23 | "@types/jest": "^29.5.11", 24 | "@types/node": "^20.11.7", 25 | "@typescript-eslint/eslint-plugin": "^6.19.1", 26 | "@typescript-eslint/parser": "^6.19.1", 27 | "aws-cdk": "^2.124.0", 28 | "aws-lambda": "^1.0.7", 29 | "esbuild": "^0.19.12", 30 | "eslint-config-airbnb-base": "^15.0.0", 31 | "eslint-config-prettier": "^9.1.0", 32 | "eslint-import-resolver-typescript": "^3.6.1", 33 | "eslint-plugin-import": "^2.29.1", 34 | "eslint-plugin-jest": "^27.6.3", 35 | "eslint-plugin-prettier": "^5.1.3", 36 | "jest": "^29.7.0", 37 | "prettier": "^3.2.4", 38 | "ts-jest": "^29.1.2", 39 | "ts-node": "^10.9.2", 40 | "typescript": "^5.3.3" 41 | }, 42 | "name": "aws-amplify-react-auth", 43 | "version": "1.0.0", 44 | "keywords": [ 45 | "aws", 46 | "cdk", 47 | "typescript", 48 | "lambda" 49 | ], 50 | "tags": [ 51 | "aws", 52 | "cdk", 53 | "typescript", 54 | "lambda" 55 | ], 56 | "author": "Borislav Hadzhiev", 57 | "license": "MIT", 58 | "private": true 59 | } 60 | -------------------------------------------------------------------------------- /backend/src/cognito-triggers/custom-messages/custom-message-entry.ts: -------------------------------------------------------------------------------- 1 | import {Callback, Context} from 'aws-lambda'; 2 | import CustomMessage from './custom-message'; 3 | 4 | if (!process.env.FRONTEND_BASE_URL) { 5 | throw new Error('Environment variable FRONTEND_BASE_URL is required.'); 6 | } 7 | 8 | type Event = { 9 | triggerSource: string; 10 | request: { 11 | codeParameter: string; 12 | userAttributes: { 13 | 'cognito:user_status': string; 14 | // eslint-disable-next-line camelcase 15 | given_name: string; 16 | // eslint-disable-next-line camelcase 17 | family_name: string; 18 | email: string; 19 | }; 20 | usernameParameter: string; 21 | }; 22 | response: { 23 | emailSubject: string; 24 | emailMessage: string; 25 | }; 26 | }; 27 | 28 | export function main( 29 | event: Event, 30 | _context: Context, 31 | callback: Callback, 32 | ): void { 33 | const { 34 | triggerSource, 35 | request: {codeParameter, userAttributes, usernameParameter}, 36 | } = event; 37 | 38 | const customMessage = new CustomMessage({ 39 | userAttributes, 40 | codeParameter, 41 | usernameParameter, 42 | }); 43 | 44 | /* eslint-disable no-param-reassign */ 45 | if ( 46 | triggerSource === 'CustomMessage_SignUp' && 47 | userAttributes['cognito:user_status'] === 'UNCONFIRMED' 48 | ) { 49 | event.response = customMessage.sendCodePostSignUp(); 50 | } else if (triggerSource === 'CustomMessage_ForgotPassword') { 51 | event.response = customMessage.sendCodeForgotPassword(); 52 | } else if (triggerSource === 'CustomMessage_UpdateUserAttribute') { 53 | event.response = customMessage.sendCodeVerifyNewEmail(); 54 | } else if (triggerSource === 'CustomMessage_AdminCreateUser') { 55 | event.response = customMessage.sendTemporaryPassword(); 56 | } else if (triggerSource === 'CustomMessage_ResendCode') { 57 | event.response = customMessage.resendConfirmationCode(); 58 | } 59 | 60 | // Return to Amazon Cognito 61 | callback(null, event); 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/cognito-triggers/custom-messages/custom-message.ts: -------------------------------------------------------------------------------- 1 | type CustomMessageProps = { 2 | codeParameter: string; 3 | userAttributes: { 4 | // eslint-disable-next-line camelcase 5 | given_name: string; 6 | // eslint-disable-next-line camelcase 7 | family_name: string; 8 | email: string; 9 | }; 10 | usernameParameter: string; 11 | }; 12 | 13 | // Merge the interface with the class 14 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 15 | interface CustomMessage extends CustomMessageProps {} 16 | 17 | type CustomMessageReturnValue = { 18 | emailSubject: string; 19 | emailMessage: string; 20 | }; 21 | 22 | class CustomMessage { 23 | FRONTEND_BASE_URL = process.env.FRONTEND_BASE_URL; 24 | FRONTEND_LINKS: { 25 | SEND_CODE_POST_SIGN_UP: string; 26 | SEND_CODE_FORGOT_PASSWORD: string; 27 | SEND_CODE_VERIFY_NEW_EMAIL: string; 28 | SEND_TEMPORARY_PASSWORD: string; 29 | RESEND_CONFIRMATION_CODE: string; 30 | }; 31 | 32 | constructor(kwargs: CustomMessageProps) { 33 | Object.assign(this, kwargs); 34 | 35 | this.FRONTEND_LINKS = { 36 | SEND_CODE_POST_SIGN_UP: `${this.FRONTEND_BASE_URL}/auth/complete-registration?code=${this.codeParameter}&email=${this.userAttributes.email}`, 37 | SEND_CODE_FORGOT_PASSWORD: `${this.FRONTEND_BASE_URL}/auth/complete-password-reset?code=${this.codeParameter}&email=${this.userAttributes.email}`, 38 | SEND_CODE_VERIFY_NEW_EMAIL: `${this.FRONTEND_BASE_URL}/auth/complete-password-reset?code=${this.codeParameter}&email=${this.userAttributes.email}`, 39 | SEND_TEMPORARY_PASSWORD: `${this.FRONTEND_BASE_URL}/auth/login`, 40 | RESEND_CONFIRMATION_CODE: `${this.FRONTEND_BASE_URL}/auth/complete-registration?code=${this.codeParameter}&email=${this.userAttributes.email}`, 41 | }; 42 | } 43 | 44 | sendCodePostSignUp(): CustomMessageReturnValue { 45 | return { 46 | emailSubject: `Validate your account for ${ 47 | this.FRONTEND_BASE_URL 48 | } | ${new Date().toLocaleString()}`, 49 | emailMessage: `Hi ${this.userAttributes.given_name} ${this.userAttributes.family_name}!
Thank you for signing up. 50 |
51 | Please click on the link to activate your account: ${this.FRONTEND_BASE_URL}. 52 | `, 53 | }; 54 | } 55 | 56 | sendCodeForgotPassword(): CustomMessageReturnValue { 57 | return { 58 | emailSubject: `Reset your password for ${ 59 | this.FRONTEND_BASE_URL 60 | } | ${new Date().toLocaleString()}`, 61 | emailMessage: `Hi ${this.userAttributes.given_name} ${this.userAttributes.family_name}! 62 | 63 |
64 | Please click on the link to update your password: ${this.FRONTEND_BASE_URL}. 65 | `, 66 | }; 67 | } 68 | 69 | sendCodeVerifyNewEmail(): CustomMessageReturnValue { 70 | return { 71 | emailSubject: `Validate your new email for ${ 72 | this.FRONTEND_BASE_URL 73 | } | ${new Date().toLocaleString()}`, 74 | emailMessage: `Hi ${this.userAttributes.given_name} ${this.userAttributes.family_name}! 75 |
76 | 77 | Please click on the link to update your email address: ${this.FRONTEND_BASE_URL}. 78 | `, 79 | }; 80 | } 81 | 82 | sendTemporaryPassword(): CustomMessageReturnValue { 83 | return { 84 | emailSubject: `Your account for ${ 85 | this.FRONTEND_BASE_URL 86 | } | ${new Date().toLocaleString()}`, 87 | emailMessage: `Hi User!
An administrator has created your credentials for ${this.FRONTEND_BASE_URL}.
Your username is ${this.usernameParameter} and your temporary password is ${this.codeParameter}
You can paste them in the form at ${this.FRONTEND_BASE_URL} in order to log in.`, 88 | }; 89 | } 90 | 91 | resendConfirmationCode(): CustomMessageReturnValue { 92 | return { 93 | emailSubject: `Your sign-up confirmation link for ${ 94 | this.FRONTEND_BASE_URL 95 | } | ${new Date().toLocaleString()}`, 96 | emailMessage: `Hi ${this.userAttributes.given_name} ${this.userAttributes.family_name}!
Thank you for signing up. 97 | 98 |
99 | Please click on the link to activate your account: ${this.FRONTEND_BASE_URL}.`, 100 | }; 101 | } 102 | } 103 | 104 | export default CustomMessage; 105 | -------------------------------------------------------------------------------- /backend/src/cognito-triggers/custom-messages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom-message-entry'; 2 | -------------------------------------------------------------------------------- /backend/src/cognito-triggers/post-confirmation/admin-add-user-to-group.ts: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; 2 | 3 | export function adminAddUserToGroup({ 4 | userPoolId, 5 | username, 6 | groupName, 7 | }: { 8 | userPoolId: string; 9 | username: string; 10 | groupName: string; 11 | }): Promise<{ 12 | $response: AWS.Response, AWS.AWSError>; 13 | }> { 14 | const params = { 15 | GroupName: groupName, 16 | UserPoolId: userPoolId, 17 | Username: username, 18 | }; 19 | 20 | const cognitoIdp = new AWS.CognitoIdentityServiceProvider(); 21 | return cognitoIdp.adminAddUserToGroup(params).promise(); 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/cognito-triggers/post-confirmation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './post-confirmation-entry'; 2 | -------------------------------------------------------------------------------- /backend/src/cognito-triggers/post-confirmation/post-confirmation-entry.ts: -------------------------------------------------------------------------------- 1 | import {Callback, Context, PostConfirmationTriggerEvent} from 'aws-lambda'; 2 | import {adminAddUserToGroup} from './admin-add-user-to-group'; 3 | 4 | export async function main( 5 | event: PostConfirmationTriggerEvent, 6 | _context: Context, 7 | callback: Callback, 8 | ): Promise { 9 | const {userPoolId, userName} = event; 10 | console.log('POST CONFIRMATION EVENT', JSON.stringify(event, null, 2)); 11 | 12 | try { 13 | await adminAddUserToGroup({ 14 | userPoolId, 15 | username: userName, 16 | groupName: 'Users', 17 | }); 18 | 19 | return callback(null, event); 20 | } catch (error) { 21 | return callback(error, event); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/get-presigned-url-s3/create-presigned-post.ts: -------------------------------------------------------------------------------- 1 | import {S3} from 'aws-sdk'; 2 | 3 | type GetPresignedPostUrlParams = { 4 | fileType: string; 5 | filePath: string; 6 | identityId: string; 7 | }; 8 | 9 | export function createPresignedPost({ 10 | fileType, 11 | filePath, 12 | identityId, 13 | }: GetPresignedPostUrlParams): Promise { 14 | const params = { 15 | Bucket: process.env.BUCKET_NAME, 16 | Fields: {key: filePath, acl: 'public-read'}, 17 | Conditions: [ 18 | // content length restrictions: 0-1MB] 19 | ['content-length-range', 0, 1000000], 20 | // specify content-type to be more generic- images only 21 | // ['starts-with', '$Content-Type', 'image/'], 22 | ['eq', '$Content-Type', fileType], 23 | ['starts-with', '$key', identityId], 24 | ], 25 | // number of seconds for which the presigned policy should be valid 26 | Expires: 15, 27 | }; 28 | 29 | const s3 = new S3(); 30 | return (s3.createPresignedPost( 31 | params, 32 | ) as unknown) as Promise; 33 | } 34 | 35 | export function getFilePath(identityId: string): string { 36 | const fileName = generateId(); 37 | return `${identityId}/${fileName}`; 38 | } 39 | 40 | function generateId() { 41 | let result = ''; 42 | const characters = 43 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!-.*()'; 44 | 45 | const length = 10; 46 | 47 | const charactersLength = characters.length; 48 | for (let i = 0; i < length; i += 1) { 49 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 50 | } 51 | 52 | const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); 53 | 54 | return `${date}_${result}`; 55 | } 56 | -------------------------------------------------------------------------------- /backend/src/get-presigned-url-s3/get-cognito-identity-id.ts: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; 2 | 3 | export function getCognitoIdentityId( 4 | jwtToken: string, 5 | ): Promise | never { 6 | const params = getCognitoIdentityIdParams(jwtToken); 7 | const cognitoIdentity = new AWS.CognitoIdentity(); 8 | 9 | return cognitoIdentity 10 | .getId(params) 11 | .promise() 12 | .then(data => { 13 | if (data.IdentityId) { 14 | return data.IdentityId; 15 | } 16 | throw new Error('Invalid authorization token.'); 17 | }); 18 | } 19 | 20 | function getCognitoIdentityIdParams(jwtToken: string) { 21 | const {USER_POOL_ID, ACCOUNT_ID, IDENTITY_POOL_ID, REGION} = process.env; 22 | const loginsKey = `cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`; 23 | 24 | return { 25 | IdentityPoolId: IDENTITY_POOL_ID, 26 | AccountId: ACCOUNT_ID, 27 | Logins: { 28 | [loginsKey]: jwtToken, 29 | }, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/get-presigned-url-s3/get-presigned-url-s3-entry.ts: -------------------------------------------------------------------------------- 1 | import {APIGatewayProxyEventV2, APIGatewayProxyResultV2} from 'aws-lambda'; 2 | import {createPresignedPost, getFilePath} from './create-presigned-post'; 3 | import {getCognitoIdentityId} from './get-cognito-identity-id'; 4 | 5 | if ( 6 | !process.env.BUCKET_NAME || 7 | !process.env.USER_POOL_ID || 8 | !process.env.IDENTITY_POOL_ID || 9 | !process.env.ACCOUNT_ID || 10 | !process.env.REGION 11 | ) 12 | throw new Error( 13 | 'Environment variables BUCKET_NAME, USER_POOL_ID, IDENTITY_POOL_ID, ACCOUNT_ID, REGION are required.', 14 | ); 15 | 16 | type Event = APIGatewayProxyEventV2 & { 17 | headers: {authorization: string}; 18 | queryStringParameters: {fileType: string}; 19 | }; 20 | 21 | export async function main(event: Event): Promise { 22 | console.log('Event is', JSON.stringify(event, null, 2)); 23 | try { 24 | if (!event.queryStringParameters?.fileType) 25 | throw new Error( 26 | 'Querystring parameter fileType must be provided when creating a presigned URL, i.e. ?fileType=image/png', 27 | ); 28 | 29 | const {fileType} = event.queryStringParameters; 30 | 31 | const identityId = await getCognitoIdentityId(event.headers.authorization); 32 | const filePath = getFilePath(identityId); 33 | 34 | const presignedPost = await createPresignedPost({ 35 | fileType, 36 | filePath, 37 | identityId, 38 | }); 39 | 40 | return { 41 | statusCode: 200, 42 | body: JSON.stringify({...presignedPost, filePath}), 43 | }; 44 | } catch (error) { 45 | if (error instanceof Error) { 46 | return { 47 | statusCode: 400, 48 | body: JSON.stringify([{message: error.message}]), 49 | }; 50 | } 51 | return { 52 | statusCode: 400, 53 | body: JSON.stringify([{message: 'Something went wrong.'}]), 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /backend/src/get-presigned-url-s3/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-presigned-url-s3-entry'; 2 | -------------------------------------------------------------------------------- /backend/src/global-types/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | FRONTEND_BASE_URL: string; 5 | BUCKET_NAME: string; 6 | USER_POOL_ID: string; 7 | IDENTITY_POOL_ID: string; 8 | REGION: string; 9 | ACCOUNT_ID: string; 10 | } 11 | } 12 | } 13 | 14 | // If this file has no import/export (i.e. is a script) 15 | // convert it into a module by adding an empty export statement. 16 | export {}; 17 | -------------------------------------------------------------------------------- /backend/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "tslint -p tsconfig.json && jest" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "aws-sdk": "^2.714.0", 13 | "node-fetch": "^2.6.1", 14 | "uuid": "^8.3.0" 15 | }, 16 | "devDependencies": { 17 | "@types/aws-lambda": "^8.10.59", 18 | "@types/html-minifier": "^4.0.0", 19 | "@types/jest": "^26.0.4", 20 | "@types/node": "^14.14.25", 21 | "@types/node-fetch": "^2.5.7", 22 | "@types/uuid": "^8.3.0", 23 | "aws-lambda": "^1.0.6", 24 | "html-minifier": "^4.0.0", 25 | "jest": "^26.1.0", 26 | "sinon": "^9.0.2", 27 | "ts-jest": "^26.1.2", 28 | "ts-mock-imports": "^1.3.0", 29 | "ts-node": "^9.1.1", 30 | "tslint": "^6.1.3", 31 | "typescript": "^4.1.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/types.ts: -------------------------------------------------------------------------------- 1 | import {APIGatewayProxyEventV2} from 'aws-lambda'; 2 | 3 | export type {APIGatewayProxyResultV2} from 'aws-lambda'; 4 | 5 | export type AuthorizedEvent = APIGatewayProxyEventV2 & { 6 | headers: { 7 | authorization: string; 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /backend/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | /* Includes for linting, to supress not being linted errors */ 4 | "include": [ 5 | "src/**/*.ts", 6 | "src/**/*.js", 7 | "global-types/**/*", 8 | "**/*.js", 9 | "**/*.ts", 10 | "infra/**/*" 11 | ], 12 | /* Test files are not excluded, because we still want them to be linted */ 13 | "exclude": ["node_modules", "**/node_modules/*", "cdk.out"] 14 | } 15 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["es2018", "ESNext.AsyncIterable"], 6 | "allowJs": true, 7 | "checkJs": true, 8 | "removeComments": true, 9 | "resolveJsonModule": true, 10 | "declaration": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noUnusedLocals": false, 18 | "noUnusedParameters": false, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "esModuleInterop": true, 22 | "allowSyntheticDefaultImports": true, 23 | "inlineSourceMap": true, 24 | "inlineSources": true, 25 | "skipLibCheck": true, 26 | "forceConsistentCasingInFileNames": true, 27 | "experimentalDecorators": true, 28 | "typeRoots": ["./node_modules/@types", "src/global-types"], 29 | "isolatedModules": true, 30 | "baseUrl": "./", 31 | "paths": { 32 | "/opt/nodejs/yup-utils": ["src/layers/yup-utils/nodejs/yup-utils"] 33 | } 34 | }, 35 | "include": [ 36 | "src/**/*.ts", 37 | "src/**/*.js", 38 | "global-types/**/*", 39 | "infra/**/*", 40 | "globals.ts" 41 | ], 42 | "exclude": ["node_modules", "**/node_modules/*", "cdk.out"] 43 | } 44 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import '@testing-library/jest-dom'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * when next build command is run with ANALYZE=true env variable 3 | * this outputs 2 files - client.html and server.html to the /analyze/ folder 4 | */ 5 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 6 | enabled: process.env.ANALYZE === 'true', 7 | }); 8 | 9 | module.exports = withBundleAnalyzer({ 10 | pageExtensions: ['page.js', 'page.jsx', 'page.ts', 'page.tsx'], 11 | webpack: function webpack(config) { 12 | config.module.rules.push({ 13 | test: /\.(png|jpe?g|gif|svg|mp4)$/i, 14 | use: [ 15 | { 16 | loader: 'file-loader', 17 | options: { 18 | publicPath: '/_next', 19 | name: 'static/media/[name].[hash].[ext]', 20 | }, 21 | }, 22 | ], 23 | }); 24 | 25 | return config; 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "setup": "npm install && npm run setup --prefix backend/", 4 | "cdk-create-stack": "npm run cdk-create-stack --prefix backend/", 5 | "cdk-deploy-prod": "npm run cdk-deploy-prod --prefix backend/", 6 | "cdk-destroy": "npm run cdk-destroy --prefix backend/", 7 | "dev": "NODE_OPTIONS=--openssl-legacy-provider next dev", 8 | "build": "NODE_OPTIONS=--openssl-legacy-provider next build && next export", 9 | "build:analyze": "NODE_OPTIONS=--openssl-legacy-provider ANALYZE=true next build", 10 | "start": "next start", 11 | "export": "next export", 12 | "test": "jest", 13 | "test:coverage": "jest --coverage --colors", 14 | "type-check": "tsc --project tsconfig.json --pretty --noEmit", 15 | "lint": "eslint . --ext js,jsx,ts,tsx --fix --ignore-pattern backend" 16 | }, 17 | "dependencies": { 18 | "@aws-amplify/auth": "^3.4.29", 19 | "@tailwindcss/aspect-ratio": "^0.2.0", 20 | "@tailwindcss/forms": "^0.2.1", 21 | "@tailwindcss/typography": "^0.4.0", 22 | "autoprefixer": "^10.2.5", 23 | "axios": "^0.21.1", 24 | "next": "^10.0.8", 25 | "next-seo": "^4.18.0", 26 | "react": "^17.0.2", 27 | "react-dom": "^17.0.2", 28 | "react-hook-form": "^6.14.2", 29 | "react-icons": "^4.2.0", 30 | "stop-runaway-react-effects": "^2.0.0", 31 | "tailwindcss": "^2.0.2" 32 | }, 33 | "devDependencies": { 34 | "@next/bundle-analyzer": "^10.0.8", 35 | "@testing-library/dom": "^7.29.4", 36 | "@testing-library/jest-dom": "^5.11.9", 37 | "@testing-library/react": "^11.2.3", 38 | "@testing-library/user-event": "^12.8.1", 39 | "@types/jest": "^26.0.20", 40 | "@types/node": "^14.14.22", 41 | "@types/react": "^17.0.0", 42 | "@types/react-dom": "^17.0.0", 43 | "@types/testing-library__jest-dom": "^5.9.5", 44 | "@types/testing-library__react": "^10.2.0", 45 | "@typescript-eslint/eslint-plugin": "^4.16.1", 46 | "@typescript-eslint/parser": "^4.16.1", 47 | "babel-jest": "^26.6.3", 48 | "eslint": "^7.21.0", 49 | "eslint-config-airbnb": "^18.2.1", 50 | "eslint-config-prettier": "^8.1.0", 51 | "eslint-import-resolver-typescript": "^2.3.0", 52 | "eslint-plugin-import": "^2.22.1", 53 | "eslint-plugin-jest": "^24.1.3", 54 | "eslint-plugin-jest-dom": "^3.6.5", 55 | "eslint-plugin-jsx-a11y": "^6.4.1", 56 | "eslint-plugin-prettier": "^3.3.1", 57 | "eslint-plugin-react": "^7.22.0", 58 | "eslint-plugin-react-hooks": "^4.2.0", 59 | "eslint-plugin-testing-library": "^3.10.1", 60 | "jest": "^26.6.3", 61 | "postcss-flexbugs-fixes": "^5.0.2", 62 | "prettier": "^2.2.1", 63 | "react-test-renderer": "^17.0.1", 64 | "typescript": "^4.2.3" 65 | }, 66 | "version": "1.0.0", 67 | "author": "Borislav Hadzhiev", 68 | "license": "MIT", 69 | "browserslist": [ 70 | "defaults", 71 | "not IE 11", 72 | "not IE_Mob 11", 73 | "maintained node versions" 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['tailwindcss', 'postcss-flexbugs-fixes', 'autoprefixer'], 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/android-chrome-256x256.png -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #fccdbf 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/favicon.ico -------------------------------------------------------------------------------- /public/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /public/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/favicons/safari-pinned-tab.svg -------------------------------------------------------------------------------- /public/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Blog - Bobby Hadz", 3 | "short_name": "Blog - Bobby Hadz", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /public/images/contacts/mail-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 25 | 28 | 29 | 30 | 32 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /public/images/global/avatar.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/images/global/avatar.webp -------------------------------------------------------------------------------- /public/images/global/og.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bobbyhadz/aws-amplify-react-auth/dccfbd5873d67af076037a6010b89263a17c7b32/public/images/global/og.webp -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | Sitemap: https://bobbyhadz.com/sitemap.xml -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://bobbyhadz.com/blog/amplify-pros-cons 5 | 6 | 7 | 8 | https://bobbyhadz.com/blog/command-line-filtering 9 | 10 | 11 | 12 | https://bobbyhadz.com/blog/default-html-layout 13 | 14 | 15 | 16 | https://bobbyhadz.com/blog/hosting-nextjs-amplify 17 | 18 | 19 | 20 | https://bobbyhadz.com/blog/javascript-polyfills 21 | 22 | 23 | 24 | https://bobbyhadz.com/blog/seo-basics 25 | 26 | 27 | 28 | https://bobbyhadz.com/blog/tailing-ubuntu-logfiles 29 | 30 | 31 | 32 | https://bobbyhadz.com/blog/tldr-man-pages 33 | 34 | 35 | 36 | https://bobbyhadz.com/blog/web-dev-performance 37 | 38 | 39 | -------------------------------------------------------------------------------- /shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | -------------------------------------------------------------------------------- /shared/utils.ts: -------------------------------------------------------------------------------- 1 | export const isValidEmail = (email?: string): boolean => { 2 | if (!email) { 3 | return false; 4 | } 5 | 6 | return /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( 7 | email, 8 | ); 9 | }; 10 | 11 | export const isMinimumLength = (field: string, length: number): boolean => 12 | field.trim().length >= length; 13 | 14 | export function getUniqueTimestamp(): number { 15 | return Date.now() + Math.random(); 16 | } 17 | 18 | // date format: "2021-02-05" 19 | export function getCurrentDate(): string { 20 | const date = new Date(); 21 | const dateString = new Date(date.getTime() - date.getTimezoneOffset() * 60000) 22 | .toISOString() 23 | .split('T')[0]; 24 | return dateString; 25 | } 26 | -------------------------------------------------------------------------------- /src/cdk-exports-dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "amplify-react-auth-dev": { 3 | "bucketName": "amplify-react-auth-dev-s3bucket5e7b98c4-i0vxlw7ziohy", 4 | "apiUrl": "https://a46kdwwq33.execute-api.eu-central-1.amazonaws.com/", 5 | "userPoolClientId": "5hb86nd9o1sb9stq2emrbi8g3h", 6 | "region": "eu-central-1", 7 | "userPoolId": "eu-central-1_cD6dPfYW8", 8 | "identityPoolId": "eu-central-1:2053a3ab-8511-43c0-be76-91a800884c92" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/components/alert/alert.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CloseIcon, 3 | ErrorIcon, 4 | InfoIcon, 5 | SuccessIcon, 6 | } from '@components/icons/icons'; 7 | import {ReactElement, useState} from 'react'; 8 | 9 | type AlertProps = { 10 | type: 'success' | 'error' | 'info'; 11 | title: string; 12 | description?: string; 13 | }; 14 | 15 | export const Alert: React.FC = ({type, title, description}) => { 16 | const [isShown, setIsShown] = useState(true); 17 | 18 | const handleClose = () => { 19 | setIsShown(false); 20 | }; 21 | 22 | let icon: ReactElement; 23 | let color = ''; 24 | 25 | if (type === 'success') { 26 | icon = ; 27 | color = 'green'; 28 | } else if (type === 'error') { 29 | icon = ; 30 | color = 'red'; 31 | } else { 32 | icon = ; 33 | color = 'blue'; 34 | } 35 | 36 | if (!isShown) { 37 | return null; 38 | } 39 | 40 | return ( 41 | <> 42 |
43 |
44 |
45 |
46 |
{icon}
47 |
48 |

49 | {title} 50 |

51 | {description && ( 52 |
53 |

{description}

54 |
55 | )} 56 |
57 |
58 |
59 | 67 |
68 |
69 |
70 |
71 |
72 |
73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/components/alert/index.ts: -------------------------------------------------------------------------------- 1 | export * from './alert'; 2 | -------------------------------------------------------------------------------- /src/components/avatar/avatar.tsx: -------------------------------------------------------------------------------- 1 | import {UserIcon} from '@components/icons'; 2 | import {S3_BUCKET_URL} from 'src/constants'; 3 | 4 | type AvatarProps = { 5 | src: string; 6 | }; 7 | 8 | export const Avatar: React.FC = ({src}) => { 9 | // NOTE: facebook's picture property returns a JSON of format: 10 | /* 11 | * { 12 | "data":{ 13 | "height":50, 14 | "is_silhouette":true, 15 | "url":"https://scontent-cdg2-1.xx.fbcdn.net/v/t1.30497-1/cp0/c15.0.50.50a/p50x50/84628273_176159830277856_972693363922829312_n.jpg?_nc_cat=1&ccb=1-3&_nc_sid=12b3be&_nc_ohc=gzQVa5-RtlIAX9kWe85&_nc_ht=scontent-cdg2-1.xx&tp=27&oh=588fb33755f8cc314bd085dfe88c0379&oe=606E8638", 16 | "width":50 17 | } 18 | } 19 | */ 20 | 21 | let faceBookPictureUrl: string; 22 | try { 23 | faceBookPictureUrl = (JSON.parse(src) as {data: {url: string}}).data.url; 24 | } catch (err: unknown) { 25 | faceBookPictureUrl = ''; 26 | } 27 | 28 | if (faceBookPictureUrl.length > 0) { 29 | return ( 30 | avatar 35 | ); 36 | } 37 | 38 | const isGoogleProviderPicture = src.includes('https://'); 39 | 40 | if (isGoogleProviderPicture) { 41 | return ( 42 | avatar 47 | ); 48 | } 49 | 50 | if (src.length > 0) { 51 | return ( 52 | avatar 57 | ); 58 | } 59 | 60 | return ( 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/avatar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './avatar'; 2 | -------------------------------------------------------------------------------- /src/components/error-boundary/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import {Component} from 'react'; 2 | 3 | export class TopLevelErrorBoundary extends Component< 4 | Record, 5 | {hasError: boolean} 6 | > { 7 | constructor(props: Record) { 8 | super(props); 9 | this.state = {hasError: false}; 10 | } 11 | 12 | static getDerivedStateFromError(error: unknown) { 13 | console.log('GetDerivedStateFromError:', error); 14 | 15 | return {hasError: true}; 16 | } 17 | 18 | componentDidMount() { 19 | window.addEventListener('unhandledrejection', this.onUnhandledRejection); 20 | } 21 | 22 | componentDidCatch(error: unknown, errorInfo: unknown) { 23 | console.log('Unexpected error occurred!', error, errorInfo); 24 | } 25 | 26 | componentWillUnmount() { 27 | window.removeEventListener('unhandledrejection', this.onUnhandledRejection); 28 | } 29 | 30 | onUnhandledRejection = (event: PromiseRejectionEvent) => { 31 | event.promise.catch(error => { 32 | this.setState(TopLevelErrorBoundary.getDerivedStateFromError(error)); 33 | }); 34 | }; 35 | 36 | render() { 37 | const {hasError} = this.state; 38 | const {children} = this.props; 39 | 40 | if (hasError) { 41 | return ( 42 |

43 | An error has occurred 44 | 45 | ❌️❌️❌️ 46 | 47 |

48 | ); 49 | } 50 | 51 | return children; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/error-boundary/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error-boundary'; 2 | -------------------------------------------------------------------------------- /src/components/footer/footer.tsx: -------------------------------------------------------------------------------- 1 | import {NextLink} from '@components/next-link'; 2 | import {FaBlog, FaGithub, FaTwitter} from 'react-icons/fa'; 3 | 4 | const socialLinks = [ 5 | { 6 | text: 'Twitter', 7 | href: 'https://twitter.com/bobbyhadz', 8 | icon: , 9 | }, 10 | {text: 'Github', href: 'https://github.com/bobbyhadz', icon: }, 11 | {text: 'Blog', href: 'https://bobbyhadz.com', icon: }, 12 | ]; 13 | 14 | export const Footer = () => ( 15 | <> 16 |
17 |
18 |
19 | {socialLinks.map(({href, text, icon}) => ( 20 | 26 | {text} 27 | {icon} 28 | 29 | ))} 30 |
31 |

32 | {new Date().getFullYear()} Borislav Hadzhiev 33 |

34 |
35 |
36 | 37 | ); 38 | 39 | function TwitterIcon({className = 'w-6 h-6'}) { 40 | return ; 41 | } 42 | 43 | function GithubIcon({className = 'w-6 h-6'}) { 44 | return ; 45 | } 46 | 47 | function BlogIcon({className = 'w-6 h-6'}) { 48 | return ; 49 | } 50 | -------------------------------------------------------------------------------- /src/components/footer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './footer'; 2 | -------------------------------------------------------------------------------- /src/components/forms/button.tsx: -------------------------------------------------------------------------------- 1 | import {ButtonHTMLAttributes} from 'react'; 2 | 3 | type ButtonProps = ButtonHTMLAttributes & { 4 | color?: 'primary' | 'secondary' | 'light' | 'danger'; 5 | size?: 'sm' | 'md'; 6 | className?: string; 7 | isFullWidth?: boolean; 8 | }; 9 | 10 | const buttonColors = { 11 | primary: 'text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500', 12 | secondary: 13 | 'text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:ring-blue-300', 14 | light: 'text-gray-700 bg-gray-100 hover:bg-gray-200 focus:ring-indigo-500', 15 | danger: 'text-gray-600 bg-pink-100 hover:bg-pink-200 focus:ring-pink-300', 16 | }; 17 | 18 | export const Button: React.FC = ({ 19 | color = 'primary', 20 | type = 'button', 21 | size = 'md', 22 | isFullWidth = false, 23 | className = '', 24 | children, 25 | disabled, 26 | ...rest 27 | }) => { 28 | const colorClasses = buttonColors[color]; 29 | const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : ''; 30 | const widthClass = isFullWidth ? 'w-full' : ''; 31 | const sizeClasses = 32 | size === 'sm' ? 'px-2 py-1 text-sm' : 'px-4 py-2 text-base'; 33 | 34 | return ( 35 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/forms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button'; 2 | export * from './input'; 3 | export * from './label'; 4 | export * from './radio-button'; 5 | export * from './select-menu'; 6 | export * from './textarea'; 7 | -------------------------------------------------------------------------------- /src/components/forms/input.tsx: -------------------------------------------------------------------------------- 1 | import {ErrorIcon} from '@components/icons/icons'; 2 | import {Flex} from '@components/layout'; 3 | import { 4 | DetailedHTMLProps, 5 | forwardRef, 6 | InputHTMLAttributes, 7 | ReactElement, 8 | ReactNode, 9 | } from 'react'; 10 | 11 | type InputProps = DetailedHTMLProps< 12 | InputHTMLAttributes, 13 | HTMLInputElement 14 | > & { 15 | error?: string; 16 | icon?: ReactNode; 17 | }; 18 | 19 | export const Input = forwardRef( 20 | ({error, icon, disabled, ...rest}, ref) => { 21 | const errorClasses = error 22 | ? 'pr-10 border-red-300 text-red-900 placeholder-red-300 focus:border-red-300 ring-2 ring-red-400 ring-opacity-50' 23 | : ''; 24 | const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : ''; 25 | const iconClasses = icon ? 'pl-10' : ''; 26 | 27 | return ( 28 | <> 29 | 30 | {icon} 31 | 32 | 42 | 43 | {error && ( 44 | 45 | 46 | 47 | )} 48 | 49 | 50 | 51 | 52 | ); 53 | }, 54 | ); 55 | 56 | Input.displayName = 'Input'; 57 | 58 | export function InputGroup({children}: {children: ReactNode}): ReactElement { 59 | return
{children}
; 60 | } 61 | 62 | function InputLeftIcon({children}: {children: ReactNode}): ReactElement { 63 | return ( 64 | 65 | {children} 66 | 67 | ); 68 | } 69 | 70 | export function InputRightIcon({ 71 | children, 72 | }: { 73 | children: ReactNode; 74 | }): ReactElement { 75 | return ( 76 | 77 | {children} 78 | 79 | ); 80 | } 81 | 82 | export function InputErrorMessage({ 83 | errorMessage, 84 | }: { 85 | errorMessage?: string; 86 | }): ReactElement | null { 87 | return

{errorMessage}

; 88 | } 89 | -------------------------------------------------------------------------------- /src/components/forms/label.tsx: -------------------------------------------------------------------------------- 1 | import {DetailedHTMLProps, LabelHTMLAttributes} from 'react'; 2 | 3 | type LabelProps = DetailedHTMLProps< 4 | LabelHTMLAttributes, 5 | HTMLLabelElement 6 | > & { 7 | htmlFor: string; 8 | }; 9 | 10 | export const Label: React.FC = ({htmlFor, children, ...rest}) => ( 11 | 18 | ); 19 | -------------------------------------------------------------------------------- /src/components/forms/radio-button.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DetailedHTMLProps, 3 | forwardRef, 4 | InputHTMLAttributes, 5 | LabelHTMLAttributes, 6 | } from 'react'; 7 | 8 | type RadioInputProps = DetailedHTMLProps< 9 | InputHTMLAttributes, 10 | HTMLInputElement 11 | >; 12 | 13 | export const RadioInput = forwardRef( 14 | ({disabled, ...rest}, ref) => { 15 | const disabledClasses = disabled ? 'opacity-50 cursror-not-allowed' : ''; 16 | 17 | return ( 18 | 25 | ); 26 | }, 27 | ); 28 | 29 | RadioInput.displayName = 'RadioInput'; 30 | 31 | type RadioLabelProps = DetailedHTMLProps< 32 | LabelHTMLAttributes, 33 | HTMLLabelElement 34 | > & { 35 | htmlFor: string; 36 | }; 37 | 38 | export const RadioLabel: React.FC = ({ 39 | htmlFor, 40 | children, 41 | ...rest 42 | }) => ( 43 | 50 | ); 51 | -------------------------------------------------------------------------------- /src/components/forms/select-menu.tsx: -------------------------------------------------------------------------------- 1 | import {DetailedHTMLProps, forwardRef, InputHTMLAttributes} from 'react'; 2 | 3 | type SelectMenuProps = DetailedHTMLProps< 4 | InputHTMLAttributes, 5 | HTMLSelectElement 6 | > & { 7 | hasError?: boolean; 8 | }; 9 | 10 | export const SelectMenu = forwardRef( 11 | ({hasError, disabled, ...rest}, ref) => { 12 | const errorClasses = hasError 13 | ? 'border-red-300 placeholder-red-300 text-red-900 focus:border-red-300 ring-2 ring-red-400 ring-opacity-50' 14 | : ''; 15 | 16 | const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : ''; 17 | 18 | return ( 19 | 38 | 39 | 40 | 41 | 42 | 43 | */ 44 | -------------------------------------------------------------------------------- /src/components/forms/textarea.tsx: -------------------------------------------------------------------------------- 1 | import {ErrorIcon} from '@components/icons/icons'; 2 | import {DetailedHTMLProps, forwardRef, TextareaHTMLAttributes} from 'react'; 3 | import {InputErrorMessage, InputGroup, InputRightIcon} from './input'; 4 | 5 | type TextAreaProps = DetailedHTMLProps< 6 | TextareaHTMLAttributes, 7 | HTMLTextAreaElement 8 | > & { 9 | error?: string; 10 | }; 11 | 12 | export const TextArea = forwardRef( 13 | ({error, ...rest}, ref) => { 14 | const errorClasses = error 15 | ? 'pr-10 border-red-300 text-red-900 placeholder-red-300 focus:border-red-300 ring-2 ring-red-400 ring-opacity-50' 16 | : ''; 17 | 18 | return ( 19 | <> 20 | 21 |