├── .all-contributorsrc ├── .env ├── .gitignore ├── .gitlab-ci.yml ├── .husky └── .gitignore ├── .node-version ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── .yarnrc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── amplify ├── .config │ └── project-config.json └── backend │ ├── auth │ └── cypressrealworldapp3a466349 │ │ ├── cypressrealworldapp3a466349-cloudformation-template.yml │ │ └── parameters.json │ └── backend-config.json ├── backend ├── app.ts ├── auth.ts ├── bankaccount-routes.ts ├── banktransfer-routes.ts ├── comment-routes.ts ├── contact-routes.ts ├── database.ts ├── graphql │ ├── resolvers │ │ ├── Mutation.ts │ │ ├── Query.ts │ │ └── index.ts │ └── schema.graphql ├── helpers.ts ├── like-routes.ts ├── notification-routes.ts ├── testdata-routes.ts ├── transaction-routes.ts ├── types.ts ├── user-routes.ts └── validators.ts ├── bitbucket-pipelines.yml ├── buildspec-types-unit.yml ├── buildspec.yml ├── codecov.yml ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── payment_spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── data ├── database-seed.json ├── database.json └── empty-seed.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── img │ └── rwa-readme-screenshot.png ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── renovate.json ├── sandbox.config.json ├── scripts ├── generateSeedData.ts ├── mock-aws-exports-es5.js ├── mock-aws-exports.js ├── seedDataUtils.ts ├── testServer.ts └── tsconfig.json ├── src ├── components │ ├── AlertBar.tsx │ ├── BankAccountForm.tsx │ ├── BankAccountItem.tsx │ ├── BankAccountList.tsx │ ├── CommentForm.tsx │ ├── CommentList.tsx │ ├── CommentListItem.tsx │ ├── EmptyList.tsx │ ├── Footer.tsx │ ├── MainLayout.tsx │ ├── NavBar.tsx │ ├── NavDrawer.tsx │ ├── NotificationList.tsx │ ├── NotificationListItem.tsx │ ├── PrivateRoute.tsx │ ├── SignInForm.tsx │ ├── SignUpForm.tsx │ ├── SkeletonList.tsx │ ├── SvgCypressLogo.tsx │ ├── SvgRwaIconLogo.tsx │ ├── SvgRwaLogo.tsx │ ├── SvgUndrawNavigatorA479.tsx │ ├── SvgUndrawPersonalFinanceTqcd.tsx │ ├── SvgUndrawPersonalSettingsKihd.tsx │ ├── SvgUndrawReminders697P.tsx │ ├── SvgUndrawTransferMoneyRywa.tsx │ ├── TransactionAmount.tsx │ ├── TransactionContactsList.tsx │ ├── TransactionCreateStepOne.tsx │ ├── TransactionCreateStepThree.tsx │ ├── TransactionCreateStepTwo.test.js │ ├── TransactionCreateStepTwo.tsx │ ├── TransactionDateRangeFilter.tsx │ ├── TransactionDetail.tsx │ ├── TransactionInfiniteList.tsx │ ├── TransactionItem.tsx │ ├── TransactionList.tsx │ ├── TransactionListAmountRangeFilter.tsx │ ├── TransactionListFilters.tsx │ ├── TransactionNavTabs.tsx │ ├── TransactionPersonalList.tsx │ ├── TransactionPublicList.tsx │ ├── TransactionTitle.tsx │ ├── UserListItem.tsx │ ├── UserListSearchForm.tsx │ ├── UserSettingsForm.tsx │ └── UsersList.tsx ├── containers │ ├── App.tsx │ ├── AppAuth0.tsx │ ├── AppCognito.tsx │ ├── AppGoogle.tsx │ ├── AppOkta.tsx │ ├── BankAccountsContainer.tsx │ ├── NotificationsContainer.tsx │ ├── PrivateRoutesContainer.tsx │ ├── TransactionCreateContainer.tsx │ ├── TransactionDetailContainer.tsx │ ├── TransactionsContainer.tsx │ ├── UserOnboardingContainer.tsx │ └── UserSettingsContainer.tsx ├── index.tsx ├── machines │ ├── authMachine.ts │ ├── bankAccountsMachine.ts │ ├── contactsTransactionsMachine.ts │ ├── createTransactionMachine.ts │ ├── dataMachine.ts │ ├── drawerMachine.ts │ ├── notificationsMachine.ts │ ├── personalTransactionsMachine.ts │ ├── publicTransactionsMachine.ts │ ├── snackbarMachine.ts │ ├── transactionDetailMachine.ts │ ├── transactionFiltersMachine.ts │ ├── userOnboardingMachine.ts │ └── usersMachine.ts ├── models │ ├── bankaccount.ts │ ├── banktransfer.ts │ ├── comment.ts │ ├── contact.ts │ ├── db-schema.ts │ ├── index.ts │ ├── like.ts │ ├── notification.ts │ ├── transaction.ts │ └── user.ts ├── react-app-env.d.ts ├── setupProxy.js ├── setupTests.ts ├── svgs │ ├── built-by-cypress.svg │ ├── cypress-logo.svg │ ├── rwa-icon-logo.svg │ ├── rwa-logo.svg │ ├── undraw_navigator_a479.svg │ ├── undraw_personal_finance_tqcd.svg │ ├── undraw_personal_settings_kihd.svg │ ├── undraw_reminders_697p.svg │ └── undraw_transfer_money_rywa.svg └── utils │ ├── asyncUtils.ts │ ├── historyUtils.ts │ ├── portUtils.ts │ └── transactionUtils.ts ├── tsconfig.json ├── tsconfig.tsnode.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [{ 8 | "login": "kevinold", 9 | "name": "Kevin Old", 10 | "avatar_url": "https://avatars0.githubusercontent.com/u/21967?v=4", 11 | "profile": "http://www.kevinold.com", 12 | "contributions": [] 13 | }, 14 | { 15 | "login": "amirrustam", 16 | "name": "Amir Rustamzadeh", 17 | "avatar_url": "https://avatars0.githubusercontent.com/u/334337?v=4", 18 | "profile": "https://twitter.com/amirrustam", 19 | "contributions": [] 20 | }, 21 | { 22 | "login": "brian-mann", 23 | "name": "Brian Mann", 24 | "avatar_url": "https://avatars2.githubusercontent.com/u/1268976?v=4", 25 | "profile": "https://cypress.io", 26 | "contributions": [] 27 | }, 28 | { 29 | "login": "bahmutov", 30 | "name": "Gleb Bahmutov", 31 | "avatar_url": "https://avatars1.githubusercontent.com/u/2212006?v=4", 32 | "profile": "https://glebbahmutov.com/", 33 | "contributions": [] 34 | }, 35 | { 36 | "login": "bencodezen", 37 | "name": "Ben Hong", 38 | "avatar_url": "https://avatars0.githubusercontent.com/u/4836334?v=4", 39 | "profile": "http://www.bencodezen.io", 40 | "contributions": [] 41 | }, 42 | { 43 | "login": "davidkpiano", 44 | "name": "David Khourshid", 45 | "avatar_url": "https://avatars2.githubusercontent.com/u/1093738?v=4", 46 | "profile": "https://github.com/davidkpiano", 47 | "contributions": [] 48 | } 49 | ], 50 | "contributorsPerLine": 7, 51 | "projectName": "cypress-realworld-app", 52 | "projectOwner": "cypress-io", 53 | "repoType": "github", 54 | "repoHost": "https://github.com", 55 | "skipCi": true 56 | } -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | SEED_USERBASE_SIZE=5 2 | SEED_CONTACTS_PER_USER=3 3 | SEED_PAYMENTS_PER_USER=15 4 | SEED_REQUESTS_PER_USER=10 5 | SEED_BANK_ACCOUNTS_PER_USER=1 6 | SEED_LIKES_PER_USER=2 7 | SEED_COMMENTS_PER_USER=2 8 | SEED_NOTIFICATIONS_PER_USER=5 9 | SEED_BANK_TRANSFERS_PER_USER=5 10 | SEED_DEFAULT_USER_PASSWORD=s3cret 11 | PAGINATION_PAGE_SIZE=10 12 | 13 | # Ports used by React (frontend) and Express (backend) 14 | REACT_APP_PORT=3000 15 | REACT_APP_BACKEND_PORT=3001 16 | 17 | # Uncomment when testing with any of the Auth providers below 18 | #REACT_APP_AUTH_TOKEN_NAME="authAccessToken" 19 | 20 | # Auth0 Configuration to be added to .env when running "yarn dev:auth0" 21 | #AUTH0_USERNAME="username@domain.com" 22 | #AUTH0_PASSWORD="s3cret1234$" 23 | #AUTH0_CLIENT_SECRET="your-auth0-client-secret" 24 | #REACT_APP_AUTH0_DOMAIN="your-auth0-domain.auth0.com" 25 | #REACT_APP_AUTH0_CLIENTID="1234567890" 26 | #REACT_APP_AUTH0_AUDIENCE="https://your-auth0-domain.auth0.com/api/v2/" 27 | #REACT_APP_AUTH0_SCOPE="openid email profile" 28 | #AUTH0_MGMT_API_TOKEN="YOUR-MANAGEMENT-API-TOKEN" 29 | 30 | 31 | # Okta Configuration to be added to .env when running "yarn dev:okta" 32 | #OKTA_USERNAME="username@domain.com" 33 | #OKTA_PASSWORD="s3cret1234$" 34 | #REACT_APP_OKTA_DOMAIN="dev-your-domain-id.okta.com" 35 | #REACT_APP_OKTA_CLIENTID="your-client-id" 36 | 37 | # AWS Cognito #Okta Configuration to be added to .env when running "yarn dev:cognito" 38 | # Additional config taken from aws-exports.js 39 | #AWS_COGNITO_USERNAME="user@domain.com" 40 | #AWS_COGNITO_PASSWORD="s3cret1234$" 41 | -------------------------------------------------------------------------------- /.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 | .idea 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | cypress/videos 23 | cypress/screenshots 24 | .nyc_output 25 | 26 | # Ignore env local files - https://create-react-app.dev/docs/adding-custom-environment-variables/#what-other-env-files-can-be-used 27 | .env*.local 28 | 29 | #amplify 30 | amplify/\#current-cloud-backend 31 | amplify/.config/local-* 32 | amplify/mock-data 33 | amplify/team-provider-info.json 34 | amplify/backend/amplify-meta.json 35 | amplify/backend/awscloudformation 36 | build/ 37 | dist/ 38 | node_modules/ 39 | aws-exports.js 40 | aws-exports-es5.js 41 | awsconfiguration.json 42 | amplifyconfiguration.json 43 | amplify-build-config.json 44 | amplify-gradle-config.json 45 | amplifytools.xcconfig 46 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | # Install Cypress, then run all tests (in parallel) 2 | stages: 3 | - build 4 | - test 5 | 6 | # Set environment variables for folders in "cache" job settings for npm modules and Cypress binary 7 | variables: 8 | npm_config_cache: "$CI_PROJECT_DIR/.npm" 9 | CYPRESS_CACHE_FOLDER: "$CI_PROJECT_DIR/cache/Cypress" 10 | 11 | # Cache using branch name 12 | # https://gitlab.com/help/ci/caching/index.md 13 | cache: 14 | key: ${CI_COMMIT_REF_SLUG} 15 | paths: 16 | - .cache/* 17 | - cache/Cypress 18 | - node_modules 19 | - build 20 | 21 | # Install NPM dependencies and Cypress 22 | install: 23 | image: cypress/browsers:node14.17.0-chrome88-ff89 24 | stage: build 25 | 26 | script: 27 | - yarn install --frozen-lockfile 28 | # check Cypress binary path and cached versions 29 | - npx cypress cache path 30 | - npx cypress cache list 31 | - yarn types 32 | - yarn lint 33 | - yarn test:unit:ci 34 | - yarn build:ci 35 | 36 | api-tests: 37 | image: cypress/browsers:node14.17.0-chrome88-ff89 38 | stage: test 39 | parallel: 5 40 | script: 41 | - yarn start:ci & npx wait-on http://localhost:3000 42 | - npx cypress run --record --parallel --browser chrome --group "API" --spec "cypress/tests/api/*" 43 | 44 | ui-chrome: 45 | image: cypress/browsers:node14.17.0-chrome88-ff89 46 | stage: test 47 | parallel: 5 48 | script: 49 | - yarn start:ci & npx wait-on http://localhost:3000 50 | - npx cypress run --record --parallel --browser chrome --group "UI - Chrome" --spec "cypress/tests/ui/*" 51 | 52 | ui-chrome-mobile: 53 | image: cypress/browsers:node14.17.0-chrome88-ff89 54 | stage: test 55 | parallel: 5 56 | script: 57 | - yarn start:ci & npx wait-on http://localhost:3000 58 | - npx cypress run --record --parallel --browser chrome --group "UI - Chrome - Mobile" --spec "cypress/tests/ui/*" --config "viewportWidth=375,viewportHeight=667" 59 | 60 | ui-firefox: 61 | image: cypress/browsers:node14.17.0-chrome88-ff89 62 | stage: test 63 | parallel: 5 64 | script: 65 | - yarn start:ci & npx wait-on http://localhost:3000 66 | - npx cypress run --record --parallel --browser firefox --group "UI - Firefox" --spec "cypress/tests/ui/*" 67 | 68 | ui-firefox-mobile: 69 | image: cypress/browsers:node14.17.0-chrome88-ff89 70 | stage: test 71 | parallel: 5 72 | script: 73 | - yarn start:ci & npx wait-on http://localhost:3000 74 | - npx cypress run --record --parallel --browser firefox --group "UI - Firefox - Mobile" --spec "cypress/tests/ui/*" --config "viewportWidth=375,viewportHeight=667" 75 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 14.17.5 -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.17.5 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore cache directory for GitLab builds 2 | /cache 3 | 4 | data 5 | build 6 | coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 100, 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug CRA Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/react-scripts", 9 | "args": ["test", "--runInBand", "--no-cache", "--env=jsdom"], 10 | "cwd": "${workspaceRoot}", 11 | "protocol": "inspector", 12 | "console": "integratedTerminal", 13 | "internalConsoleOptions": "neverOpen" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | network-timeout 500000 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [hello@cypress.io](mailto:hello@cypress.io). All 38 | complaints will be reviewed and investigated and will result in a response that 39 | is deemed necessary and appropriate to the circumstances. The project team is 40 | obligated to maintain confidentiality with regard to the reporter of an incident. 41 | Further details of specific enforcement policies may be posted separately. 42 | 43 | Project maintainers who do not follow or enforce the Code of Conduct in good 44 | faith may face temporary or permanent repercussions as determined by other 45 | members of the project's leadership. 46 | 47 | ## Attribution 48 | 49 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 50 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 51 | 52 | [homepage]: https://www.contributor-covenant.org 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2020 Cypress.io 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Thanks to team Cypress for this real world demo app ❤️ 2 | 3 | Original repo -------------------------------------------------------------------------------- /amplify/.config/project-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "cypressrealworldapp", 3 | "version": "3.0", 4 | "frontend": "javascript", 5 | "javascript": { 6 | "framework": "react", 7 | "config": { 8 | "SourceDir": "src", 9 | "DistributionDir": "build", 10 | "BuildCommand": "npm run-script build", 11 | "StartCommand": "yarn dev:cognito" 12 | } 13 | }, 14 | "providers": [ 15 | "awscloudformation" 16 | ] 17 | } -------------------------------------------------------------------------------- /amplify/backend/auth/cypressrealworldapp3a466349/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "identityPoolName": "cypressrealworldapp3a466349_identitypool_3a466349", 3 | "allowUnauthenticatedIdentities": false, 4 | "resourceNameTruncated": "cypres3a466349", 5 | "userPoolName": "cypressrealworldapp3a466349_userpool_3a466349", 6 | "autoVerifiedAttributes": [ 7 | "email" 8 | ], 9 | "mfaConfiguration": "OFF", 10 | "mfaTypes": [ 11 | "SMS Text Message" 12 | ], 13 | "smsAuthenticationMessage": "Your authentication code is {####}", 14 | "smsVerificationMessage": "Your verification code is {####}", 15 | "emailVerificationSubject": "Your verification code", 16 | "emailVerificationMessage": "Your verification code is {####}", 17 | "defaultPasswordPolicy": false, 18 | "passwordPolicyMinLength": 8, 19 | "passwordPolicyCharacters": [], 20 | "requiredAttributes": [ 21 | "email" 22 | ], 23 | "userpoolClientGenerateSecret": true, 24 | "userpoolClientRefreshTokenValidity": 30, 25 | "userpoolClientWriteAttributes": [ 26 | "email" 27 | ], 28 | "userpoolClientReadAttributes": [ 29 | "email" 30 | ], 31 | "userpoolClientLambdaRole": "cypres3a466349_userpoolclient_lambda_role", 32 | "userpoolClientSetAttributes": false, 33 | "sharedId": "3a466349", 34 | "resourceName": "cypressrealworldapp3a466349", 35 | "authSelections": "identityPoolAndUserPool", 36 | "authRoleArn": { 37 | "Fn::GetAtt": [ 38 | "AuthRole", 39 | "Arn" 40 | ] 41 | }, 42 | "unauthRoleArn": { 43 | "Fn::GetAtt": [ 44 | "UnauthRole", 45 | "Arn" 46 | ] 47 | }, 48 | "useDefault": "default", 49 | "usernameAttributes": [ 50 | "email" 51 | ], 52 | "userPoolGroupList": [], 53 | "dependsOn": [] 54 | } -------------------------------------------------------------------------------- /amplify/backend/backend-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "cypressrealworldapp3a466349": { 4 | "service": "Cognito", 5 | "providerPlugin": "awscloudformation", 6 | "dependsOn": [], 7 | "customAuth": false 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /backend/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { join } from "path"; 3 | import logger from "morgan"; 4 | import passport from "passport"; 5 | import session from "express-session"; 6 | import bodyParser from "body-parser"; 7 | import cors from "cors"; 8 | import paginate from "express-paginate"; 9 | import { graphqlHTTP } from "express-graphql"; 10 | import { loadSchemaSync } from "@graphql-tools/load"; 11 | import { GraphQLFileLoader } from "@graphql-tools/graphql-file-loader"; 12 | import { addResolversToSchema } from "@graphql-tools/schema"; 13 | 14 | import auth from "./auth"; 15 | import userRoutes from "./user-routes"; 16 | import contactRoutes from "./contact-routes"; 17 | import bankAccountRoutes from "./bankaccount-routes"; 18 | import transactionRoutes from "./transaction-routes"; 19 | import likeRoutes from "./like-routes"; 20 | import commentRoutes from "./comment-routes"; 21 | import notificationRoutes from "./notification-routes"; 22 | import bankTransferRoutes from "./banktransfer-routes"; 23 | import testDataRoutes from "./testdata-routes"; 24 | import { checkAuth0Jwt, verifyOktaToken, checkCognitoJwt, checkGoogleJwt } from "./helpers"; 25 | import resolvers from "./graphql/resolvers"; 26 | import { frontendPort, backendPort } from "../src/utils/portUtils"; 27 | 28 | require("dotenv").config(); 29 | 30 | const corsOption = { 31 | origin: `http://localhost:${frontendPort}`, 32 | credentials: true, 33 | }; 34 | 35 | const schema = loadSchemaSync(join(__dirname, "./graphql/schema.graphql"), { 36 | loaders: [new GraphQLFileLoader()], 37 | }); 38 | 39 | const schemaWithResolvers = addResolversToSchema({ 40 | schema, 41 | resolvers, 42 | }); 43 | 44 | const app = express(); 45 | 46 | /* istanbul ignore next */ 47 | // @ts-ignore 48 | if (global.__coverage__) { 49 | require("@cypress/code-coverage/middleware/express")(app); 50 | } 51 | 52 | app.use(cors(corsOption)); 53 | app.use(logger("dev")); 54 | app.use(bodyParser.urlencoded({ extended: false })); 55 | app.use(bodyParser.json()); 56 | 57 | app.use( 58 | session({ 59 | secret: "session secret", 60 | resave: false, 61 | saveUninitialized: false, 62 | unset: "destroy", 63 | }) 64 | ); 65 | app.use(passport.initialize()); 66 | app.use(passport.session()); 67 | 68 | app.use(paginate.middleware(+process.env.PAGINATION_PAGE_SIZE!)); 69 | 70 | /* istanbul ignore next */ 71 | if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") { 72 | app.use("/testData", testDataRoutes); 73 | } 74 | 75 | app.use(auth); 76 | 77 | /* istanbul ignore if */ 78 | if (process.env.REACT_APP_AUTH0) { 79 | app.use(checkAuth0Jwt); 80 | } 81 | 82 | /* istanbul ignore if */ 83 | if (process.env.REACT_APP_OKTA) { 84 | app.use(verifyOktaToken); 85 | } 86 | 87 | /* istanbul ignore if */ 88 | if (process.env.REACT_APP_AWS_COGNITO) { 89 | app.use(checkCognitoJwt); 90 | } 91 | 92 | /* istanbul ignore if */ 93 | if (process.env.REACT_APP_GOOGLE) { 94 | app.use(checkGoogleJwt); 95 | } 96 | 97 | app.use( 98 | "/graphql", 99 | graphqlHTTP({ 100 | schema: schemaWithResolvers, 101 | graphiql: true, 102 | }) 103 | ); 104 | 105 | app.use("/users", userRoutes); 106 | app.use("/contacts", contactRoutes); 107 | app.use("/bankAccounts", bankAccountRoutes); 108 | app.use("/transactions", transactionRoutes); 109 | app.use("/likes", likeRoutes); 110 | app.use("/comments", commentRoutes); 111 | app.use("/notifications", notificationRoutes); 112 | app.use("/bankTransfers", bankTransferRoutes); 113 | 114 | app.use(express.static(join(__dirname, "../public"))); 115 | 116 | app.listen(backendPort); 117 | -------------------------------------------------------------------------------- /backend/auth.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import passport from "passport"; 3 | import express, { Request, Response } from "express"; 4 | import { User } from "../src/models/user"; 5 | import { getUserBy, getUserById } from "./database"; 6 | 7 | const LocalStrategy = require("passport-local").Strategy; 8 | const router = express.Router(); 9 | 10 | // configure passport for local strategy 11 | passport.use( 12 | new LocalStrategy(function (username: string, password: string, done: Function) { 13 | const user = getUserBy("username", username); 14 | 15 | const failureMessage = "Incorrect username or password."; 16 | if (!user) { 17 | return done(null, false, { message: failureMessage }); 18 | } 19 | 20 | // validate password 21 | if (!bcrypt.compareSync(password, user.password)) { 22 | return done(null, false, { message: failureMessage }); 23 | } 24 | 25 | return done(null, user); 26 | }) 27 | ); 28 | 29 | passport.serializeUser(function (user: User, done) { 30 | done(null, user.id); 31 | }); 32 | 33 | passport.deserializeUser(function (id: string, done) { 34 | const user = getUserById(id); 35 | done(null, user); 36 | }); 37 | 38 | // authentication routes 39 | router.post("/login", passport.authenticate("local"), (req: Request, res: Response): void => { 40 | if (req.body.remember) { 41 | req.session!.cookie.maxAge = 24 * 60 * 60 * 1000 * 30; // Expire in 30 days 42 | } else { 43 | req.session!.cookie.expires = undefined; 44 | } 45 | 46 | res.send({ user: req.user }); 47 | }); 48 | 49 | router.post("/logout", (req: Request, res: Response): void => { 50 | res.clearCookie("connect.sid"); 51 | req.logout(); 52 | req.session!.destroy(function (err) { 53 | res.redirect("/"); 54 | }); 55 | }); 56 | 57 | router.get("/checkAuth", (req, res) => { 58 | /* istanbul ignore next */ 59 | if (!req.user) { 60 | res.status(401).json({ error: "User is unauthorized" }); 61 | } else { 62 | res.status(200).json({ user: req.user }); 63 | } 64 | }); 65 | 66 | export default router; 67 | -------------------------------------------------------------------------------- /backend/bankaccount-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | 5 | import { 6 | getBankAccountsByUserId, 7 | getBankAccountById, 8 | createBankAccountForUser, 9 | removeBankAccountById, 10 | } from "./database"; 11 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 12 | import { shortIdValidation, isBankAccountValidator } from "./validators"; 13 | const router = express.Router(); 14 | 15 | // Routes 16 | 17 | //GET /bankAccounts (scoped-user) 18 | router.get("/", ensureAuthenticated, (req, res) => { 19 | /* istanbul ignore next */ 20 | const accounts = getBankAccountsByUserId(req.user?.id!); 21 | 22 | res.status(200); 23 | res.json({ results: accounts }); 24 | }); 25 | 26 | //GET /bankAccounts/:bankAccountId (scoped-user) 27 | router.get( 28 | "/:bankAccountId", 29 | ensureAuthenticated, 30 | validateMiddleware([shortIdValidation("bankAccountId")]), 31 | (req, res) => { 32 | const { bankAccountId } = req.params; 33 | 34 | const account = getBankAccountById(bankAccountId); 35 | 36 | res.status(200); 37 | res.json({ account }); 38 | } 39 | ); 40 | 41 | //POST /bankAccounts (scoped-user) 42 | router.post("/", ensureAuthenticated, validateMiddleware(isBankAccountValidator), (req, res) => { 43 | /* istanbul ignore next */ 44 | const account = createBankAccountForUser(req.user?.id!, req.body); 45 | 46 | res.status(200); 47 | res.json({ account }); 48 | }); 49 | 50 | //DELETE (soft) /bankAccounts (scoped-user) 51 | router.delete( 52 | "/:bankAccountId", 53 | ensureAuthenticated, 54 | validateMiddleware([shortIdValidation("bankAccountId")]), 55 | (req, res) => { 56 | const { bankAccountId } = req.params; 57 | 58 | const account = removeBankAccountById(bankAccountId); 59 | 60 | res.status(200); 61 | res.json({ account }); 62 | } 63 | ); 64 | 65 | export default router; 66 | -------------------------------------------------------------------------------- /backend/banktransfer-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | 5 | import { getBankTransfersByUserId } from "./database"; 6 | import { ensureAuthenticated } from "./helpers"; 7 | const router = express.Router(); 8 | 9 | // Routes 10 | 11 | //GET /bankTransfers (scoped-user) 12 | router.get("/", ensureAuthenticated, (req, res) => { 13 | /* istanbul ignore next */ 14 | const transfers = getBankTransfersByUserId(req.user?.id!); 15 | 16 | res.status(200); 17 | res.json({ transfers }); 18 | }); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /backend/comment-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { getCommentsByTransactionId, createComments } from "./database"; 5 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 6 | import { shortIdValidation, isCommentValidator } from "./validators"; 7 | const router = express.Router(); 8 | 9 | // Routes 10 | 11 | //GET /comments/:transactionId 12 | router.get( 13 | "/:transactionId", 14 | ensureAuthenticated, 15 | validateMiddleware([shortIdValidation("transactionId")]), 16 | (req, res) => { 17 | const { transactionId } = req.params; 18 | const comments = getCommentsByTransactionId(transactionId); 19 | 20 | res.status(200); 21 | res.json({ comments }); 22 | } 23 | ); 24 | 25 | //POST /comments/:transactionId 26 | router.post( 27 | "/:transactionId", 28 | ensureAuthenticated, 29 | validateMiddleware([shortIdValidation("transactionId"), isCommentValidator]), 30 | (req, res) => { 31 | const { transactionId } = req.params; 32 | const { content } = req.body; 33 | 34 | /* istanbul ignore next */ 35 | createComments(req.user?.id!, transactionId, content); 36 | 37 | res.sendStatus(200); 38 | } 39 | ); 40 | 41 | export default router; 42 | -------------------------------------------------------------------------------- /backend/contact-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | 5 | import { getContactsByUsername, removeContactById, createContactForUser } from "./database"; 6 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 7 | import { shortIdValidation } from "./validators"; 8 | const router = express.Router(); 9 | 10 | // Routes 11 | //GET /contacts/:username 12 | router.get("/:username", (req, res) => { 13 | const { username } = req.params; 14 | 15 | const contacts = getContactsByUsername(username); 16 | 17 | res.status(200); 18 | res.json({ contacts }); 19 | }); 20 | 21 | //POST /contacts (scoped-user) 22 | router.post( 23 | "/", 24 | ensureAuthenticated, 25 | validateMiddleware([shortIdValidation("contactUserId")]), 26 | (req, res) => { 27 | const { contactUserId } = req.body; 28 | /* istanbul ignore next */ 29 | const contact = createContactForUser(req.user?.id!, contactUserId); 30 | 31 | res.status(200); 32 | res.json({ contact }); 33 | } 34 | ); 35 | //DELETE /contacts/:contactId (scoped-user) 36 | router.delete( 37 | "/:contactId", 38 | ensureAuthenticated, 39 | validateMiddleware([shortIdValidation("contactId")]), 40 | (req, res) => { 41 | const { contactId } = req.params; 42 | 43 | const contacts = removeContactById(contactId); 44 | 45 | res.status(200); 46 | res.json({ contacts }); 47 | } 48 | ); 49 | 50 | export default router; 51 | -------------------------------------------------------------------------------- /backend/graphql/resolvers/Mutation.ts: -------------------------------------------------------------------------------- 1 | import { createBankAccountForUser, removeBankAccountById } from "../../database"; 2 | 3 | const Mutation = { 4 | createBankAccount: (obj: any, args: any, ctx: any) => { 5 | const account = createBankAccountForUser(ctx.user.id!, args); 6 | return account; 7 | }, 8 | deleteBankAccount: (obj: any, args: any, ctx: any) => { 9 | removeBankAccountById(args.id); 10 | return true; 11 | }, 12 | }; 13 | 14 | export default Mutation; 15 | -------------------------------------------------------------------------------- /backend/graphql/resolvers/Query.ts: -------------------------------------------------------------------------------- 1 | import { getBankAccountsByUserId } from "../../database"; 2 | 3 | const Query = { 4 | listBankAccount(obj: any, args: any, ctx: any) { 5 | /* istanbul ignore next */ 6 | try { 7 | const accounts = getBankAccountsByUserId(ctx.user.id!); 8 | 9 | return accounts; 10 | /* istanbul ignore next */ 11 | } catch (err) { 12 | /* istanbul ignore next */ 13 | throw new Error(err); 14 | } 15 | }, 16 | }; 17 | 18 | export default Query; 19 | -------------------------------------------------------------------------------- /backend/graphql/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import Query from "./Query"; 2 | import Mutation from "./Mutation"; 3 | 4 | const items = { 5 | Query, 6 | Mutation, 7 | }; 8 | 9 | export default items; 10 | -------------------------------------------------------------------------------- /backend/graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | listBankAccount: [BankAccount!] 3 | } 4 | 5 | type Mutation { 6 | createBankAccount( 7 | bankName: String!, 8 | accountNumber: String!, 9 | routingNumber: String! 10 | ): BankAccount 11 | deleteBankAccount( 12 | id: ID! 13 | ): Boolean 14 | } 15 | 16 | type BankAccount { 17 | id: ID! 18 | uuid: String 19 | userId: String 20 | bankName: String 21 | accountNumber: String 22 | routingNumber: String 23 | isDeleted: Boolean 24 | createdAt: String 25 | modifiedAt: String 26 | } -------------------------------------------------------------------------------- /backend/helpers.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { set } from "lodash"; 3 | import { Request, Response, NextFunction } from "express"; 4 | import { validationResult } from "express-validator"; 5 | import jwt from "express-jwt"; 6 | import jwksRsa from "jwks-rsa"; 7 | 8 | // @ts-ignore 9 | import OktaJwtVerifier from "@okta/jwt-verifier"; 10 | // @ts-ignore 11 | import awsConfig from "../src/aws-exports"; 12 | 13 | dotenv.config(); 14 | 15 | const auth0JwtConfig = { 16 | secret: jwksRsa.expressJwtSecret({ 17 | cache: true, 18 | rateLimit: true, 19 | jwksRequestsPerMinute: 5, 20 | jwksUri: `https://${process.env.REACT_APP_AUTH0_DOMAIN}/.well-known/jwks.json`, 21 | }), 22 | 23 | // Validate the audience and the issuer. 24 | audience: process.env.REACT_APP_AUTH0_AUDIENCE, 25 | issuer: `https://${process.env.REACT_APP_AUTH0_DOMAIN}/`, 26 | algorithms: ["RS256"], 27 | }; 28 | 29 | // Okta Validate the JWT Signature 30 | const oktaJwtVerifier = new OktaJwtVerifier({ 31 | issuer: `https://${process.env.REACT_APP_OKTA_DOMAIN}/oauth2/default`, 32 | clientId: process.env.REACT_APP_OKTA_CLIENTID, 33 | assertClaims: { 34 | aud: "api://default", 35 | cid: process.env.REACT_APP_OKTA_CLIENTID, 36 | }, 37 | }); 38 | const googleJwtConfig = { 39 | secret: jwksRsa.expressJwtSecret({ 40 | cache: true, 41 | rateLimit: true, 42 | jwksRequestsPerMinute: 5, 43 | jwksUri: "https://www.googleapis.com/oauth2/v3/certs", 44 | }), 45 | 46 | // Validate the audience and the issuer. 47 | audience: process.env.REACT_APP_GOOGLE_CLIENTID, 48 | issuer: "accounts.google.com", 49 | algorithms: ["RS256"], 50 | }; 51 | 52 | /* istanbul ignore next */ 53 | export const verifyOktaToken = (req: Request, res: Response, next: NextFunction) => { 54 | const bearerHeader = req.headers["authorization"]; 55 | 56 | if (bearerHeader) { 57 | const bearer = bearerHeader.split(" "); 58 | const bearerToken = bearer[1]; 59 | 60 | oktaJwtVerifier 61 | .verifyAccessToken(bearerToken, "api://default") 62 | .then((jwt: any) => { 63 | // the token is valid 64 | req.user = { 65 | // @ts-ignore 66 | sub: jwt.sub, 67 | }; 68 | return next(); 69 | }) 70 | .catch((err: any) => { 71 | // a validation failed, inspect the error 72 | console.log("error", err); 73 | }); 74 | } else { 75 | res.status(401).send({ 76 | error: "Unauthorized", 77 | }); 78 | } 79 | }; 80 | 81 | // Amazon Cognito Validate the JWT Signature 82 | // https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html#amazon-cognito-user-pools-using-tokens-step-2 83 | const awsCognitoJwtConfig = { 84 | secret: jwksRsa.expressJwtSecret({ 85 | jwksUri: `https://cognito-idp.${awsConfig.aws_cognito_region}.amazonaws.com/${awsConfig.aws_user_pools_id}/.well-known/jwks.json`, 86 | }), 87 | 88 | issuer: `https://cognito-idp.${awsConfig.aws_cognito_region}.amazonaws.com/${awsConfig.aws_user_pools_id}`, 89 | algorithms: ["RS256"], 90 | }; 91 | 92 | export const checkAuth0Jwt = jwt(auth0JwtConfig).unless({ path: ["/testData/*"] }); 93 | export const checkCognitoJwt = jwt(awsCognitoJwtConfig).unless({ path: ["/testData/*"] }); 94 | export const checkGoogleJwt = jwt(googleJwtConfig).unless({ path: ["/testData/*"] }); 95 | 96 | export const ensureAuthenticated = (req: Request, res: Response, next: NextFunction) => { 97 | if (req.isAuthenticated()) { 98 | // @ts-ignore 99 | // Map sub to id on req.user 100 | if (req.user?.sub) { 101 | /* istanbul ignore next */ 102 | // @ts-ignore 103 | set(req.user, "id", req.user.sub); 104 | } 105 | return next(); 106 | } 107 | /* istanbul ignore next */ 108 | res.status(401).send({ 109 | error: "Unauthorized", 110 | }); 111 | }; 112 | 113 | export const validateMiddleware = (validations: any[]) => { 114 | return async (req: Request, res: Response, next: NextFunction) => { 115 | await Promise.all(validations.map((validation: any) => validation.run(req))); 116 | 117 | const errors = validationResult(req); 118 | if (errors.isEmpty()) { 119 | return next(); 120 | } 121 | 122 | res.status(422).json({ errors: errors.array() }); 123 | }; 124 | }; 125 | -------------------------------------------------------------------------------- /backend/like-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { getLikesByTransactionId, createLikes } from "./database"; 5 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 6 | import { shortIdValidation } from "./validators"; 7 | const router = express.Router(); 8 | 9 | // Routes 10 | 11 | //GET /likes/:transactionId 12 | router.get( 13 | "/:transactionId", 14 | ensureAuthenticated, 15 | validateMiddleware([shortIdValidation("transactionId")]), 16 | (req, res) => { 17 | const { transactionId } = req.params; 18 | const likes = getLikesByTransactionId(transactionId); 19 | 20 | res.status(200); 21 | res.json({ likes }); 22 | } 23 | ); 24 | 25 | //POST /likes/:transactionId 26 | router.post( 27 | "/:transactionId", 28 | ensureAuthenticated, 29 | validateMiddleware([shortIdValidation("transactionId")]), 30 | (req, res) => { 31 | const { transactionId } = req.params; 32 | /* istanbul ignore next */ 33 | createLikes(req.user?.id!, transactionId); 34 | 35 | res.sendStatus(200); 36 | } 37 | ); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /backend/notification-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { 5 | createNotifications, 6 | updateNotificationById, 7 | getUnreadNotificationsByUserId, 8 | } from "./database"; 9 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 10 | import { 11 | isNotificationsBodyValidator, 12 | shortIdValidation, 13 | isNotificationPatchValidator, 14 | } from "./validators"; 15 | const router = express.Router(); 16 | 17 | // Routes 18 | 19 | //GET /notifications/ 20 | router.get("/", ensureAuthenticated, (req, res) => { 21 | /* istanbul ignore next */ 22 | const notifications = getUnreadNotificationsByUserId(req.user?.id!); 23 | 24 | res.status(200); 25 | res.json({ results: notifications }); 26 | }); 27 | 28 | //POST /notifications/bulk 29 | router.post( 30 | "/bulk", 31 | ensureAuthenticated, 32 | validateMiddleware([...isNotificationsBodyValidator]), 33 | (req, res) => { 34 | const { items } = req.body; 35 | /* istanbul ignore next */ 36 | const notifications = createNotifications(req.user?.id!, items); 37 | 38 | res.status(200); 39 | // @ts-ignore 40 | res.json({ results: notifications }); 41 | } 42 | ); 43 | 44 | //PATCH /notifications/:notificationId - scoped-user 45 | router.patch( 46 | "/:notificationId", 47 | ensureAuthenticated, 48 | validateMiddleware([shortIdValidation("notificationId"), ...isNotificationPatchValidator]), 49 | (req, res) => { 50 | const { notificationId } = req.params; 51 | /* istanbul ignore next */ 52 | updateNotificationById(req.user?.id!, notificationId, req.body); 53 | 54 | res.sendStatus(204); 55 | } 56 | ); 57 | 58 | export default router; 59 | -------------------------------------------------------------------------------- /backend/testdata-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { getAllForEntity, seedDatabase } from "./database"; 5 | import { validateMiddleware } from "./helpers"; 6 | import { isValidEntityValidator } from "./validators"; 7 | import { DbSchema } from "../src/models/db-schema"; 8 | const router = express.Router(); 9 | 10 | // Routes 11 | 12 | //POST /testData/seed 13 | router.post("/seed", (req, res) => { 14 | seedDatabase(); 15 | res.sendStatus(200); 16 | }); 17 | 18 | //GET /testData/:entity 19 | router.get("/:entity", validateMiddleware([...isValidEntityValidator]), (req, res) => { 20 | const { entity } = req.params; 21 | const results = getAllForEntity(entity as keyof DbSchema); 22 | 23 | res.status(200); 24 | res.json({ results }); 25 | }); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /backend/transaction-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { remove, isEmpty, slice, concat } from "lodash/fp"; 5 | import { 6 | getTransactionsForUserContacts, 7 | createTransaction, 8 | updateTransactionById, 9 | getPublicTransactionsDefaultSort, 10 | getTransactionByIdForApi, 11 | getTransactionsForUserForApi, 12 | getPublicTransactionsByQuery, 13 | } from "./database"; 14 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 15 | import { 16 | sanitizeTransactionStatus, 17 | sanitizeRequestStatus, 18 | isTransactionQSValidator, 19 | isTransactionPayloadValidator, 20 | shortIdValidation, 21 | isTransactionPatchValidator, 22 | isTransactionPublicQSValidator, 23 | } from "./validators"; 24 | import { getPaginatedItems } from "../src/utils/transactionUtils"; 25 | const router = express.Router(); 26 | 27 | // Routes 28 | 29 | //GET /transactions - scoped user, auth-required 30 | router.get( 31 | "/", 32 | ensureAuthenticated, 33 | validateMiddleware([ 34 | sanitizeTransactionStatus, 35 | sanitizeRequestStatus, 36 | ...isTransactionQSValidator, 37 | ]), 38 | (req, res) => { 39 | /* istanbul ignore next */ 40 | const transactions = getTransactionsForUserForApi(req.user?.id!, req.query); 41 | 42 | const { totalPages, data: paginatedItems } = getPaginatedItems( 43 | req.query.page, 44 | req.query.limit, 45 | transactions 46 | ); 47 | 48 | res.status(200); 49 | res.json({ 50 | pageData: { 51 | page: res.locals.paginate.page, 52 | limit: res.locals.paginate.limit, 53 | hasNextPages: res.locals.paginate.hasNextPages(totalPages), 54 | totalPages, 55 | }, 56 | results: paginatedItems, 57 | }); 58 | } 59 | ); 60 | 61 | //GET /transactions/contacts - scoped user, auth-required 62 | router.get( 63 | "/contacts", 64 | ensureAuthenticated, 65 | validateMiddleware([ 66 | sanitizeTransactionStatus, 67 | sanitizeRequestStatus, 68 | ...isTransactionQSValidator, 69 | ]), 70 | (req, res) => { 71 | /* istanbul ignore next */ 72 | const transactions = getTransactionsForUserContacts(req.user?.id!, req.query); 73 | 74 | const { totalPages, data: paginatedItems } = getPaginatedItems( 75 | req.query.page, 76 | req.query.limit, 77 | transactions 78 | ); 79 | 80 | res.status(200); 81 | res.json({ 82 | pageData: { 83 | page: res.locals.paginate.page, 84 | limit: res.locals.paginate.limit, 85 | hasNextPages: res.locals.paginate.hasNextPages(totalPages), 86 | totalPages, 87 | }, 88 | results: paginatedItems, 89 | }); 90 | } 91 | ); 92 | 93 | //GET /transactions/public - auth-required 94 | router.get( 95 | "/public", 96 | ensureAuthenticated, 97 | validateMiddleware(isTransactionPublicQSValidator), 98 | (req, res) => { 99 | const isFirstPage = req.query.page === 1; 100 | 101 | /* istanbul ignore next */ 102 | let transactions = !isEmpty(req.query) 103 | ? getPublicTransactionsByQuery(req.user?.id!, req.query) 104 | : /* istanbul ignore next */ 105 | getPublicTransactionsDefaultSort(req.user?.id!); 106 | 107 | const { contactsTransactions, publicTransactions } = transactions; 108 | 109 | let publicTransactionsWithContacts; 110 | 111 | if (isFirstPage) { 112 | const firstFiveContacts = slice(0, 5, contactsTransactions); 113 | 114 | publicTransactionsWithContacts = concat(firstFiveContacts, publicTransactions); 115 | } 116 | 117 | const { totalPages, data: paginatedItems } = getPaginatedItems( 118 | req.query.page, 119 | req.query.limit, 120 | isFirstPage ? publicTransactionsWithContacts : publicTransactions 121 | ); 122 | 123 | res.status(200); 124 | res.json({ 125 | pageData: { 126 | page: res.locals.paginate.page, 127 | limit: res.locals.paginate.limit, 128 | hasNextPages: res.locals.paginate.hasNextPages(totalPages), 129 | totalPages, 130 | }, 131 | results: paginatedItems, 132 | }); 133 | } 134 | ); 135 | 136 | //POST /transactions - scoped-user 137 | router.post( 138 | "/", 139 | ensureAuthenticated, 140 | validateMiddleware(isTransactionPayloadValidator), 141 | (req, res) => { 142 | const transactionPayload = req.body; 143 | const transactionType = transactionPayload.transactionType; 144 | 145 | remove("transactionType", transactionPayload); 146 | 147 | /* istanbul ignore next */ 148 | const transaction = createTransaction(req.user?.id!, transactionType, transactionPayload); 149 | 150 | res.status(200); 151 | res.json({ transaction }); 152 | } 153 | ); 154 | 155 | //GET /transactions/:transactionId - scoped-user 156 | router.get( 157 | "/:transactionId", 158 | ensureAuthenticated, 159 | validateMiddleware([shortIdValidation("transactionId")]), 160 | (req, res) => { 161 | const { transactionId } = req.params; 162 | 163 | const transaction = getTransactionByIdForApi(transactionId); 164 | 165 | res.status(200); 166 | res.json({ transaction }); 167 | } 168 | ); 169 | 170 | //PATCH /transactions/:transactionId - scoped-user 171 | router.patch( 172 | "/:transactionId", 173 | ensureAuthenticated, 174 | validateMiddleware([shortIdValidation("transactionId"), ...isTransactionPatchValidator]), 175 | (req, res) => { 176 | const { transactionId } = req.params; 177 | 178 | /* istanbul ignore next */ 179 | updateTransactionById(transactionId, req.body); 180 | 181 | res.sendStatus(204); 182 | } 183 | ); 184 | 185 | export default router; 186 | -------------------------------------------------------------------------------- /backend/types.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | import { User as IUser } from "../src/models/user"; 3 | 4 | declare global { 5 | // eslint-disable-next-line @typescript-eslint/no-namespace 6 | namespace Express { 7 | interface User extends IUser {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/user-routes.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import express from "express"; 4 | import { isEqual, pick } from "lodash/fp"; 5 | 6 | import { 7 | getAllUsers, 8 | createUser, 9 | updateUserById, 10 | getUserById, 11 | getUserByUsername, 12 | searchUsers, 13 | removeUserFromResults, 14 | } from "./database"; 15 | import { User } from "../src/models/user"; 16 | import { ensureAuthenticated, validateMiddleware } from "./helpers"; 17 | import { 18 | shortIdValidation, 19 | searchValidation, 20 | userFieldsValidator, 21 | isUserValidator, 22 | } from "./validators"; 23 | const router = express.Router(); 24 | 25 | // Routes 26 | router.get("/", ensureAuthenticated, (req, res) => { 27 | /* istanbul ignore next */ 28 | const users = removeUserFromResults(req.user?.id!, getAllUsers()); 29 | res.status(200).json({ results: users }); 30 | }); 31 | 32 | router.get("/search", ensureAuthenticated, validateMiddleware([searchValidation]), (req, res) => { 33 | const { q } = req.query; 34 | 35 | /* istanbul ignore next */ 36 | const users = removeUserFromResults(req.user?.id!, searchUsers(q)); 37 | 38 | res.status(200).json({ results: users }); 39 | }); 40 | 41 | router.post("/", userFieldsValidator, validateMiddleware(isUserValidator), (req, res) => { 42 | const userDetails: User = req.body; 43 | 44 | const user = createUser(userDetails); 45 | 46 | res.status(201); 47 | res.json({ user: user }); 48 | }); 49 | 50 | router.get( 51 | "/:userId", 52 | ensureAuthenticated, 53 | validateMiddleware([shortIdValidation("userId")]), 54 | (req, res) => { 55 | const { userId } = req.params; 56 | 57 | // Permission: account owner 58 | /* istanbul ignore next */ 59 | if (!isEqual(userId, req.user?.id)) { 60 | return res.status(401).send({ 61 | error: "Unauthorized", 62 | }); 63 | } 64 | 65 | const user = getUserById(userId); 66 | 67 | res.status(200); 68 | res.json({ user }); 69 | } 70 | ); 71 | 72 | router.get("/profile/:username", (req, res) => { 73 | const { username } = req.params; 74 | 75 | const user = pick(["firstName", "lastName", "avatar"], getUserByUsername(username)); 76 | 77 | res.status(200); 78 | res.json({ user }); 79 | }); 80 | 81 | router.patch( 82 | "/:userId", 83 | ensureAuthenticated, 84 | userFieldsValidator, 85 | validateMiddleware([shortIdValidation("userId"), ...isUserValidator]), 86 | (req, res) => { 87 | const { userId } = req.params; 88 | 89 | const edits: User = req.body; 90 | 91 | updateUserById(userId, edits); 92 | 93 | res.sendStatus(204); 94 | } 95 | ); 96 | 97 | export default router; 98 | -------------------------------------------------------------------------------- /backend/validators.ts: -------------------------------------------------------------------------------- 1 | import { body, check, oneOf, query, sanitizeQuery } from "express-validator"; 2 | import { isValid } from "shortid"; 3 | import { 4 | TransactionStatus, 5 | TransactionRequestStatus, 6 | DefaultPrivacyLevel, 7 | NotificationsType, 8 | } from "../src/models"; 9 | import { includes } from "lodash/fp"; 10 | 11 | const TransactionStatusValues = Object.values(TransactionStatus); 12 | const RequestStatusValues = Object.values(TransactionRequestStatus); 13 | const DefaultPrivacyLevelValues = Object.values(DefaultPrivacyLevel); 14 | const NotificationsTypeValues = Object.values(NotificationsType); 15 | 16 | // Validators 17 | 18 | const isShortId = (value: string) => isValid(value); 19 | 20 | export const shortIdValidation = (key: string) => check(key).custom(isShortId); 21 | 22 | export const searchValidation = query("q").exists(); 23 | 24 | export const userFieldsValidator = oneOf([ 25 | check("firstName").exists(), 26 | check("lastName").exists(), 27 | check("password").exists(), 28 | check("balance").exists(), 29 | check("avatar").exists(), 30 | check("defaultPrivacyLevel").exists(), 31 | ]); 32 | 33 | export const isBankAccountValidator = [ 34 | body("bankName").isString().trim(), 35 | body("accountNumber").isString().trim(), 36 | body("routingNumber").isString().trim(), 37 | ]; 38 | 39 | export const isUserValidator = [ 40 | check("firstName").optional({ checkFalsy: true }).isString().trim(), 41 | check("lastName").optional({ checkFalsy: true }).isString().trim(), 42 | check("username").optional({ checkFalsy: true }).isString().trim(), 43 | check("password").optional({ checkFalsy: true }).isString().trim(), 44 | check("email").optional({ checkFalsy: true }).isString().trim(), 45 | check("phoneNumber").optional({ checkFalsy: true }).isString().trim(), 46 | check("balance").optional({ checkFalsy: true }).isNumeric().trim(), 47 | check("avatar").optional({ checkFalsy: true }).isURL().trim(), 48 | check("defaultPrivacyLevel") 49 | .optional({ checkFalsy: true }) 50 | .isIn(["public", "private", "contacts"]), 51 | ]; 52 | 53 | export const sanitizeTransactionStatus = sanitizeQuery("status").customSanitizer((value) => { 54 | /* istanbul ignore if*/ 55 | if (includes(value, TransactionStatusValues)) { 56 | return value; 57 | } 58 | return; 59 | }); 60 | 61 | // default request status to undefined if not provided 62 | export const sanitizeRequestStatus = sanitizeQuery("requestStatus").customSanitizer((value) => { 63 | /* istanbul ignore if*/ 64 | if (includes(value, RequestStatusValues)) { 65 | return value; 66 | } 67 | return; 68 | }); 69 | 70 | export const isTransactionQSValidator = [ 71 | query("status").isIn(TransactionStatusValues).optional().trim(), 72 | query("requestStatus").optional({ checkFalsy: true }).isIn(RequestStatusValues).trim(), 73 | query("receiverId").optional({ checkFalsy: true }).isString().trim(), 74 | query("senderId").optional({ checkFalsy: true }).isString().trim(), 75 | query("rangeStartTs").optional({ checkFalsy: true }).isString().trim(), 76 | query("rangeEndTs").optional({ checkFalsy: true }).isString().trim(), 77 | query("amountMax").optional({ checkFalsy: true }).isNumeric().trim(), 78 | query("amountMin").optional({ checkFalsy: true }).isNumeric().trim(), 79 | ]; 80 | 81 | export const isTransactionPayloadValidator = [ 82 | body("transactionType").isIn(["payment", "request"]).trim(), 83 | body("privacyLevel").optional().isIn(DefaultPrivacyLevelValues).trim(), 84 | body("source").optional().isString().trim(), 85 | body("receiverId").isString().trim(), 86 | body("description").isString().trim(), 87 | body("amount").isNumeric().trim().toInt(), 88 | ]; 89 | 90 | export const isTransactionPatchValidator = [body("requestStatus").isIn(RequestStatusValues)]; 91 | 92 | export const isTransactionPublicQSValidator = [ 93 | query("order").optional({ checkFalsy: true }).isIn(["default"]), 94 | ]; 95 | 96 | export const isCommentValidator = body("content").isString().trim(); 97 | 98 | export const isNotificationsBodyValidator = [ 99 | body("items.*.type").isIn(NotificationsTypeValues).trim(), 100 | body("items.*.transactionId").custom(isShortId), 101 | ]; 102 | 103 | export const isNotificationPatchValidator = [body("isRead").isBoolean()]; 104 | 105 | export const isValidEntityValidator = [ 106 | check("entity") 107 | .isIn([ 108 | "users", 109 | "contacts", 110 | "bankaccounts", 111 | "notifications", 112 | "transactions", 113 | "likes", 114 | "comments", 115 | "banktransfers", 116 | ]) 117 | .trim(), 118 | ]; 119 | -------------------------------------------------------------------------------- /bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | image: cypress/browsers:node14.17.0-chrome88-ff89 2 | 3 | api-tests: &api-tests 4 | name: "API Tests" 5 | caches: 6 | - cypress 7 | - node 8 | script: 9 | - yarn start:ci & npx wait-on http://localhost:3000 10 | - npx cypress run --record --parallel --browser chrome --group "API" --spec "cypress/tests/api/*" --ci-build-id $BITBUCKET_BUILD_NUMBER 11 | 12 | ui-chrome-tests: &ui-chrome-tests 13 | name: "UI Tests - Chrome" 14 | caches: 15 | - cypress 16 | - node 17 | script: 18 | - yarn start:ci & npx wait-on http://localhost:3000 19 | - npx cypress run --record --parallel --browser chrome --group "UI - Chrome" --spec "cypress/tests/ui/*" --ci-build-id $BITBUCKET_BUILD_NUMBER 20 | 21 | ui-chrome-tests-mobile: &ui-chrome-tests-mobile 22 | name: "UI Tests - Chrome - Mobile" 23 | caches: 24 | - cypress 25 | - node 26 | script: 27 | - yarn start:ci & npx wait-on http://localhost:3000 28 | - npx cypress run --record --parallel --browser chrome --group "UI - Chrome - Mobile" --spec "cypress/tests/ui/*" --ci-build-id $BITBUCKET_BUILD_NUMBER --config "viewportWidth=375,viewportHeight=667" 29 | 30 | ui-firefox-tests: &ui-firefox-tests 31 | name: "UI Tests - Firefox" 32 | caches: 33 | - cypress 34 | - node 35 | script: 36 | - yarn start:ci & npx wait-on http://localhost:3000 37 | - npx cypress run --record --parallel --browser firefox --group "UI - Firefox" --spec "cypress/tests/ui/*" --ci-build-id $BITBUCKET_BUILD_NUMBER 38 | 39 | ui-firefox-tests-mobile: &ui-firefox-tests-mobile 40 | name: "UI Tests - Firefox - Mobile" 41 | caches: 42 | - cypress 43 | - node 44 | script: 45 | - yarn start:ci & npx wait-on http://localhost:3000 46 | - npx cypress run --record --parallel --browser firefox --group "UI - Firefox - Mobile" --spec "cypress/tests/ui/*" --ci-build-id $BITBUCKET_BUILD_NUMBER --config "viewportWidth=375,viewportHeight=667" 47 | 48 | pipelines: 49 | default: 50 | - step: 51 | name: Install dependencies and build frontend application 52 | caches: 53 | - yarn 54 | - cypress 55 | - node 56 | script: 57 | - yarn install --frozen-lockfile 58 | - yarn types 59 | - yarn lint 60 | - yarn test:unit:ci 61 | - yarn build:ci 62 | artifacts: 63 | - build/** 64 | - parallel: 65 | - step: 66 | <<: *api-tests 67 | - step: 68 | <<: *ui-chrome-tests 69 | - step: 70 | <<: *ui-chrome-tests 71 | - step: 72 | <<: *ui-chrome-tests 73 | - step: 74 | <<: *ui-chrome-tests 75 | - step: 76 | <<: *ui-chrome-tests 77 | - step: 78 | <<: *ui-chrome-tests-mobile 79 | - step: 80 | <<: *ui-chrome-tests-mobile 81 | - step: 82 | <<: *ui-chrome-tests-mobile 83 | - step: 84 | <<: *ui-chrome-tests-mobile 85 | - step: 86 | <<: *ui-chrome-tests-mobile 87 | - step: 88 | <<: *ui-firefox-tests 89 | - step: 90 | <<: *ui-firefox-tests 91 | - step: 92 | <<: *ui-firefox-tests 93 | - step: 94 | <<: *ui-firefox-tests 95 | - step: 96 | <<: *ui-firefox-tests 97 | - step: 98 | <<: *ui-firefox-tests-mobile 99 | - step: 100 | <<: *ui-firefox-tests-mobile 101 | - step: 102 | <<: *ui-firefox-tests-mobile 103 | - step: 104 | <<: *ui-firefox-tests-mobile 105 | - step: 106 | <<: *ui-firefox-tests-mobile 107 | definitions: 108 | caches: 109 | yarn: $HOME/.cache 110 | cypress: $HOME/.cache/Cypress 111 | -------------------------------------------------------------------------------- /buildspec-types-unit.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | - yarn install --frozen-lockfile 7 | build: 8 | commands: 9 | - yarn types 10 | - yarn lint 11 | - yarn test:unit:ci 12 | -------------------------------------------------------------------------------- /buildspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | batch: 4 | fast-fail: false 5 | build-matrix: 6 | static: 7 | ignore-failure: false 8 | env: 9 | type: LINUX_CONTAINER 10 | privileged-mode: true 11 | compute-type: BUILD_GENERAL1_MEDIUM 12 | dynamic: 13 | env: 14 | compute-type: 15 | - BUILD_GENERAL1_MEDIUM 16 | image: 17 | - public.ecr.aws/cypress-io/cypress/browsers:node14.16.0-chrome90-ff88 18 | variables: 19 | CY_GROUP_SPEC: 20 | - "UI - Chrome|chrome|cypress/tests/ui/*" 21 | - "UI - Chrome - Mobile|chrome|cypress/tests/ui/*|viewportWidth=375,viewportHeight=667" 22 | - "API|chrome|cypress/tests/api/*" 23 | - "UI - Firefox|firefox|cypress/tests/ui/*" 24 | - "UI - Firefox - Mobile|firefox|cypress/tests/ui/*|viewportWidth=375,viewportHeight=667" 25 | WORKERS: 26 | - 1 27 | - 2 28 | - 3 29 | - 4 30 | - 5 31 | 32 | phases: 33 | install: 34 | commands: 35 | # Set COMMIT_INFO variables to send Git specifics to Cypress Dashboard when recording 36 | # https://docs.cypress.io/guides/continuous-integration/introduction#Git-information 37 | - export COMMIT_INFO_BRANCH="$(git rev-parse HEAD | xargs git name-rev | cut -d' ' -f2 | sed 's/remotes\/origin\///g')" 38 | - export COMMIT_INFO_MESSAGE="$(git log -1 --pretty=%B)" 39 | - export COMMIT_INFO_EMAIL="$(git log -1 --pretty=%ae)" 40 | - export COMMIT_INFO_AUTHOR="$(git log -1 --pretty=%an)" 41 | - export COMMIT_INFO_SHA="$(git log -1 --pretty=%H)" 42 | - export COMMIT_INFO_REMOTE="$(git config --get remote.origin.url)" 43 | - echo $COMMIT_INFO_BRANCH 44 | - echo $COMMIT_INFO_MESSAGE 45 | - echo $COMMIT_INFO_EMAIL 46 | - echo $COMMIT_INFO_AUTHOR 47 | - echo $COMMIT_INFO_SHA 48 | - echo $COMMIT_INFO_REMOTE 49 | - echo $CODEBUILD_INITIATOR 50 | - echo $CY_GROUP_SPEC 51 | - CY_GROUP=$(echo $CY_GROUP_SPEC | cut -d'|' -f1) 52 | - CY_BROWSER=$(echo $CY_GROUP_SPEC | cut -d'|' -f2) 53 | - CY_SPEC=$(echo $CY_GROUP_SPEC | cut -d'|' -f3) 54 | - CY_CONFIG=$(echo $CY_GROUP_SPEC | cut -d'|' -f4) 55 | - echo $CY_GROUP 56 | - echo $CY_BROWSER 57 | - echo $CY_SPEC 58 | - echo $CY_CONFIG 59 | - yarn install --frozen-lockfile 60 | pre_build: 61 | commands: 62 | - yarn types 63 | - yarn lint 64 | - yarn test:unit:ci 65 | - yarn build:ci 66 | build: 67 | commands: 68 | - yarn start:ci & npx wait-on http://localhost:3000 69 | - npx cypress run --record --parallel --browser $CY_BROWSER --ci-build-id $CODEBUILD_INITIATOR --group "$CY_GROUP" --spec "$CY_SPEC" --config "$CY_CONFIG" 70 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: nearest 7 | range: "90...100" 8 | status: 9 | project: 10 | default: 11 | base: auto 12 | target: auto 13 | threshold: 0.1% 14 | if_not_found: success 15 | backend: 16 | paths: 17 | - backend 18 | frontend: 19 | paths: 20 | - src 21 | 22 | comment: 23 | layout: "reach,diff,flags,tree" 24 | behavior: default 25 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000", 3 | "projectId": "7s5okt", 4 | "viewportHeight": 1000, 5 | "viewportWidth": 1280, 6 | "retries": { 7 | "runMode": 2, 8 | "openMode": 1 9 | }, 10 | "env": { 11 | "apiUrl": "http://localhost:3001", 12 | "mobileViewportWidthBreakpoint": 414, 13 | "coverage": false, 14 | "codeCoverage": { 15 | "url": "http://localhost:3001/__coverage__" 16 | } 17 | }, 18 | "experimentalStudio": true 19 | } 20 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/integration/payment_spec.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | 3 | describe('payment', () => { 4 | it('user can make payment', () => { 5 | // login 6 | cy.visit('/'); 7 | cy.findByRole('textbox', { name: /username/i }).type('johndoe'); 8 | cy.findByLabelText(/password/i).type('s3cret'); 9 | cy.findByRole('checkbox', { name: /remember me/i }).check(); 10 | cy.findByRole('button', { name: /sign in/i }).click(); 11 | 12 | // check account balance 13 | let oldBalance; 14 | cy.get('[data-test=sidenav-user-balance]').then($balance => oldBalance = $balance.text()); 15 | 16 | // click on new button 17 | cy.findByRole('button', { name: /new/i }).click(); 18 | 19 | // search for user 20 | cy.findByRole('textbox').type('devon becker'); 21 | cy.findByText(/devon becker/i).click(); 22 | 23 | // add amount and note and click pay 24 | const paymentAmount = "5.00"; 25 | cy.findByPlaceholderText(/amount/i).type(paymentAmount); 26 | const note = uuidv4(); 27 | cy.findByPlaceholderText(/add a note/i).type(note); 28 | cy.findByRole('button', { name: /pay/i }).click(); 29 | 30 | // return to transactions 31 | cy.findByRole('button', { name: /return to transactions/i }).click(); 32 | 33 | // go to personal payments 34 | cy.findByRole('tab', { name: /mine/i }).click(); 35 | 36 | // click on payment 37 | cy.findByText(note).click({ force: true }); 38 | 39 | // verify if payment was made 40 | cy.findByText(`-$${paymentAmount}`).should('be.visible'); 41 | cy.findByText(note).should('be.visible'); 42 | 43 | // verify if payment amount was deducted 44 | cy.get('[data-test=sidenav-user-balance]').then($balance => { 45 | const convertedOldBalance = parseFloat(oldBalance.replace(/\$|,/g, "")); 46 | const convertedNewBalance = parseFloat($balance.text().replace(/\$|,/g, "")); 47 | expect(convertedOldBalance - convertedNewBalance).to.equal(parseFloat(paymentAmount)); 48 | }); 49 | }); 50 | }); -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/cypress/add-commands" -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /data/empty-seed.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [], 3 | "contacts": [], 4 | "bankaccounts": [], 5 | "transactions": [], 6 | "likes": [], 7 | "comments": [], 8 | "notifications": [], 9 | "banktransfers": [] 10 | } 11 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitchelSt/react-testing-finished/bb65902a657fee6eb854ec81a3fa11ebca9ec3ec/public/favicon.ico -------------------------------------------------------------------------------- /public/img/rwa-readme-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitchelSt/react-testing-finished/bb65902a657fee6eb854ec81a3fa11ebca9ec3ec/public/img/rwa-readme-screenshot.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Cypress Real World App 25 | 26 | 27 |
28 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitchelSt/react-testing-finished/bb65902a657fee6eb854ec81a3fa11ebca9ec3ec/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MitchelSt/react-testing-finished/bb65902a657fee6eb854ec81a3fa11ebca9ec3ec/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "baseBranch": "develop", 4 | "automerge": false, 5 | "commitMessage": "{{semanticPrefix}}Update {{depName}} to {{newVersion}} 🌟", 6 | "prTitle": "{{semanticPrefix}}{{#if isPin}}Pin{{else}}Update{{/if}} dependency {{depName}} to version {{#if isRange}}{{newVersion}}{{else}}{{#if isMajor}}{{newVersionMajor}}.x{{else}}{{newVersion}}{{/if}}{{/if}} 🌟", 7 | "major": { 8 | "automerge": false 9 | }, 10 | "minor": { 11 | "automerge": false 12 | }, 13 | "prHourlyLimit": 1, 14 | "updateNotScheduled": false, 15 | "timezone": "America/New_York", 16 | "lockFileMaintenance": { 17 | "enabled": true 18 | }, 19 | "masterIssue": true, 20 | "schedule": ["every weekend"], 21 | "packageRules": [ 22 | { 23 | "packageNames": ["cypress"], 24 | "schedule": ["every weekday"] 25 | }, 26 | { 27 | "packageNames": ["@babel/compat-data"], 28 | "allowedVersions": "7.9.0" 29 | }, 30 | { 31 | "packageNames": ["babel-loader"], 32 | "allowedVersions": "8.0.6" 33 | }, 34 | { 35 | "packageNames": ["@types/express"], 36 | "allowedVersions": "4.17.2" 37 | }, 38 | { 39 | "packageNames": ["@types/express-serve-static-core"], 40 | "allowedVersions": "4.17.2" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "template": "node", 5 | "container": { 6 | "port": 3000, 7 | "startScript": "start:codesandbox" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /scripts/generateSeedData.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import { buildDatabase } from "./seedDataUtils"; 4 | import { TDatabase } from "../backend/database"; 5 | const testSeed: TDatabase = buildDatabase(); 6 | 7 | const fileData = JSON.stringify(testSeed, null, 2); 8 | 9 | fs.writeFile(path.join(process.cwd(), "data", "database-seed.json"), fileData, (err) => { 10 | if (err) { 11 | console.error(err); 12 | return; 13 | } 14 | console.log("test seed generated"); 15 | }); 16 | -------------------------------------------------------------------------------- /scripts/mock-aws-exports-es5.js: -------------------------------------------------------------------------------- 1 | // mock aws-exports-es5.js 2 | 3 | const awsmobile = { 4 | aws_cognito_region: "", 5 | aws_user_pools_id: "", 6 | }; 7 | 8 | exports.default = awsmobile; 9 | -------------------------------------------------------------------------------- /scripts/mock-aws-exports.js: -------------------------------------------------------------------------------- 1 | // mock aws-exports.js 2 | 3 | const awsmobile = { 4 | aws_cognito_region: "", 5 | aws_user_pools_id: "", 6 | }; 7 | 8 | export default awsmobile; 9 | -------------------------------------------------------------------------------- /scripts/testServer.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import express from "express"; 3 | import history from "connect-history-api-fallback"; 4 | import setupProxy from "../src/setupProxy"; 5 | import { frontendPort } from "../src/utils/portUtils"; 6 | 7 | const app = express(); 8 | 9 | setupProxy(app); 10 | 11 | app.use(history()); 12 | app.use(express.static(path.join(__dirname, "../build"))); 13 | 14 | app.listen(frontendPort); 15 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./*/*.ts", "../src", "../models"], 4 | "exclude": [], 5 | "compilerOptions": { 6 | "types": ["node"], 7 | "isolatedModules": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/AlertBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Snackbar } from "@material-ui/core"; 3 | import { Interpreter } from "xstate"; 4 | import { SnackbarContext, SnackbarSchema, SnackbarEvents } from "../machines/snackbarMachine"; 5 | import { useService } from "@xstate/react"; 6 | import { Alert } from "@material-ui/lab"; 7 | 8 | interface Props { 9 | snackbarService: Interpreter; 10 | } 11 | 12 | const AlertBar: React.FC = ({ snackbarService }) => { 13 | const [snackbarState] = useService(snackbarService); 14 | 15 | return ( 16 | 21 | 27 | {snackbarState?.context.message} 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default AlertBar; 34 | -------------------------------------------------------------------------------- /src/components/BankAccountForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, TextField, Button, Grid } from "@material-ui/core"; 3 | import { Formik, Form, Field, FieldProps } from "formik"; 4 | import { string, object } from "yup"; 5 | import { BankAccountPayload, User } from "../models"; 6 | import { useHistory } from "react-router"; 7 | 8 | const validationSchema = object({ 9 | bankName: string().min(5, "Must contain at least 5 characters").required("Enter a bank name"), 10 | routingNumber: string() 11 | .length(9, "Must contain a valid routing number") 12 | .required("Enter a valid bank routing number"), 13 | accountNumber: string() 14 | .min(9, "Must contain at least 9 digits") 15 | .max(12, "Must contain no more than 12 digits") 16 | .required("Enter a valid bank account number"), 17 | }); 18 | 19 | const useStyles = makeStyles((theme) => ({ 20 | paper: { 21 | marginTop: theme.spacing(8), 22 | display: "flex", 23 | flexDirection: "column", 24 | alignItems: "center", 25 | }, 26 | form: { 27 | width: "100%", // Fix IE 11 issue. 28 | marginTop: theme.spacing(1), 29 | }, 30 | submit: { 31 | margin: theme.spacing(3, 0, 2), 32 | }, 33 | })); 34 | 35 | export interface BankAccountFormProps { 36 | userId: User["id"]; 37 | createBankAccount: Function; 38 | onboarding?: boolean; 39 | } 40 | 41 | const BankAccountForm: React.FC = ({ 42 | userId, 43 | createBankAccount, 44 | onboarding, 45 | }) => { 46 | const history = useHistory(); 47 | const classes = useStyles(); 48 | const initialValues: BankAccountPayload = { 49 | userId, 50 | bankName: "", 51 | accountNumber: "", 52 | routingNumber: "", 53 | }; 54 | 55 | return ( 56 | { 60 | setSubmitting(true); 61 | 62 | createBankAccount({ ...values, userId }); 63 | 64 | if (!onboarding) { 65 | history.push("/bankaccounts"); 66 | } 67 | }} 68 | > 69 | {({ isValid, isSubmitting }) => ( 70 |
71 | 72 | {({ field, meta: { error, value, initialValue, touched } }: FieldProps) => ( 73 | 86 | )} 87 | 88 | 89 | {({ field, meta: { error, value, initialValue, touched } }: FieldProps) => ( 90 | 103 | )} 104 | 105 | 106 | {({ field, meta: { error, value, initialValue, touched } }: FieldProps) => ( 107 | 120 | )} 121 | 122 | 123 | 124 | 135 | 136 | 137 |
138 | )} 139 |
140 | ); 141 | }; 142 | 143 | export default BankAccountForm; 144 | -------------------------------------------------------------------------------- /src/components/BankAccountItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Grid, Typography, Button, ListItem } from "@material-ui/core"; 4 | import { BankAccount } from "../models"; 5 | 6 | export interface BankAccountListItemProps { 7 | bankAccount: BankAccount; 8 | deleteBankAccount: Function; 9 | } 10 | 11 | const BankAccountListItem: React.FC = ({ 12 | bankAccount, 13 | deleteBankAccount, 14 | }) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | {bankAccount.bankName} {bankAccount.isDeleted ? "(Deleted)" : undefined} 21 | 22 | 23 | {!bankAccount.isDeleted && ( 24 | 25 | 36 | 37 | )} 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default BankAccountListItem; 44 | -------------------------------------------------------------------------------- /src/components/BankAccountList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import { BankAccount } from "../models"; 5 | import BankAccountItem from "./BankAccountItem"; 6 | import EmptyList from "./EmptyList"; 7 | 8 | export interface BankAccountListProps { 9 | bankAccounts: BankAccount[]; 10 | deleteBankAccount: Function; 11 | } 12 | 13 | const BankAccountList: React.FC = ({ bankAccounts, deleteBankAccount }) => { 14 | return ( 15 | <> 16 | {bankAccounts?.length > 0 ? ( 17 | 18 | {bankAccounts.map((bankAccount: BankAccount) => ( 19 | 24 | ))} 25 | 26 | ) : ( 27 | 28 | )} 29 | 30 | ); 31 | }; 32 | 33 | export default BankAccountList; 34 | -------------------------------------------------------------------------------- /src/components/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, TextField } from "@material-ui/core"; 3 | import { Formik, Form, Field, FieldProps } from "formik"; 4 | import { string, object } from "yup"; 5 | 6 | const validationSchema = object({ 7 | content: string(), 8 | }); 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | paper: { 12 | marginTop: theme.spacing(8), 13 | display: "flex", 14 | flexDirection: "column", 15 | alignItems: "center", 16 | }, 17 | form: { 18 | width: "100%", // Fix IE 11 issue. 19 | marginTop: theme.spacing(1), 20 | }, 21 | })); 22 | 23 | export interface CommentFormProps { 24 | transactionId: string; 25 | transactionComment: (payload: object) => void; 26 | } 27 | 28 | const CommentForm: React.FC = ({ transactionId, transactionComment }) => { 29 | const classes = useStyles(); 30 | const initialValues = { content: "" }; 31 | 32 | return ( 33 |
34 | { 38 | setSubmitting(true); 39 | transactionComment({ transactionId, ...values }); 40 | }} 41 | > 42 | {() => ( 43 |
44 | 45 | {({ field, meta }: FieldProps) => ( 46 | 58 | )} 59 | 60 |
61 | )} 62 |
63 |
64 | ); 65 | }; 66 | 67 | export default CommentForm; 68 | -------------------------------------------------------------------------------- /src/components/CommentList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import CommentListItem from "./CommentListItem"; 5 | import { Comment } from "../models"; 6 | 7 | export interface CommentsListProps { 8 | comments: Comment[]; 9 | } 10 | 11 | const CommentsList: React.FC = ({ comments }) => { 12 | return ( 13 | 14 | {comments && 15 | comments.map((comment: Comment) => )} 16 | 17 | ); 18 | }; 19 | 20 | export default CommentsList; 21 | -------------------------------------------------------------------------------- /src/components/CommentListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ListItem, ListItemText } from "@material-ui/core"; 3 | 4 | import { Comment } from "../models"; 5 | 6 | export interface CommentListItemProps { 7 | comment: Comment; 8 | } 9 | 10 | const CommentListItem: React.FC = ({ comment }) => { 11 | return ( 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default CommentListItem; 19 | -------------------------------------------------------------------------------- /src/components/EmptyList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Typography, Grid, colors } from "@material-ui/core"; 3 | 4 | const { grey } = colors; 5 | 6 | const EmptyList: React.FC<{ entity: string; children?: React.ReactNode }> = ({ 7 | entity, 8 | children, 9 | }) => { 10 | return ( 11 | 20 | 28 | 29 | 30 | No {entity} 31 | 32 | 33 | 34 | 41 | {children} 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default EmptyList; 50 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Container, Typography } from "@material-ui/core"; 3 | 4 | import CypressLogo from "../components/SvgCypressLogo"; 5 | 6 | export default function Footer() { 7 | return ( 8 | 9 | 10 | Built by 11 | 17 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { Interpreter } from "xstate"; 4 | import { makeStyles, Container, Grid, useMediaQuery, useTheme } from "@material-ui/core"; 5 | 6 | import Footer from "./Footer"; 7 | import NavBar from "./NavBar"; 8 | import NavDrawer from "./NavDrawer"; 9 | import { DataContext, DataEvents } from "../machines/dataMachine"; 10 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 11 | import { drawerMachine } from "../machines/drawerMachine"; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | root: { 15 | display: "flex", 16 | }, 17 | toolbar: { 18 | paddingRight: 24, // keep right padding when drawer closed 19 | }, 20 | appBarSpacer: { 21 | minHeight: theme.spacing(13), 22 | [theme.breakpoints.up("sm")]: { 23 | minHeight: theme.spacing(14), 24 | }, 25 | }, 26 | content: { 27 | flexGrow: 1, 28 | height: "100vh", 29 | overflow: "auto", 30 | }, 31 | container: { 32 | minHeight: "77vh", 33 | paddingTop: theme.spacing(1), 34 | paddingBottom: theme.spacing(1), 35 | [theme.breakpoints.up("sm")]: { 36 | paddingTop: theme.spacing(4), 37 | padding: theme.spacing(4), 38 | }, 39 | }, 40 | })); 41 | 42 | interface Props { 43 | children: React.ReactNode; 44 | authService: Interpreter; 45 | notificationsService: Interpreter; 46 | } 47 | 48 | const MainLayout: React.FC = ({ children, notificationsService, authService }) => { 49 | const classes = useStyles(); 50 | const theme = useTheme(); 51 | const [drawerState, sendDrawer] = useMachine(drawerMachine); 52 | 53 | const aboveSmallBreakpoint = useMediaQuery(theme.breakpoints.up("sm")); 54 | const xsBreakpoint = useMediaQuery(theme.breakpoints.only("xs")); 55 | 56 | const desktopDrawerOpen = drawerState?.matches({ desktop: "open" }); 57 | const mobileDrawerOpen = drawerState?.matches({ mobile: "open" }); 58 | const toggleDesktopDrawer = () => { 59 | sendDrawer("TOGGLE_DESKTOP"); 60 | }; 61 | const toggleMobileDrawer = () => { 62 | sendDrawer("TOGGLE_MOBILE"); 63 | }; 64 | 65 | const openDesktopDrawer = (payload: any) => sendDrawer("OPEN_DESKTOP", payload); 66 | const closeMobileDrawer = () => sendDrawer("CLOSE_MOBILE"); 67 | 68 | useEffect(() => { 69 | if (!desktopDrawerOpen && aboveSmallBreakpoint) { 70 | openDesktopDrawer({ aboveSmallBreakpoint }); 71 | } 72 | // eslint-disable-next-line react-hooks/exhaustive-deps 73 | }, [aboveSmallBreakpoint, desktopDrawerOpen]); 74 | 75 | return ( 76 | <> 77 | 82 | 88 |
89 |
90 | 91 | 92 | 93 | {children} 94 | 95 | 96 | 97 |
98 |
99 |
100 |
101 | 102 | ); 103 | }; 104 | 105 | export default MainLayout; 106 | -------------------------------------------------------------------------------- /src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import { Interpreter } from "xstate"; 4 | import { useService } from "@xstate/react"; 5 | import { 6 | makeStyles, 7 | AppBar, 8 | Toolbar, 9 | Typography, 10 | IconButton, 11 | Badge, 12 | Button, 13 | useTheme, 14 | useMediaQuery, 15 | Link, 16 | } from "@material-ui/core"; 17 | import { 18 | Menu as MenuIcon, 19 | Notifications as NotificationsIcon, 20 | AttachMoney as AttachMoneyIcon, 21 | } from "@material-ui/icons"; 22 | import { Link as RouterLink, useLocation } from "react-router-dom"; 23 | 24 | import { DataContext, DataEvents } from "../machines/dataMachine"; 25 | import TransactionNavTabs from "./TransactionNavTabs"; 26 | import RWALogo from "./SvgRwaLogo"; 27 | import RWALogoIcon from "./SvgRwaIconLogo"; 28 | 29 | const drawerWidth = 240; 30 | 31 | const useStyles = makeStyles((theme) => ({ 32 | toolbar: { 33 | paddingRight: 24, // keep right padding when drawer closed 34 | }, 35 | appBar: { 36 | zIndex: theme.zIndex.drawer + 1, 37 | transition: theme.transitions.create(["width", "margin"], { 38 | easing: theme.transitions.easing.sharp, 39 | duration: theme.transitions.duration.leavingScreen, 40 | }), 41 | }, 42 | appBarShift: { 43 | marginLeft: drawerWidth, 44 | width: `calc(100% - ${drawerWidth}px)`, 45 | transition: theme.transitions.create(["width", "margin"], { 46 | easing: theme.transitions.easing.sharp, 47 | duration: theme.transitions.duration.enteringScreen, 48 | }), 49 | }, 50 | menuButtonHidden: { 51 | display: "none", 52 | }, 53 | title: { 54 | flexGrow: 1, 55 | textAlign: "center", 56 | }, 57 | logo: { 58 | color: "white", 59 | verticalAlign: "bottom", 60 | }, 61 | newTransactionButton: { 62 | fontSize: 16, 63 | backgroundColor: "#00C853", 64 | paddingTop: 5, 65 | paddingBottom: 5, 66 | paddingRight: 20, 67 | fontWeight: "bold", 68 | "&:hover": { 69 | backgroundColor: "#4CAF50", 70 | borderColor: "#00C853", 71 | boxShadow: "none", 72 | }, 73 | }, 74 | customBadge: { 75 | backgroundColor: "red", 76 | color: "white", 77 | }, 78 | })); 79 | 80 | interface NavBarProps { 81 | drawerOpen: boolean; 82 | toggleDrawer: Function; 83 | notificationsService: Interpreter; 84 | } 85 | 86 | const NavBar: React.FC = ({ drawerOpen, toggleDrawer, notificationsService }) => { 87 | const match = useLocation(); 88 | const classes = useStyles(); 89 | const theme = useTheme(); 90 | const [notificationsState] = useService(notificationsService); 91 | 92 | const allNotifications = notificationsState?.context?.results; 93 | const xsBreakpoint = useMediaQuery(theme.breakpoints.only("xs")); 94 | 95 | return ( 96 | 97 | 98 | toggleDrawer()} 104 | > 105 | 106 | 107 | 115 | 116 | {xsBreakpoint ? ( 117 | 118 | ) : ( 119 | 120 | )} 121 | 122 | 123 | 133 | 139 | 144 | 145 | 146 | 147 | 148 | {(match.pathname === "/" || RegExp("/(?:public|contacts|personal)").test(match.pathname)) && ( 149 | 150 | )} 151 | 152 | ); 153 | }; 154 | 155 | export default NavBar; 156 | -------------------------------------------------------------------------------- /src/components/NotificationList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import NotificationListItem from "./NotificationListItem"; 5 | import { NotificationResponseItem } from "../models"; 6 | import EmptyList from "./EmptyList"; 7 | import RemindersIllustration from "./SvgUndrawReminders697P"; 8 | 9 | export interface NotificationsListProps { 10 | notifications: NotificationResponseItem[]; 11 | updateNotification: Function; 12 | } 13 | 14 | const NotificationsList: React.FC = ({ 15 | notifications, 16 | updateNotification, 17 | }) => { 18 | return ( 19 | <> 20 | {notifications?.length > 0 ? ( 21 | 22 | {notifications.map((notification: NotificationResponseItem) => ( 23 | 28 | ))} 29 | 30 | ) : ( 31 | 32 | 33 | 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export default NotificationsList; 40 | -------------------------------------------------------------------------------- /src/components/NotificationListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Check as CheckIcon, 5 | ThumbUpAltOutlined as LikeIcon, 6 | Payment as PaymentIcon, 7 | CommentRounded as CommentIcon, 8 | MonetizationOn as MonetizationOnIcon, 9 | } from "@material-ui/icons"; 10 | import { 11 | Button, 12 | makeStyles, 13 | ListItemIcon, 14 | ListItemText, 15 | useTheme, 16 | useMediaQuery, 17 | ListItem, 18 | IconButton, 19 | } from "@material-ui/core"; 20 | import { 21 | isCommentNotification, 22 | isLikeNotification, 23 | isPaymentNotification, 24 | isPaymentRequestedNotification, 25 | isPaymentReceivedNotification, 26 | } from "../utils/transactionUtils"; 27 | import { NotificationResponseItem } from "../models"; 28 | 29 | export interface NotificationListItemProps { 30 | notification: NotificationResponseItem; 31 | updateNotification: Function; 32 | } 33 | 34 | const useStyles = makeStyles({ 35 | card: { 36 | minWidth: "100%", 37 | }, 38 | title: { 39 | fontSize: 18, 40 | }, 41 | green: { 42 | color: "#4CAF50", 43 | }, 44 | red: { 45 | color: "red", 46 | }, 47 | blue: { 48 | color: "blue", 49 | }, 50 | }); 51 | 52 | const NotificationListItem: React.FC = ({ 53 | notification, 54 | updateNotification, 55 | }) => { 56 | const classes = useStyles(); 57 | const theme = useTheme(); 58 | let listItemText = undefined; 59 | let listItemIcon = undefined; 60 | const xsBreakpoint = useMediaQuery(theme.breakpoints.only("xs")); 61 | 62 | if (isCommentNotification(notification)) { 63 | listItemIcon = ; 64 | listItemText = `${notification.userFullName} commented on a transaction.`; 65 | } 66 | 67 | if (isLikeNotification(notification)) { 68 | listItemIcon = ; 69 | listItemText = `${notification.userFullName} liked a transaction.`; 70 | } 71 | 72 | if (isPaymentNotification(notification)) { 73 | if (isPaymentRequestedNotification(notification)) { 74 | listItemIcon = ; 75 | listItemText = `${notification.userFullName} requested payment.`; 76 | } else if (isPaymentReceivedNotification(notification)) { 77 | listItemIcon = ; 78 | listItemText = `${notification.userFullName} received payment.`; 79 | } 80 | } 81 | 82 | return ( 83 | 84 | {listItemIcon!} 85 | 86 | {xsBreakpoint && ( 87 | updateNotification({ id: notification.id, isRead: true })} 91 | data-test={`notification-mark-read-${notification.id}`} 92 | > 93 | 94 | 95 | )} 96 | {!xsBreakpoint && ( 97 | 105 | )} 106 | 107 | ); 108 | }; 109 | 110 | export default NotificationListItem; 111 | -------------------------------------------------------------------------------- /src/components/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect, RouteProps } from "react-router-dom"; 3 | 4 | interface IPrivateRouteProps extends RouteProps { 5 | isLoggedIn: boolean; 6 | } 7 | 8 | function PrivateRoute({ isLoggedIn, children, ...rest }: IPrivateRouteProps) { 9 | return ( 10 | 13 | isLoggedIn ? ( 14 | children 15 | ) : ( 16 | /* istanbul ignore next */ 17 | 23 | ) 24 | } 25 | /> 26 | ); 27 | } 28 | 29 | export default PrivateRoute; 30 | -------------------------------------------------------------------------------- /src/components/SkeletonList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Skeleton } from "@material-ui/lab"; 3 | import { makeStyles } from "@material-ui/core"; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | root: { 7 | minHeight: "100vh", 8 | marginLeft: theme.spacing(2), 9 | marginRight: theme.spacing(2), 10 | width: "95%", 11 | }, 12 | })); 13 | 14 | const ListSkeleton = () => { 15 | const classes = useStyles(); 16 | return ( 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 |
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 | export default ListSkeleton; 67 | -------------------------------------------------------------------------------- /src/components/SvgRwaIconLogo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | function SvgRwaIconLogo(props: any) { 4 | return ( 5 | 12 | 17 | 18 | ); 19 | } 20 | 21 | export default SvgRwaIconLogo; 22 | -------------------------------------------------------------------------------- /src/components/TransactionAmount.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Typography } from "@material-ui/core"; 3 | import { TransactionResponseItem } from "../models"; 4 | import { isRequestTransaction, formatAmount } from "../utils/transactionUtils"; 5 | 6 | const useStyles = makeStyles((theme) => ({ 7 | amountPositive: { 8 | fontSize: 24, 9 | [theme.breakpoints.down("sm")]: { 10 | fontSize: theme.typography.body1.fontSize, 11 | }, 12 | color: "#4CAF50", 13 | }, 14 | amountNegative: { 15 | fontSize: 24, 16 | [theme.breakpoints.down("sm")]: { 17 | fontSize: theme.typography.body1.fontSize, 18 | }, 19 | color: "red", 20 | }, 21 | })); 22 | 23 | const TransactionAmount: React.FC<{ 24 | transaction: TransactionResponseItem; 25 | }> = ({ transaction }) => { 26 | const classes = useStyles(); 27 | 28 | return ( 29 | 38 | {isRequestTransaction(transaction) ? "+" : "-"} 39 | {transaction.amount && formatAmount(transaction.amount)} 40 | 41 | ); 42 | }; 43 | 44 | export default TransactionAmount; 45 | -------------------------------------------------------------------------------- /src/components/TransactionContactsList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ReactNode } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { 4 | TransactionPagination, 5 | TransactionResponseItem, 6 | TransactionDateRangePayload, 7 | TransactionAmountRangePayload, 8 | } from "../models"; 9 | import TransactionList from "./TransactionList"; 10 | import { contactsTransactionsMachine } from "../machines/contactsTransactionsMachine"; 11 | 12 | export interface TransactionContactListProps { 13 | filterComponent: ReactNode; 14 | dateRangeFilters: TransactionDateRangePayload; 15 | amountRangeFilters: TransactionAmountRangePayload; 16 | } 17 | 18 | const TransactionContactsList: React.FC = ({ 19 | filterComponent, 20 | dateRangeFilters, 21 | amountRangeFilters, 22 | }) => { 23 | const [current, send, contactTransactionService] = useMachine(contactsTransactionsMachine); 24 | const { pageData, results } = current.context; 25 | 26 | // @ts-ignore 27 | if (window.Cypress) { 28 | // @ts-ignore 29 | window.contactTransactionService = contactTransactionService; 30 | } 31 | 32 | useEffect(() => { 33 | send("FETCH", { ...dateRangeFilters, ...amountRangeFilters }); 34 | }, [send, dateRangeFilters, amountRangeFilters]); 35 | 36 | const loadNextPage = (page: number) => 37 | send("FETCH", { page, ...dateRangeFilters, ...amountRangeFilters }); 38 | 39 | return ( 40 | <> 41 | 50 | 51 | ); 52 | }; 53 | 54 | export default TransactionContactsList; 55 | -------------------------------------------------------------------------------- /src/components/TransactionCreateStepOne.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Paper } from "@material-ui/core"; 3 | import UsersList from "./UsersList"; 4 | import { User } from "../models"; 5 | import UserListSearchForm from "./UserListSearchForm"; 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | paper: { 9 | //marginTop: theme.spacing(2), 10 | padding: theme.spacing(2), 11 | display: "flex", 12 | overflow: "auto", 13 | flexDirection: "column", 14 | }, 15 | })); 16 | 17 | export interface TransactionCreateStepOneProps { 18 | setReceiver: Function; 19 | userListSearch: Function; 20 | users: User[]; 21 | } 22 | 23 | const TransactionCreateStepOne: React.FC = ({ 24 | setReceiver, 25 | userListSearch, 26 | users, 27 | }) => { 28 | const classes = useStyles(); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default TransactionCreateStepOne; 39 | -------------------------------------------------------------------------------- /src/components/TransactionCreateStepThree.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link as RouterLink, useHistory } from "react-router-dom"; 3 | import { Paper, Typography, Grid, Avatar, Box, Button, makeStyles } from "@material-ui/core"; 4 | import { Interpreter } from "xstate"; 5 | import { 6 | CreateTransactionMachineContext, 7 | CreateTransactionMachineEvents, 8 | } from "../machines/createTransactionMachine"; 9 | import { useService } from "@xstate/react"; 10 | import { formatAmount } from "../utils/transactionUtils"; 11 | 12 | const useStyles = makeStyles((theme) => ({ 13 | paper: { 14 | display: "flex", 15 | flexDirection: "column", 16 | alignItems: "center", 17 | }, 18 | })); 19 | 20 | export interface TransactionCreateStepThreeProps { 21 | createTransactionService: Interpreter< 22 | CreateTransactionMachineContext, 23 | any, 24 | CreateTransactionMachineEvents, 25 | any 26 | >; 27 | } 28 | 29 | const TransactionCreateStepThree: React.FC = ({ 30 | createTransactionService, 31 | }) => { 32 | const history = useHistory(); 33 | const classes = useStyles(); 34 | const [createTransactionState, sendCreateTransaction] = useService(createTransactionService); 35 | 36 | const receiver = createTransactionState?.context?.receiver; 37 | const transactionDetails = createTransactionState?.context?.transactionDetails; 38 | 39 | return ( 40 | 41 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {receiver.firstName} {receiver.lastName} 58 | 59 | 60 | 61 | 62 | 63 | 64 | 71 | 72 | 73 | 74 | {transactionDetails?.transactionType === "payment" ? "Paid " : "Requested "} 75 | {transactionDetails?.amount && 76 | formatAmount(parseInt(transactionDetails.amount, 10) * 100)}{" "} 77 | for {transactionDetails?.description} 78 | 79 | 80 | 81 | 82 | 89 | 90 | 91 | 100 | 101 | 102 | 114 | 115 | 116 | 117 | 118 | ); 119 | }; 120 | 121 | export default TransactionCreateStepThree; 122 | -------------------------------------------------------------------------------- /src/components/TransactionCreateStepTwo.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import TransactionCreateStepTwo from "./TransactionCreateStepTwo"; 4 | 5 | test('if an amount and note is entered, the pay button becomes enabled', async () => { 6 | render(); 7 | 8 | expect(await screen.findByRole('button', { name: /pay/i })).toBeDisabled(); 9 | 10 | userEvent.type(screen.getByPlaceholderText(/amount/i), "50"); 11 | userEvent.type(screen.getByPlaceholderText(/add a note/i), "dinner"); 12 | 13 | expect(await screen.findByRole('button', { name: /pay/i })).toBeEnabled(); 14 | }); -------------------------------------------------------------------------------- /src/components/TransactionInfiniteList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { get } from "lodash/fp"; 3 | import { useTheme, makeStyles, useMediaQuery, Divider } from "@material-ui/core"; 4 | import { InfiniteLoader, List, Index } from "react-virtualized"; 5 | import "react-virtualized/styles.css"; // only needs to be imported once 6 | 7 | import TransactionItem from "./TransactionItem"; 8 | import { TransactionResponseItem, TransactionPagination } from "../models"; 9 | 10 | export interface TransactionListProps { 11 | transactions: TransactionResponseItem[]; 12 | loadNextPage: Function; 13 | pagination: TransactionPagination; 14 | } 15 | 16 | const useStyles = makeStyles((theme) => ({ 17 | transactionList: { 18 | width: "100%", 19 | minHeight: "80vh", 20 | display: "flex", 21 | overflow: "auto", 22 | flexDirection: "column", 23 | }, 24 | })); 25 | 26 | const TransactionInfiniteList: React.FC = ({ 27 | transactions, 28 | loadNextPage, 29 | pagination, 30 | }) => { 31 | const classes = useStyles(); 32 | const theme = useTheme(); 33 | const isXsBreakpoint = useMediaQuery(theme.breakpoints.down("xs")); 34 | const isMobile = useMediaQuery(theme.breakpoints.down("sm")); 35 | 36 | const itemCount = pagination.hasNextPages ? transactions.length + 1 : transactions.length; 37 | 38 | const loadMoreItems = () => { 39 | return new Promise((resolve) => { 40 | return resolve(pagination.hasNextPages && loadNextPage(pagination.page + 1)); 41 | }); 42 | }; 43 | 44 | const isRowLoaded = (params: Index) => 45 | !pagination.hasNextPages || params.index < transactions.length; 46 | 47 | // @ts-ignore 48 | function rowRenderer({ key, index, style }) { 49 | const transaction = get(index, transactions); 50 | 51 | if (index < transactions.length) { 52 | return ( 53 |
54 | 55 | 56 |
57 | ); 58 | } 59 | } 60 | 61 | return ( 62 | 68 | {({ onRowsRendered, registerChild }) => ( 69 |
70 | 79 |
80 | )} 81 |
82 | ); 83 | }; 84 | 85 | export default TransactionInfiniteList; 86 | -------------------------------------------------------------------------------- /src/components/TransactionItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useHistory } from "react-router"; 3 | import { 4 | ListItem, 5 | Typography, 6 | Grid, 7 | Avatar, 8 | ListItemAvatar, 9 | Paper, 10 | Badge, 11 | withStyles, 12 | Theme, 13 | createStyles, 14 | makeStyles, 15 | } from "@material-ui/core"; 16 | import { ThumbUpAltOutlined as LikeIcon, CommentRounded as CommentIcon } from "@material-ui/icons"; 17 | import { TransactionResponseItem } from "../models"; 18 | import TransactionTitle from "./TransactionTitle"; 19 | import TransactionAmount from "./TransactionAmount"; 20 | 21 | const useStyles = makeStyles((theme) => ({ 22 | root: { 23 | flexGrow: 1, 24 | }, 25 | paper: { 26 | padding: theme.spacing(0), 27 | margin: "auto", 28 | width: "100%", 29 | }, 30 | avatar: { 31 | width: theme.spacing(2), 32 | }, 33 | socialStats: { 34 | [theme.breakpoints.down("sm")]: { 35 | marginTop: theme.spacing(2), 36 | }, 37 | }, 38 | countIcons: { 39 | color: theme.palette.grey[400], 40 | }, 41 | countText: { 42 | color: theme.palette.grey[400], 43 | marginTop: 2, 44 | height: theme.spacing(2), 45 | width: theme.spacing(2), 46 | }, 47 | })); 48 | 49 | type TransactionProps = { 50 | transaction: TransactionResponseItem; 51 | }; 52 | 53 | const SmallAvatar = withStyles((theme: Theme) => 54 | createStyles({ 55 | root: { 56 | width: 22, 57 | height: 22, 58 | border: `2px solid ${theme.palette.background.paper}`, 59 | }, 60 | }) 61 | )(Avatar); 62 | 63 | const TransactionItem: React.FC = ({ transaction }) => { 64 | const classes = useStyles(); 65 | const history = useHistory(); 66 | 67 | const showTransactionDetail = (transactionId: string) => { 68 | history.push(`/transaction/${transactionId}`); 69 | }; 70 | 71 | return ( 72 | showTransactionDetail(transaction.id)} 76 | > 77 | 78 | 79 | 80 | 81 | } 88 | > 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | {transaction.description} 99 | 100 | 108 | 109 | 110 | 111 | 112 | 113 | {transaction.likes.length} 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | {transaction.comments.length} 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | ); 135 | }; 136 | 137 | export default TransactionItem; 138 | -------------------------------------------------------------------------------- /src/components/TransactionList.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { makeStyles, Paper, Button, ListSubheader, Grid } from "@material-ui/core"; 3 | import { Link as RouterLink } from "react-router-dom"; 4 | import { isEmpty } from "lodash/fp"; 5 | 6 | import SkeletonList from "./SkeletonList"; 7 | import { TransactionResponseItem, TransactionPagination } from "../models"; 8 | import EmptyList from "./EmptyList"; 9 | import TransactionInfiniteList from "./TransactionInfiniteList"; 10 | import TransferMoneyIllustration from "./SvgUndrawTransferMoneyRywa"; 11 | 12 | export interface TransactionListProps { 13 | header: string; 14 | transactions: TransactionResponseItem[]; 15 | isLoading: Boolean; 16 | showCreateButton?: Boolean; 17 | loadNextPage: Function; 18 | pagination: TransactionPagination; 19 | filterComponent: ReactNode; 20 | } 21 | 22 | const useStyles = makeStyles((theme) => ({ 23 | paper: { 24 | paddingLeft: theme.spacing(1), 25 | }, 26 | })); 27 | 28 | const TransactionList: React.FC = ({ 29 | header, 30 | transactions, 31 | isLoading, 32 | showCreateButton, 33 | loadNextPage, 34 | pagination, 35 | filterComponent, 36 | }) => { 37 | const classes = useStyles(); 38 | 39 | const showEmptyList = !isLoading && transactions?.length === 0; 40 | const showSkeleton = isLoading && isEmpty(pagination); 41 | 42 | return ( 43 | 44 | {filterComponent} 45 | {header} 46 | {showSkeleton && } 47 | {transactions.length > 0 && ( 48 | 53 | )} 54 | {showEmptyList && ( 55 | 56 | 64 | 65 | 66 | 67 | 68 | {showCreateButton && ( 69 | 78 | )} 79 | 80 | 81 | 82 | )} 83 | 84 | ); 85 | }; 86 | 87 | export default TransactionList; 88 | -------------------------------------------------------------------------------- /src/components/TransactionListFilters.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Paper, Grid } from "@material-ui/core"; 3 | import { TransactionDateRangePayload, TransactionAmountRangePayload } from "../models"; 4 | import TransactionListDateRangeFilter from "./TransactionDateRangeFilter"; 5 | import TransactionListAmountRangeFilter from "./TransactionListAmountRangeFilter"; 6 | import { debounce } from "lodash/fp"; 7 | 8 | const useStyles = makeStyles((theme) => ({ 9 | paper: { 10 | padding: theme.spacing(2), 11 | display: "flex", 12 | overflow: "auto", 13 | flexDirection: "column", 14 | }, 15 | })); 16 | 17 | export type TransactionListFiltersProps = { 18 | sendFilterEvent: Function; 19 | dateRangeFilters: TransactionDateRangePayload; 20 | amountRangeFilters: TransactionAmountRangePayload; 21 | }; 22 | 23 | const TransactionListFilters: React.FC = ({ 24 | sendFilterEvent, 25 | dateRangeFilters, 26 | amountRangeFilters, 27 | }) => { 28 | const classes = useStyles(); 29 | 30 | const filterDateRange = (payload: TransactionDateRangePayload) => 31 | sendFilterEvent("DATE_FILTER", payload); 32 | const resetDateRange = () => sendFilterEvent("DATE_RESET"); 33 | 34 | const filterAmountRange = debounce(200, (payload: TransactionAmountRangePayload) => 35 | sendFilterEvent("AMOUNT_FILTER", payload) 36 | ); 37 | const resetAmountRange = () => sendFilterEvent("AMOUNT_RESET"); 38 | 39 | return ( 40 | 41 | 42 | 43 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default TransactionListFilters; 62 | -------------------------------------------------------------------------------- /src/components/TransactionNavTabs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Tabs, Tab } from "@material-ui/core"; 3 | import { Link, useRouteMatch } from "react-router-dom"; 4 | 5 | export default function TransactionNavTabs() { 6 | const match = useRouteMatch(); 7 | 8 | // Route Lookup for tabs 9 | const navUrls: any = { 10 | "/": 0, 11 | "/public": 0, 12 | "/contacts": 1, 13 | "/personal": 2, 14 | }; 15 | 16 | // Set selected tab based on url 17 | const [value, setValue] = React.useState(navUrls[match.url]); 18 | 19 | const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { 20 | setValue(newValue); 21 | }; 22 | 23 | return ( 24 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/TransactionPersonalList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ReactNode } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { 4 | TransactionPagination, 5 | TransactionResponseItem, 6 | TransactionDateRangePayload, 7 | TransactionAmountRangePayload, 8 | } from "../models"; 9 | import TransactionList from "./TransactionList"; 10 | import { personalTransactionsMachine } from "../machines/personalTransactionsMachine"; 11 | 12 | export interface TransactionPersonalListProps { 13 | filterComponent: ReactNode; 14 | dateRangeFilters: TransactionDateRangePayload; 15 | amountRangeFilters: TransactionAmountRangePayload; 16 | } 17 | 18 | const TransactionPersonalList: React.FC = ({ 19 | filterComponent, 20 | dateRangeFilters, 21 | amountRangeFilters, 22 | }) => { 23 | const [current, send, personalTransactionService] = useMachine(personalTransactionsMachine); 24 | const { pageData, results } = current.context; 25 | 26 | // @ts-ignore 27 | if (window.Cypress) { 28 | // @ts-ignore 29 | window.personalTransactionService = personalTransactionService; 30 | } 31 | 32 | useEffect(() => { 33 | send("FETCH", { ...dateRangeFilters, ...amountRangeFilters }); 34 | }, [send, dateRangeFilters, amountRangeFilters]); 35 | 36 | const loadNextPage = (page: number) => 37 | send("FETCH", { page, ...dateRangeFilters, ...amountRangeFilters }); 38 | 39 | return ( 40 | <> 41 | 50 | 51 | ); 52 | }; 53 | 54 | export default TransactionPersonalList; 55 | -------------------------------------------------------------------------------- /src/components/TransactionPublicList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ReactNode } from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { 4 | TransactionPagination, 5 | TransactionResponseItem, 6 | TransactionDateRangePayload, 7 | TransactionAmountRangePayload, 8 | } from "../models"; 9 | import TransactionList from "./TransactionList"; 10 | import { publicTransactionsMachine } from "../machines/publicTransactionsMachine"; 11 | 12 | export interface TransactionPublicListProps { 13 | filterComponent: ReactNode; 14 | dateRangeFilters: TransactionDateRangePayload; 15 | amountRangeFilters: TransactionAmountRangePayload; 16 | } 17 | 18 | const TransactionPublicList: React.FC = ({ 19 | filterComponent, 20 | dateRangeFilters, 21 | amountRangeFilters, 22 | }) => { 23 | const [current, send, publicTransactionService] = useMachine(publicTransactionsMachine); 24 | const { pageData, results } = current.context; 25 | 26 | // @ts-ignore 27 | if (window.Cypress) { 28 | // @ts-ignore 29 | window.publicTransactionService = publicTransactionService; 30 | } 31 | 32 | useEffect(() => { 33 | send("FETCH", { ...dateRangeFilters, ...amountRangeFilters }); 34 | }, [send, dateRangeFilters, amountRangeFilters]); 35 | 36 | const loadNextPage = (page: number) => 37 | send("FETCH", { page, ...dateRangeFilters, ...amountRangeFilters }); 38 | 39 | return ( 40 | <> 41 | 50 | 51 | ); 52 | }; 53 | 54 | export default TransactionPublicList; 55 | -------------------------------------------------------------------------------- /src/components/TransactionTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Typography } from "@material-ui/core"; 3 | import { makeStyles } from "@material-ui/core"; 4 | import { TransactionResponseItem } from "../models"; 5 | import { isRequestTransaction, isAcceptedRequestTransaction } from "../utils/transactionUtils"; 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | title: { 9 | fontSize: 18, 10 | [theme.breakpoints.down("sm")]: { 11 | fontSize: theme.typography.fontSize, 12 | }, 13 | }, 14 | titleAction: { 15 | fontSize: 18, 16 | [theme.breakpoints.down("sm")]: { 17 | fontSize: theme.typography.fontSize, 18 | }, 19 | }, 20 | titleName: { 21 | fontSize: 18, 22 | [theme.breakpoints.down("sm")]: { 23 | fontSize: theme.typography.fontSize, 24 | }, 25 | color: "#1A202C", 26 | }, 27 | })); 28 | 29 | const TransactionTitle: React.FC<{ 30 | transaction: TransactionResponseItem; 31 | }> = ({ transaction }) => { 32 | const classes = useStyles(); 33 | 34 | return ( 35 | 36 | 42 | {transaction.senderName} 43 | 44 | 50 | {isRequestTransaction(transaction) 51 | ? isAcceptedRequestTransaction(transaction) 52 | ? " charged " 53 | : " requested " 54 | : " paid "} 55 | 56 | 62 | {transaction.receiverName} 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default TransactionTitle; 69 | -------------------------------------------------------------------------------- /src/components/UserListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ListItem, ListItemText, ListItemAvatar, Avatar, Grid } from "@material-ui/core"; 3 | 4 | import { User } from "../models"; 5 | 6 | export interface UserListItemProps { 7 | user: User; 8 | setReceiver: Function; 9 | index: Number; 10 | } 11 | 12 | const UserListItem: React.FC = ({ user, setReceiver, index }) => { 13 | return ( 14 | setReceiver(user)}> 15 | 16 | 17 | 18 | 22 | 30 | 31 | U: 32 | {user.username} 33 | 34 | 35 | • 36 | 37 | 38 | E: 39 | {user.email} 40 | 41 | 42 | • 43 | 44 | 45 | P: 46 | {user.phoneNumber} 47 | 48 | 49 | 50 | } 51 | /> 52 | 53 | ); 54 | }; 55 | 56 | export default UserListItem; 57 | -------------------------------------------------------------------------------- /src/components/UserListSearchForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { makeStyles, TextField } from "@material-ui/core"; 3 | 4 | const useStyles = makeStyles((theme) => ({ 5 | paper: { 6 | marginTop: theme.spacing(8), 7 | display: "flex", 8 | flexDirection: "column", 9 | alignItems: "center", 10 | }, 11 | form: { 12 | width: "100%", // Fix IE 11 issue. 13 | marginTop: theme.spacing(1), 14 | }, 15 | })); 16 | 17 | export interface UserListSearchFormProps { 18 | userListSearch: Function; 19 | } 20 | 21 | const UserListSearchForm: React.FC = ({ userListSearch }) => { 22 | const classes = useStyles(); 23 | const inputEl = useRef(null); 24 | 25 | return ( 26 |
27 |
28 | { 39 | if (null !== inputEl.current) { 40 | inputEl.current.value = ""; 41 | inputEl.current.focus(); 42 | } 43 | }} 44 | onChange={({ target: { value: q } }) => { 45 | userListSearch({ q }); 46 | }} 47 | /> 48 | 49 |
50 | ); 51 | }; 52 | 53 | export default UserListSearchForm; 54 | -------------------------------------------------------------------------------- /src/components/UsersList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { List } from "@material-ui/core"; 3 | 4 | import UserListItem from "./UserListItem"; 5 | import { User } from "../models"; 6 | 7 | export interface UsersListProps { 8 | users: User[]; 9 | setReceiver: Function; 10 | } 11 | 12 | const UsersList: React.FC = ({ users, setReceiver }) => { 13 | return ( 14 | 15 | {users && 16 | users.map((user: User, index: number) => ( 17 | 18 | ))} 19 | 20 | ); 21 | }; 22 | 23 | export default UsersList; 24 | -------------------------------------------------------------------------------- /src/containers/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Switch, Route, Redirect } from "react-router-dom"; 3 | import { useService, useMachine } from "@xstate/react"; 4 | import { makeStyles } from "@material-ui/core"; 5 | import { CssBaseline } from "@material-ui/core"; 6 | 7 | import { snackbarMachine } from "../machines/snackbarMachine"; 8 | import { notificationsMachine } from "../machines/notificationsMachine"; 9 | import { authService } from "../machines/authMachine"; 10 | import AlertBar from "../components/AlertBar"; 11 | import SignInForm from "../components/SignInForm"; 12 | import SignUpForm from "../components/SignUpForm"; 13 | import { bankAccountsMachine } from "../machines/bankAccountsMachine"; 14 | import PrivateRoutesContainer from "./PrivateRoutesContainer"; 15 | 16 | // @ts-ignore 17 | if (window.Cypress) { 18 | // Expose authService on window for Cypress 19 | // @ts-ignore 20 | window.authService = authService; 21 | } 22 | 23 | const useStyles = makeStyles((theme) => ({ 24 | root: { 25 | display: "flex", 26 | }, 27 | })); 28 | 29 | const App: React.FC = () => { 30 | const classes = useStyles(); 31 | const [authState] = useService(authService); 32 | const [, , notificationsService] = useMachine(notificationsMachine); 33 | 34 | const [, , snackbarService] = useMachine(snackbarMachine); 35 | 36 | const [, , bankAccountsService] = useMachine(bankAccountsMachine); 37 | 38 | const isLoggedIn = 39 | authState.matches("authorized") || 40 | authState.matches("refreshing") || 41 | authState.matches("updating"); 42 | 43 | return ( 44 |
45 | 46 | 47 | {isLoggedIn && ( 48 | 55 | )} 56 | {authState.matches("unauthorized") && ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | )} 73 | 74 |
75 | ); 76 | }; 77 | 78 | export default App; 79 | -------------------------------------------------------------------------------- /src/containers/AppAuth0.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore next */ 2 | import React, { useEffect } from "react"; 3 | import { useService, useMachine } from "@xstate/react"; 4 | import { makeStyles } from "@material-ui/core/styles"; 5 | import { CssBaseline } from "@material-ui/core"; 6 | 7 | import { snackbarMachine } from "../machines/snackbarMachine"; 8 | import { notificationsMachine } from "../machines/notificationsMachine"; 9 | import { authService } from "../machines/authMachine"; 10 | import AlertBar from "../components/AlertBar"; 11 | import { bankAccountsMachine } from "../machines/bankAccountsMachine"; 12 | import PrivateRoutesContainer from "./PrivateRoutesContainer"; 13 | import { useAuth0, withAuthenticationRequired } from "@auth0/auth0-react"; 14 | 15 | // @ts-ignore 16 | if (window.Cypress) { 17 | // Expose authService on window for Cypress 18 | // @ts-ignore 19 | window.authService = authService; 20 | } 21 | 22 | const useStyles = makeStyles((theme) => ({ 23 | root: { 24 | display: "flex", 25 | }, 26 | })); 27 | 28 | /* istanbul ignore next */ 29 | const AppAuth0: React.FC = () => { 30 | const { isAuthenticated, user, getAccessTokenSilently } = useAuth0(); 31 | const classes = useStyles(); 32 | const [authState] = useService(authService); 33 | const [, , notificationsService] = useMachine(notificationsMachine); 34 | 35 | const [, , snackbarService] = useMachine(snackbarMachine); 36 | 37 | const [, , bankAccountsService] = useMachine(bankAccountsMachine); 38 | 39 | // @ts-ignore 40 | if (window.Cypress) { 41 | // eslint-disable-next-line react-hooks/rules-of-hooks 42 | useEffect(() => { 43 | const auth0 = JSON.parse(localStorage.getItem("auth0Cypress")!); 44 | authService.send("AUTH0", { 45 | user: auth0.user, 46 | token: auth0.token, 47 | }); 48 | }, []); 49 | } else { 50 | // eslint-disable-next-line react-hooks/rules-of-hooks 51 | useEffect(() => { 52 | (async function waitForToken() { 53 | const token = await getAccessTokenSilently(); 54 | authService.send("AUTH0", { user, token }); 55 | })(); 56 | }, [isAuthenticated, user, getAccessTokenSilently]); 57 | } 58 | 59 | const isLoggedIn = 60 | authState.matches("authorized") || 61 | authState.matches("refreshing") || 62 | authState.matches("updating"); 63 | 64 | return ( 65 |
66 | 67 | 68 | {isLoggedIn && ( 69 | 76 | )} 77 | 78 | 79 |
80 | ); 81 | }; 82 | 83 | //@ts-ignore 84 | let appAuth0 = window.Cypress ? AppAuth0 : withAuthenticationRequired(AppAuth0); 85 | export default appAuth0; 86 | -------------------------------------------------------------------------------- /src/containers/AppCognito.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useService, useMachine } from "@xstate/react"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { CssBaseline, Container } from "@material-ui/core"; 5 | 6 | import { snackbarMachine } from "../machines/snackbarMachine"; 7 | import { notificationsMachine } from "../machines/notificationsMachine"; 8 | import { authService } from "../machines/authMachine"; 9 | import AlertBar from "../components/AlertBar"; 10 | import { bankAccountsMachine } from "../machines/bankAccountsMachine"; 11 | import PrivateRoutesContainer from "./PrivateRoutesContainer"; 12 | import Amplify, { Auth } from "aws-amplify"; 13 | import { AmplifyAuthenticator, AmplifySignUp, AmplifySignIn } from "@aws-amplify/ui-react"; 14 | import { AuthState, onAuthUIStateChange } from "@aws-amplify/ui-components"; 15 | 16 | // @ts-ignore 17 | import awsConfig from "../aws-exports"; 18 | 19 | Amplify.configure(awsConfig); 20 | 21 | // @ts-ignore 22 | if (window.Cypress) { 23 | // Expose authService on window for Cypress 24 | // @ts-ignore 25 | window.authService = authService; 26 | } 27 | 28 | const useStyles = makeStyles((theme) => ({ 29 | root: { 30 | display: "flex", 31 | }, 32 | })); 33 | 34 | const AppCognito: React.FC = /* istanbul ignore next */ () => { 35 | const classes = useStyles(); 36 | const [authState] = useService(authService); 37 | const [, , notificationsService] = useMachine(notificationsMachine); 38 | 39 | const [, , snackbarService] = useMachine(snackbarMachine); 40 | 41 | const [, , bankAccountsService] = useMachine(bankAccountsMachine); 42 | 43 | useEffect(() => { 44 | return onAuthUIStateChange((nextAuthState, authData) => { 45 | console.log("authData: ", authData); 46 | if (nextAuthState === AuthState.SignedIn) { 47 | authService.send("COGNITO", { user: authData }); 48 | } 49 | }); 50 | }, []); 51 | 52 | useEffect(() => { 53 | authService.onEvent(async (event) => { 54 | if (event.type === "done.invoke.performLogout") { 55 | console.log("AppCognito authService.onEvent done.invoke.performLogout"); 56 | await Auth.signOut(); 57 | } 58 | }); 59 | }, []); 60 | 61 | const isLoggedIn = 62 | authState.matches("authorized") || 63 | authState.matches("refreshing") || 64 | authState.matches("updating"); 65 | 66 | return isLoggedIn ? ( 67 |
68 | 69 | 70 | 77 | 78 | 79 |
80 | ) : ( 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | ); 89 | }; 90 | 91 | export default AppCognito; 92 | -------------------------------------------------------------------------------- /src/containers/AppGoogle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useService, useMachine } from "@xstate/react"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { Container, CssBaseline } from "@material-ui/core"; 5 | 6 | import { snackbarMachine } from "../machines/snackbarMachine"; 7 | import { notificationsMachine } from "../machines/notificationsMachine"; 8 | import { authService } from "../machines/authMachine"; 9 | import AlertBar from "../components/AlertBar"; 10 | import { bankAccountsMachine } from "../machines/bankAccountsMachine"; 11 | import PrivateRoutesContainer from "./PrivateRoutesContainer"; 12 | import { GoogleLogin, useGoogleLogin } from "react-google-login"; 13 | 14 | // @ts-ignore 15 | if (window.Cypress) { 16 | // Expose authService on window for Cypress 17 | // @ts-ignore 18 | window.authService = authService; 19 | } 20 | 21 | const useStyles = makeStyles((theme) => ({ 22 | root: { 23 | display: "flex", 24 | }, 25 | paper: { 26 | marginTop: theme.spacing(8), 27 | display: "flex", 28 | flexDirection: "column", 29 | alignItems: "center", 30 | }, 31 | })); 32 | 33 | /* istanbul ignore next */ 34 | const AppGoogle: React.FC = () => { 35 | const classes = useStyles(); 36 | const [authState] = useService(authService); 37 | const [, , notificationsService] = useMachine(notificationsMachine); 38 | 39 | const [, , snackbarService] = useMachine(snackbarMachine); 40 | 41 | const [, , bankAccountsService] = useMachine(bankAccountsMachine); 42 | 43 | // @ts-ignore 44 | if (window.Cypress) { 45 | // eslint-disable-next-line react-hooks/rules-of-hooks 46 | useEffect(() => { 47 | const { user, token } = JSON.parse(localStorage.getItem("googleCypress")!); 48 | authService.send("GOOGLE", { 49 | user, 50 | token, 51 | }); 52 | }, []); 53 | } else { 54 | // eslint-disable-next-line react-hooks/rules-of-hooks 55 | useGoogleLogin({ 56 | clientId: process.env.REACT_APP_GOOGLE_CLIENTID!, 57 | onSuccess: (res) => { 58 | console.log("onSuccess", res); 59 | // @ts-ignore 60 | authService.send("GOOGLE", { user: res.profileObj, token: res.tokenId }); 61 | }, 62 | cookiePolicy: "single_host_origin", 63 | isSignedIn: true, 64 | }); 65 | } 66 | 67 | const isLoggedIn = authState.matches("authorized"); 68 | 69 | return ( 70 |
71 | 72 | 73 | {isLoggedIn && ( 74 | 81 | )} 82 | 83 | {authState.matches("unauthorized") && ( 84 | 85 | 86 |
87 | 92 |
93 |
94 | )} 95 | 96 | 97 |
98 | ); 99 | }; 100 | 101 | export default AppGoogle; 102 | -------------------------------------------------------------------------------- /src/containers/AppOkta.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore next */ 2 | import React, { useEffect } from "react"; 3 | import { useService, useMachine } from "@xstate/react"; 4 | import { makeStyles } from "@material-ui/core/styles"; 5 | import { CssBaseline } from "@material-ui/core"; 6 | // @ts-ignore 7 | import { LoginCallback, SecureRoute, useOktaAuth, withOktaAuth } from "@okta/okta-react"; 8 | import { Route } from "react-router-dom"; 9 | 10 | import { snackbarMachine } from "../machines/snackbarMachine"; 11 | import { notificationsMachine } from "../machines/notificationsMachine"; 12 | import { authService } from "../machines/authMachine"; 13 | import AlertBar from "../components/AlertBar"; 14 | import { bankAccountsMachine } from "../machines/bankAccountsMachine"; 15 | import PrivateRoutesContainer from "./PrivateRoutesContainer"; 16 | 17 | // @ts-ignore 18 | if (window.Cypress) { 19 | // Expose authService on window for Cypress 20 | // @ts-ignore 21 | window.authService = authService; 22 | } 23 | 24 | const useStyles = makeStyles((theme) => ({ 25 | root: { 26 | display: "flex", 27 | }, 28 | })); 29 | 30 | /* istanbul ignore next */ 31 | const AppOkta: React.FC = () => { 32 | const { authState: oktaAuthState, oktaAuth: oktaAuthService } = useOktaAuth(); 33 | const classes = useStyles(); 34 | const [authState] = useService(authService); 35 | const [, , notificationsService] = useMachine(notificationsMachine); 36 | 37 | const [, , snackbarService] = useMachine(snackbarMachine); 38 | 39 | const [, , bankAccountsService] = useMachine(bankAccountsMachine); 40 | 41 | // @ts-ignore 42 | if (window.Cypress) { 43 | // eslint-disable-next-line react-hooks/rules-of-hooks 44 | useEffect(() => { 45 | const okta = JSON.parse(localStorage.getItem("oktaCypress")!); 46 | authService.send("OKTA", { 47 | user: okta.user, 48 | token: okta.token, 49 | }); 50 | }, []); 51 | } else { 52 | // eslint-disable-next-line react-hooks/rules-of-hooks 53 | useEffect(() => { 54 | if (oktaAuthState.isAuthenticated) { 55 | oktaAuthService.getUser().then((user: any) => { 56 | authService.send("OKTA", { user, token: oktaAuthState.accessToken }); 57 | }); 58 | } 59 | }, [oktaAuthState, oktaAuthService]); 60 | } 61 | 62 | const isLoggedIn = 63 | authState.matches("authorized") || 64 | authState.matches("refreshing") || 65 | authState.matches("updating"); 66 | 67 | return ( 68 |
69 | 70 | 71 | {isLoggedIn && ( 72 | 79 | )} 80 | {authState.matches("unauthorized") && ( 81 | <> 82 | 83 | 84 | 85 | )} 86 | 87 | 88 |
89 | ); 90 | }; 91 | 92 | //@ts-ignore 93 | let appOkta = window.Cypress ? AppOkta : withOktaAuth(AppOkta); 94 | export default appOkta; 95 | -------------------------------------------------------------------------------- /src/containers/BankAccountsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useService } from "@xstate/react"; 3 | import { Interpreter } from "xstate"; 4 | import { Link as RouterLink, useRouteMatch } from "react-router-dom"; 5 | import { makeStyles, Grid, Button, Paper, Typography } from "@material-ui/core"; 6 | 7 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 8 | import { DataContext, DataEvents } from "../machines/dataMachine"; 9 | import BankAccountForm from "../components/BankAccountForm"; 10 | import BankAccountList from "../components/BankAccountList"; 11 | 12 | export interface Props { 13 | authService: Interpreter; 14 | bankAccountsService: Interpreter; 15 | } 16 | 17 | const useStyles = makeStyles((theme) => ({ 18 | paper: { 19 | padding: theme.spacing(2), 20 | display: "flex", 21 | overflow: "auto", 22 | flexDirection: "column", 23 | }, 24 | })); 25 | 26 | const BankAccountsContainer: React.FC = ({ authService, bankAccountsService }) => { 27 | const match = useRouteMatch(); 28 | const classes = useStyles(); 29 | const [authState] = useService(authService); 30 | const [bankAccountsState, sendBankAccounts] = useService(bankAccountsService); 31 | 32 | const currentUser = authState?.context.user; 33 | 34 | const createBankAccount = (payload: any) => { 35 | sendBankAccounts({ type: "CREATE", ...payload }); 36 | }; 37 | 38 | const deleteBankAccount = (payload: any) => { 39 | sendBankAccounts({ type: "DELETE", ...payload }); 40 | }; 41 | 42 | useEffect(() => { 43 | sendBankAccounts("FETCH"); 44 | }, [sendBankAccounts]); 45 | 46 | if (match.url === "/bankaccounts/new" && currentUser?.id) { 47 | return ( 48 | 49 | 50 | Create Bank Account 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | return ( 58 | 59 | 60 | 61 | 62 | Bank Accounts 63 | 64 | 65 | 66 | 76 | 77 | 78 | 82 | 83 | ); 84 | }; 85 | export default BankAccountsContainer; 86 | -------------------------------------------------------------------------------- /src/containers/NotificationsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Interpreter } from "xstate"; 3 | import { useService } from "@xstate/react"; 4 | import { makeStyles, Paper, Typography } from "@material-ui/core"; 5 | import { NotificationUpdatePayload } from "../models"; 6 | import NotificationList from "../components/NotificationList"; 7 | import { DataContext, DataSchema, DataEvents } from "../machines/dataMachine"; 8 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 9 | 10 | const useStyles = makeStyles((theme) => ({ 11 | paper: { 12 | minHeight: "90vh", 13 | padding: theme.spacing(2), 14 | display: "flex", 15 | overflow: "auto", 16 | flexDirection: "column", 17 | }, 18 | })); 19 | 20 | export interface Props { 21 | authService: Interpreter; 22 | notificationsService: Interpreter; 23 | } 24 | 25 | const NotificationsContainer: React.FC = ({ authService, notificationsService }) => { 26 | const classes = useStyles(); 27 | const [authState] = useService(authService); 28 | const [notificationsState, sendNotifications] = useService(notificationsService); 29 | 30 | useEffect(() => { 31 | sendNotifications({ type: "FETCH" }); 32 | }, [authState, sendNotifications]); 33 | 34 | const updateNotification = (payload: NotificationUpdatePayload) => 35 | sendNotifications({ type: "UPDATE", ...payload }); 36 | 37 | return ( 38 | 39 | 40 | Notifications 41 | 42 | 46 | 47 | ); 48 | }; 49 | 50 | export default NotificationsContainer; 51 | -------------------------------------------------------------------------------- /src/containers/PrivateRoutesContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Switch } from "react-router"; 3 | import { Interpreter } from "xstate"; 4 | import MainLayout from "../components/MainLayout"; 5 | import PrivateRoute from "../components/PrivateRoute"; 6 | import TransactionsContainer from "./TransactionsContainer"; 7 | import UserSettingsContainer from "./UserSettingsContainer"; 8 | import NotificationsContainer from "./NotificationsContainer"; 9 | import BankAccountsContainer from "./BankAccountsContainer"; 10 | import TransactionCreateContainer from "./TransactionCreateContainer"; 11 | import TransactionDetailContainer from "./TransactionDetailContainer"; 12 | import { DataContext, DataSchema, DataEvents } from "../machines/dataMachine"; 13 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 14 | import { SnackbarContext, SnackbarSchema, SnackbarEvents } from "../machines/snackbarMachine"; 15 | import { useService } from "@xstate/react"; 16 | import UserOnboardingContainer from "./UserOnboardingContainer"; 17 | 18 | export interface Props { 19 | isLoggedIn: boolean; 20 | authService: Interpreter; 21 | notificationsService: Interpreter; 22 | snackbarService: Interpreter; 23 | bankAccountsService: Interpreter; 24 | } 25 | 26 | const PrivateRoutesContainer: React.FC = ({ 27 | isLoggedIn, 28 | authService, 29 | notificationsService, 30 | snackbarService, 31 | bankAccountsService, 32 | }) => { 33 | const [, sendNotifications] = useService(notificationsService); 34 | 35 | useEffect(() => { 36 | sendNotifications({ type: "FETCH" }); 37 | }, [sendNotifications]); 38 | 39 | return ( 40 | 41 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export default PrivateRoutesContainer; 76 | -------------------------------------------------------------------------------- /src/containers/TransactionCreateContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useMachine, useService } from "@xstate/react"; 3 | import { User, TransactionPayload } from "../models"; 4 | import TransactionCreateStepOne from "../components/TransactionCreateStepOne"; 5 | import TransactionCreateStepTwo from "../components/TransactionCreateStepTwo"; 6 | import TransactionCreateStepThree from "../components/TransactionCreateStepThree"; 7 | import { createTransactionMachine } from "../machines/createTransactionMachine"; 8 | import { usersMachine } from "../machines/usersMachine"; 9 | import { debounce } from "lodash/fp"; 10 | import { Interpreter } from "xstate"; 11 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 12 | import { SnackbarSchema, SnackbarContext, SnackbarEvents } from "../machines/snackbarMachine"; 13 | import { Stepper, Step, StepLabel } from "@material-ui/core"; 14 | 15 | export interface Props { 16 | authService: Interpreter; 17 | snackbarService: Interpreter; 18 | } 19 | 20 | const TransactionCreateContainer: React.FC = ({ authService, snackbarService }) => { 21 | const [authState] = useService(authService); 22 | const [, sendSnackbar] = useService(snackbarService); 23 | 24 | const [createTransactionState, sendCreateTransaction, createTransactionService] = 25 | useMachine(createTransactionMachine); 26 | 27 | // Expose createTransactionService on window for Cypress 28 | // @ts-ignore 29 | window.createTransactionService = createTransactionService; 30 | 31 | const [usersState, sendUsers] = useMachine(usersMachine); 32 | 33 | useEffect(() => { 34 | sendUsers({ type: "FETCH" }); 35 | }, [sendUsers]); 36 | 37 | const sender = authState?.context?.user; 38 | const setReceiver = (receiver: User) => { 39 | // @ts-ignore 40 | sendCreateTransaction({ type: "SET_USERS", sender, receiver }); 41 | }; 42 | const createTransaction = (payload: TransactionPayload) => { 43 | sendCreateTransaction("CREATE", payload); 44 | }; 45 | const userListSearch = debounce(200, (payload: any) => sendUsers({ type: "FETCH", ...payload })); 46 | 47 | const showSnackbar = (payload: SnackbarContext) => sendSnackbar({ type: "SHOW", ...payload }); 48 | 49 | let activeStep; 50 | if (createTransactionState.matches("stepTwo")) { 51 | activeStep = 1; 52 | } else if (createTransactionState.matches("stepThree")) { 53 | activeStep = 3; 54 | } else { 55 | activeStep = 0; 56 | } 57 | 58 | return ( 59 | <> 60 | 61 | 62 | Select Contact 63 | 64 | 65 | Payment 66 | 67 | 68 | Complete 69 | 70 | 71 | {createTransactionState.matches("stepOne") && ( 72 | 77 | )} 78 | {sender && createTransactionState.matches("stepTwo") && ( 79 | 85 | )} 86 | {createTransactionState.matches("stepThree") && ( 87 | 88 | )} 89 | 90 | ); 91 | }; 92 | 93 | export default TransactionCreateContainer; 94 | -------------------------------------------------------------------------------- /src/containers/TransactionDetailContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useMachine, useService } from "@xstate/react"; 3 | import { useParams } from "react-router-dom"; 4 | import TransactionDetail from "../components/TransactionDetail"; 5 | import { Transaction } from "../models"; 6 | import { transactionDetailMachine } from "../machines/transactionDetailMachine"; 7 | import { first } from "lodash/fp"; 8 | import { Interpreter } from "xstate"; 9 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 10 | 11 | export interface Props { 12 | authService: Interpreter; 13 | } 14 | interface Params { 15 | transactionId: string; 16 | } 17 | 18 | const TransactionDetailsContainer: React.FC = ({ authService }) => { 19 | const { transactionId }: Params = useParams(); 20 | const [authState] = useService(authService); 21 | const [transactionDetailState, sendTransactionDetail] = useMachine(transactionDetailMachine); 22 | useEffect(() => { 23 | sendTransactionDetail("FETCH", { transactionId }); 24 | }, [sendTransactionDetail, transactionId]); 25 | 26 | const transactionLike = (transactionId: Transaction["id"]) => 27 | sendTransactionDetail("CREATE", { entity: "LIKE", transactionId }); 28 | 29 | const transactionComment = (payload: any) => 30 | sendTransactionDetail("CREATE", { entity: "COMMENT", ...payload }); 31 | 32 | const transactionUpdate = (payload: any) => sendTransactionDetail("UPDATE", payload); 33 | 34 | const transaction = first(transactionDetailState.context?.results); 35 | const currentUser = authState?.context?.user; 36 | 37 | return ( 38 | <> 39 | {transactionDetailState.matches("idle") && ( 40 |
41 | Loading... 42 |
43 |
44 | )} 45 | {currentUser && transactionDetailState.matches("success") && ( 46 | 53 | )} 54 | 55 | ); 56 | }; 57 | 58 | export default TransactionDetailsContainer; 59 | -------------------------------------------------------------------------------- /src/containers/TransactionsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useMachine } from "@xstate/react"; 3 | import { Switch, Route } from "react-router"; 4 | import { TransactionDateRangePayload, TransactionAmountRangePayload } from "../models"; 5 | import TransactionListFilters from "../components/TransactionListFilters"; 6 | import TransactionContactsList from "../components/TransactionContactsList"; 7 | import { transactionFiltersMachine } from "../machines/transactionFiltersMachine"; 8 | import { getDateQueryFields, getAmountQueryFields } from "../utils/transactionUtils"; 9 | import TransactionPersonalList from "../components/TransactionPersonalList"; 10 | import TransactionPublicList from "../components/TransactionPublicList"; 11 | 12 | const TransactionsContainer: React.FC = () => { 13 | const [currentFilters, sendFilterEvent] = useMachine(transactionFiltersMachine); 14 | 15 | const hasDateRangeFilter = currentFilters.matches({ dateRange: "filter" }); 16 | const hasAmountRangeFilter = currentFilters.matches({ 17 | amountRange: "filter", 18 | }); 19 | 20 | const dateRangeFilters = hasDateRangeFilter && getDateQueryFields(currentFilters.context); 21 | const amountRangeFilters = hasAmountRangeFilter && getAmountQueryFields(currentFilters.context); 22 | 23 | const Filters = ( 24 | 29 | ); 30 | 31 | return ( 32 | 33 | 34 | 39 | 40 | 41 | 46 | 47 | 48 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default TransactionsContainer; 59 | -------------------------------------------------------------------------------- /src/containers/UserOnboardingContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { 3 | Button, 4 | Box, 5 | useTheme, 6 | useMediaQuery, 7 | Grid, 8 | Dialog, 9 | DialogActions, 10 | DialogContent, 11 | DialogContentText, 12 | DialogTitle, 13 | } from "@material-ui/core"; 14 | import { Interpreter } from "xstate"; 15 | import { isEmpty } from "lodash/fp"; 16 | import { useService, useMachine } from "@xstate/react"; 17 | 18 | import { userOnboardingMachine } from "../machines/userOnboardingMachine"; 19 | import BankAccountForm from "../components/BankAccountForm"; 20 | import { DataContext, DataEvents } from "../machines/dataMachine"; 21 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 22 | import NavigatorIllustration from "../components/SvgUndrawNavigatorA479"; 23 | import PersonalFinance from "../components/SvgUndrawPersonalFinanceTqcd"; 24 | 25 | export interface Props { 26 | authService: Interpreter; 27 | bankAccountsService: Interpreter; 28 | } 29 | 30 | const UserOnboardingContainer: React.FC = ({ authService, bankAccountsService }) => { 31 | const theme = useTheme(); 32 | const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); 33 | const [bankAccountsState, sendBankAccounts] = useService(bankAccountsService); 34 | const [authState, sendAuth] = useService(authService); 35 | const [userOnboardingState, sendUserOnboarding] = useMachine(userOnboardingMachine); 36 | 37 | const currentUser = authState?.context?.user; 38 | 39 | useEffect(() => { 40 | sendBankAccounts("FETCH"); 41 | }, [sendBankAccounts]); 42 | 43 | const noBankAccounts = 44 | bankAccountsState?.matches("success.withoutData") && 45 | isEmpty(bankAccountsState?.context?.results); 46 | 47 | const dialogIsOpen = 48 | (userOnboardingState.matches("stepTwo") && !noBankAccounts) || 49 | (userOnboardingState.matches("stepThree") && !noBankAccounts) || 50 | (!userOnboardingState.matches("done") && noBankAccounts) || 51 | false; 52 | 53 | const nextStep = () => sendUserOnboarding("NEXT"); 54 | 55 | const createBankAccountWithNextStep = (payload: any) => { 56 | sendBankAccounts({ type: "CREATE", ...payload }); 57 | nextStep(); 58 | }; 59 | 60 | return ( 61 | 62 | 63 | {userOnboardingState.matches("stepOne") && "Get Started with Real World App"} 64 | {userOnboardingState.matches("stepTwo") && "Create Bank Account"} 65 | {userOnboardingState.matches("stepThree") && "Finished"} 66 | 67 | 68 | 69 | {userOnboardingState.matches("stepOne") && ( 70 | <> 71 | 72 |
73 | 74 | Real World App requires a Bank Account to perform transactions. 75 |
76 |
77 | Click Next to begin setup of your Bank Account. 78 |
79 | 80 | )} 81 | {userOnboardingState.matches("stepTwo") && ( 82 | 87 | )} 88 | {userOnboardingState.matches("stepThree") && ( 89 | <> 90 | 91 |
92 | 93 | You're all set! 94 |
95 |
96 | We're excited to have you aboard the Real World App! 97 |
98 | 99 | )} 100 |
101 |
102 | 103 | 104 | 105 | 113 | 114 | 115 | {!userOnboardingState.matches("stepTwo") && ( 116 | 119 | )} 120 | 121 | 122 | 123 |
124 | ); 125 | }; 126 | 127 | export default UserOnboardingContainer; 128 | -------------------------------------------------------------------------------- /src/containers/UserSettingsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles, Paper, Typography, Grid } from "@material-ui/core"; 3 | import UserSettingsForm from "../components/UserSettingsForm"; 4 | import { Interpreter } from "xstate"; 5 | import { AuthMachineContext, AuthMachineEvents } from "../machines/authMachine"; 6 | import { useService } from "@xstate/react"; 7 | import PersonalSettingsIllustration from "../components/SvgUndrawPersonalSettingsKihd"; 8 | 9 | const useStyles = makeStyles((theme) => ({ 10 | paper: { 11 | padding: theme.spacing(2), 12 | display: "flex", 13 | overflow: "auto", 14 | flexDirection: "column", 15 | }, 16 | })); 17 | 18 | export interface Props { 19 | authService: Interpreter; 20 | } 21 | 22 | const UserSettingsContainer: React.FC = ({ authService }) => { 23 | const classes = useStyles(); 24 | const [authState, sendAuth] = useService(authService); 25 | 26 | const currentUser = authState?.context?.user; 27 | const updateUser = (payload: any) => sendAuth({ type: "UPDATE", ...payload }); 28 | 29 | return ( 30 | 31 | 32 | User Settings 33 | 34 | 35 | 36 | 37 | 38 | 39 | {currentUser && } 40 | 41 | 42 | 43 | ); 44 | }; 45 | export default UserSettingsContainer; 46 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Router } from "react-router-dom"; 4 | import { createMuiTheme, ThemeProvider } from "@material-ui/core"; 5 | import { Auth0Provider } from "@auth0/auth0-react"; 6 | /* istanbul ignore next */ 7 | // @ts-ignore 8 | import { OktaAuth } from "@okta/okta-auth-js"; 9 | import { Security } from "@okta/okta-react"; 10 | 11 | import App from "./containers/App"; 12 | import AppGoogle from "./containers/AppGoogle"; 13 | import AppAuth0 from "./containers/AppAuth0"; 14 | import AppOkta from "./containers/AppOkta"; 15 | import AppCognito from "./containers/AppCognito"; 16 | import { history } from "./utils/historyUtils"; 17 | 18 | const theme = createMuiTheme({ 19 | palette: { 20 | secondary: { 21 | main: "#fff", 22 | }, 23 | }, 24 | }); 25 | 26 | /* istanbul ignore next */ 27 | const onRedirectCallback = (appState: any) => { 28 | history.replace((appState && appState.returnTo) || window.location.pathname); 29 | }; 30 | 31 | /* istanbul ignore if */ 32 | if (process.env.REACT_APP_AUTH0) { 33 | ReactDOM.render( 34 | 43 | 44 | 45 | 46 | 47 | 48 | , 49 | document.getElementById("root") 50 | ); 51 | } else if (process.env.REACT_APP_OKTA) { 52 | const oktaAuth = new OktaAuth({ 53 | issuer: `https://${process.env.REACT_APP_OKTA_DOMAIN}/oauth2/default`, 54 | clientId: process.env.REACT_APP_OKTA_CLIENTID, 55 | redirectUri: window.location.origin + "/implicit/callback", 56 | }); 57 | 58 | /* istanbul ignore next */ 59 | ReactDOM.render( 60 | 61 | 62 | {process.env.REACT_APP_OKTA ? ( 63 | /* istanbul ignore next */ 64 | 65 | 66 | 67 | ) : ( 68 | 69 | )} 70 | 71 | , 72 | document.getElementById("root") 73 | ); 74 | } else if (process.env.REACT_APP_AWS_COGNITO) { 75 | /* istanbul ignore next */ 76 | ReactDOM.render( 77 | 78 | 79 | 80 | 81 | , 82 | document.getElementById("root") 83 | ); 84 | } else if (process.env.REACT_APP_GOOGLE) { 85 | /* istanbul ignore next */ 86 | ReactDOM.render( 87 | 88 | 89 | 90 | 91 | , 92 | document.getElementById("root") 93 | ); 94 | } else { 95 | ReactDOM.render( 96 | 97 | 98 | 99 | 100 | , 101 | document.getElementById("root") 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/machines/bankAccountsMachine.ts: -------------------------------------------------------------------------------- 1 | import { omit } from "lodash/fp"; 2 | import gql from "graphql-tag"; 3 | import { dataMachine } from "./dataMachine"; 4 | import { httpClient } from "../utils/asyncUtils"; 5 | import { backendPort } from "../utils/portUtils"; 6 | 7 | const listBankAccountQuery = gql` 8 | query ListBankAccount { 9 | listBankAccount { 10 | id 11 | uuid 12 | userId 13 | bankName 14 | accountNumber 15 | routingNumber 16 | isDeleted 17 | createdAt 18 | modifiedAt 19 | } 20 | } 21 | `; 22 | 23 | const deleteBankAccountMutation = gql` 24 | mutation DeleteBankAccount($id: ID!) { 25 | deleteBankAccount(id: $id) 26 | } 27 | `; 28 | 29 | const createBankAccountMutation = gql` 30 | mutation CreateBankAccount($bankName: String!, $accountNumber: String!, $routingNumber: String!) { 31 | createBankAccount( 32 | bankName: $bankName 33 | accountNumber: $accountNumber 34 | routingNumber: $routingNumber 35 | ) { 36 | id 37 | uuid 38 | userId 39 | bankName 40 | accountNumber 41 | routingNumber 42 | isDeleted 43 | createdAt 44 | } 45 | } 46 | `; 47 | 48 | export const bankAccountsMachine = dataMachine("bankAccounts").withConfig({ 49 | services: { 50 | fetchData: async (ctx, event: any) => { 51 | const resp = await httpClient.post(`http://localhost:${backendPort}/graphql`, { 52 | operationName: "ListBankAccount", 53 | query: listBankAccountQuery.loc?.source.body, 54 | }); 55 | return { results: resp.data.data.listBankAccount, pageData: {} }; 56 | }, 57 | deleteData: async (ctx, event: any) => { 58 | const payload = omit("type", event); 59 | const resp = await httpClient.post(`http://localhost:${backendPort}/graphql`, { 60 | operationName: "DeleteBankAccount", 61 | query: deleteBankAccountMutation.loc?.source.body, 62 | variables: payload, 63 | }); 64 | return resp.data; 65 | }, 66 | createData: async (ctx, event: any) => { 67 | const payload = omit("type", event); 68 | const resp = await httpClient.post(`http://localhost:${backendPort}/graphql`, { 69 | operationName: "CreateBankAccount", 70 | query: createBankAccountMutation.loc?.source.body, 71 | variables: payload, 72 | }); 73 | return resp.data; 74 | }, 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /src/machines/contactsTransactionsMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | import { backendPort } from "../utils/portUtils"; 5 | 6 | export const contactsTransactionsMachine = dataMachine("contactsTransactions").withConfig({ 7 | services: { 8 | fetchData: async (ctx, event: any) => { 9 | const payload = omit("type", event); 10 | const resp = await httpClient.get(`http://localhost:${backendPort}/transactions/contacts`, { 11 | params: !isEmpty(payload) ? payload : undefined, 12 | }); 13 | return resp.data; 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/machines/createTransactionMachine.ts: -------------------------------------------------------------------------------- 1 | import { omit } from "lodash/fp"; 2 | import { Machine, assign } from "xstate"; 3 | import { dataMachine } from "./dataMachine"; 4 | import { httpClient } from "../utils/asyncUtils"; 5 | import { User, TransactionCreatePayload } from "../models"; 6 | import { authService } from "./authMachine"; 7 | import { backendPort } from "../utils/portUtils"; 8 | 9 | export interface CreateTransactionMachineSchema { 10 | states: { 11 | stepOne: {}; 12 | stepTwo: {}; 13 | stepThree: {}; 14 | }; 15 | } 16 | 17 | const transactionDataMachine = dataMachine("transactionData").withConfig({ 18 | services: { 19 | createData: async (ctx, event: any) => { 20 | const payload = omit("type", event); 21 | const resp = await httpClient.post(`http://localhost:${backendPort}/transactions`, payload); 22 | authService.send("REFRESH"); 23 | return resp.data; 24 | }, 25 | }, 26 | }); 27 | 28 | export type CreateTransactionMachineEvents = 29 | | { type: "SET_USERS" } 30 | | { type: "CREATE" } 31 | | { type: "RESET" }; 32 | 33 | export interface CreateTransactionMachineContext { 34 | sender: User; 35 | receiver: User; 36 | transactionDetails: TransactionCreatePayload; 37 | } 38 | 39 | export const createTransactionMachine = Machine< 40 | CreateTransactionMachineContext, 41 | CreateTransactionMachineSchema, 42 | CreateTransactionMachineEvents 43 | >( 44 | { 45 | id: "createTransaction", 46 | initial: "stepOne", 47 | states: { 48 | stepOne: { 49 | entry: "clearContext", 50 | on: { 51 | SET_USERS: "stepTwo", 52 | }, 53 | }, 54 | stepTwo: { 55 | entry: "setSenderAndReceiver", 56 | invoke: { 57 | id: "transactionDataMachine", 58 | src: transactionDataMachine, 59 | autoForward: true, 60 | }, 61 | on: { 62 | CREATE: "stepThree", 63 | }, 64 | }, 65 | stepThree: { 66 | entry: "setTransactionDetails", 67 | on: { 68 | RESET: "stepOne", 69 | }, 70 | }, 71 | }, 72 | }, 73 | { 74 | actions: { 75 | setSenderAndReceiver: assign((ctx, event: any) => ({ 76 | sender: event.sender, 77 | receiver: event.receiver, 78 | })), 79 | setTransactionDetails: assign((ctx, event: any) => ({ 80 | transactionDetails: event, 81 | })), 82 | clearContext: assign((ctx, event: any) => ({})), 83 | }, 84 | } 85 | ); 86 | -------------------------------------------------------------------------------- /src/machines/dataMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from "xstate"; 2 | import { concat } from "lodash/fp"; 3 | 4 | export interface DataSchema { 5 | states: { 6 | idle: {}; 7 | loading: {}; 8 | updating: {}; 9 | creating: {}; 10 | deleting: {}; 11 | success: { 12 | states: { 13 | unknown: {}; 14 | withData: {}; 15 | withoutData: {}; 16 | }; 17 | }; 18 | failure: {}; 19 | }; 20 | } 21 | 22 | type SuccessEvent = { type: "SUCCESS"; results: any[]; pageData: object }; 23 | type FailureEvent = { type: "FAILURE"; message: string }; 24 | export type DataEvents = 25 | | { type: "FETCH" } 26 | | { type: "UPDATE" } 27 | | { type: "CREATE" } 28 | | { type: "DELETE" } 29 | | SuccessEvent 30 | | FailureEvent; 31 | 32 | export interface DataContext { 33 | pageData?: object; 34 | results?: any[]; 35 | message?: string; 36 | } 37 | 38 | export const dataMachine = (machineId: string) => 39 | Machine( 40 | { 41 | id: machineId, 42 | initial: "idle", 43 | context: { 44 | pageData: {}, 45 | results: [], 46 | message: undefined, 47 | }, 48 | states: { 49 | idle: { 50 | on: { 51 | FETCH: "loading", 52 | CREATE: "creating", 53 | UPDATE: "updating", 54 | DELETE: "deleting", 55 | }, 56 | }, 57 | loading: { 58 | invoke: { 59 | src: "fetchData", 60 | onDone: { target: "success" }, 61 | onError: { target: "failure", actions: "setMessage" }, 62 | }, 63 | }, 64 | updating: { 65 | invoke: { 66 | src: "updateData", 67 | onDone: { target: "loading" }, 68 | onError: { target: "failure", actions: "setMessage" }, 69 | }, 70 | }, 71 | creating: { 72 | invoke: { 73 | src: "createData", 74 | onDone: { target: "loading" }, 75 | onError: { target: "failure", actions: "setMessage" }, 76 | }, 77 | }, 78 | deleting: { 79 | invoke: { 80 | src: "deleteData", 81 | onDone: { target: "loading" }, 82 | onError: { target: "failure", actions: "setMessage" }, 83 | }, 84 | }, 85 | success: { 86 | entry: ["setResults", "setPageData"], 87 | on: { 88 | FETCH: "loading", 89 | CREATE: "creating", 90 | UPDATE: "updating", 91 | DELETE: "deleting", 92 | }, 93 | initial: "unknown", 94 | states: { 95 | unknown: { 96 | on: { 97 | "": [{ target: "withData", cond: "hasData" }, { target: "withoutData" }], 98 | }, 99 | }, 100 | withData: {}, 101 | withoutData: {}, 102 | }, 103 | }, 104 | failure: { 105 | entry: ["setMessage"], 106 | on: { 107 | FETCH: "loading", 108 | }, 109 | }, 110 | }, 111 | }, 112 | { 113 | actions: { 114 | setResults: assign((ctx: DataContext, event: any) => ({ 115 | results: 116 | event.data && event.data.pageData && event.data.pageData.page > 1 117 | ? concat(ctx.results, event.data.results) 118 | : event.data.results, 119 | })), 120 | setPageData: assign((ctx: DataContext, event: any) => ({ 121 | pageData: event.data.pageData, 122 | })), 123 | 124 | setMessage: /* istanbul ignore next */ assign((ctx, event: any) => ({ 125 | message: event.message, 126 | })), 127 | }, 128 | guards: { 129 | hasData: (ctx: DataContext, event) => !!ctx.results && ctx.results.length > 0, 130 | }, 131 | } 132 | ); 133 | -------------------------------------------------------------------------------- /src/machines/drawerMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine } from "xstate"; 2 | 3 | export const drawerMachine = Machine( 4 | { 5 | id: "drawer", 6 | type: "parallel", 7 | states: { 8 | desktop: { 9 | initial: "open", 10 | states: { 11 | closed: { 12 | on: { 13 | TOGGLE_DESKTOP: "open", 14 | OPEN_DESKTOP: { target: "open", cond: "shouldOpenDesktop" }, 15 | }, 16 | }, 17 | open: { 18 | on: { TOGGLE_DESKTOP: "closed", CLOSE_DESKTOP: "closed" }, 19 | }, 20 | hist: { 21 | type: "history", 22 | }, 23 | }, 24 | }, 25 | mobile: { 26 | initial: "closed", 27 | states: { 28 | closed: { 29 | on: { TOGGLE_MOBILE: "open", OPEN_MOBILE: "open" }, 30 | }, 31 | open: { 32 | on: { TOGGLE_MOBILE: "closed", CLOSE_MOBILE: "closed" }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }, 38 | { 39 | guards: { 40 | shouldOpenDesktop: (context, event, guardMeta) => { 41 | return ( 42 | guardMeta.state.history?.context.aboveSmallBreakpoint !== context.aboveSmallBreakpoint 43 | ); 44 | }, 45 | }, 46 | } 47 | ); 48 | -------------------------------------------------------------------------------- /src/machines/notificationsMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | import { backendPort } from "../utils/portUtils"; 5 | 6 | export const notificationsMachine = dataMachine("notifications").withConfig({ 7 | services: { 8 | fetchData: async (ctx, event: any) => { 9 | const payload = omit("type", event); 10 | const resp = await httpClient.get(`http://localhost:${backendPort}/notifications`, { 11 | params: !isEmpty(payload) && event.type === "FETCH" ? payload : undefined, 12 | }); 13 | return resp.data; 14 | }, 15 | updateData: async (ctx, event: any) => { 16 | const payload = omit("type", event); 17 | const resp = await httpClient.patch( 18 | `http://localhost:${backendPort}/notifications/${payload.id}`, 19 | payload 20 | ); 21 | return resp.data; 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/machines/personalTransactionsMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | import { backendPort } from "../utils/portUtils"; 5 | 6 | export const personalTransactionsMachine = dataMachine("personalTransactions").withConfig({ 7 | services: { 8 | fetchData: async (ctx, event: any) => { 9 | const payload = omit("type", event); 10 | const resp = await httpClient.get(`http://localhost:${backendPort}/transactions`, { 11 | params: !isEmpty(payload) ? payload : undefined, 12 | }); 13 | return resp.data; 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/machines/publicTransactionsMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | import { backendPort } from "../utils/portUtils"; 5 | 6 | export const publicTransactionsMachine = dataMachine("publicTransactions").withConfig({ 7 | services: { 8 | fetchData: async (ctx, event: any) => { 9 | const payload = omit("type", event); 10 | const resp = await httpClient.get(`http://localhost:${backendPort}/transactions/public`, { 11 | params: !isEmpty(payload) ? payload : undefined, 12 | }); 13 | return resp.data; 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/machines/snackbarMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from "xstate"; 2 | 3 | export interface SnackbarSchema { 4 | states: { 5 | invisible: {}; 6 | visible: {}; 7 | }; 8 | } 9 | 10 | export type SnackbarEvents = { type: "SHOW" } | { type: "HIDE" }; 11 | 12 | export interface SnackbarContext { 13 | severity?: "success" | "info" | "warning" | "error"; 14 | message?: string; 15 | } 16 | 17 | export const snackbarMachine = Machine( 18 | { 19 | id: "snackbar", 20 | initial: "invisible", 21 | context: { 22 | severity: undefined, 23 | message: undefined, 24 | }, 25 | states: { 26 | invisible: { 27 | entry: "resetSnackbar", 28 | on: { SHOW: "visible" }, 29 | }, 30 | visible: { 31 | entry: "setSnackbar", 32 | on: { HIDE: "invisible" }, 33 | after: { 34 | // after 3 seconds, transition to invisible 35 | 3000: "invisible", 36 | }, 37 | }, 38 | }, 39 | }, 40 | { 41 | actions: { 42 | setSnackbar: assign((ctx, event: any) => ({ 43 | severity: event.severity, 44 | message: event.message, 45 | })), 46 | resetSnackbar: assign((ctx, event: any) => ({ 47 | severity: undefined, 48 | message: undefined, 49 | })), 50 | }, 51 | } 52 | ); 53 | -------------------------------------------------------------------------------- /src/machines/transactionDetailMachine.ts: -------------------------------------------------------------------------------- 1 | import { omit, flow, first, isEmpty } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | import { backendPort } from "../utils/portUtils"; 5 | 6 | export const transactionDetailMachine = dataMachine("transactionData").withConfig({ 7 | services: { 8 | fetchData: async (ctx, event: any) => { 9 | const payload = omit("type", event); 10 | const contextTransactionId = !isEmpty(ctx.results) && first(ctx.results)["id"]; 11 | const transactionId = contextTransactionId || payload.transactionId; 12 | const resp = await httpClient.get( 13 | `http://localhost:${backendPort}/transactions/${transactionId}` 14 | ); 15 | return { results: [resp.data.transaction] }; 16 | }, 17 | createData: async (ctx, event: any) => { 18 | let route = event.entity === "LIKE" ? "likes" : "comments"; 19 | const payload = flow(omit("type"), omit("entity"))(event); 20 | const resp = await httpClient.post( 21 | `http://localhost:${backendPort}/${route}/${payload.transactionId}`, 22 | payload 23 | ); 24 | return resp.data; 25 | }, 26 | updateData: async (ctx, event: any) => { 27 | const payload = omit("type", event); 28 | const contextTransactionId = !isEmpty(ctx.results) && first(ctx.results)["id"]; 29 | const transactionId = contextTransactionId || payload.id; 30 | const resp = await httpClient.patch( 31 | `http://localhost:${backendPort}/transactions/${transactionId}`, 32 | payload 33 | ); 34 | return resp.data; 35 | }, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/machines/transactionFiltersMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from "xstate"; 2 | 3 | interface FilterSchema { 4 | states: { 5 | dateRange: { 6 | states: { 7 | none: {}; 8 | filter: {}; 9 | }; 10 | }; 11 | amountRange: { 12 | states: { 13 | none: {}; 14 | filter: {}; 15 | }; 16 | }; 17 | }; 18 | } 19 | 20 | type DateFilterEvent = { 21 | type: "DATE_FILTER"; 22 | dateRangeStart: string; 23 | dateRangeEnd: string; 24 | }; 25 | type AmountFilterEvent = { 26 | type: "AMOUNT_FILTER"; 27 | amountMin: string; 28 | amountMax: string; 29 | }; 30 | type DateResetEvent = { type: "DATE_RESET" }; 31 | type AmountResetEvent = { type: "AMOUNT_RESET" }; 32 | type FilterEvents = 33 | | { type: "NONE" } 34 | | DateFilterEvent 35 | | AmountFilterEvent 36 | | DateResetEvent 37 | | AmountResetEvent; 38 | 39 | export interface FilterContext {} 40 | 41 | export const transactionFiltersMachine = Machine( 42 | { 43 | id: "filters", 44 | type: "parallel", 45 | context: {}, 46 | states: { 47 | dateRange: { 48 | initial: "none", 49 | states: { 50 | none: { 51 | entry: "resetDateRange", 52 | on: { 53 | DATE_FILTER: "filter", 54 | }, 55 | }, 56 | filter: { 57 | entry: "setDateRange", 58 | on: { 59 | DATE_RESET: "none", 60 | }, 61 | }, 62 | }, 63 | }, 64 | amountRange: { 65 | initial: "none", 66 | states: { 67 | none: { 68 | entry: "resetAmountRange", 69 | on: { 70 | AMOUNT_FILTER: "filter", 71 | }, 72 | }, 73 | filter: { 74 | entry: "setAmountRange", 75 | on: { 76 | AMOUNT_RESET: "none", 77 | AMOUNT_FILTER: "filter", 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | { 85 | actions: { 86 | setDateRange: assign((ctx: FilterContext, event: any) => ({ 87 | dateRangeStart: event.dateRangeStart, 88 | dateRangeEnd: event.dateRangeEnd, 89 | })), 90 | resetDateRange: assign((ctx: FilterContext, event: any) => ({ 91 | dateRangeStart: undefined, 92 | dateRangeEnd: undefined, 93 | })), 94 | setAmountRange: assign((ctx: FilterContext, event: any) => ({ 95 | amountMin: event.amountMin, 96 | amountMax: event.amountMax, 97 | })), 98 | resetAmountRange: assign((ctx: FilterContext, event: any) => ({ 99 | amountMin: undefined, 100 | amountMax: undefined, 101 | })), 102 | }, 103 | } 104 | ); 105 | -------------------------------------------------------------------------------- /src/machines/userOnboardingMachine.ts: -------------------------------------------------------------------------------- 1 | import { Machine } from "xstate"; 2 | 3 | export interface UserOnboardingMachineSchema { 4 | states: { 5 | idle: {}; 6 | stepOne: {}; 7 | stepTwo: {}; 8 | stepThree: {}; 9 | done: {}; 10 | }; 11 | } 12 | 13 | export type UserOnboardingMachineEvents = { type: "PREV" } | { type: "NEXT" }; 14 | 15 | export interface UserOnboardingMachineContext {} 16 | 17 | export const userOnboardingMachine = Machine< 18 | UserOnboardingMachineContext, 19 | UserOnboardingMachineSchema, 20 | UserOnboardingMachineEvents 21 | >({ 22 | id: "userOnboarding", 23 | initial: "stepOne", 24 | states: { 25 | idle: { 26 | on: { 27 | NEXT: "stepOne", 28 | }, 29 | }, 30 | stepOne: { 31 | on: { 32 | NEXT: "stepTwo", 33 | }, 34 | }, 35 | stepTwo: { 36 | on: { 37 | PREV: "stepOne", 38 | NEXT: "stepThree", 39 | }, 40 | }, 41 | stepThree: { 42 | on: { 43 | PREV: "stepTwo", 44 | NEXT: "done", 45 | }, 46 | }, 47 | done: { 48 | type: "final", 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/machines/usersMachine.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, omit } from "lodash/fp"; 2 | import { dataMachine } from "./dataMachine"; 3 | import { httpClient } from "../utils/asyncUtils"; 4 | import { backendPort } from "../utils/portUtils"; 5 | 6 | export const usersMachine = dataMachine("users").withConfig({ 7 | services: { 8 | fetchData: async (ctx, event: any) => { 9 | const payload = omit("type", event); 10 | let route = isEmpty(payload) ? "users" : "users/search"; 11 | const resp = await httpClient.get(`http://localhost:${backendPort}/${route}`, { 12 | params: !isEmpty(payload) ? payload : undefined, 13 | }); 14 | return resp.data; 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/models/bankaccount.ts: -------------------------------------------------------------------------------- 1 | export interface BankAccount { 2 | id: string; 3 | uuid: string; 4 | userId: string; 5 | bankName: string; 6 | accountNumber: string; 7 | routingNumber: string; 8 | isDeleted: boolean; 9 | createdAt: Date; 10 | modifiedAt: Date; 11 | } 12 | 13 | export type BankAccountPayload = Pick< 14 | BankAccount, 15 | "userId" | "bankName" | "accountNumber" | "routingNumber" 16 | >; 17 | -------------------------------------------------------------------------------- /src/models/banktransfer.ts: -------------------------------------------------------------------------------- 1 | export enum BankTransferType { 2 | withdrawal = "withdrawal", 3 | deposit = "deposit", 4 | } 5 | export interface BankTransfer { 6 | id: string; 7 | uuid: string; 8 | userId: string; 9 | source: string; 10 | amount: number; 11 | type: BankTransferType; 12 | transactionId: string; 13 | createdAt: Date; 14 | modifiedAt: Date; 15 | } 16 | export type BankTransferPayload = Omit; 17 | -------------------------------------------------------------------------------- /src/models/comment.ts: -------------------------------------------------------------------------------- 1 | export interface Comment { 2 | id: string; 3 | uuid: string; 4 | content: string; 5 | userId: string; 6 | transactionId: string; 7 | createdAt: Date; 8 | modifiedAt: Date; 9 | } 10 | -------------------------------------------------------------------------------- /src/models/contact.ts: -------------------------------------------------------------------------------- 1 | export interface Contact { 2 | id: string; 3 | uuid: string; 4 | userId: string; 5 | contactUserId: string; 6 | createdAt: Date; 7 | modifiedAt: Date; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/db-schema.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user"; 2 | import { Contact } from "./contact"; 3 | import { BankAccount } from "./bankaccount"; 4 | import { Transaction } from "./transaction"; 5 | import { Like } from "./like"; 6 | import { BankTransfer } from "./banktransfer"; 7 | import { NotificationType } from "./notification"; 8 | import { Comment } from "./comment"; 9 | 10 | export interface DbSchema { 11 | users: User[]; 12 | contacts: Contact[]; 13 | bankaccounts: BankAccount[]; 14 | transactions: Transaction[]; 15 | likes: Like[]; 16 | comments: Comment[]; 17 | notifications: NotificationType[]; 18 | banktransfers: BankTransfer[]; 19 | } 20 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./user"; 2 | export * from "./bankaccount"; 3 | export * from "./contact"; 4 | export * from "./transaction"; 5 | export * from "./like"; 6 | export * from "./comment"; 7 | export * from "./notification"; 8 | export * from "./banktransfer"; 9 | -------------------------------------------------------------------------------- /src/models/like.ts: -------------------------------------------------------------------------------- 1 | export interface Like { 2 | id: string; 3 | uuid: string; 4 | userId: string; 5 | transactionId: string; 6 | createdAt: Date; 7 | modifiedAt: Date; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/notification.ts: -------------------------------------------------------------------------------- 1 | export enum PaymentNotificationStatus { 2 | requested = "requested", 3 | received = "received", 4 | incomplete = "incomplete", 5 | } 6 | 7 | export enum NotificationsType { 8 | payment = "payment", 9 | like = "like", 10 | comment = "comment", 11 | } 12 | 13 | export interface NotificationBase { 14 | id: string; 15 | uuid: string; 16 | userId: string; 17 | transactionId: string; 18 | isRead: boolean; 19 | createdAt: Date; 20 | modifiedAt: Date; 21 | } 22 | 23 | export type NotificationUpdatePayload = Partial>; 24 | 25 | export interface PaymentNotification extends NotificationBase { 26 | status: PaymentNotificationStatus; 27 | } 28 | 29 | export interface LikeNotification extends NotificationBase { 30 | likeId: string; 31 | } 32 | 33 | export interface CommentNotification extends NotificationBase { 34 | commentId: string; 35 | } 36 | 37 | export interface PaymentNotificationResponseItem extends PaymentNotification { 38 | userFullName: string; 39 | } 40 | 41 | export interface CommentNotificationResponseItem extends CommentNotification { 42 | userFullName: string; 43 | } 44 | 45 | export interface LikeNotificationResponseItem extends LikeNotification { 46 | userFullName: string; 47 | } 48 | 49 | export interface NotificationPayloadBase { 50 | type: NotificationsType; 51 | transactionId: string; 52 | } 53 | 54 | export interface PaymentNotificationPayload extends NotificationPayloadBase { 55 | status: PaymentNotificationStatus; 56 | } 57 | 58 | export interface LikeNotificationPayload extends NotificationPayloadBase { 59 | likeId: string; 60 | } 61 | 62 | export interface CommentNotificationPayload extends NotificationPayloadBase { 63 | commentId: string; 64 | } 65 | 66 | export type NotificationType = PaymentNotification | LikeNotification | CommentNotification; 67 | 68 | export type NotificationPayloadType = 69 | | PaymentNotificationPayload 70 | | LikeNotificationPayload 71 | | CommentNotificationPayload; 72 | 73 | export type NotificationResponseItem = 74 | | PaymentNotificationResponseItem 75 | | LikeNotificationResponseItem 76 | | CommentNotificationResponseItem; 77 | -------------------------------------------------------------------------------- /src/models/transaction.ts: -------------------------------------------------------------------------------- 1 | import { DefaultPrivacyLevel } from "./user"; 2 | import { Like, Comment } from "."; 3 | 4 | export enum TransactionStatus { 5 | pending = "pending", 6 | incomplete = "incomplete", 7 | complete = "complete", 8 | } 9 | 10 | export enum TransactionRequestStatus { 11 | pending = "pending", 12 | accepted = "accepted", 13 | rejected = "rejected", 14 | } 15 | 16 | export interface Transaction { 17 | id: string; 18 | uuid: string; 19 | source: string; // Empty if Payment or Request; Populated with BankAccount ID 20 | amount: number; 21 | description: string; 22 | privacyLevel: DefaultPrivacyLevel; 23 | receiverId: string; 24 | senderId: string; 25 | balanceAtCompletion?: number; 26 | status: TransactionStatus; 27 | requestStatus?: TransactionRequestStatus | string; 28 | requestResolvedAt?: Date | string; 29 | createdAt: Date; 30 | modifiedAt: Date; 31 | } 32 | 33 | export interface FakeTransaction { 34 | id?: string; 35 | uuid?: string; 36 | source?: string; // Empty if Payment or Request; Populated with BankAccount ID 37 | amount?: number; 38 | description?: string; 39 | privacyLevel?: DefaultPrivacyLevel; 40 | receiverId: string; 41 | senderId: string; 42 | balanceAtCompletion?: number; 43 | status?: TransactionStatus; 44 | requestStatus?: TransactionRequestStatus | string; 45 | requestResolvedAt?: Date | string; 46 | createdAt?: Date; 47 | modifiedAt?: Date; 48 | } 49 | 50 | export interface TransactionResponseItem extends Transaction { 51 | likes: Like[]; 52 | comments: Comment[]; 53 | receiverName: string; 54 | receiverAvatar: string; 55 | senderName: string; 56 | senderAvatar: string; 57 | } 58 | 59 | export type TransactionScenario = { 60 | status: TransactionStatus; 61 | requestStatus: TransactionRequestStatus | string; 62 | }; 63 | 64 | export type TransactionPayload = Omit; 65 | 66 | export type TransactionCreatePayload = Partial< 67 | Pick & { 68 | amount: string; 69 | transactionType: string; 70 | } 71 | >; 72 | 73 | export type TransactionUpdateActionPayload = Pick; 74 | 75 | type TransactionQueryBase = { 76 | dateRangeStart?: string; 77 | dateRangeEnd?: string; 78 | amountMin?: number; 79 | amountMax?: number; 80 | status?: TransactionStatus; 81 | limit?: number; 82 | page?: number; 83 | }; 84 | 85 | export type TransactionQueryPayload = Partial; 86 | 87 | export type TransactionDateRangePayload = Partial< 88 | Pick 89 | >; 90 | 91 | export type TransactionAmountRangePayload = Partial< 92 | Pick 93 | >; 94 | 95 | export type TransactionPaginationPayload = Partial>; 96 | 97 | export type TransactionClearFiltersPayload = { 98 | filterType: "date" | "amount"; 99 | }; 100 | 101 | export type TransactionPagination = { 102 | page: number; 103 | limit: number; 104 | hasNextPages: boolean; 105 | totalPages: number; 106 | }; 107 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | export enum DefaultPrivacyLevel { 2 | public = "public", 3 | private = "private", 4 | contacts = "contacts", 5 | } 6 | 7 | export interface User { 8 | id: string; 9 | uuid: string; 10 | firstName: string; 11 | lastName: string; 12 | username: string; 13 | password: string; 14 | email: string; 15 | phoneNumber: string; 16 | balance: number; 17 | avatar: string; 18 | defaultPrivacyLevel: DefaultPrivacyLevel; 19 | createdAt: Date; 20 | modifiedAt: Date; 21 | } 22 | 23 | export type UserSettingsPayload = Pick< 24 | User, 25 | "firstName" | "lastName" | "email" | "phoneNumber" | "defaultPrivacyLevel" 26 | >; 27 | 28 | export type SignInPayload = Pick & { 29 | remember?: Boolean; 30 | }; 31 | 32 | export type SignUpPayload = Pick; 33 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const createProxyMiddleware = require("http-proxy-middleware"); 2 | require("dotenv").config(); 3 | 4 | module.exports = function (app) { 5 | app.use( 6 | createProxyMiddleware(["/login", "/callback", "/logout", "/checkAuth", "graphql"], { 7 | target: `http://localhost:${process.env.BACKEND_PORT}`, 8 | changeOrigin: true, 9 | logLevel: "debug", 10 | }) 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/svgs/rwa-icon-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /src/utils/asyncUtils.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import axios from "axios"; 3 | 4 | dotenv.config(); 5 | 6 | const httpClient = axios.create({ 7 | withCredentials: true, 8 | }); 9 | 10 | httpClient.interceptors.request.use((config) => { 11 | /* istanbul ignore if */ 12 | if ( 13 | process.env.REACT_APP_AUTH0 || 14 | process.env.REACT_APP_OKTA || 15 | process.env.REACT_APP_AWS_COGNITO || 16 | process.env.REACT_APP_GOOGLE 17 | ) { 18 | const accessToken = localStorage.getItem(process.env.REACT_APP_AUTH_TOKEN_NAME!); 19 | config.headers["Authorization"] = `Bearer ${accessToken}`; 20 | } 21 | return config; 22 | }); 23 | 24 | export { httpClient }; 25 | -------------------------------------------------------------------------------- /src/utils/historyUtils.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | 3 | export const history = createBrowserHistory(); 4 | -------------------------------------------------------------------------------- /src/utils/portUtils.ts: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | export const frontendPort = process.env.REACT_APP_PORT; 4 | export const backendPort = process.env.REACT_APP_BACKEND_PORT; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "noFallthroughCasesInSwitch": true 19 | }, 20 | "include": ["src", "scripts", "backend", "src/__tests__"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.tsnode.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "isolatedModules": false 6 | } 7 | } 8 | --------------------------------------------------------------------------------