├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------